Spaces:
Sleeping
Sleeping
Commit Β·
639f871
1
Parent(s): c8fa249
first commit
Browse files- .gitignore +131 -0
- Dockerfile +18 -0
- README.md +544 -1
- __init__.py +0 -0
- app.py +225 -0
- config.py +216 -0
- core/__init__.py +30 -0
- core/distance.py +68 -0
- core/fitness.py +165 -0
- core/models.py +135 -0
- core/profile.py +275 -0
- data/__init__.py +0 -0
- data/custom_profile.json +25 -0
- data/pois.json +475 -0
- data/tour_results +3 -0
- demo_rome.py +134 -0
- ga/__init__.py +0 -0
- ga/operators.py +191 -0
- ga/repair.py +389 -0
- ga/seeding.py +143 -0
- requirements.txt +8 -0
- solver.py +264 -0
- streamlit_ui.py +168 -0
.gitignore
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
*.egg-info/
|
| 24 |
+
.installed.cfg
|
| 25 |
+
*.egg
|
| 26 |
+
MANIFEST
|
| 27 |
+
|
| 28 |
+
# PyInstaller
|
| 29 |
+
# Usually these files are written by a python script from a template
|
| 30 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 31 |
+
*.manifest
|
| 32 |
+
*.spec
|
| 33 |
+
|
| 34 |
+
# Installer logs
|
| 35 |
+
pip-log.txt
|
| 36 |
+
pip-delete-this-directory.txt
|
| 37 |
+
|
| 38 |
+
# Unit test / coverage reports
|
| 39 |
+
htmlcov/
|
| 40 |
+
.tox/
|
| 41 |
+
.nox/
|
| 42 |
+
.coverage
|
| 43 |
+
.coverage.*
|
| 44 |
+
.cache
|
| 45 |
+
nosetests.xml
|
| 46 |
+
coverage.xml
|
| 47 |
+
*.cover
|
| 48 |
+
.hypothesis/
|
| 49 |
+
.pytest_cache/
|
| 50 |
+
|
| 51 |
+
# Translations
|
| 52 |
+
*.mo
|
| 53 |
+
*.pot
|
| 54 |
+
|
| 55 |
+
# Django stuff:
|
| 56 |
+
*.log
|
| 57 |
+
local_settings.py
|
| 58 |
+
db.sqlite3
|
| 59 |
+
|
| 60 |
+
# Flask stuff:
|
| 61 |
+
instance/
|
| 62 |
+
.webassets-cache
|
| 63 |
+
|
| 64 |
+
# Scrapy stuff:
|
| 65 |
+
.scrapy
|
| 66 |
+
|
| 67 |
+
# Sphinx documentation
|
| 68 |
+
docs/_build/
|
| 69 |
+
|
| 70 |
+
# PyBuilder
|
| 71 |
+
target/
|
| 72 |
+
|
| 73 |
+
# Jupyter Notebook
|
| 74 |
+
.ipynb_checkpoints
|
| 75 |
+
|
| 76 |
+
# IPython
|
| 77 |
+
profile_default/
|
| 78 |
+
ipython_config.py
|
| 79 |
+
|
| 80 |
+
# pyenv
|
| 81 |
+
.python-version
|
| 82 |
+
|
| 83 |
+
# celery beat schedule file
|
| 84 |
+
celerybeat-schedule
|
| 85 |
+
|
| 86 |
+
# SageMath parsed files
|
| 87 |
+
*.sage.py
|
| 88 |
+
|
| 89 |
+
# Environments
|
| 90 |
+
.env
|
| 91 |
+
.venv
|
| 92 |
+
env/
|
| 93 |
+
venv/
|
| 94 |
+
ENV/
|
| 95 |
+
env.bak/
|
| 96 |
+
venv.bak/
|
| 97 |
+
|
| 98 |
+
# Spyder project settings
|
| 99 |
+
.spyderproject
|
| 100 |
+
.spyproject
|
| 101 |
+
|
| 102 |
+
# Rope project settings
|
| 103 |
+
.ropeproject
|
| 104 |
+
|
| 105 |
+
# mkdocs documentation
|
| 106 |
+
/site
|
| 107 |
+
|
| 108 |
+
# mypy
|
| 109 |
+
.mypy_cache/
|
| 110 |
+
.dmypy.json
|
| 111 |
+
dmypy.json
|
| 112 |
+
|
| 113 |
+
# IDEs
|
| 114 |
+
.vscode/
|
| 115 |
+
.idea/
|
| 116 |
+
*.swp
|
| 117 |
+
*.swo
|
| 118 |
+
*~
|
| 119 |
+
|
| 120 |
+
# OS
|
| 121 |
+
.DS_Store
|
| 122 |
+
.DS_Store?
|
| 123 |
+
._*
|
| 124 |
+
.Spotlight-V100
|
| 125 |
+
.Trashes
|
| 126 |
+
ehthumbs.db
|
| 127 |
+
Thumbs.db
|
| 128 |
+
|
| 129 |
+
# Project specific
|
| 130 |
+
data/tour_results.json
|
| 131 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
RUN useradd -m -u 1000 user
|
| 4 |
+
USER user
|
| 5 |
+
|
| 6 |
+
ENV PATH="/home/user/.local/bin:$PATH" \
|
| 7 |
+
PYTHONUNBUFFERED=1
|
| 8 |
+
|
| 9 |
+
WORKDIR /home/user/app
|
| 10 |
+
|
| 11 |
+
COPY --chown=user:user requirements.txt /home/user/app/
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY --chown=user:user . /home/user/app/
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD bash -c "python app.py & sleep 3 && streamlit run streamlit_ui.py --server.port 7860 --server.address 0.0.0.0"
|
README.md
CHANGED
|
@@ -8,4 +8,547 @@ pinned: false
|
|
| 8 |
license: apache-2.0
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
license: apache-2.0
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# tour_ga β Generatore di tour turistici con NSGA-II e TOP-TW
|
| 12 |
+
|
| 13 |
+
Progetto Python per la generazione automatica di tour personalizzati in cittΓ (Roma come caso di studio) tramite un algoritmo genetico multi-obiettivo. Il problema Γ¨ modellato come **Team Orienteering Problem with Time Windows (TOP-TW)** e risolto con una variante di **NSGA-II** (Non-dominated Sorting Genetic Algorithm II, Deb et al., 2002).
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## Indice
|
| 18 |
+
|
| 19 |
+
1. [Il problema: TOP-TW](#1-il-problema-top-tw)
|
| 20 |
+
2. [Fondamenti teorici: NSGA-II](#2-fondamenti-teorici-nsga-ii)
|
| 21 |
+
3. [Struttura del progetto](#3-struttura-del-progetto)
|
| 22 |
+
4. [Modello dati](#4-modello-dati)
|
| 23 |
+
5. [Profilo turista (TouristProfile)](#5-profilo-turista-touristprofile)
|
| 24 |
+
6. [Funzione fitness multi-obiettivo](#6-funzione-fitness-multi-obiettivo)
|
| 25 |
+
7. [Operatori genetici](#7-operatori-genetici)
|
| 26 |
+
8. [Riparazione genetica (Repair Engine)](#8-riparazione-genetica-repair-engine)
|
| 27 |
+
9. [Greedy Seeding](#9-greedy-seeding)
|
| 28 |
+
10. [Modello di trasporto realistico](#10-modello-di-trasporto-realistico)
|
| 29 |
+
11. [Configurazione e avvio](#11-configurazione-e-avvio)
|
| 30 |
+
12. [Risultati di esempio](#12-risultati-di-esempio)
|
| 31 |
+
13. [Riferimenti](#13-riferimenti)
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 1. Il problema: TOP-TW
|
| 36 |
+
|
| 37 |
+
Il **Team Orienteering Problem with Time Windows** Γ¨ una generalizzazione del Travelling Salesman Problem in cui:
|
| 38 |
+
|
| 39 |
+
- Esiste un insieme di **Punti di Interesse (PoI)** con uno score di attrattivitΓ , una durata di visita stimata e una finestra temporale di apertura `[open, close]`.
|
| 40 |
+
- Non tutti i PoI possono essere visitati entro il **budget di tempo** giornaliero.
|
| 41 |
+
- L'obiettivo Γ¨ selezionare e ordinare un sottoinsieme di PoI in modo da **massimizzare lo score totale** rispettando i vincoli temporali.
|
| 42 |
+
|
| 43 |
+
In questo progetto il problema viene esteso con tre obiettivi competitivi simultanei:
|
| 44 |
+
|
| 45 |
+
| Obiettivo | Direzione | Significato |
|
| 46 |
+
|-----------|-----------|-------------|
|
| 47 |
+
| `total_score` | Massimizza | Interesse culturale/gastronomico del tour |
|
| 48 |
+
| `total_distance` | Minimizza | Fatica e costi di spostamento |
|
| 49 |
+
| `time_penalty` | Minimizza | Sforamento del budget giornaliero |
|
| 50 |
+
|
| 51 |
+
La natura multi-obiettivo rende la ricerca di un'unica soluzione ottima impossibile: esistono molteplici soluzioni di compromesso (fronte di Pareto) tra le quali il turista sceglie in base alle proprie preferenze.
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## 2. Fondamenti teorici: NSGA-II
|
| 56 |
+
|
| 57 |
+
L'algoritmo NSGA-II (Deb, Pratap, Agarwal, Meyarivan β *IEEE Transactions on Evolutionary Computation*, Vol. 6, No. 2, 2002) risolve i tre limiti principali del suo predecessore NSGA:
|
| 58 |
+
|
| 59 |
+
1. **ComplessitΓ computazionale** β Il sorting non-dominato originale era O(MNΒ³). NSGA-II lo riduce a **O(MNΒ²)** con un algoritmo di conteggio della dominanza.
|
| 60 |
+
2. **Mancanza di elitismo** β NSGA-II combina popolazione corrente e figli in un pool di 2N individui e seleziona i migliori N: le soluzioni eccellenti non vengono mai perse.
|
| 61 |
+
3. **Parametro di sharing** β Sostituito dalla **crowding distance**, una metrica parameter-free che misura la densitΓ delle soluzioni nell'intorno di un individuo nello spazio degli obiettivi.
|
| 62 |
+
|
| 63 |
+
### 2.1 Fast Non-Dominated Sorting
|
| 64 |
+
|
| 65 |
+
Per ogni individuo `i` si calcolano:
|
| 66 |
+
- `dom_count[i]`: numero di individui che dominano `i`
|
| 67 |
+
- `dom_set[i]`: insieme degli individui dominati da `i`
|
| 68 |
+
|
| 69 |
+
Un individuo `A` **domina** `B` se Γ¨ migliore o uguale su tutti gli obiettivi e strettamente migliore su almeno uno. Gli individui con `dom_count = 0` formano il **Fronte 1** (Pareto front). Rimuovendo il fronte 1 e ripetendo si ottengono i fronti successivi. La complessitΓ totale Γ¨ **O(MNΒ²)**.
|
| 70 |
+
|
| 71 |
+
### 2.2 Crowding Distance
|
| 72 |
+
|
| 73 |
+
Per ogni fronte, la crowding distance di un individuo `i` Γ¨ la somma delle distanze normalizzate tra i vicini immediati lungo ciascun obiettivo:
|
| 74 |
+
|
| 75 |
+
```
|
| 76 |
+
CD(i) = Ξ£_m |f_m(i+1) - f_m(i-1)| / (f_m_max - f_m_min)
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Gli individui agli estremi del fronte ricevono distanza infinita. La crowding distance alta indica un individuo in una regione poco affollata β preferito nella selezione per mantenere diversitΓ .
|
| 80 |
+
|
| 81 |
+
### 2.3 Crowded-Comparison Operator
|
| 82 |
+
|
| 83 |
+
Nella selezione a torneo, un individuo `A` Γ¨ preferito a `B` se:
|
| 84 |
+
- `rank(A) < rank(B)` (rango Pareto migliore), oppure
|
| 85 |
+
- `rank(A) == rank(B)` e `crowd(A) > crowd(B)` (regione meno affollata)
|
| 86 |
+
|
| 87 |
+
### 2.4 Ciclo evolutivo principale
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
Inizializzazione popolazione P (greedy seeding + casuale riparato)
|
| 91 |
+
β
|
| 92 |
+
Valutazione fitness (3 obiettivi)
|
| 93 |
+
β
|
| 94 |
+
Fast non-dominated sort β assegna rank e crowding distance
|
| 95 |
+
β
|
| 96 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
β Selezione torneo (crowded-comparison) β
|
| 98 |
+
β Crossover (OX | PoI-aware, prob cx_prob) β
|
| 99 |
+
β Mutazione (swap | insert | reverse | add-remove) β
|
| 100 |
+
β Riparazione (categoria β TW β budget β cap β pasto) β
|
| 101 |
+
β β popolazione figli Q (size N) β
|
| 102 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
β
|
| 104 |
+
Unione R = P βͺ Q (size 2N)
|
| 105 |
+
β
|
| 106 |
+
Non-dominated sort su R β seleziona migliori N β nuova P
|
| 107 |
+
β
|
| 108 |
+
Criterio di stop? (max generazioni | stagnazione)
|
| 109 |
+
β NO β SΓ
|
| 110 |
+
(loop) Restituisce Fronte 1
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
### 2.5 Adattamenti al TOP-TW
|
| 114 |
+
|
| 115 |
+
L'NSGA-II standard opera su cromosomi di lunghezza fissa con variabili continue. Per il TOP-TW sono stati introdotti:
|
| 116 |
+
|
| 117 |
+
- **Cromosomi a lunghezza variabile** β lista ordinata di PoI (sottoinsieme del pool, non permutazione completa)
|
| 118 |
+
- **Repair-before-evaluation** β ogni individuo viene riparato dopo ogni operatore genetico, prima della valutazione fitness
|
| 119 |
+
- **Gene Jolly** (via `add_remove_mutation`) β placeholder che viene sostituito con il PoI piΓΉ conveniente disponibile
|
| 120 |
+
- **PenalitΓ dinamica sulle attese** β scoraggia tour con buchi temporali lunghi
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## 3. Struttura del progetto
|
| 125 |
+
|
| 126 |
+
```
|
| 127 |
+
tour_generator_ga/
|
| 128 |
+
βββ core/
|
| 129 |
+
β βββ models.py # PoI, Individual, TourSchedule, FitnessScore
|
| 130 |
+
β βββ distance.py # DistanceMatrix (Haversine Γ 1.3)
|
| 131 |
+
β βββ fitness.py # FitnessEvaluator multi-obiettivo
|
| 132 |
+
β βββ profile.py # TouristProfile + modello trasporto realistico
|
| 133 |
+
βββ ga/
|
| 134 |
+
β βββ operators.py # crossover (OX, PoI-aware), mutation, selection
|
| 135 |
+
β βββ repair.py # RepairEngine: 7 step di riparazione
|
| 136 |
+
β βββ seeding.py # GreedySeeder: costruzione iniziale Ξ±-greedy
|
| 137 |
+
βββ data/ # (placeholder per loader OSM/CSV)
|
| 138 |
+
βββ solver.py # NSGA2Solver: ciclo evolutivo completo
|
| 139 |
+
βββ demo_rome.py # Demo con dataset Roma e confronto profili
|
| 140 |
+
βββ README.md
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## 4. Modello dati
|
| 146 |
+
|
| 147 |
+
### `PoI`
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
@dataclass
|
| 151 |
+
class PoI:
|
| 152 |
+
id: str
|
| 153 |
+
name: str
|
| 154 |
+
lat, lon: float
|
| 155 |
+
score: float # attrattivitΓ [0, 1]
|
| 156 |
+
visit_duration: int # minuti di visita
|
| 157 |
+
time_window: TimeWindow # (open, close) in minuti dalla mezzanotte
|
| 158 |
+
category: PoICategory # MUSEUM | MONUMENT | RESTAURANT | BAR | GELATERIA | PARK | VIEWPOINT
|
| 159 |
+
tags: list[str] # es. ["arte", "antico", "fotogenico"]
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
### `Individual` (cromosoma)
|
| 163 |
+
|
| 164 |
+
```python
|
| 165 |
+
class Individual:
|
| 166 |
+
genes: list[PoI] # sequenza ordinata del tour
|
| 167 |
+
fitness: FitnessScore # calcolata dopo evaluate()
|
| 168 |
+
_schedule: TourSchedule # cache dello schedule decodificato
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
La rappresentazione come lista ordinata permette di codificare naturalmente sia l'insieme dei PoI visitati sia l'ordine di visita, senza vincoli di lunghezza fissa.
|
| 172 |
+
|
| 173 |
+
### `TourSchedule`
|
| 174 |
+
|
| 175 |
+
Output della decodifica del cromosoma: per ogni PoI vengono calcolati orario di arrivo, attesa all'apertura e orario di partenza. Il campo `total_wait` traccia i minuti di attesa cumulati e contribuisce alla penalitΓ fitness.
|
| 176 |
+
|
| 177 |
+
### `PoICategory`
|
| 178 |
+
|
| 179 |
+
```
|
| 180 |
+
MUSEUM | MONUMENT | RESTAURANT | BAR | GELATERIA | PARK | VIEWPOINT
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
La distinzione tra `RESTAURANT` (pasto formale) e `BAR`/`GELATERIA` (sosta breve) permette al `RepairEngine` di applicare vincoli differenziati per tipo di sosta alimentare.
|
| 184 |
+
|
| 185 |
+
---
|
| 186 |
+
|
| 187 |
+
## 5. Profilo turista (TouristProfile)
|
| 188 |
+
|
| 189 |
+
Il `TouristProfile` Γ¨ l'oggetto centrale che attraversa l'intero pipeline e determina il comportamento di ogni componente.
|
| 190 |
+
|
| 191 |
+
```python
|
| 192 |
+
@dataclass
|
| 193 |
+
class TouristProfile:
|
| 194 |
+
transport_mode: TransportMode # WALK | CAR | TRANSIT | MIXED
|
| 195 |
+
mobility: MobilityLevel # NORMAL | LIMITED
|
| 196 |
+
allowed_categories: list[str] # categorie PoI ammesse
|
| 197 |
+
want_lunch: bool # richiede sosta pranzo
|
| 198 |
+
want_dinner: bool # richiede sosta cena
|
| 199 |
+
lunch_time: int # orario preferito pranzo (minuti)
|
| 200 |
+
dinner_time: int # orario preferito cena (minuti)
|
| 201 |
+
meal_window: int # flessibilitΓ Β±minuti attorno all'orario
|
| 202 |
+
max_bar_stops: int = 1 # max soste bar nel tour
|
| 203 |
+
max_gelateria_stops: int = 1 # max soste gelateria nel tour
|
| 204 |
+
tag_weights: dict[str, float] # boost per tag di interesse
|
| 205 |
+
max_entry_fee: float | None # budget biglietti per PoI
|
| 206 |
+
group_size: int = 1 # persone (influenza durata visite)
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
**Profili predefiniti disponibili:**
|
| 210 |
+
|
| 211 |
+
| Factory | ModalitΓ | Categorie | Pasti |
|
| 212 |
+
|---------|----------|-----------|-------|
|
| 213 |
+
| `profile_cultural_walker()` | WALK | museum, monument, viewpoint, restaurant | pranzo |
|
| 214 |
+
| `profile_foodie_transit()` | TRANSIT | restaurant, bar, gelateria, monument, viewpoint | pranzo + cena |
|
| 215 |
+
| `profile_family_mixed()` | MIXED / LIMITED | monument, park, viewpoint, restaurant | pranzo |
|
| 216 |
+
| `profile_art_lover_car()` | CAR | museum, monument | pranzo |
|
| 217 |
+
|
| 218 |
+
**Effetti sul pipeline:**
|
| 219 |
+
|
| 220 |
+
- `DistanceMatrix` β usa `profile.travel_time_min(km)` per i tempi di percorrenza
|
| 221 |
+
- `GreedySeeder` β filtra `allowed_pois` per categoria; usa `effective_score()` (con boost tag)
|
| 222 |
+
- `RepairEngine` β filtra categorie, applica cap ristoranti/snack, garantisce slot pasto
|
| 223 |
+
- `FitnessEvaluator` β calcola score con boost da `tag_weights`; penalizza pasti mancanti
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## 6. Funzione fitness multi-obiettivo
|
| 228 |
+
|
| 229 |
+
La fitness Γ¨ calcolata in `FitnessEvaluator.evaluate()` e comprende tre componenti separate per NSGA-II e uno scalare aggregato per confronti rapidi (tournament selection):
|
| 230 |
+
|
| 231 |
+
### Score effettivo con boost
|
| 232 |
+
|
| 233 |
+
```python
|
| 234 |
+
effective_score(poi) = min(poi.score Γ Ξ (1 + tag_weight - 1), 1.5)
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
I `tag_weights` del profilo amplificano i PoI tematicamente rilevanti (es. `arte Γ 1.4` per un turista culturale). Il cap a 1.5 evita distorsioni eccessive.
|
| 238 |
+
|
| 239 |
+
### Scalare aggregato
|
| 240 |
+
|
| 241 |
+
```
|
| 242 |
+
scalar = w_score Γ norm(total_score)
|
| 243 |
+
- w_dist Γ norm(total_distance)
|
| 244 |
+
- time_over_penalty # solo sullo sforamento, non sull'uso del budget
|
| 245 |
+
- wait_penalty # penalizza attese cumulate > 5 min
|
| 246 |
+
- meal_penalty # penalizza slot pasto non coperti
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
La scelta deliberata di **non penalizzare l'uso del budget** (solo lo sforamento) evita che il GA produca tour brevissimi per minimizzare il tempo. Un tour di 10 ore che rispetta il budget di 11 ore non Γ¨ peggio di uno da 3 ore.
|
| 250 |
+
|
| 251 |
+
### I tre obiettivi Pareto
|
| 252 |
+
|
| 253 |
+
| Obiettivo | Misura | Direzione |
|
| 254 |
+
|-----------|--------|-----------|
|
| 255 |
+
| `total_score` | score effettivo cumulato | Massimizza |
|
| 256 |
+
| `total_distance` | km percorsi (Haversine Γ 1.3) | Minimizza |
|
| 257 |
+
| `total_time` | minuti totali del tour | Minimizza |
|
| 258 |
+
|
| 259 |
+
La dominanza Pareto Γ¨ implementata in `FitnessScore.dominates()`:
|
| 260 |
+
|
| 261 |
+
```python
|
| 262 |
+
def dominates(self, other) -> bool:
|
| 263 |
+
better_or_equal = (score β₯ and dist β€ and time β€)
|
| 264 |
+
strictly_better = (score > or dist < or time <)
|
| 265 |
+
return better_or_equal and strictly_better
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## 7. Operatori genetici
|
| 271 |
+
|
| 272 |
+
### Selezione
|
| 273 |
+
|
| 274 |
+
**Tournament selection** con `k=3` candidati casuali. Con `use_pareto=True` (default) preferisce rango Pareto basso e crowding distance alta, implementando l'operatore crowded-comparison di NSGA-II.
|
| 275 |
+
|
| 276 |
+
### Crossover
|
| 277 |
+
|
| 278 |
+
**Order Crossover (OX) adattato** β Preserva l'ordine relativo dei PoI condivisi tra i genitori senza duplicati. Adattato al TOP-TW per gestire cromosomi a lunghezza variabile (sottoinsiemi, non permutazioni complete).
|
| 279 |
+
|
| 280 |
+
```
|
| 281 |
+
Genitore A: [Trevi, Navona, Pantheon, Pranzo, Colosseo]
|
| 282 |
+
Genitore B: [Colosseo, Pranzo, Borghese, Trevi, Castel]
|
| 283 |
+
|
| 284 |
+
Segmento da A (pos 1-2): [Navona, Pantheon]
|
| 285 |
+
Riempimento da B (escl. duplicati): [Colosseo, Pranzo, Trevi, Castel]
|
| 286 |
+
Figlio: [Colosseo, Navona, Pantheon, Pranzo, Trevi, Castel]
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
**PoI-aware Crossover** β Scambia interi blocchi per categoria (es. tutti i musei da A, tutti i ristoranti da B). Preserva la "nicchia tematica" del genitore e mantiene la coerenza del profilo turista.
|
| 290 |
+
|
| 291 |
+
### Mutazione
|
| 292 |
+
|
| 293 |
+
Quattro operatori applicati con probabilitΓ adattiva:
|
| 294 |
+
|
| 295 |
+
| Operatore | Effetto | Caso d'uso |
|
| 296 |
+
|-----------|---------|------------|
|
| 297 |
+
| `swap_mutation` | Scambia 2 PoI nella sequenza | Esplorazione locale |
|
| 298 |
+
| `insert_mutation` | Rimuove e reinserisce un PoI | Fix ordinamento temporale |
|
| 299 |
+
| `reverse_segment_mutation` | Inverte un sottosegmento | Elimina crossing geografici |
|
| 300 |
+
| `add_remove_mutation` | Aggiunge/rimuove un PoI dal pool ammesso | Modifica lunghezza tour |
|
| 301 |
+
|
| 302 |
+
La `add_remove_mutation` opera **solo sul pool filtrato** per le categorie del profilo β non puΓ² mai inserire un ristorante nel tour di un turista che li ha esclusi.
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
## 8. Riparazione genetica (Repair Engine)
|
| 307 |
+
|
| 308 |
+
Ogni individuo viene riparato dopo ogni operatore genetico, prima della valutazione fitness. La pipeline di riparazione ha **7 step in sequenza**:
|
| 309 |
+
|
| 310 |
+
```
|
| 311 |
+
1. _filter_allowed_categories β rimuove PoI di categorie non ammesse
|
| 312 |
+
2. _sort_by_earliest_deadline β riordina per orario di apertura (EDF)
|
| 313 |
+
3. repair_time_windows β rimuove PoI con TW violata o attesa > max_wait_min
|
| 314 |
+
4. repair_budget β rimuove PoI a minor score/durata finchΓ© budget rispettato
|
| 315 |
+
5. _cap_restaurants β limita ristoranti formali al numero di slot pasto
|
| 316 |
+
6. _cap_snacks β limita bar e gelaterie ai massimi del profilo
|
| 317 |
+
7. _ensure_meal_slots β garantisce un ristorante per ogni slot pasto richiesto
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
### Step 3: max_wait_min
|
| 321 |
+
|
| 322 |
+
Il parametro `max_wait_min` (default 30) definisce la massima attesa tollerata per qualsiasi PoI. Un ristorante che apre alle 12:00 e a cui si arriva alle 9:30 viene **rimosso** β non ha senso aspettare 2 ore e mezza. L'eccezione Γ¨ `_ensure_meal_slots`, che tolera fino a 45 minuti di attesa per garantire la sosta pranzo/cena.
|
| 323 |
+
|
| 324 |
+
### Step 7: strategia di inserimento/sostituzione
|
| 325 |
+
|
| 326 |
+
Quando deve garantire un pasto, `_ensure_meal_slots` prova due strategie:
|
| 327 |
+
|
| 328 |
+
1. **Inserimento diretto** β aggiunge il ristorante nella posizione temporalmente corretta senza sforare il budget.
|
| 329 |
+
2. **Sostituzione** β se non c'Γ¨ spazio, rimuove il PoI con il peggior rapporto score/durata e lo sostituisce con il ristorante. Questo evita che tour a budget pieno perdano la garanzia del pasto.
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
## 9. Greedy Seeding
|
| 334 |
+
|
| 335 |
+
La popolazione iniziale Γ¨ costruita con una strategia mista 20/20/60:
|
| 336 |
+
|
| 337 |
+
| Quota | Strategia | Scopo |
|
| 338 |
+
|-------|-----------|-------|
|
| 339 |
+
| 20% | Greedy puro (`alpha=0`) | Massima qualitΓ iniziale, convergenza rapida |
|
| 340 |
+
| 20% | Ξ±-greedy perturbato (`alpha` da 0.15 a 0.50) | VarietΓ controllata vicino all'ottimo |
|
| 341 |
+
| 60% | Casuale riparato | Massima diversitΓ genetica |
|
| 342 |
+
|
| 343 |
+
### Criterio greedy: ratio score/overhead
|
| 344 |
+
|
| 345 |
+
```python
|
| 346 |
+
ratio = effective_score(poi) / (travel_min + wait_min + visit_duration)
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
Dove `effective_score` include giΓ i boost da `tag_weights` del profilo. Il criterio seleziona il PoI che massimizza il valore per minuto di tempo investito (spostamento + attesa + visita).
|
| 350 |
+
|
| 351 |
+
### Restricted Candidate List (GRASP-like)
|
| 352 |
+
|
| 353 |
+
Con `alpha > 0`, invece di scegliere sempre il candidato migliore, si sceglie **casualmente tra il top 20%** per ratio. Questo produce soluzioni diverse ma ancora di buona qualitΓ , avvicinandosi alla strategia GRASP (Greedy Randomized Adaptive Search Procedure).
|
| 354 |
+
|
| 355 |
+
Tutti gli individui iniziali (greedy inclusi) passano per `repair.repair()` per garantire la coerenza con i vincoli del profilo fin dalla prima generazione.
|
| 356 |
+
|
| 357 |
+
---
|
| 358 |
+
|
| 359 |
+
## 10. Modello di trasporto realistico
|
| 360 |
+
|
| 361 |
+
Il calcolo dei tempi di percorrenza in `profile.travel_time_min(km)` usa un modello che riflette la realtΓ urbana:
|
| 362 |
+
|
| 363 |
+
### WALK
|
| 364 |
+
|
| 365 |
+
```
|
| 366 |
+
t = km / v_walk
|
| 367 |
+
```
|
| 368 |
+
- `v_walk_normal = 4.5 km/h`, `v_walk_limited = 3.0 km/h`
|
| 369 |
+
|
| 370 |
+
### TRANSIT (bus + metro)
|
| 371 |
+
|
| 372 |
+
```
|
| 373 |
+
if km < 0.40: # soglia: prendere il mezzo non conviene
|
| 374 |
+
t = km / v_walk
|
| 375 |
+
else:
|
| 376 |
+
t = km / 20.0 + 10 min # 10 min overhead (attesa + fermata)
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
L'overhead fisso di 10 minuti per tratta modella la realtΓ di Roma: frequenza media bus 8-12 minuti, metro 4-5 minuti, piΓΉ il cammino alle fermate.
|
| 380 |
+
|
| 381 |
+
### CAR / TAXI
|
| 382 |
+
|
| 383 |
+
```
|
| 384 |
+
t = km / 25.0 + 5 min # 5 min overhead parcheggio
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
### MIXED
|
| 388 |
+
|
| 389 |
+
Per distanze sotto `600 m` si usa `v_walk`; oltre si usa la velocitΓ transit con overhead.
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
## 11. Configurazione e avvio
|
| 394 |
+
|
| 395 |
+
### Requisiti
|
| 396 |
+
|
| 397 |
+
```
|
| 398 |
+
Python β₯ 3.10 (per syntax X | None nei type hint)
|
| 399 |
+
Nessuna dipendenza esterna (stdlib only)
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
### SolverConfig
|
| 403 |
+
|
| 404 |
+
```python
|
| 405 |
+
from tour_ga.solver import SolverConfig
|
| 406 |
+
|
| 407 |
+
config = SolverConfig(
|
| 408 |
+
pop_size = 80, # dimensione popolazione
|
| 409 |
+
max_generations = 300, # generazioni massime
|
| 410 |
+
cx_prob = 0.85, # probabilitΓ crossover
|
| 411 |
+
mut_prob = 0.20, # probabilitΓ mutazione
|
| 412 |
+
tournament_k = 3, # candidati per torneo
|
| 413 |
+
stagnation_limit = 50, # early stop per stagnazione
|
| 414 |
+
start_time = 540, # 09:00 (minuti dalla mezzanotte)
|
| 415 |
+
budget = 480, # 8 ore di tour
|
| 416 |
+
start_lat = 41.896, # coordinate punto di partenza
|
| 417 |
+
start_lon = 12.484,
|
| 418 |
+
max_wait_min = 30, # attesa massima tollerata per PoI
|
| 419 |
+
w_score = 0.50, # peso obiettivo score
|
| 420 |
+
w_dist = 0.20, # peso obiettivo distanza
|
| 421 |
+
w_time = 0.30, # peso penalitΓ tempo
|
| 422 |
+
)
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
### Avvio base
|
| 426 |
+
|
| 427 |
+
```python
|
| 428 |
+
from tour_ga.core.models import PoI, PoICategory, TimeWindow
|
| 429 |
+
from tour_ga.core.distance import DistanceMatrix
|
| 430 |
+
from tour_ga.core.profile import profile_cultural_walker
|
| 431 |
+
from tour_ga.solver import NSGA2Solver, SolverConfig
|
| 432 |
+
|
| 433 |
+
pois = [...] # lista di PoI
|
| 434 |
+
profile = profile_cultural_walker()
|
| 435 |
+
|
| 436 |
+
dm = DistanceMatrix(pois, profile=profile)
|
| 437 |
+
dm.build() # calcola matrice distanze (una volta sola)
|
| 438 |
+
|
| 439 |
+
solver = NSGA2Solver(pois, dm, config, profile=profile)
|
| 440 |
+
pareto_front = solver.solve() # restituisce lista di Individual
|
| 441 |
+
|
| 442 |
+
# Selezione della soluzione consigliata dal fronte
|
| 443 |
+
best = max(pareto_front, key=lambda x: x.fitness.scalar)
|
| 444 |
+
schedule = solver.evaluator.decode(best)
|
| 445 |
+
print(schedule.summary())
|
| 446 |
+
```
|
| 447 |
+
|
| 448 |
+
### Profilo custom
|
| 449 |
+
|
| 450 |
+
```python
|
| 451 |
+
from tour_ga.core.profile import TouristProfile, TransportMode
|
| 452 |
+
|
| 453 |
+
profile = TouristProfile(
|
| 454 |
+
transport_mode = TransportMode.TRANSIT,
|
| 455 |
+
allowed_categories = ["monument", "viewpoint", "bar"],
|
| 456 |
+
want_lunch = False,
|
| 457 |
+
want_dinner = False,
|
| 458 |
+
max_bar_stops = 2,
|
| 459 |
+
tag_weights = {"fotogenico": 1.5, "panorama": 1.3},
|
| 460 |
+
)
|
| 461 |
+
```
|
| 462 |
+
|
| 463 |
+
### Callback di monitoraggio
|
| 464 |
+
|
| 465 |
+
```python
|
| 466 |
+
def progress(gen, pareto_front, stats):
|
| 467 |
+
print(f"Gen {gen}: pareto={stats['pareto_size']}, "
|
| 468 |
+
f"best={stats['best_scalar']:.4f}, "
|
| 469 |
+
f"feasible={stats['feasible_pct']:.0f}%")
|
| 470 |
+
|
| 471 |
+
front = solver.solve(callback=progress)
|
| 472 |
+
```
|
| 473 |
+
|
| 474 |
+
### Demo completa
|
| 475 |
+
|
| 476 |
+
```bash
|
| 477 |
+
python tour_ga/demo_rome.py
|
| 478 |
+
```
|
| 479 |
+
|
| 480 |
+
---
|
| 481 |
+
|
| 482 |
+
## 12. Risultati di esempio
|
| 483 |
+
|
| 484 |
+
Configurazione: `budget=660 min` (11h), `start_time=570` (09:30), `pop_size=60`, `max_generations=200`, Roma.
|
| 485 |
+
|
| 486 |
+
### Profilo gastronomico con mezzi (TRANSIT, pranzo + cena)
|
| 487 |
+
|
| 488 |
+
```
|
| 489 |
+
09:42β10:12 Fontana di Trevi
|
| 490 |
+
10:24β10:54 Piazza di Spagna
|
| 491 |
+
11:08β11:53 Piazza Navona
|
| 492 |
+
11:56β12:16 Sant'Eustachio il CaffΓ¨ β bar pomeridiano
|
| 493 |
+
12:27β13:27 Osteria del Rione β pranzo garantito
|
| 494 |
+
13:41β15:11 Castel Sant'Angelo
|
| 495 |
+
15:24β16:24 Pantheon
|
| 496 |
+
16:37β18:07 Foro Romano
|
| 497 |
+
18:21β18:41 Fatamorgana β gelateria pomeridiana
|
| 498 |
+
19:00β20:20 Ristorante San Pietro β cena garantita (attesa 4 min)
|
| 499 |
+
Totale: 650 min, 10.9 km, attese 4 min
|
| 500 |
+
Composizione: 1Γbar, 1Γgelateria, 4Γmonument, 2Γrestaurant, 2Γviewpoint
|
| 501 |
+
```
|
| 502 |
+
|
| 503 |
+
### Profilo culturale a piedi (WALK, solo pranzo)
|
| 504 |
+
|
| 505 |
+
```
|
| 506 |
+
09:39β10:09 Fontana di Trevi
|
| 507 |
+
10:18β10:48 Piazza di Spagna
|
| 508 |
+
11:06β11:51 Piazza Navona
|
| 509 |
+
12:00β13:00 Osteria del Rione β pranzo (attesa 3 min)
|
| 510 |
+
13:05β14:05 Pantheon
|
| 511 |
+
14:21β15:51 Foro Romano
|
| 512 |
+
16:01β18:01 Colosseo
|
| 513 |
+
18:32β20:02 Trastevere
|
| 514 |
+
Totale: 632 min, 8.1 km, attese 3 min
|
| 515 |
+
```
|
| 516 |
+
|
| 517 |
+
### Profilo custom: solo viste, nessun pasto
|
| 518 |
+
|
| 519 |
+
```
|
| 520 |
+
09:39β10:09 Fontana di Trevi
|
| 521 |
+
10:18β10:48 Piazza di Spagna
|
| 522 |
+
11:06β11:51 Piazza Navona
|
| 523 |
+
11:54β12:14 Sant'Eustachio il CaffΓ¨
|
| 524 |
+
12:16β13:16 Pantheon
|
| 525 |
+
13:33β15:03 Castel Sant'Angelo
|
| 526 |
+
15:14β15:34 Fatamorgana
|
| 527 |
+
Totale: 364 min, 5.5 km
|
| 528 |
+
```
|
| 529 |
+
|
| 530 |
+
---
|
| 531 |
+
|
| 532 |
+
## 13. Riferimenti
|
| 533 |
+
|
| 534 |
+
### Articolo scientifico principale
|
| 535 |
+
|
| 536 |
+
Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. (2002). **A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II**. *IEEE Transactions on Evolutionary Computation*, 6(2), 182β197.
|
| 537 |
+
[https://www.cse.unr.edu/~sushil/class/gas/papers/nsga2.pdf](https://www.cse.unr.edu/~sushil/class/gas/papers/nsga2.pdf)
|
| 538 |
+
|
| 539 |
+
Il paper introduce tre contributi fondamentali qui implementati: il fast non-dominated sorting O(MNΒ²) tramite conteggio della dominanza (implementato in `solver.py::_fast_non_dominated_sort`), la crowding distance come meccanismo di diversitΓ parameter-free (implementato in `_assign_crowding_distance`), e il crowded-comparison operator per selezione a torneo (in `operators.py::tournament_select`).
|
| 540 |
+
|
| 541 |
+
### Riferimento divulgativo
|
| 542 |
+
|
| 543 |
+
Non-Dominated Sorting Genetic Algorithm 2 (NSGA-II) β GeeksforGeeks.
|
| 544 |
+
[https://www.geeksforgeeks.org/deep-learning/non-dominated-sorting-genetic-algorithm-2-nsga-ii/](https://www.geeksforgeeks.org/deep-learning/non-dominated-sorting-genetic-algorithm-2-nsga-ii/)
|
| 545 |
+
|
| 546 |
+
### Problema di riferimento
|
| 547 |
+
|
| 548 |
+
Vansteenwegen, P., Souffriau, W., & Van Oudheusden, D. (2011). **The Orienteering Problem: A Survey**. *European Journal of Operational Research*, 209(1), 1β10.
|
| 549 |
+
|
| 550 |
+
Chao, I. M., Golden, B. L., & Wasil, E. A. (1996). **The Team Orienteering Problem**. *European Journal of Operational Research*, 88(3), 464β474.
|
| 551 |
+
|
| 552 |
+
### Calcolo distanze
|
| 553 |
+
|
| 554 |
+
Formula di Haversine per la distanza geodetica tra due coordinate GPS, con fattore correttivo 1.3 per approssimare il percorso urbano reale rispetto alla linea d'aria.
|
__init__.py
ADDED
|
File without changes
|
app.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py β FastAPI backend for Tour Generator.
|
| 3 |
+
Exposes API endpoints for generating tours.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
import json
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from core.models import PoI, PoICategory, TimeWindow
|
| 10 |
+
from core.profile import (
|
| 11 |
+
TouristProfile, MobilityLevel, TransportMode,
|
| 12 |
+
profile_cultural_walker, profile_foodie_transit,
|
| 13 |
+
profile_family_mixed, profile_art_lover_car
|
| 14 |
+
)
|
| 15 |
+
from core.distance import DistanceMatrix, haversine_km
|
| 16 |
+
from solver import NSGA2Solver, SolverConfig
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="Tour Generator API", description="API for generating optimized tours using genetic algorithms")
|
| 20 |
+
|
| 21 |
+
# Predefined profiles
|
| 22 |
+
PREDEFINED_PROFILES = {
|
| 23 |
+
"cultural_walker": profile_cultural_walker(),
|
| 24 |
+
"foodie_transit": profile_foodie_transit(),
|
| 25 |
+
"family_mixed": profile_family_mixed(),
|
| 26 |
+
"art_lover_car": profile_art_lover_car(),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
class PoIModel(BaseModel):
|
| 30 |
+
id: str
|
| 31 |
+
name: str
|
| 32 |
+
lat: float
|
| 33 |
+
lon: float
|
| 34 |
+
score: float
|
| 35 |
+
visit_duration: int
|
| 36 |
+
time_window_open: int
|
| 37 |
+
time_window_close: int
|
| 38 |
+
category: str
|
| 39 |
+
tags: List[str] = []
|
| 40 |
+
|
| 41 |
+
class ProfileModel(BaseModel):
|
| 42 |
+
transport_mode: TransportMode = TransportMode.WALK
|
| 43 |
+
mobility: MobilityLevel = MobilityLevel.NORMAL
|
| 44 |
+
allowed_categories: List[str] = ["museum", "monument", "restaurant", "park", "viewpoint"]
|
| 45 |
+
want_lunch: bool = True
|
| 46 |
+
want_dinner: bool = True
|
| 47 |
+
lunch_time: int = 720
|
| 48 |
+
dinner_time: int = 1140
|
| 49 |
+
meal_window: int = 120
|
| 50 |
+
max_bar_stops: int = 2
|
| 51 |
+
max_gelateria_stops: int = 1
|
| 52 |
+
tag_weights: dict = {}
|
| 53 |
+
max_entry_fee: Optional[float] = None
|
| 54 |
+
group_size: int = 1
|
| 55 |
+
|
| 56 |
+
@app.post("/generate_tour")
|
| 57 |
+
async def generate_tour(
|
| 58 |
+
pois_file: Optional[UploadFile] = File(None),
|
| 59 |
+
pois_json: Optional[str] = Form(None),
|
| 60 |
+
profile_name: Optional[str] = Form(None),
|
| 61 |
+
profile_json: Optional[str] = Form(None),
|
| 62 |
+
budget: int = Form(480),
|
| 63 |
+
start_time: int = Form(540),
|
| 64 |
+
start_lat: float = Form(41.9028),
|
| 65 |
+
start_lon: float = Form(12.4964),
|
| 66 |
+
):
|
| 67 |
+
"""
|
| 68 |
+
Generate an optimized tour based on POIs and user profile.
|
| 69 |
+
|
| 70 |
+
- pois_file: Upload CSV or JSON file with POIs
|
| 71 |
+
- pois_json: JSON string with list of POIs
|
| 72 |
+
- profile_name: Name of predefined profile
|
| 73 |
+
- profile_json: JSON string with custom profile
|
| 74 |
+
- budget: Time budget in minutes
|
| 75 |
+
- start_time: Start time in minutes from midnight
|
| 76 |
+
- start_lat/lon: Starting coordinates
|
| 77 |
+
"""
|
| 78 |
+
# Load POIs
|
| 79 |
+
pois = []
|
| 80 |
+
if pois_file:
|
| 81 |
+
content = await pois_file.read()
|
| 82 |
+
if pois_file.filename.endswith('.csv'):
|
| 83 |
+
df = pd.read_csv(pd.io.common.BytesIO(content))
|
| 84 |
+
for _, row in df.iterrows():
|
| 85 |
+
pois.append(PoI(
|
| 86 |
+
id=str(row['id']),
|
| 87 |
+
name=str(row['name']),
|
| 88 |
+
lat=float(row['lat']),
|
| 89 |
+
lon=float(row['lon']),
|
| 90 |
+
score=float(row['score']),
|
| 91 |
+
visit_duration=int(row['visit_duration']),
|
| 92 |
+
time_window=TimeWindow(int(row['time_window_open']), int(row['time_window_close'])),
|
| 93 |
+
category=PoICategory(row['category']),
|
| 94 |
+
tags=str(row.get('tags', '')).split(',') if pd.notna(row.get('tags')) else []
|
| 95 |
+
))
|
| 96 |
+
elif pois_file.filename.endswith('.json'):
|
| 97 |
+
data = json.loads(content.decode('utf-8'))
|
| 98 |
+
for p in data:
|
| 99 |
+
pois.append(PoI(
|
| 100 |
+
id=p['id'],
|
| 101 |
+
name=p['name'],
|
| 102 |
+
lat=p['lat'],
|
| 103 |
+
lon=p['lon'],
|
| 104 |
+
score=p['score'],
|
| 105 |
+
visit_duration=p['visit_duration'],
|
| 106 |
+
time_window=TimeWindow(p['time_window']['open'], p['time_window']['close']),
|
| 107 |
+
category=PoICategory(p['category']),
|
| 108 |
+
tags=p.get('tags', [])
|
| 109 |
+
))
|
| 110 |
+
else:
|
| 111 |
+
raise HTTPException(status_code=400, detail="Unsupported file type for POIs. Use CSV or JSON.")
|
| 112 |
+
elif pois_json:
|
| 113 |
+
pois_data = json.loads(pois_json)
|
| 114 |
+
for p in pois_data:
|
| 115 |
+
pois.append(PoI(
|
| 116 |
+
id=p['id'],
|
| 117 |
+
name=p['name'],
|
| 118 |
+
lat=p['lat'],
|
| 119 |
+
lon=p['lon'],
|
| 120 |
+
score=p['score'],
|
| 121 |
+
visit_duration=p['visit_duration'],
|
| 122 |
+
time_window=TimeWindow(p['time_window']['open'], p['time_window']['close']),
|
| 123 |
+
category=PoICategory(p['category']),
|
| 124 |
+
tags=p.get('tags', [])
|
| 125 |
+
))
|
| 126 |
+
else:
|
| 127 |
+
raise HTTPException(status_code=400, detail="POIs not provided. Upload a file or provide JSON.")
|
| 128 |
+
|
| 129 |
+
# Load profile
|
| 130 |
+
if profile_name:
|
| 131 |
+
if profile_name in PREDEFINED_PROFILES:
|
| 132 |
+
profile = PREDEFINED_PROFILES[profile_name]
|
| 133 |
+
else:
|
| 134 |
+
raise HTTPException(status_code=400, detail=f"Invalid profile name. Available: {list(PREDEFINED_PROFILES.keys())}")
|
| 135 |
+
elif profile_json:
|
| 136 |
+
profile_data = json.loads(profile_json)
|
| 137 |
+
profile = TouristProfile(**profile_data)
|
| 138 |
+
else:
|
| 139 |
+
profile = TouristProfile() # default
|
| 140 |
+
|
| 141 |
+
# Create distance matrix
|
| 142 |
+
dm = DistanceMatrix(pois, profile)
|
| 143 |
+
|
| 144 |
+
# Config
|
| 145 |
+
config = SolverConfig(budget=budget, start_time=start_time, start_lat=start_lat, start_lon=start_lon)
|
| 146 |
+
|
| 147 |
+
# Solve
|
| 148 |
+
def cb(gen, pareto, stats):
|
| 149 |
+
if gen % 30 == 0 or gen == 1:
|
| 150 |
+
print(f" gen {gen:3d} | pareto={stats['pareto_size']:2d} | "
|
| 151 |
+
f"best={stats['best_scalar']:.4f} | feasible={stats['feasible_pct']:.0f}%")
|
| 152 |
+
|
| 153 |
+
solver = NSGA2Solver(pois, dm, config, profile)
|
| 154 |
+
population = solver.solve(callback=cb)
|
| 155 |
+
|
| 156 |
+
feasible = [x for x in population if x.fitness.is_feasible] or population
|
| 157 |
+
if not feasible:
|
| 158 |
+
raise HTTPException(status_code=500, detail="No solutions found")
|
| 159 |
+
|
| 160 |
+
# Get best tour (highest scalar fitness)
|
| 161 |
+
best = max(feasible, key=lambda individual: individual.fitness.scalar)
|
| 162 |
+
tour = solver.evaluator.decode(best)
|
| 163 |
+
|
| 164 |
+
if tour is None:
|
| 165 |
+
raise HTTPException(status_code=500, detail="Failed to generate schedule")
|
| 166 |
+
|
| 167 |
+
# Return as dict
|
| 168 |
+
stops_list = []
|
| 169 |
+
for i, s in enumerate(tour.stops):
|
| 170 |
+
if i == 0:
|
| 171 |
+
dist = haversine_km(start_lat, start_lon, s.poi.lat, s.poi.lon)
|
| 172 |
+
else:
|
| 173 |
+
dist = haversine_km(tour.stops[i-1].poi.lat, tour.stops[i-1].poi.lon, s.poi.lat, s.poi.lon)
|
| 174 |
+
time_min = profile.travel_time_min(dist)
|
| 175 |
+
|
| 176 |
+
stop_dict = {
|
| 177 |
+
"poi_id": s.poi.id,
|
| 178 |
+
"poi_name": s.poi.name,
|
| 179 |
+
"arrival": s.arrival,
|
| 180 |
+
"departure": s.departure,
|
| 181 |
+
"wait": s.wait,
|
| 182 |
+
"travel_distance_km": round(dist, 2),
|
| 183 |
+
"travel_time_min": time_min
|
| 184 |
+
}
|
| 185 |
+
stops_list.append(stop_dict)
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
"total_score": best.fitness.total_score,
|
| 189 |
+
"total_distance": tour.total_distance,
|
| 190 |
+
"total_time": tour.total_time,
|
| 191 |
+
"is_feasible": tour.is_feasible,
|
| 192 |
+
"stops": stops_list
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
@app.get("/profiles")
|
| 196 |
+
def get_profiles():
|
| 197 |
+
"""Get list of available predefined profiles."""
|
| 198 |
+
return {"profiles": list(PREDEFINED_PROFILES.keys())}
|
| 199 |
+
|
| 200 |
+
@app.get("/profiles/{name}")
|
| 201 |
+
def get_profile(name: str):
|
| 202 |
+
"""Get details of a specific predefined profile."""
|
| 203 |
+
if name in PREDEFINED_PROFILES:
|
| 204 |
+
profile = PREDEFINED_PROFILES[name]
|
| 205 |
+
return {
|
| 206 |
+
"transport_mode": profile.transport_mode.value,
|
| 207 |
+
"mobility": profile.mobility.value,
|
| 208 |
+
"allowed_categories": profile.allowed_categories,
|
| 209 |
+
"want_lunch": profile.want_lunch,
|
| 210 |
+
"want_dinner": profile.want_dinner,
|
| 211 |
+
"lunch_time": profile.lunch_time,
|
| 212 |
+
"dinner_time": profile.dinner_time,
|
| 213 |
+
"meal_window": profile.meal_window,
|
| 214 |
+
"max_bar_stops": profile.max_bar_stops,
|
| 215 |
+
"max_gelateria_stops": profile.max_gelateria_stops,
|
| 216 |
+
"tag_weights": profile.tag_weights,
|
| 217 |
+
"max_entry_fee": profile.max_entry_fee,
|
| 218 |
+
"group_size": profile.group_size
|
| 219 |
+
}
|
| 220 |
+
else:
|
| 221 |
+
raise HTTPException(status_code=404, detail="Profile not found")
|
| 222 |
+
|
| 223 |
+
if __name__ == "__main__":
|
| 224 |
+
import uvicorn
|
| 225 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
config.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config.py β Configurazione centralizzata del progetto tour_ga.
|
| 3 |
+
|
| 4 |
+
Tutte le costanti "magiche" del progetto sono raccolte qui.
|
| 5 |
+
I moduli importano da questo file invece di avere valori hardcoded.
|
| 6 |
+
|
| 7 |
+
Struttura:
|
| 8 |
+
TRANSPORT β velocitΓ , overhead, soglie di modalitΓ
|
| 9 |
+
FITNESS β pesi obiettivi, penalitΓ , normalizzazione
|
| 10 |
+
REPAIR β vincoli di riparazione genetica
|
| 11 |
+
SEEDING β parametri costruzione popolazione iniziale
|
| 12 |
+
GA β default algoritmo evolutivo
|
| 13 |
+
VISUALIZER β colori e stili per la mappa HTML
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
# ============================================================
|
| 18 |
+
# TRANSPORT β Modello di percorrenza realistico
|
| 19 |
+
# ============================================================
|
| 20 |
+
|
| 21 |
+
# Fattore correttivo Haversine β percorso stradale reale
|
| 22 |
+
# 1.0 = linea d'aria, 1.3 = stima tipica percorso urbano
|
| 23 |
+
ROUTE_DETOUR_FACTOR: float = 1.3
|
| 24 |
+
|
| 25 |
+
# Soglia sotto la quale, anche in modalitΓ MIXED, si usa v_walk
|
| 26 |
+
# (600 m β preferisce a piedi rispetto al mezzo)
|
| 27 |
+
MIXED_THRESHOLD_M: int = 600
|
| 28 |
+
|
| 29 |
+
# Soglia sotto la quale non conviene prendere il mezzo in TRANSIT
|
| 30 |
+
# (prendere bus/metro per <400m Γ¨ piΓΉ lento del cammino)
|
| 31 |
+
TRANSIT_WALK_THRESHOLD_KM: float = 0.40
|
| 32 |
+
|
| 33 |
+
# Overhead fisso per ogni tratta in mezzo pubblico (minuti)
|
| 34 |
+
# Comprende: cammino alla fermata + attesa mezzo + cammino dalla fermata
|
| 35 |
+
# Roma: bus ~8-12 min freq., metro ~4-5 min freq. β media 10 min
|
| 36 |
+
TRANSIT_OVERHEAD_MIN: int = 10
|
| 37 |
+
|
| 38 |
+
# Overhead fisso per ogni tratta in auto/taxi (minuti)
|
| 39 |
+
# Comprende: ricerca parcheggio + cammino dal parcheggio
|
| 40 |
+
CAR_OVERHEAD_MIN: int = 5
|
| 41 |
+
|
| 42 |
+
# VelocitΓ medie di percorrenza in km/h (escluso overhead)
|
| 43 |
+
# Chiave: (TransportMode.value, MobilityLevel.value)
|
| 44 |
+
SPEED_KMH: dict[tuple[str, str], float] = {
|
| 45 |
+
("walk", "normal"): 4.5,
|
| 46 |
+
("walk", "limited"): 3.0,
|
| 47 |
+
("car", "normal"): 25.0,
|
| 48 |
+
("car", "limited"): 25.0,
|
| 49 |
+
("transit", "normal"): 20.0, # metro Roma in media ~20 km/h
|
| 50 |
+
("transit", "limited"): 16.0,
|
| 51 |
+
("mixed", "normal"): 4.5, # segmenti brevi β v_walk
|
| 52 |
+
("mixed", "limited"): 3.0,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# VelocitΓ per segmenti lunghi in modalitΓ MIXED (oltre MIXED_THRESHOLD_M)
|
| 56 |
+
MIXED_LONG_SPEED_KMH: dict[str, float] = {
|
| 57 |
+
"normal": 20.0,
|
| 58 |
+
"limited": 16.0,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# ============================================================
|
| 62 |
+
# FITNESS β Funzione di valutazione multi-obiettivo
|
| 63 |
+
# ============================================================
|
| 64 |
+
|
| 65 |
+
# Pesi default per la funzione scalare aggregata
|
| 66 |
+
W_SCORE: float = 0.50 # peso obiettivo score (da massimizzare)
|
| 67 |
+
W_DIST: float = 0.20 # peso obiettivo distanza (da minimizzare)
|
| 68 |
+
W_TIME: float = 0.30 # peso penalitΓ tempo (non usato nello scalare diretto)
|
| 69 |
+
|
| 70 |
+
# PenalitΓ per sforamento budget (per ora di sforamento)
|
| 71 |
+
PENALTY_BUDGET_OVERRUN: float = 50.0
|
| 72 |
+
|
| 73 |
+
# PenalitΓ scalare per slot pasto non coperto
|
| 74 |
+
# Applicata solo se "restaurant" Γ¨ nelle categorie ammesse dal profilo
|
| 75 |
+
PENALTY_MEAL_MISSING: float = 0.25
|
| 76 |
+
|
| 77 |
+
# Soglia di attesa cumulata (minuti) sotto cui non si penalizza
|
| 78 |
+
# Attese brevi (es. 3 min prima dell'apertura) sono accettabili
|
| 79 |
+
WAIT_PENALTY_THRESHOLD_MIN: int = 5
|
| 80 |
+
|
| 81 |
+
# Fattore di penalitΓ per i minuti di attesa eccedenti la soglia
|
| 82 |
+
# (per ora di attesa cumulata oltre la soglia)
|
| 83 |
+
WAIT_PENALTY_FACTOR: float = 10.0
|
| 84 |
+
|
| 85 |
+
# Cap moltiplicativo per effective_score con boost da tag_weights
|
| 86 |
+
# Evita che tag boost portino lo score molto sopra 1.0
|
| 87 |
+
SCORE_BOOST_CAP: float = 1.5
|
| 88 |
+
|
| 89 |
+
# Distanza massima di normalizzazione per la fitness (km)
|
| 90 |
+
# Dipende dalla modalitΓ di trasporto
|
| 91 |
+
MAX_DIST_WALK_KM: float = 15.0
|
| 92 |
+
MAX_DIST_TRANSIT_KM: float = 50.0
|
| 93 |
+
MAX_DIST_CAR_KM: float = 80.0
|
| 94 |
+
|
| 95 |
+
# Minuti di visita extra per ogni membro del gruppo oltre il primo
|
| 96 |
+
GROUP_VISIT_OVERHEAD_PER_PERSON: int = 5
|
| 97 |
+
|
| 98 |
+
# Fitness utilization bonus
|
| 99 |
+
FITNESS_UTILIZATION_BONUS_FACTOR : float = 0.3
|
| 100 |
+
|
| 101 |
+
# ============================================================
|
| 102 |
+
# REPAIR β Vincoli del motore di riparazione genetica
|
| 103 |
+
# ============================================================
|
| 104 |
+
|
| 105 |
+
# Attesa massima tollerata per qualsiasi PoI (minuti)
|
| 106 |
+
# PoI che richiederebbero un'attesa maggiore vengono rimossi dal tour
|
| 107 |
+
MAX_WAIT_MIN: int = 30
|
| 108 |
+
|
| 109 |
+
# Tolleranza di attesa speciale per l'inserimento di ristoranti
|
| 110 |
+
# nei slot pasto: arrivare poco prima dell'apertura Γ¨ comportamento normale
|
| 111 |
+
MEAL_SLOT_WAIT_OVERRIDE_MIN: int = 45
|
| 112 |
+
|
| 113 |
+
# Fraction della lunghezza del tour usata nell'ordinamento per
|
| 114 |
+
# spostare i ristoranti nella posizione temporalmente corretta
|
| 115 |
+
# (non una costante numerica, ma un commento di design)
|
| 116 |
+
|
| 117 |
+
# ============================================================
|
| 118 |
+
# SEEDING β Costruzione della popolazione iniziale
|
| 119 |
+
# ============================================================
|
| 120 |
+
|
| 121 |
+
# Frazione di individui costruiti con greedy deterministico
|
| 122 |
+
SEED_GREEDY_FRACTION: float = 0.20
|
| 123 |
+
|
| 124 |
+
# Frazione di individui costruiti con Ξ±-greedy perturbato
|
| 125 |
+
SEED_PERTURBED_FRACTION: float = 0.20
|
| 126 |
+
|
| 127 |
+
# Il restante (1 - greedy - perturbed) viene costruito casualmente e riparato
|
| 128 |
+
|
| 129 |
+
# Range del parametro alpha per l'Ξ±-greedy perturbato
|
| 130 |
+
# alpha=0 β greedy puro; alpha=0.5 β semi-casuale (GRASP-like)
|
| 131 |
+
SEED_ALPHA_MIN: float = 0.15
|
| 132 |
+
SEED_ALPHA_MAX: float = 0.50 # alpha_min + 0.35
|
| 133 |
+
|
| 134 |
+
# Dimensione della Restricted Candidate List come frazione dei candidati
|
| 135 |
+
# (top RCL_FRACTION vengono estratti casualmente invece del migliore assoluto)
|
| 136 |
+
RCL_FRACTION: float = 0.20
|
| 137 |
+
|
| 138 |
+
# ============================================================
|
| 139 |
+
# PASTI - Valori di default
|
| 140 |
+
# ============================================================
|
| 141 |
+
|
| 142 |
+
# --- Preferenze pasti ---
|
| 143 |
+
WANT_LUNCH: bool = True
|
| 144 |
+
WANT_DINNER: bool = False
|
| 145 |
+
LUNCH_TIME: int = 720
|
| 146 |
+
DINNER_TIME: int = 1140
|
| 147 |
+
MEAL_WINDOW: int = 60
|
| 148 |
+
MAX_BAR_STOPS: int = 1
|
| 149 |
+
MAX_GELATERIA_STOPS: int = 1
|
| 150 |
+
MEAL_RESERVE_MIN: int = 90
|
| 151 |
+
EVENING_THRESHOLD: int = 1140 # 19:00
|
| 152 |
+
|
| 153 |
+
# ============================================================
|
| 154 |
+
# GA β Default dell'algoritmo evolutivo (usati da SolverConfig)
|
| 155 |
+
# ============================================================
|
| 156 |
+
|
| 157 |
+
GA_POP_SIZE: int = 80
|
| 158 |
+
GA_MAX_GENERATIONS: int = 300
|
| 159 |
+
GA_CX_PROB: float = 0.85 # probabilitΓ di crossover
|
| 160 |
+
GA_MUT_PROB: float = 0.20 # probabilitΓ di mutazione
|
| 161 |
+
GA_TOURNAMENT_K: int = 3 # candidati per torneo
|
| 162 |
+
GA_STAGNATION_LIMIT: int = 50 # generazioni senza miglioramento β stop
|
| 163 |
+
GA_MAX_WAIT_MIN: int = MAX_WAIT_MIN # propagato al RepairEngine
|
| 164 |
+
GA_OX_CROSSOVER_PROB: float = 0.60
|
| 165 |
+
# ProbabilitΓ di usare Order Crossover (OX) vs PoI-aware crossover,
|
| 166 |
+
# condizionata all'aver giΓ deciso di fare crossover (cx_prob).
|
| 167 |
+
# OX Γ¨ piΓΉ conservativo (preserva ordine globale),
|
| 168 |
+
# PoI-aware Γ¨ piΓΉ espolorativo (scambia blocchi per categoria).
|
| 169 |
+
# Un mix 60/40 bilancia convergenza ed esplorazione tematica.
|
| 170 |
+
# Orario di partenza e budget default (minuti dalla mezzanotte)
|
| 171 |
+
|
| 172 |
+
DEFAULT_START_TIME: int = 540 # 09:00
|
| 173 |
+
DEFAULT_BUDGET: int = 480 # 8 ore
|
| 174 |
+
|
| 175 |
+
# Coordinate default (Roma, centro storico)
|
| 176 |
+
DEFAULT_START_LAT: float = 41.8960
|
| 177 |
+
DEFAULT_START_LON: float = 12.4840
|
| 178 |
+
|
| 179 |
+
# ============================================================
|
| 180 |
+
# VISUALIZER β Stili per la mappa HTML interattiva
|
| 181 |
+
# ============================================================
|
| 182 |
+
|
| 183 |
+
# Colori hex per categoria PoI sulla mappa
|
| 184 |
+
# Basati sui ramp del design system del progetto
|
| 185 |
+
CATEGORY_COLORS: dict[str, str] = {
|
| 186 |
+
"museum": "#7F77DD", # purple-400
|
| 187 |
+
"monument": "#378ADD", # blue-400
|
| 188 |
+
"restaurant": "#D85A30", # coral-400
|
| 189 |
+
"bar": "#BA7517", # amber-400
|
| 190 |
+
"gelateria": "#D4537E", # pink-400
|
| 191 |
+
"park": "#639922", # green-400
|
| 192 |
+
"viewpoint": "#1D9E75", # teal-400
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
# Colori speciali per elementi della mappa
|
| 196 |
+
MAP_ROUTE_COLOR: str = "#378ADD" # polyline del percorso
|
| 197 |
+
MAP_START_COLOR: str = "#1D9E75" # marker punto di partenza
|
| 198 |
+
MAP_ARROW_COLOR: str = "#5F5E5A" # frecce di direzione
|
| 199 |
+
|
| 200 |
+
# OpacitΓ della polyline del percorso (0.0β1.0)
|
| 201 |
+
MAP_ROUTE_OPACITY: float = 0.75
|
| 202 |
+
|
| 203 |
+
# Spessore della polyline in pixel
|
| 204 |
+
MAP_ROUTE_WEIGHT: int = 4
|
| 205 |
+
|
| 206 |
+
# Raggio dei cerchi marker in pixel
|
| 207 |
+
MAP_MARKER_RADIUS: int = 10
|
| 208 |
+
|
| 209 |
+
# Zoom default sulla mappa al caricamento
|
| 210 |
+
MAP_ZOOM_DEFAULT: int = 14
|
| 211 |
+
|
| 212 |
+
# URL tiles OpenStreetMap (nessuna API key richiesta)
|
| 213 |
+
MAP_TILE_URL: str = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
| 214 |
+
MAP_TILE_ATTRIBUTION: str = (
|
| 215 |
+
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
| 216 |
+
)
|
core/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tour_generator_GA.core β Strutture dati e logica di valutazione del problema TOP-TW.
|
| 3 |
+
|
| 4 |
+
Esporta le classi principali per comoditΓ di importazione:
|
| 5 |
+
from tour_generator_GA.core import PoI, Individual, FitnessEvaluator, DistanceMatrix
|
| 6 |
+
"""
|
| 7 |
+
from .models import (
|
| 8 |
+
PoI,
|
| 9 |
+
PoICategory,
|
| 10 |
+
TimeWindow,
|
| 11 |
+
FitnessScore,
|
| 12 |
+
Individual,
|
| 13 |
+
TourSchedule,
|
| 14 |
+
ScheduledStop,
|
| 15 |
+
)
|
| 16 |
+
from .distance import DistanceMatrix, haversine_km
|
| 17 |
+
from .fitness import FitnessEvaluator
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
"PoI",
|
| 21 |
+
"PoICategory",
|
| 22 |
+
"TimeWindow",
|
| 23 |
+
"FitnessScore",
|
| 24 |
+
"Individual",
|
| 25 |
+
"TourSchedule",
|
| 26 |
+
"ScheduledStop",
|
| 27 |
+
"DistanceMatrix",
|
| 28 |
+
"haversine_km",
|
| 29 |
+
"FitnessEvaluator",
|
| 30 |
+
]
|
core/distance.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/distance.py β Matrice delle distanze e tempi di percorrenza.
|
| 3 |
+
Supporta Haversine (offline) e profilo turista per la velocitΓ .
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
import math
|
| 7 |
+
from typing import Union, Optional, TYPE_CHECKING
|
| 8 |
+
from .models import PoI
|
| 9 |
+
from config import ROUTE_DETOUR_FACTOR
|
| 10 |
+
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from .profile import TouristProfile
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 16 |
+
"""Distanza geodetica tra due coordinate in chilometri."""
|
| 17 |
+
R = 6371.0
|
| 18 |
+
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
| 19 |
+
dphi = math.radians(lat2 - lat1)
|
| 20 |
+
dlambda = math.radians(lon2 - lon1)
|
| 21 |
+
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
|
| 22 |
+
return R * 2 * math.asin(math.sqrt(a))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class DistanceMatrix:
|
| 26 |
+
"""
|
| 27 |
+
Precalcola tutte le distanze tra i PoI (km).
|
| 28 |
+
I TEMPI vengono calcolati on-the-fly tramite il TouristProfile,
|
| 29 |
+
così un cambio di modalità non richiede di ricostruire la matrice.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, pois: list[PoI], profile: Optional["TouristProfile"] = None):
|
| 33 |
+
self.pois = pois
|
| 34 |
+
self.profile = profile
|
| 35 |
+
self.idx = {poi.id: i for i, poi in enumerate(pois)}
|
| 36 |
+
n = len(pois)
|
| 37 |
+
self._dist = [[0.0] * n for _ in range(n)] # km (invariante)
|
| 38 |
+
|
| 39 |
+
def build(self):
|
| 40 |
+
"""Popola la matrice delle distanze. Chiama una volta sola."""
|
| 41 |
+
for i, a in enumerate(self.pois):
|
| 42 |
+
for j, b in enumerate(self.pois):
|
| 43 |
+
if i == j:
|
| 44 |
+
continue
|
| 45 |
+
km = haversine_km(a.lat, a.lon, b.lat, b.lon) * ROUTE_DETOUR_FACTOR
|
| 46 |
+
self._dist[i][j] = km
|
| 47 |
+
|
| 48 |
+
def dist(self, a: Union[PoI, str], b: Union[PoI, str]) -> float:
|
| 49 |
+
"""Distanza in km tra due PoI."""
|
| 50 |
+
ia = self.idx[a.id if isinstance(a, PoI) else a]
|
| 51 |
+
ib = self.idx[b.id if isinstance(b, PoI) else b]
|
| 52 |
+
return self._dist[ia][ib]
|
| 53 |
+
|
| 54 |
+
def time(self, a: Union[PoI, str], b: Union[PoI, str]) -> int:
|
| 55 |
+
"""Tempo di percorrenza in minuti, rispettando la modalitΓ del profilo."""
|
| 56 |
+
km = self.dist(a, b)
|
| 57 |
+
return self._km_to_min(km)
|
| 58 |
+
|
| 59 |
+
def time_from_coord(self, lat: float, lon: float, poi: PoI) -> int:
|
| 60 |
+
"""Tempo in minuti da coordinate arbitrarie (es. hotel) a un PoI."""
|
| 61 |
+
km = haversine_km(lat, lon, poi.lat, poi.lon) * ROUTE_DETOUR_FACTOR
|
| 62 |
+
return self._km_to_min(km)
|
| 63 |
+
|
| 64 |
+
def _km_to_min(self, km: float) -> int:
|
| 65 |
+
if self.profile is not None:
|
| 66 |
+
return self.profile.travel_time_min(km)
|
| 67 |
+
# Fallback sicuro: a piedi 4.5 km/h
|
| 68 |
+
return max(1, int((km / 4.5) * 60))
|
core/fitness.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/fitness.py β Valutazione fitness multi-obiettivo con profilo turista.
|
| 3 |
+
I tre obiettivi (score, distanza, tempo) vengono calcolati tenendo conto di:
|
| 4 |
+
- tag_weights del profilo β boost/malus per score effettivo
|
| 5 |
+
- transport_mode β velocitΓ di spostamento corretta
|
| 6 |
+
- want_lunch / want_dinner β penalitΓ se manca il ristorante atteso
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
from core.models import Individual, FitnessScore, TourSchedule, ScheduledStop, PoICategory
|
| 10 |
+
from core.distance import DistanceMatrix
|
| 11 |
+
from core.profile import TouristProfile
|
| 12 |
+
from config import (W_SCORE, W_DIST, W_TIME, PENALTY_BUDGET_OVERRUN, PENALTY_MEAL_MISSING,
|
| 13 |
+
GROUP_VISIT_OVERHEAD_PER_PERSON, ROUTE_DETOUR_FACTOR, MAX_DIST_TRANSIT_KM,
|
| 14 |
+
FITNESS_UTILIZATION_BONUS_FACTOR,
|
| 15 |
+
WAIT_PENALTY_FACTOR, SCORE_BOOST_CAP, MAX_DIST_WALK_KM)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FitnessEvaluator:
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
dist_matrix: DistanceMatrix,
|
| 23 |
+
profile: TouristProfile,
|
| 24 |
+
start_time: int,
|
| 25 |
+
budget: int,
|
| 26 |
+
start_lat: float,
|
| 27 |
+
start_lon: float,
|
| 28 |
+
w_score: float = W_SCORE,
|
| 29 |
+
w_dist: float = W_DIST,
|
| 30 |
+
w_time: float = W_TIME,
|
| 31 |
+
penalty: float = PENALTY_BUDGET_OVERRUN,
|
| 32 |
+
meal_penalty: float = PENALTY_MEAL_MISSING,
|
| 33 |
+
):
|
| 34 |
+
self.dm = dist_matrix
|
| 35 |
+
self.profile = profile
|
| 36 |
+
self.start_time = start_time
|
| 37 |
+
self.budget = budget
|
| 38 |
+
self.start_lat = start_lat
|
| 39 |
+
self.start_lon = start_lon
|
| 40 |
+
self.w_score = w_score
|
| 41 |
+
self.w_dist = w_dist
|
| 42 |
+
self.w_time = w_time
|
| 43 |
+
self.penalty = penalty
|
| 44 |
+
self.meal_penalty = meal_penalty
|
| 45 |
+
|
| 46 |
+
def decode(self, individual: Individual) -> TourSchedule:
|
| 47 |
+
if individual._schedule is not None:
|
| 48 |
+
return individual._schedule
|
| 49 |
+
|
| 50 |
+
schedule = TourSchedule()
|
| 51 |
+
time_now = self.start_time
|
| 52 |
+
prev_lat = self.start_lat
|
| 53 |
+
prev_lon = self.start_lon
|
| 54 |
+
total_dist_km = 0.0
|
| 55 |
+
total_wait_min = 0
|
| 56 |
+
feasible = True
|
| 57 |
+
|
| 58 |
+
for poi in individual.genes:
|
| 59 |
+
km = self._km(prev_lat, prev_lon, poi)
|
| 60 |
+
travel_min = self.profile.travel_time_min(km)
|
| 61 |
+
total_dist_km += km
|
| 62 |
+
|
| 63 |
+
arrival = time_now + travel_min
|
| 64 |
+
|
| 65 |
+
if arrival > poi.time_window.close:
|
| 66 |
+
feasible = False
|
| 67 |
+
wait = 0
|
| 68 |
+
else:
|
| 69 |
+
wait = max(0, poi.time_window.open - arrival)
|
| 70 |
+
arrival = max(arrival, poi.time_window.open)
|
| 71 |
+
|
| 72 |
+
# Visita piΓΉ lunga in gruppo: +5 min per persona extra
|
| 73 |
+
duration = poi.visit_duration + max(0, self.profile.group_size - 1) * GROUP_VISIT_OVERHEAD_PER_PERSON
|
| 74 |
+
departure = arrival + duration
|
| 75 |
+
time_now = departure
|
| 76 |
+
prev_lat = poi.lat
|
| 77 |
+
prev_lon = poi.lon
|
| 78 |
+
total_wait_min += wait # accumula attese per penalitΓ
|
| 79 |
+
|
| 80 |
+
schedule.stops.append(ScheduledStop(
|
| 81 |
+
poi=poi, arrival=arrival, departure=departure, wait=wait
|
| 82 |
+
))
|
| 83 |
+
|
| 84 |
+
end_time = self.start_time + self.budget
|
| 85 |
+
schedule.total_time = time_now - self.start_time
|
| 86 |
+
schedule.total_distance = round(total_dist_km, 2)
|
| 87 |
+
schedule.total_wait = total_wait_min
|
| 88 |
+
schedule.is_feasible = feasible and (time_now <= end_time)
|
| 89 |
+
individual._schedule = schedule
|
| 90 |
+
return schedule
|
| 91 |
+
|
| 92 |
+
def evaluate(self, individual: Individual) -> FitnessScore:
|
| 93 |
+
schedule = self.decode(individual)
|
| 94 |
+
end_time = self.start_time + self.budget
|
| 95 |
+
|
| 96 |
+
# Score effettivo con boost da tag_weights del profilo
|
| 97 |
+
total_score = sum(
|
| 98 |
+
self.profile.effective_score(stop.poi)
|
| 99 |
+
for stop in schedule.stops
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
time_over = max(0, (self.start_time + schedule.total_time) - end_time)
|
| 103 |
+
missing_meal_pen = self._meal_coverage_penalty(schedule)
|
| 104 |
+
|
| 105 |
+
# Normalizzazione basata sui PoI AMMESSI dal profilo (non sul totale)
|
| 106 |
+
allowed_pois = [
|
| 107 |
+
p for p in self.dm.pois
|
| 108 |
+
if self.profile.allows_category(p.category.value)
|
| 109 |
+
]
|
| 110 |
+
max_score = max(len(allowed_pois) * SCORE_BOOST_CAP, 1.0)
|
| 111 |
+
max_dist = MAX_DIST_TRANSIT_KM if self.profile.transport_mode.value in ("car", "transit") else MAX_DIST_WALK_KM
|
| 112 |
+
|
| 113 |
+
norm_score = total_score / max_score
|
| 114 |
+
norm_dist = min(schedule.total_distance / max_dist, 1.0)
|
| 115 |
+
time_over_h = (time_over / 60) * self.penalty
|
| 116 |
+
# Penalizza attese eccessive (oltre 5 min totali)
|
| 117 |
+
total_wait = getattr(schedule, 'total_wait', 0)
|
| 118 |
+
wait_penalty = max(0, (total_wait - 5) / 60) * WAIT_PENALTY_FACTOR
|
| 119 |
+
# Bonus per utilizzo del budget: incentiva tour piΓΉ ricchi.
|
| 120 |
+
# Senza questo termine, il GA converge a tour corti (meno distanza).
|
| 121 |
+
# Il bonus cresce linearmente con i minuti usati, cappato al budget.
|
| 122 |
+
utilization_bonus = min(schedule.total_time, self.budget) / self.budget * self.w_score * FITNESS_UTILIZATION_BONUS_FACTOR
|
| 123 |
+
|
| 124 |
+
scalar = (
|
| 125 |
+
self.w_score * norm_score
|
| 126 |
+
+ utilization_bonus # premia l'uso del budget disponibile
|
| 127 |
+
- self.w_dist * norm_dist
|
| 128 |
+
- time_over_h
|
| 129 |
+
- wait_penalty
|
| 130 |
+
- missing_meal_pen
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
fitness = FitnessScore(
|
| 134 |
+
total_score = round(total_score, 4),
|
| 135 |
+
total_distance = schedule.total_distance,
|
| 136 |
+
total_time = schedule.total_time,
|
| 137 |
+
is_feasible = schedule.is_feasible,
|
| 138 |
+
scalar = round(scalar, 6),
|
| 139 |
+
)
|
| 140 |
+
individual.fitness = fitness
|
| 141 |
+
return fitness
|
| 142 |
+
|
| 143 |
+
def _meal_coverage_penalty(self, schedule: TourSchedule) -> float:
|
| 144 |
+
"""
|
| 145 |
+
PenalitΓ per ogni slot pasto richiesto dal profilo ma non coperto.
|
| 146 |
+
NON si applica se il profilo non include la categoria ristorante:
|
| 147 |
+
non ha senso penalizzare chi ha esplicitamente escluso i ristoranti.
|
| 148 |
+
"""
|
| 149 |
+
if "restaurant" not in self.profile.allowed_categories:
|
| 150 |
+
return 0.0
|
| 151 |
+
|
| 152 |
+
penalty = 0.0
|
| 153 |
+
for (slot_open, slot_close) in self.profile.needs_meal_slot():
|
| 154 |
+
covered = any(
|
| 155 |
+
stop.poi.category == PoICategory.RESTAURANT
|
| 156 |
+
and slot_open <= stop.arrival <= slot_close
|
| 157 |
+
for stop in schedule.stops
|
| 158 |
+
)
|
| 159 |
+
if not covered:
|
| 160 |
+
penalty += self.meal_penalty
|
| 161 |
+
return penalty
|
| 162 |
+
|
| 163 |
+
def _km(self, lat: float, lon: float, poi) -> float:
|
| 164 |
+
from .distance import haversine_km
|
| 165 |
+
return haversine_km(lat, lon, poi.lat, poi.lon) * ROUTE_DETOUR_FACTOR
|
core/models.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/models.py β Strutture dati fondamentali per il TOP-TW turistico.
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from typing import Optional
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PoICategory(Enum):
|
| 12 |
+
MUSEUM = "museum"
|
| 13 |
+
MONUMENT = "monument"
|
| 14 |
+
RESTAURANT = "restaurant" # pranzo / cena formale
|
| 15 |
+
BAR = "bar" # caffè, aperitivo, sosta breve
|
| 16 |
+
GELATERIA = "gelateria" # sosta dolce pomeridiana
|
| 17 |
+
PARK = "park"
|
| 18 |
+
VIEWPOINT = "viewpoint"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class TimeWindow:
|
| 23 |
+
open: int # minuti dalla mezzanotte (es. 540 = 09:00)
|
| 24 |
+
close: int # minuti dalla mezzanotte (es. 1080 = 18:00)
|
| 25 |
+
|
| 26 |
+
def __repr__(self) -> str:
|
| 27 |
+
return f"{self.open//60:02d}:{self.open%60:02d}β{self.close//60:02d}:{self.close%60:02d}"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class PoI:
|
| 32 |
+
id: str
|
| 33 |
+
name: str
|
| 34 |
+
lat: float
|
| 35 |
+
lon: float
|
| 36 |
+
score: float # interesse normalizzato [0, 1]
|
| 37 |
+
visit_duration: int # minuti di visita stimati
|
| 38 |
+
time_window: TimeWindow
|
| 39 |
+
category: PoICategory
|
| 40 |
+
tags: list[str] = field(default_factory=list)
|
| 41 |
+
|
| 42 |
+
def __hash__(self):
|
| 43 |
+
return hash(self.id)
|
| 44 |
+
|
| 45 |
+
def __eq__(self, other):
|
| 46 |
+
return isinstance(other, PoI) and self.id == other.id
|
| 47 |
+
|
| 48 |
+
def __repr__(self):
|
| 49 |
+
return f"PoI({self.name!r}, score={self.score:.2f}, {self.time_window})"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class FitnessScore:
|
| 54 |
+
total_score: float = 0.0 # somma score PoI visitati
|
| 55 |
+
total_distance: float = 0.0 # km totali percorsi
|
| 56 |
+
total_time: int = 0 # minuti totali (spostamenti + visite)
|
| 57 |
+
is_feasible: bool = False # rispetta TW e budget?
|
| 58 |
+
scalar: float = 0.0 # valore aggregato per confronti rapidi
|
| 59 |
+
rank: int = 0 # rango Pareto (NSGA-II)
|
| 60 |
+
crowd: float = 0.0 # crowding distance (NSGA-II)
|
| 61 |
+
|
| 62 |
+
def dominates(self, other: FitnessScore) -> bool:
|
| 63 |
+
"""
|
| 64 |
+
self domina other se Γ¨ β₯ su tutti gli obiettivi e > su almeno uno.
|
| 65 |
+
Obiettivi: massimizza score, minimizza distance, minimizza time.
|
| 66 |
+
"""
|
| 67 |
+
better_or_equal = (
|
| 68 |
+
self.total_score >= other.total_score and
|
| 69 |
+
self.total_distance <= other.total_distance and
|
| 70 |
+
self.total_time <= other.total_time
|
| 71 |
+
)
|
| 72 |
+
strictly_better = (
|
| 73 |
+
self.total_score > other.total_score or
|
| 74 |
+
self.total_distance < other.total_distance or
|
| 75 |
+
self.total_time < other.total_time
|
| 76 |
+
)
|
| 77 |
+
return better_or_equal and strictly_better
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class ScheduledStop:
|
| 82 |
+
poi: PoI
|
| 83 |
+
arrival: int # minuti dalla mezzanotte
|
| 84 |
+
departure: int # minuti dalla mezzanotte
|
| 85 |
+
wait: int # minuti di attesa prima dell'apertura
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@dataclass
|
| 89 |
+
class TourSchedule:
|
| 90 |
+
stops: list[ScheduledStop] = field(default_factory=list)
|
| 91 |
+
total_time: int = 0
|
| 92 |
+
total_distance: float = 0.0
|
| 93 |
+
total_wait: int = 0 # minuti di attesa cumulati (attese a TW)
|
| 94 |
+
is_feasible: bool = False
|
| 95 |
+
|
| 96 |
+
def summary(self) -> str:
|
| 97 |
+
lines = []
|
| 98 |
+
for s in self.stops:
|
| 99 |
+
a = f"{s.arrival//60:02d}:{s.arrival%60:02d}"
|
| 100 |
+
d = f"{s.departure//60:02d}:{s.departure%60:02d}"
|
| 101 |
+
w = f" (attesa {s.wait} min)" if s.wait > 0 else ""
|
| 102 |
+
lines.append(f" {a}β{d} {s.poi.name}{w}")
|
| 103 |
+
wait_note = f", attese {self.total_wait} min" if self.total_wait > 0 else ""
|
| 104 |
+
lines.append(
|
| 105 |
+
f" Totale: {self.total_time} min, "
|
| 106 |
+
f"{self.total_distance:.1f} km{wait_note}"
|
| 107 |
+
)
|
| 108 |
+
return "\n".join(lines)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class Individual:
|
| 112 |
+
"""
|
| 113 |
+
Cromosoma = lista ordinata di PoI che compongono il tour.
|
| 114 |
+
Il gene jolly (WildcardGene) Γ¨ un placeholder che viene
|
| 115 |
+
materializzato al momento della decodifica.
|
| 116 |
+
"""
|
| 117 |
+
|
| 118 |
+
def __init__(self, genes: list[PoI]):
|
| 119 |
+
self.genes: list[PoI] = genes
|
| 120 |
+
self.fitness: FitnessScore = FitnessScore()
|
| 121 |
+
self._schedule: Optional[TourSchedule] = None # cache
|
| 122 |
+
|
| 123 |
+
def clone(self) -> Individual:
|
| 124 |
+
return Individual(genes=list(self.genes))
|
| 125 |
+
|
| 126 |
+
def invalidate_cache(self):
|
| 127 |
+
self._schedule = None
|
| 128 |
+
self.fitness = FitnessScore()
|
| 129 |
+
|
| 130 |
+
def __len__(self):
|
| 131 |
+
return len(self.genes)
|
| 132 |
+
|
| 133 |
+
def __repr__(self):
|
| 134 |
+
names = [p.name for p in self.genes]
|
| 135 |
+
return f"Individual([{', '.join(names)}], scalar={self.fitness.scalar:.3f})"
|
core/profile.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/profile.py β Profilo del turista con tutte le preferenze personali.
|
| 3 |
+
Γ l'oggetto centrale che attraversa TUTTO il pipeline GA:
|
| 4 |
+
DistanceMatrix β velocitΓ di spostamento per modalitΓ
|
| 5 |
+
GreedySeeder β whitelist categorie + slot pasto garantito
|
| 6 |
+
RepairEngine β blacklist categorie + rimozione violazioni profilo
|
| 7 |
+
FitnessEvaluator β boost/malus score per tag di interesse
|
| 8 |
+
GeneticOperators β add/remove usa solo PoI ammessi dal profilo
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from enum import Enum
|
| 13 |
+
from typing import Optional
|
| 14 |
+
from config import (MIXED_THRESHOLD_M, TRANSIT_WALK_THRESHOLD_KM, SCORE_BOOST_CAP,
|
| 15 |
+
TRANSIT_OVERHEAD_MIN, CAR_OVERHEAD_MIN, SPEED_KMH,
|
| 16 |
+
MIXED_LONG_SPEED_KMH, WANT_LUNCH, WANT_DINNER, LUNCH_TIME,
|
| 17 |
+
DINNER_TIME, MEAL_WINDOW, MAX_BAR_STOPS, MAX_GELATERIA_STOPS)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TransportMode(Enum):
|
| 21 |
+
WALK = "walk" # tutto a piedi (centro storico)
|
| 22 |
+
CAR = "car" # auto / taxi
|
| 23 |
+
TRANSIT = "transit" # bus + metro
|
| 24 |
+
MIXED = "mixed" # a piedi <MIXED_THRESHOLD_M, transit/auto oltre
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MobilityLevel(Enum):
|
| 28 |
+
NORMAL = "normal" # nessuna limitazione
|
| 29 |
+
LIMITED = "limited" # difficoltΓ con scale, distanze lunghe β penalizza PoI lontani
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Soglia in metri sotto la quale si va a piedi anche in modalitΓ MIXED
|
| 33 |
+
#MIXED_THRESHOLD_M = 600
|
| 34 |
+
|
| 35 |
+
# Soglia al di sotto della quale, anche in modalitΓ TRANSIT, si cammina:
|
| 36 |
+
# prendere un mezzo per 300m Γ¨ spesso piΓΉ lento che andare a piedi.
|
| 37 |
+
#TRANSIT_WALK_THRESHOLD_KM = 0.40
|
| 38 |
+
|
| 39 |
+
# Overhead fisso per ogni tratta in mezzo pubblico (minuti):
|
| 40 |
+
# comprende cammino alla fermata + attesa + cammino dalla fermata.
|
| 41 |
+
# Roma: metro ha frequenza ~5 min, bus ~8-12 min β media ~10 min overhead.
|
| 42 |
+
#TRANSIT_OVERHEAD_MIN = 10
|
| 43 |
+
|
| 44 |
+
# Overhead per auto/taxi: parcheggio + spostamento a piedi dal parcheggio.
|
| 45 |
+
#CAR_OVERHEAD_MIN = 5
|
| 46 |
+
|
| 47 |
+
# VelocitΓ medie di percorrenza (escluso overhead)
|
| 48 |
+
# SPEED_TABLE: dict[tuple[TransportMode, MobilityLevel], float] = {
|
| 49 |
+
# (TransportMode.WALK, MobilityLevel.NORMAL): 4.5,
|
| 50 |
+
# (TransportMode.WALK, MobilityLevel.LIMITED): 3.0,
|
| 51 |
+
# (TransportMode.CAR, MobilityLevel.NORMAL): 25.0,
|
| 52 |
+
# (TransportMode.CAR, MobilityLevel.LIMITED): 25.0,
|
| 53 |
+
# (TransportMode.TRANSIT, MobilityLevel.NORMAL): 20.0, # velocitΓ effettiva metro/bus Roma
|
| 54 |
+
# (TransportMode.TRANSIT, MobilityLevel.LIMITED): 16.0,
|
| 55 |
+
# (TransportMode.MIXED, MobilityLevel.NORMAL): 4.5, # usata per segmenti brevi
|
| 56 |
+
# (TransportMode.MIXED, MobilityLevel.LIMITED): 3.0,
|
| 57 |
+
# }
|
| 58 |
+
|
| 59 |
+
# MIXED_LONG_SPEED: dict[MobilityLevel, float] = {
|
| 60 |
+
# MobilityLevel.NORMAL: 20.0,
|
| 61 |
+
# MobilityLevel.LIMITED: 16.0,
|
| 62 |
+
# }
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class TouristProfile:
|
| 67 |
+
"""
|
| 68 |
+
Preferenze complete del turista.
|
| 69 |
+
Tutti i campi hanno un default sensato per un turista generico.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
# --- Trasporto ---
|
| 73 |
+
transport_mode: TransportMode = TransportMode.WALK
|
| 74 |
+
mobility: MobilityLevel = MobilityLevel.NORMAL
|
| 75 |
+
|
| 76 |
+
# --- Categorie ammesse ---
|
| 77 |
+
# Se una categoria non Γ¨ in questa lista, i suoi PoI vengono
|
| 78 |
+
# completamente ignorati in seeding, repair e mutation.
|
| 79 |
+
allowed_categories: list[str] = field(default_factory=lambda: [
|
| 80 |
+
"museum", "monument", "restaurant", "park", "viewpoint"
|
| 81 |
+
])
|
| 82 |
+
|
| 83 |
+
# --- Preferenze pasti ---
|
| 84 |
+
want_lunch: bool = WANT_LUNCH
|
| 85 |
+
want_dinner: bool = WANT_DINNER
|
| 86 |
+
lunch_time: int = LUNCH_TIME
|
| 87 |
+
dinner_time: int = DINNER_TIME
|
| 88 |
+
meal_window: int = MEAL_WINDOW
|
| 89 |
+
|
| 90 |
+
# --- Soste snack (bar, gelateria) ---
|
| 91 |
+
# Numero massimo di soste snack per tipo nel tour.
|
| 92 |
+
# None = nessun limite (utile per profili food-focused).
|
| 93 |
+
max_bar_stops: int = MAX_BAR_STOPS
|
| 94 |
+
max_gelateria_stops: int = MAX_GELATERIA_STOPS
|
| 95 |
+
|
| 96 |
+
# --- Interessi tematici (tag) ---
|
| 97 |
+
# Ogni tag elencato riceve un boost moltiplicativo allo score del PoI.
|
| 98 |
+
# Es. {"arte": 1.5, "antico": 1.3} β i musei d'arte valgono 50% di piΓΉ.
|
| 99 |
+
tag_weights: dict[str, float] = field(default_factory=dict)
|
| 100 |
+
|
| 101 |
+
# --- Budget economico ---
|
| 102 |
+
max_entry_fee: Optional[float] = None # euro; None = nessun limite
|
| 103 |
+
|
| 104 |
+
# --- Gruppo ---
|
| 105 |
+
group_size: int = 1 # utile per entry fee totale e ritmo di visita
|
| 106 |
+
|
| 107 |
+
def __post_init__(self):
|
| 108 |
+
# Normalizza le categorie in minuscolo
|
| 109 |
+
self.allowed_categories = [c.lower() for c in self.allowed_categories]
|
| 110 |
+
# Coerci transport_mode da stringa a enum se necessario
|
| 111 |
+
if isinstance(self.transport_mode, str):
|
| 112 |
+
self.transport_mode = TransportMode(self.transport_mode.lower())
|
| 113 |
+
|
| 114 |
+
# Coerci mobility da stringa a enum se necessario
|
| 115 |
+
if isinstance(self.mobility, str):
|
| 116 |
+
self.mobility = MobilityLevel(self.mobility.lower())
|
| 117 |
+
|
| 118 |
+
def allows_category(self, category_value: str) -> bool:
|
| 119 |
+
"""Restituisce True se la categoria Γ¨ ammessa dal profilo."""
|
| 120 |
+
return category_value.lower() in self.allowed_categories
|
| 121 |
+
|
| 122 |
+
def effective_score(self, poi) -> float:
|
| 123 |
+
"""
|
| 124 |
+
Score del PoI moltiplicato per i boost dei tag di interesse.
|
| 125 |
+
Un PoI senza tag corrispondenti mantiene lo score base.
|
| 126 |
+
"""
|
| 127 |
+
boost = 1.0
|
| 128 |
+
for tag in poi.tags:
|
| 129 |
+
if tag in self.tag_weights:
|
| 130 |
+
boost += self.tag_weights[tag] - 1.0 # additive boost
|
| 131 |
+
return min(poi.score * boost, SCORE_BOOST_CAP)
|
| 132 |
+
|
| 133 |
+
def travel_speed_kmh(self, dist_km: float) -> float:
|
| 134 |
+
"""VelocitΓ di percorrenza pura (senza overhead fisso)."""
|
| 135 |
+
if self.transport_mode == TransportMode.MIXED:
|
| 136 |
+
dist_m = dist_km * 1000
|
| 137 |
+
if dist_m <= MIXED_THRESHOLD_M:
|
| 138 |
+
return SPEED_KMH[(TransportMode.WALK.value, self.mobility.value)]
|
| 139 |
+
else:
|
| 140 |
+
return MIXED_LONG_SPEED_KMH[self.mobility]
|
| 141 |
+
return SPEED_KMH.get((self.transport_mode.value, self.mobility.value))
|
| 142 |
+
|
| 143 |
+
def travel_time_min(self, dist_km: float) -> int:
|
| 144 |
+
"""
|
| 145 |
+
Tempo di percorrenza realistico in minuti.
|
| 146 |
+
|
| 147 |
+
Modello per modalitΓ :
|
| 148 |
+
WALK β dist / v_walk
|
| 149 |
+
CAR β dist / v_car + CAR_OVERHEAD (parcheggio)
|
| 150 |
+
TRANSIT β se dist < soglia: a piedi (prendere il mezzo non conviene)
|
| 151 |
+
altrimenti: dist / v_transit + TRANSIT_OVERHEAD (attesa + fermata)
|
| 152 |
+
MIXED β a piedi se dist < MIXED_THRESHOLD, altrimenti come TRANSIT
|
| 153 |
+
"""
|
| 154 |
+
mode = self.transport_mode
|
| 155 |
+
walk_speed = SPEED_KMH[(TransportMode.WALK.value, self.mobility.value)]
|
| 156 |
+
|
| 157 |
+
if mode == TransportMode.WALK:
|
| 158 |
+
return max(1, int((dist_km / walk_speed) * 60))
|
| 159 |
+
|
| 160 |
+
if mode == TransportMode.CAR:
|
| 161 |
+
speed = SPEED_KMH.get((TransportMode.CAR.value, self.mobility.value))
|
| 162 |
+
return max(3, int((dist_km / speed) * 60) + CAR_OVERHEAD_MIN)
|
| 163 |
+
|
| 164 |
+
if mode == TransportMode.TRANSIT:
|
| 165 |
+
if dist_km < TRANSIT_WALK_THRESHOLD_KM:
|
| 166 |
+
# Distanza troppo corta: a piedi Γ¨ piΓΉ veloce del mezzo
|
| 167 |
+
return max(1, int((dist_km / walk_speed) * 60))
|
| 168 |
+
speed = SPEED_KMH[(TransportMode.TRANSIT.value, self.mobility.value)]
|
| 169 |
+
ride = int((dist_km / speed) * 60)
|
| 170 |
+
return ride + TRANSIT_OVERHEAD_MIN
|
| 171 |
+
|
| 172 |
+
if mode == TransportMode.MIXED:
|
| 173 |
+
dist_m = dist_km * 1000
|
| 174 |
+
if dist_m <= MIXED_THRESHOLD_M:
|
| 175 |
+
return max(1, int((dist_km / walk_speed) * 60))
|
| 176 |
+
long_speed = MIXED_LONG_SPEED_KMH[self.mobility.value]
|
| 177 |
+
ride = int((dist_km / long_speed) * 60)
|
| 178 |
+
return ride + TRANSIT_OVERHEAD_MIN
|
| 179 |
+
|
| 180 |
+
# Fallback
|
| 181 |
+
return max(1, int((dist_km / walk_speed) * 60))
|
| 182 |
+
|
| 183 |
+
def needs_meal_slot(self) -> list[tuple[int, int]]:
|
| 184 |
+
"""
|
| 185 |
+
Restituisce la lista di finestre temporali in cui il profilo
|
| 186 |
+
richiede un ristorante nel tour.
|
| 187 |
+
Es. [(660, 780), (1080, 1200)] per pranzo+cena.
|
| 188 |
+
"""
|
| 189 |
+
slots = []
|
| 190 |
+
if self.want_lunch:
|
| 191 |
+
slots.append((
|
| 192 |
+
self.lunch_time - self.meal_window,
|
| 193 |
+
self.lunch_time + self.meal_window
|
| 194 |
+
))
|
| 195 |
+
if self.want_dinner:
|
| 196 |
+
slots.append((
|
| 197 |
+
self.dinner_time - self.meal_window,
|
| 198 |
+
self.dinner_time + self.meal_window
|
| 199 |
+
))
|
| 200 |
+
return slots
|
| 201 |
+
|
| 202 |
+
def summary(self) -> str:
|
| 203 |
+
lines = [
|
| 204 |
+
f"Trasporto : {self.transport_mode.value} | MobilitΓ : {self.mobility.value}",
|
| 205 |
+
f"Categorie : {', '.join(self.allowed_categories)}",
|
| 206 |
+
f"Pranzo : {'sì (' + str(self.lunch_time//60) + ':00)' if self.want_lunch else 'no'} | "
|
| 207 |
+
f"Cena: {'sì (' + str(self.dinner_time//60) + ':00)' if self.want_dinner else 'no'}",
|
| 208 |
+
]
|
| 209 |
+
if self.tag_weights:
|
| 210 |
+
tw = ", ".join(f"{k}Γ{v}" for k, v in self.tag_weights.items())
|
| 211 |
+
lines.append(f"Interessi : {tw}")
|
| 212 |
+
if self.max_entry_fee is not None:
|
| 213 |
+
lines.append(f"Budget biglietti: β¬{self.max_entry_fee:.0f} max a PoI")
|
| 214 |
+
return "\n".join(lines)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# Factory: profili predefiniti pronti all'uso
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
#TODO: rivedere la definizione di tutti i profili e i pesi, facendo attenzione alle allowed_categories e alle tag_weights per coerenza interna.
|
| 221 |
+
def profile_cultural_walker() -> TouristProfile:
|
| 222 |
+
"""Turista culturale a piedi, interessato ad arte e storia. Include una sosta pranzo."""
|
| 223 |
+
return TouristProfile(
|
| 224 |
+
transport_mode = TransportMode.WALK,
|
| 225 |
+
allowed_categories = ["museum", "monument", "viewpoint", "restaurant", "bar", "gelateria"],
|
| 226 |
+
want_lunch = True,
|
| 227 |
+
want_dinner = False,
|
| 228 |
+
tag_weights = {"arte": 1.4, "antico": 1.3, "rinascimento": 1.5, "unesco": 1.2},
|
| 229 |
+
max_bar_stops = 1,
|
| 230 |
+
max_gelateria_stops= 1
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def profile_foodie_transit() -> TouristProfile:
|
| 235 |
+
"""Turista gastronomico con mezzi pubblici, ristoranti inclusi."""
|
| 236 |
+
return TouristProfile(
|
| 237 |
+
transport_mode = TransportMode.TRANSIT,
|
| 238 |
+
allowed_categories = ["restaurant", "monument", "bar", "gelateria", "viewpoint", "park"],
|
| 239 |
+
want_lunch = True,
|
| 240 |
+
want_dinner = True,
|
| 241 |
+
tag_weights = {"cucina_romana": 1.6, "offal": 0.5, "vivace": 1.2},
|
| 242 |
+
max_bar_stops = 2,
|
| 243 |
+
max_gelateria_stops= 2
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def profile_family_mixed() -> TouristProfile:
|
| 248 |
+
"""Famiglia con bambini: percorso misto, evita musei pesanti."""
|
| 249 |
+
return TouristProfile(
|
| 250 |
+
transport_mode = TransportMode.MIXED,
|
| 251 |
+
mobility = MobilityLevel.LIMITED,
|
| 252 |
+
allowed_categories = ["monument", "park", "viewpoint", "restaurant", "bar", "gelateria"],
|
| 253 |
+
want_lunch = True,
|
| 254 |
+
want_dinner = False,
|
| 255 |
+
group_size = 4,
|
| 256 |
+
tag_weights = {"fotogenico": 1.3, "vivace": 1.2},
|
| 257 |
+
max_entry_fee = 15.0,
|
| 258 |
+
max_bar_stops = 1,
|
| 259 |
+
max_gelateria_stops= 1
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def profile_art_lover_car() -> TouristProfile:
|
| 264 |
+
"""Appassionato d'arte con auto: vuole visitare musei anche lontani."""
|
| 265 |
+
return TouristProfile(
|
| 266 |
+
transport_mode = TransportMode.CAR,
|
| 267 |
+
allowed_categories = ["museum", "monument", "bar", "gelateria"],
|
| 268 |
+
want_lunch = True,
|
| 269 |
+
want_dinner = False,
|
| 270 |
+
tag_weights = {"arte": 1.6, "scultura": 1.5, "rinascimento": 1.4, "antico": 1.1},
|
| 271 |
+
max_entry_fee = 30.0,
|
| 272 |
+
max_bar_stops = 1,
|
| 273 |
+
max_gelateria_stops= 1
|
| 274 |
+
|
| 275 |
+
)
|
data/__init__.py
ADDED
|
File without changes
|
data/custom_profile.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"transport_mode":"MIXED",
|
| 3 |
+
"mobility":"NORMAL",
|
| 4 |
+
"allowed_categories":[
|
| 5 |
+
"monument",
|
| 6 |
+
"park",
|
| 7 |
+
"viewpoint",
|
| 8 |
+
"restaurant",
|
| 9 |
+
"bar",
|
| 10 |
+
"gelateria"
|
| 11 |
+
],
|
| 12 |
+
"want_lunch":true,
|
| 13 |
+
"want_dinner":true,
|
| 14 |
+
"lunch_time":720,
|
| 15 |
+
"dinner_time":1230,
|
| 16 |
+
"meal_window":90,
|
| 17 |
+
"max_bar_stops":2,
|
| 18 |
+
"max_gelateria_stops":1,
|
| 19 |
+
"tag_weights":{
|
| 20 |
+
"fotogenico":1.3,
|
| 21 |
+
"vivace":1.2
|
| 22 |
+
},
|
| 23 |
+
"max_entry_fee":15,
|
| 24 |
+
"group_size":4
|
| 25 |
+
}
|
data/pois.json
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "colosseo",
|
| 4 |
+
"name": "Colosseo",
|
| 5 |
+
"lat": 41.8902,
|
| 6 |
+
"lon": 12.4922,
|
| 7 |
+
"score": 0.98,
|
| 8 |
+
"visit_duration": 120,
|
| 9 |
+
"time_window": { "open": 540, "close": 1110 },
|
| 10 |
+
"category": "monument",
|
| 11 |
+
"tags": ["antico", "unesco"]
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"id": "foro",
|
| 15 |
+
"name": "Foro Romano",
|
| 16 |
+
"lat": 41.8925,
|
| 17 |
+
"lon": 12.4853,
|
| 18 |
+
"score": 0.9,
|
| 19 |
+
"visit_duration": 90,
|
| 20 |
+
"time_window": { "open": 540, "close": 1110 },
|
| 21 |
+
"category": "monument",
|
| 22 |
+
"tags": ["antico"]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"id": "vaticano",
|
| 26 |
+
"name": "Musei Vaticani",
|
| 27 |
+
"lat": 41.9065,
|
| 28 |
+
"lon": 12.4534,
|
| 29 |
+
"score": 0.97,
|
| 30 |
+
"visit_duration": 180,
|
| 31 |
+
"time_window": { "open": 540, "close": 1080 },
|
| 32 |
+
"category": "museum",
|
| 33 |
+
"tags": ["arte", "unesco"]
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"id": "sistina",
|
| 37 |
+
"name": "Cappella Sistina",
|
| 38 |
+
"lat": 41.9029,
|
| 39 |
+
"lon": 12.4545,
|
| 40 |
+
"score": 0.96,
|
| 41 |
+
"visit_duration": 60,
|
| 42 |
+
"time_window": { "open": 540, "close": 1080 },
|
| 43 |
+
"category": "museum",
|
| 44 |
+
"tags": ["arte", "rinascimento"]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"id": "pantheon",
|
| 48 |
+
"name": "Pantheon",
|
| 49 |
+
"lat": 41.8986,
|
| 50 |
+
"lon": 12.4769,
|
| 51 |
+
"score": 0.93,
|
| 52 |
+
"visit_duration": 60,
|
| 53 |
+
"time_window": { "open": 540, "close": 1140 },
|
| 54 |
+
"category": "monument",
|
| 55 |
+
"tags": ["antico", "architettura"]
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"id": "trevi",
|
| 59 |
+
"name": "Fontana di Trevi",
|
| 60 |
+
"lat": 41.9009,
|
| 61 |
+
"lon": 12.4833,
|
| 62 |
+
"score": 0.88,
|
| 63 |
+
"visit_duration": 30,
|
| 64 |
+
"time_window": { "open": 0, "close": 1440 },
|
| 65 |
+
"category": "monument",
|
| 66 |
+
"tags": ["barocco", "fotogenico"]
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"id": "spagna",
|
| 70 |
+
"name": "Piazza di Spagna",
|
| 71 |
+
"lat": 41.9059,
|
| 72 |
+
"lon": 12.4823,
|
| 73 |
+
"score": 0.8,
|
| 74 |
+
"visit_duration": 30,
|
| 75 |
+
"time_window": { "open": 0, "close": 1440 },
|
| 76 |
+
"category": "viewpoint",
|
| 77 |
+
"tags": ["shopping", "fotogenico"]
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "borghese",
|
| 81 |
+
"name": "Galleria Borghese",
|
| 82 |
+
"lat": 41.9143,
|
| 83 |
+
"lon": 12.4923,
|
| 84 |
+
"score": 0.92,
|
| 85 |
+
"visit_duration": 120,
|
| 86 |
+
"time_window": { "open": 540, "close": 1140 },
|
| 87 |
+
"category": "museum",
|
| 88 |
+
"tags": ["arte", "scultura"]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"id": "navona",
|
| 92 |
+
"name": "Piazza Navona",
|
| 93 |
+
"lat": 41.8992,
|
| 94 |
+
"lon": 12.4731,
|
| 95 |
+
"score": 0.85,
|
| 96 |
+
"visit_duration": 45,
|
| 97 |
+
"time_window": { "open": 0, "close": 1440 },
|
| 98 |
+
"category": "viewpoint",
|
| 99 |
+
"tags": ["barocco", "fotogenico"]
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"id": "trastevere",
|
| 103 |
+
"name": "Trastevere",
|
| 104 |
+
"lat": 41.8897,
|
| 105 |
+
"lon": 12.4703,
|
| 106 |
+
"score": 0.82,
|
| 107 |
+
"visit_duration": 90,
|
| 108 |
+
"time_window": { "open": 600, "close": 1380 },
|
| 109 |
+
"category": "viewpoint",
|
| 110 |
+
"tags": ["quartiere", "fotogenico"]
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"id": "castel",
|
| 114 |
+
"name": "Castel Sant'Angelo",
|
| 115 |
+
"lat": 41.9031,
|
| 116 |
+
"lon": 12.4663,
|
| 117 |
+
"score": 0.83,
|
| 118 |
+
"visit_duration": 90,
|
| 119 |
+
"time_window": { "open": 540, "close": 1080 },
|
| 120 |
+
"category": "monument",
|
| 121 |
+
"tags": ["medievale", "panorama"]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"id": "aventino",
|
| 125 |
+
"name": "Giardino degli Aranci",
|
| 126 |
+
"lat": 41.8837,
|
| 127 |
+
"lon": 12.4793,
|
| 128 |
+
"score": 0.75,
|
| 129 |
+
"visit_duration": 40,
|
| 130 |
+
"time_window": { "open": 480, "close": 1200 },
|
| 131 |
+
"category": "park",
|
| 132 |
+
"tags": ["panorama", "fotogenico"]
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"id": "terme",
|
| 136 |
+
"name": "Terme di Caracalla",
|
| 137 |
+
"lat": 41.8788,
|
| 138 |
+
"lon": 12.4924,
|
| 139 |
+
"score": 0.78,
|
| 140 |
+
"visit_duration": 75,
|
| 141 |
+
"time_window": { "open": 540, "close": 1080 },
|
| 142 |
+
"category": "monument",
|
| 143 |
+
"tags": ["antico"]
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"id": "rist_rione",
|
| 147 |
+
"name": "Osteria del Rione",
|
| 148 |
+
"lat": 41.8962,
|
| 149 |
+
"lon": 12.4751,
|
| 150 |
+
"score": 0.74,
|
| 151 |
+
"visit_duration": 60,
|
| 152 |
+
"time_window": { "open": 720, "close": 900 },
|
| 153 |
+
"category": "restaurant",
|
| 154 |
+
"tags": ["cucina_romana"]
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"id": "rist_prati",
|
| 158 |
+
"name": "Trattoria Prati",
|
| 159 |
+
"lat": 41.9042,
|
| 160 |
+
"lon": 12.4601,
|
| 161 |
+
"score": 0.7,
|
| 162 |
+
"visit_duration": 75,
|
| 163 |
+
"time_window": { "open": 720, "close": 900 },
|
| 164 |
+
"category": "restaurant",
|
| 165 |
+
"tags": ["cucina_romana"]
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"id": "rist_testac",
|
| 169 |
+
"name": "Testaccio da Mario",
|
| 170 |
+
"lat": 41.8792,
|
| 171 |
+
"lon": 12.477,
|
| 172 |
+
"score": 0.76,
|
| 173 |
+
"visit_duration": 70,
|
| 174 |
+
"time_window": { "open": 720, "close": 900 },
|
| 175 |
+
"category": "restaurant",
|
| 176 |
+
"tags": ["offal", "cucina_romana"]
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"id": "rist_cena1",
|
| 180 |
+
"name": "Ristorante San Pietro",
|
| 181 |
+
"lat": 41.905,
|
| 182 |
+
"lon": 12.458,
|
| 183 |
+
"score": 0.72,
|
| 184 |
+
"visit_duration": 80,
|
| 185 |
+
"time_window": { "open": 1140, "close": 1320 },
|
| 186 |
+
"category": "restaurant",
|
| 187 |
+
"tags": ["cucina_romana"]
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"id": "rist_cena2",
|
| 191 |
+
"name": "Da Enzo al 29",
|
| 192 |
+
"lat": 41.8891,
|
| 193 |
+
"lon": 12.4697,
|
| 194 |
+
"score": 0.78,
|
| 195 |
+
"visit_duration": 90,
|
| 196 |
+
"time_window": { "open": 1140, "close": 1320 },
|
| 197 |
+
"category": "restaurant",
|
| 198 |
+
"tags": ["cucina_romana", "trastevere"]
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"id": "bar_greco",
|
| 202 |
+
"name": "Caffè Greco",
|
| 203 |
+
"lat": 41.9057,
|
| 204 |
+
"lon": 12.4818,
|
| 205 |
+
"score": 0.72,
|
| 206 |
+
"visit_duration": 20,
|
| 207 |
+
"time_window": { "open": 480, "close": 1320 },
|
| 208 |
+
"category": "bar",
|
| 209 |
+
"tags": ["storico", "caffe"]
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"id": "bar_sant",
|
| 213 |
+
"name": "Sant'Eustachio il Caffè",
|
| 214 |
+
"lat": 41.899,
|
| 215 |
+
"lon": 12.4752,
|
| 216 |
+
"score": 0.75,
|
| 217 |
+
"visit_duration": 20,
|
| 218 |
+
"time_window": { "open": 480, "close": 1380 },
|
| 219 |
+
"category": "bar",
|
| 220 |
+
"tags": ["caffe", "storico"]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"id": "bar_campo",
|
| 224 |
+
"name": "Bar del Fico",
|
| 225 |
+
"lat": 41.8968,
|
| 226 |
+
"lon": 12.472,
|
| 227 |
+
"score": 0.65,
|
| 228 |
+
"visit_duration": 25,
|
| 229 |
+
"time_window": { "open": 600, "close": 1380 },
|
| 230 |
+
"category": "bar",
|
| 231 |
+
"tags": ["aperitivo", "vivace"]
|
| 232 |
+
},
|
| 233 |
+
{
|
| 234 |
+
"id": "gel_fatamorgana",
|
| 235 |
+
"name": "Fatamorgana",
|
| 236 |
+
"lat": 41.8993,
|
| 237 |
+
"lon": 12.4729,
|
| 238 |
+
"score": 0.7,
|
| 239 |
+
"visit_duration": 20,
|
| 240 |
+
"time_window": { "open": 660, "close": 1200 },
|
| 241 |
+
"category": "gelateria",
|
| 242 |
+
"tags": ["artigianale", "insolito"]
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"id": "gel_giolitti",
|
| 246 |
+
"name": "Giolitti",
|
| 247 |
+
"lat": 41.9003,
|
| 248 |
+
"lon": 12.4765,
|
| 249 |
+
"score": 0.68,
|
| 250 |
+
"visit_duration": 20,
|
| 251 |
+
"time_window": { "open": 660, "close": 1260 },
|
| 252 |
+
"category": "gelateria",
|
| 253 |
+
"tags": ["storico", "classico"]
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"id": "capitolini",
|
| 257 |
+
"name": "Musei Capitolini",
|
| 258 |
+
"lat": 41.8933,
|
| 259 |
+
"lon": 12.4839,
|
| 260 |
+
"score": 0.94,
|
| 261 |
+
"visit_duration": 120,
|
| 262 |
+
"time_window": { "open": 570, "close": 1170 },
|
| 263 |
+
"category": "museum",
|
| 264 |
+
"tags": ["arte", "storia_romana", "panorama"]
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"id": "ara_pacis",
|
| 268 |
+
"name": "Ara Pacis",
|
| 269 |
+
"lat": 41.9061,
|
| 270 |
+
"lon": 12.4748,
|
| 271 |
+
"score": 0.85,
|
| 272 |
+
"visit_duration": 60,
|
| 273 |
+
"time_window": { "open": 540, "close": 1140 },
|
| 274 |
+
"category": "museum",
|
| 275 |
+
"tags": ["antico", "architettura"]
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"id": "laterano",
|
| 279 |
+
"name": "San Giovanni in Laterano",
|
| 280 |
+
"lat": 41.8859,
|
| 281 |
+
"lon": 12.5057,
|
| 282 |
+
"score": 0.88,
|
| 283 |
+
"visit_duration": 60,
|
| 284 |
+
"time_window": { "open": 420, "close": 1110 },
|
| 285 |
+
"category": "monument",
|
| 286 |
+
"tags": ["chiesa", "barocco"]
|
| 287 |
+
},
|
| 288 |
+
{
|
| 289 |
+
"id": "maggiore",
|
| 290 |
+
"name": "Santa Maria Maggiore",
|
| 291 |
+
"lat": 41.8975,
|
| 292 |
+
"lon": 12.4985,
|
| 293 |
+
"score": 0.89,
|
| 294 |
+
"visit_duration": 60,
|
| 295 |
+
"time_window": { "open": 420, "close": 1140 },
|
| 296 |
+
"category": "monument",
|
| 297 |
+
"tags": ["chiesa", "mosaici"]
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"id": "catacombe",
|
| 301 |
+
"name": "Catacombe di San Callisto",
|
| 302 |
+
"lat": 41.8587,
|
| 303 |
+
"lon": 12.5108,
|
| 304 |
+
"score": 0.82,
|
| 305 |
+
"visit_duration": 75,
|
| 306 |
+
"time_window": { "open": 540, "close": 1020 },
|
| 307 |
+
"category": "monument",
|
| 308 |
+
"tags": ["antico", "sotterraneo"]
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
"id": "coppede",
|
| 312 |
+
"name": "Quartiere Coppedè",
|
| 313 |
+
"lat": 41.9189,
|
| 314 |
+
"lon": 12.5011,
|
| 315 |
+
"score": 0.78,
|
| 316 |
+
"visit_duration": 45,
|
| 317 |
+
"time_window": { "open": 0, "close": 1440 },
|
| 318 |
+
"category": "viewpoint",
|
| 319 |
+
"tags": ["liberty", "architettura"]
|
| 320 |
+
},
|
| 321 |
+
{
|
| 322 |
+
"id": "garbatella",
|
| 323 |
+
"name": "Garbatella",
|
| 324 |
+
"lat": 41.8615,
|
| 325 |
+
"lon": 12.4891,
|
| 326 |
+
"score": 0.76,
|
| 327 |
+
"visit_duration": 90,
|
| 328 |
+
"time_window": { "open": 0, "close": 1440 },
|
| 329 |
+
"category": "viewpoint",
|
| 330 |
+
"tags": ["quartiere", "popolare"]
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"id": "monti",
|
| 334 |
+
"name": "Rione Monti",
|
| 335 |
+
"lat": 41.8947,
|
| 336 |
+
"lon": 12.4901,
|
| 337 |
+
"score": 0.82,
|
| 338 |
+
"visit_duration": 90,
|
| 339 |
+
"time_window": { "open": 600, "close": 1440 },
|
| 340 |
+
"category": "viewpoint",
|
| 341 |
+
"tags": ["quartiere", "artigianato"]
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"id": "piramide",
|
| 345 |
+
"name": "Piramide Cestia",
|
| 346 |
+
"lat": 41.8765,
|
| 347 |
+
"lon": 12.4808,
|
| 348 |
+
"score": 0.74,
|
| 349 |
+
"visit_duration": 30,
|
| 350 |
+
"time_window": { "open": 540, "close": 1140 },
|
| 351 |
+
"category": "monument",
|
| 352 |
+
"tags": ["antico", "egizio"]
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"id": "san_paolo",
|
| 356 |
+
"name": "San Paolo fuori le Mura",
|
| 357 |
+
"lat": 41.8587,
|
| 358 |
+
"lon": 12.4768,
|
| 359 |
+
"score": 0.86,
|
| 360 |
+
"visit_duration": 70,
|
| 361 |
+
"time_window": { "open": 420, "close": 1140 },
|
| 362 |
+
"category": "monument",
|
| 363 |
+
"tags": ["unesco", "chiesa"]
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"id": "doria_pamphilj",
|
| 367 |
+
"name": "Palazzo Doria Pamphilj",
|
| 368 |
+
"lat": 41.8978,
|
| 369 |
+
"lon": 12.4815,
|
| 370 |
+
"score": 0.87,
|
| 371 |
+
"visit_duration": 100,
|
| 372 |
+
"time_window": { "open": 540, "close": 1140 },
|
| 373 |
+
"category": "museum",
|
| 374 |
+
"tags": ["arte", "palazzo"]
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"id": "luigi_francesi",
|
| 378 |
+
"name": "San Luigi dei Francesi",
|
| 379 |
+
"lat": 41.8996,
|
| 380 |
+
"lon": 12.4747,
|
| 381 |
+
"score": 0.84,
|
| 382 |
+
"visit_duration": 30,
|
| 383 |
+
"time_window": { "open": 570, "close": 1140 },
|
| 384 |
+
"category": "monument",
|
| 385 |
+
"tags": ["caravaggio", "barocco"]
|
| 386 |
+
},
|
| 387 |
+
{
|
| 388 |
+
"id": "orto_botanico",
|
| 389 |
+
"name": "Orto Botanico",
|
| 390 |
+
"lat": 41.8929,
|
| 391 |
+
"lon": 12.4633,
|
| 392 |
+
"score": 0.79,
|
| 393 |
+
"visit_duration": 90,
|
| 394 |
+
"time_window": { "open": 540, "close": 1110 },
|
| 395 |
+
"category": "park",
|
| 396 |
+
"tags": ["natura", "trastevere"]
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"id": "villa_torlonia",
|
| 400 |
+
"name": "Villa Torlonia",
|
| 401 |
+
"lat": 41.9138,
|
| 402 |
+
"lon": 12.5117,
|
| 403 |
+
"score": 0.77,
|
| 404 |
+
"visit_duration": 100,
|
| 405 |
+
"time_window": { "open": 540, "close": 1140 },
|
| 406 |
+
"category": "park",
|
| 407 |
+
"tags": ["storia", "giardino"]
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"id": "rist_felice",
|
| 411 |
+
"name": "Felice a Testaccio",
|
| 412 |
+
"lat": 41.8795,
|
| 413 |
+
"lon": 12.4783,
|
| 414 |
+
"score": 0.9,
|
| 415 |
+
"visit_duration": 90,
|
| 416 |
+
"time_window": { "open": 1140, "close": 1380 },
|
| 417 |
+
"category": "restaurant",
|
| 418 |
+
"tags": ["cacio_e_pepe", "testaccio"]
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"id": "rist_roscioli",
|
| 422 |
+
"name": "Roscioli Salumeria",
|
| 423 |
+
"lat": 41.8943,
|
| 424 |
+
"lon": 12.4741,
|
| 425 |
+
"score": 0.89,
|
| 426 |
+
"visit_duration": 90,
|
| 427 |
+
"time_window": { "open": 720, "close": 960 },
|
| 428 |
+
"category": "restaurant",
|
| 429 |
+
"tags": ["carbonara", "gourmet"]
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
"id": "rist_bonci",
|
| 433 |
+
"name": "Pizzarium Bonci",
|
| 434 |
+
"lat": 41.9075,
|
| 435 |
+
"lon": 12.4507,
|
| 436 |
+
"score": 0.88,
|
| 437 |
+
"visit_duration": 30,
|
| 438 |
+
"time_window": { "open": 660, "close": 1320 },
|
| 439 |
+
"category": "restaurant",
|
| 440 |
+
"tags": ["pizza_al_taglio", "street_food"]
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"id": "bar_pompi",
|
| 444 |
+
"name": "Pompi TiramisΓΉ",
|
| 445 |
+
"lat": 41.8831,
|
| 446 |
+
"lon": 12.5111,
|
| 447 |
+
"score": 0.85,
|
| 448 |
+
"visit_duration": 20,
|
| 449 |
+
"time_window": { "open": 600, "close": 1440 },
|
| 450 |
+
"category": "bar",
|
| 451 |
+
"tags": ["dolce", "tiramisu"]
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"id": "gel_neve",
|
| 455 |
+
"name": "Neve di Latte",
|
| 456 |
+
"lat": 41.9272,
|
| 457 |
+
"lon": 12.4648,
|
| 458 |
+
"score": 0.86,
|
| 459 |
+
"visit_duration": 20,
|
| 460 |
+
"time_window": { "open": 720, "close": 1320 },
|
| 461 |
+
"category": "gelateria",
|
| 462 |
+
"tags": ["artigianale", "gourmet"]
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"id": "pincio",
|
| 466 |
+
"name": "Terrazza del Pincio",
|
| 467 |
+
"lat": 41.9114,
|
| 468 |
+
"lon": 12.4795,
|
| 469 |
+
"score": 0.91,
|
| 470 |
+
"visit_duration": 30,
|
| 471 |
+
"time_window": { "open": 0, "close": 1440 },
|
| 472 |
+
"category": "viewpoint",
|
| 473 |
+
"tags": ["panorama", "fotogenico"]
|
| 474 |
+
}
|
| 475 |
+
]
|
data/tour_results
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"timestamp": "2026-03-25 01:39:09.552542", "profile": "family_mixed", "budget": 600, "start_time": 570, "start_lat": 41.9028, "start_lon": 12.4964, "result": {"total_distance": 7.3, "total_time": 532, "is_feasible": true, "stops": [{"poi_id": "trevi", "poi_name": "Fontana di Trevi", "arrival": 585, "departure": 630, "wait": 0}, {"poi_id": "navona", "poi_name": "Piazza Navona", "arrival": 644, "departure": 704, "wait": 0}, {"poi_id": "rist_roscioli", "poi_name": "Roscioli Salumeria", "arrival": 720, "departure": 825, "wait": 4}, {"poi_id": "bar_sant", "poi_name": "Sant'Eustachio il Caffè", "arrival": 837, "departure": 872, "wait": 0}, {"poi_id": "pantheon", "poi_name": "Pantheon", "arrival": 875, "departure": 950, "wait": 0}, {"poi_id": "luigi_francesi", "poi_name": "San Luigi dei Francesi", "arrival": 955, "departure": 1000, "wait": 0}, {"poi_id": "gel_fatamorgana", "poi_name": "Fatamorgana", "arrival": 1003, "departure": 1038, "wait": 0}, {"poi_id": "rist_bonci", "poi_name": "Pizzarium Bonci", "arrival": 1057, "departure": 1102, "wait": 0}]}}
|
| 2 |
+
{"timestamp": "2026-03-25 02:02:36.908968", "profile": "family_mixed", "budget": 780, "start_time": 450, "start_lat": 41.9028, "start_lon": 12.4964, "result": {"total_score": 6.449, "total_distance": 5.0, "total_time": 435, "is_feasible": true, "stops": [{"poi_id": "trevi", "poi_name": "Fontana di Trevi", "arrival": 465, "departure": 510, "wait": 0, "travel_distance_km": 1.1, "travel_time_min": 14}, {"poi_id": "spagna", "poi_name": "Piazza di Spagna", "arrival": 522, "departure": 567, "wait": 0, "travel_distance_km": 0.0, "travel_time_min": 1}, {"poi_id": "navona", "poi_name": "Piazza Navona", "arrival": 582, "departure": 642, "wait": 0, "travel_distance_km": 0.0, "travel_time_min": 1}, {"poi_id": "bar_sant", "poi_name": "Sant'Eustachio il Caffè", "arrival": 646, "departure": 681, "wait": 0, "travel_distance_km": 0.0, "travel_time_min": 1}, {"poi_id": "luigi_francesi", "poi_name": "San Luigi dei Francesi", "arrival": 683, "departure": 728, "wait": 0, "travel_distance_km": 0.0, "travel_time_min": 1}, {"poi_id": "gel_giolitti", "poi_name": "Giolitti", "arrival": 732, "departure": 767, "wait": 0, "travel_distance_km": 0.0, "travel_time_min": 1}, {"poi_id": "rist_roscioli", "poi_name": "Roscioli Salumeria", "arrival": 780, "departure": 885, "wait": 0, "travel_distance_km": 0.0, "travel_time_min": 1}]}}
|
| 3 |
+
{"timestamp": "2026-03-25 02:08:10.347900", "profile": "family_mixed", "budget": 780, "start_time": 450, "start_lat": 41.9028, "start_lon": 12.4964, "result": {"total_score": 5.659, "total_distance": 4.03, "total_time": 418, "is_feasible": true, "stops": [{"poi_id": "trevi", "poi_name": "Fontana di Trevi", "arrival": 465, "departure": 510, "wait": 0, "travel_distance_km": 1.1, "travel_time_min": 14}, {"poi_id": "navona", "poi_name": "Piazza Navona", "arrival": 524, "departure": 584, "wait": 0, "travel_distance_km": 0.87, "travel_time_min": 13}, {"poi_id": "bar_sant", "poi_name": "Sant'Eustachio il Caffè", "arrival": 588, "departure": 623, "wait": 0, "travel_distance_km": 0.18, "travel_time_min": 3}, {"poi_id": "pantheon", "poi_name": "Pantheon", "arrival": 626, "departure": 701, "wait": 0, "travel_distance_km": 0.15, "travel_time_min": 2}, {"poi_id": "luigi_francesi", "poi_name": "San Luigi dei Francesi", "arrival": 706, "departure": 751, "wait": 0, "travel_distance_km": 0.21, "travel_time_min": 4}, {"poi_id": "rist_roscioli", "poi_name": "Roscioli Salumeria", "arrival": 763, "departure": 868, "wait": 0, "travel_distance_km": 0.59, "travel_time_min": 11}]}}
|
demo_rome.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
demo_rome.py β Demo aggiornato: TouristProfile + fix ristoranti + tempi transit realistici.
|
| 3 |
+
"""
|
| 4 |
+
import sys, os
|
| 5 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 6 |
+
|
| 7 |
+
from core.models import PoI, PoICategory, TimeWindow
|
| 8 |
+
from core.distance import DistanceMatrix
|
| 9 |
+
from core.profile import (
|
| 10 |
+
profile_cultural_walker, profile_foodie_transit,
|
| 11 |
+
profile_family_mixed, profile_art_lover_car,
|
| 12 |
+
TouristProfile, TransportMode, MobilityLevel,
|
| 13 |
+
)
|
| 14 |
+
from solver import NSGA2Solver, SolverConfig
|
| 15 |
+
|
| 16 |
+
ROME_POIS = [
|
| 17 |
+
# Monumenti e luoghi
|
| 18 |
+
PoI("colosseo", "Colosseo", 41.8902,12.4922,0.98,120,TimeWindow(540,1110),PoICategory.MONUMENT, ["antico","unesco"]),
|
| 19 |
+
PoI("foro", "Foro Romano", 41.8925,12.4853,0.90, 90,TimeWindow(540,1110),PoICategory.MONUMENT, ["antico"]),
|
| 20 |
+
PoI("vaticano", "Musei Vaticani", 41.9065,12.4534,0.97,180,TimeWindow(540,1080),PoICategory.MUSEUM, ["arte","unesco"]),
|
| 21 |
+
PoI("sistina", "Cappella Sistina", 41.9029,12.4545,0.96, 60,TimeWindow(540,1080),PoICategory.MUSEUM, ["arte","rinascimento"]),
|
| 22 |
+
PoI("pantheon", "Pantheon", 41.8986,12.4769,0.93, 60,TimeWindow(540,1140),PoICategory.MONUMENT, ["antico","architettura"]),
|
| 23 |
+
PoI("trevi", "Fontana di Trevi", 41.9009,12.4833,0.88, 30,TimeWindow(0, 1440),PoICategory.MONUMENT, ["barocco","fotogenico"]),
|
| 24 |
+
PoI("spagna", "Piazza di Spagna", 41.9059,12.4823,0.80, 30,TimeWindow(0, 1440),PoICategory.VIEWPOINT, ["shopping","fotogenico"]),
|
| 25 |
+
PoI("borghese", "Galleria Borghese", 41.9143,12.4923,0.92,120,TimeWindow(540,1140),PoICategory.MUSEUM, ["arte","scultura"]),
|
| 26 |
+
PoI("navona", "Piazza Navona", 41.8992,12.4731,0.85, 45,TimeWindow(0, 1440),PoICategory.VIEWPOINT, ["barocco","fotogenico"]),
|
| 27 |
+
PoI("trastevere", "Trastevere", 41.8897,12.4703,0.82, 90,TimeWindow(600,1380),PoICategory.VIEWPOINT, ["quartiere","fotogenico"]),
|
| 28 |
+
PoI("castel", "Castel Sant'Angelo", 41.9031,12.4663,0.83, 90,TimeWindow(540,1080),PoICategory.MONUMENT, ["medievale","panorama"]),
|
| 29 |
+
PoI("aventino", "Giardino degli Aranci", 41.8837,12.4793,0.75, 40,TimeWindow(480,1200),PoICategory.PARK, ["panorama","fotogenico"]),
|
| 30 |
+
PoI("terme", "Terme di Caracalla", 41.8788,12.4924,0.78, 75,TimeWindow(540,1080),PoICategory.MONUMENT, ["antico"]),
|
| 31 |
+
# Ristoranti: solo per pranzo (TW 12-15) o cena (TW 19-22)
|
| 32 |
+
PoI("rist_rione", "Osteria del Rione", 41.8962,12.4751,0.74, 60,TimeWindow(720, 900),PoICategory.RESTAURANT,["cucina_romana"]),
|
| 33 |
+
PoI("rist_prati", "Trattoria Prati", 41.9042,12.4601,0.70, 75,TimeWindow(720, 900),PoICategory.RESTAURANT,["cucina_romana"]),
|
| 34 |
+
PoI("rist_testac","Testaccio da Mario", 41.8792,12.4770,0.76, 70,TimeWindow(720, 900),PoICategory.RESTAURANT,["offal","cucina_romana"]),
|
| 35 |
+
PoI("rist_cena1", "Ristorante San Pietro", 41.9050,12.4580,0.72, 80,TimeWindow(1140,1320),PoICategory.RESTAURANT,["cucina_romana"]),
|
| 36 |
+
PoI("rist_cena2", "Da Enzo al 29", 41.8891,12.4697,0.78, 90,TimeWindow(1140,1320),PoICategory.RESTAURANT,["cucina_romana","trastevere"]),
|
| 37 |
+
# Bar e caffè: colazione o pausa (TW ampia)
|
| 38 |
+
PoI("bar_greco", "Caffè Greco", 41.9057,12.4818,0.72, 20,TimeWindow(480,1320),PoICategory.BAR, ["storico","caffe"]),
|
| 39 |
+
PoI("bar_sant", "Sant'Eustachio il Caffè",41.8990,12.4752,0.75, 20,TimeWindow(480,1380),PoICategory.BAR, ["caffe","storico"]),
|
| 40 |
+
PoI("bar_campo", "Bar del Fico", 41.8968,12.4720,0.65, 25,TimeWindow(600,1380),PoICategory.BAR, ["aperitivo","vivace"]),
|
| 41 |
+
# Gelaterie: pomeriggio (TW 11-20)
|
| 42 |
+
PoI("gel_fatamorgana","Fatamorgana", 41.8993,12.4729,0.70, 20,TimeWindow(660,1200),PoICategory.GELATERIA, ["artigianale","insolito"]),
|
| 43 |
+
PoI("gel_giolitti", "Giolitti", 41.9003,12.4765,0.68, 20,TimeWindow(660,1260),PoICategory.GELATERIA, ["storico","classico"]),
|
| 44 |
+
PoI("gel_prati", "Fatamorgana Prati", 41.9090,12.4626,0.66, 20,TimeWindow(660,1260),PoICategory.GELATERIA, ["artigianale"]),
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def profile_foodie_transit_updated() -> TouristProfile:
|
| 49 |
+
"""Gastronomico con mezzi: pranzo + cena, bar e gelateria nel pomeriggio."""
|
| 50 |
+
return TouristProfile(
|
| 51 |
+
transport_mode = TransportMode.TRANSIT,
|
| 52 |
+
mobility = MobilityLevel.NORMAL,
|
| 53 |
+
allowed_categories = ["restaurant", "bar", "gelateria", "monument", "viewpoint", "park"],
|
| 54 |
+
want_lunch = True,
|
| 55 |
+
want_dinner = True,
|
| 56 |
+
lunch_time = 720, # 12:00
|
| 57 |
+
dinner_time = 1200, # 20:00
|
| 58 |
+
meal_window = 60,
|
| 59 |
+
tag_weights = {"cucina_romana": 1.6, "offal": 0.5, "vivace": 1.2, "caffe": 1.1},
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def run_profile(name, profile, config, dm):
|
| 64 |
+
print(f"\n{'β'*60}")
|
| 65 |
+
print(f" Profilo: {name}")
|
| 66 |
+
print(f"{'β'*60}")
|
| 67 |
+
print(profile.summary())
|
| 68 |
+
dm.profile = profile
|
| 69 |
+
solver = NSGA2Solver(ROME_POIS, dm, config, profile=profile)
|
| 70 |
+
|
| 71 |
+
def cb(gen, pareto, stats):
|
| 72 |
+
if gen % 30 == 0 or gen == 1:
|
| 73 |
+
print(f" gen {gen:3d} | pareto={stats['pareto_size']:2d} | "
|
| 74 |
+
f"best={stats['best_scalar']:.4f} | feasible={stats['feasible_pct']:.0f}%")
|
| 75 |
+
|
| 76 |
+
front = solver.solve(callback=cb)
|
| 77 |
+
feasible = [x for x in front if x.fitness.is_feasible] or front
|
| 78 |
+
if not feasible:
|
| 79 |
+
print(" Nessuna soluzione trovata.")
|
| 80 |
+
return
|
| 81 |
+
best = max(feasible, key=lambda x: x.fitness.scalar)
|
| 82 |
+
sched = solver.evaluator.decode(best)
|
| 83 |
+
|
| 84 |
+
# Conta categorie per verifica
|
| 85 |
+
cats = {}
|
| 86 |
+
for stop in sched.stops:
|
| 87 |
+
k = stop.poi.category.value
|
| 88 |
+
cats[k] = cats.get(k, 0) + 1
|
| 89 |
+
|
| 90 |
+
print(f"\n β
Tour: {len(best.genes)} PoI | score={best.fitness.total_score:.2f} | "
|
| 91 |
+
f"{best.fitness.total_distance:.1f}km | {best.fitness.total_time}min")
|
| 92 |
+
print(f" Composizione: {', '.join(f'{v}Γ{k}' for k,v in sorted(cats.items()))}")
|
| 93 |
+
print()
|
| 94 |
+
if sched:
|
| 95 |
+
print(sched.summary())
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main():
|
| 99 |
+
print("Costruzione matrice distanze...")
|
| 100 |
+
dm = DistanceMatrix(ROME_POIS)
|
| 101 |
+
dm.build()
|
| 102 |
+
|
| 103 |
+
config = SolverConfig(
|
| 104 |
+
pop_size = 60,
|
| 105 |
+
max_generations = 200,
|
| 106 |
+
budget = 660, # 11 ore (09:30β20:30)
|
| 107 |
+
start_time = 570, # 09:30
|
| 108 |
+
start_lat = 41.896,
|
| 109 |
+
start_lon = 12.484,
|
| 110 |
+
stagnation_limit = 25,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
profiles = [
|
| 114 |
+
("Gastronomico con mezzi (aggiornato)", profile_foodie_transit_updated()),
|
| 115 |
+
("Culturale a piedi", profile_cultural_walker()),
|
| 116 |
+
("Gastronomico con mezzi (standard)", profile_foodie_transit()),
|
| 117 |
+
("Family: misto", profile_family_mixed()),
|
| 118 |
+
("Art Lover: con auto", profile_art_lover_car()),
|
| 119 |
+
("Custom: solo viste, no pasti", TouristProfile(
|
| 120 |
+
transport_mode=TransportMode.WALK,
|
| 121 |
+
allowed_categories=["monument","viewpoint","park","bar","gelateria"],
|
| 122 |
+
want_lunch=False, want_dinner=False,
|
| 123 |
+
tag_weights={"fotogenico":1.5,"panorama":1.4,"caffe":1.1},
|
| 124 |
+
)),
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
for name, profile in profiles:
|
| 128 |
+
run_profile(name, profile, config, dm)
|
| 129 |
+
|
| 130 |
+
print(f"\n{'β'*60}\n Completato.\n")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
main()
|
ga/__init__.py
ADDED
|
File without changes
|
ga/operators.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ga/operators.py β Operatori genetici: crossover, mutation, selection.
|
| 3 |
+
Tutti gli operatori lavorano su copie degli individui senza modificare
|
| 4 |
+
i genitori (operazioni pure).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import random
|
| 8 |
+
from core.models import Individual, PoI
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# SELECTION
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
def tournament_select(
|
| 16 |
+
population: list[Individual],
|
| 17 |
+
k: int = 3,
|
| 18 |
+
use_pareto: bool = True,
|
| 19 |
+
) -> Individual:
|
| 20 |
+
"""
|
| 21 |
+
Selezione torneo: estrae k individui casuali e restituisce il migliore.
|
| 22 |
+
Con use_pareto=True preferisce rango Pareto basso + crowding alto.
|
| 23 |
+
"""
|
| 24 |
+
contestants = random.sample(population, k)
|
| 25 |
+
if use_pareto:
|
| 26 |
+
return min(contestants, key=lambda x: (x.fitness.rank, -x.fitness.crowd))
|
| 27 |
+
return max(contestants, key=lambda x: x.fitness.scalar)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# CROSSOVER
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def order_crossover(parent1: Individual, parent2: Individual) -> tuple[Individual, Individual]:
|
| 35 |
+
"""
|
| 36 |
+
Order Crossover (OX) adattato a tour a lunghezza variabile.
|
| 37 |
+
Preserva l'ordine relativo dei PoI condivisi tra i genitori.
|
| 38 |
+
"""
|
| 39 |
+
g1, g2 = parent1.genes, parent2.genes
|
| 40 |
+
if len(g1) < 2 or len(g2) < 2:
|
| 41 |
+
return parent1.clone(), parent2.clone()
|
| 42 |
+
|
| 43 |
+
def ox(donor: list[PoI], receiver: list[PoI]) -> list[PoI]:
|
| 44 |
+
if len(donor) < 2:
|
| 45 |
+
return list(donor)
|
| 46 |
+
cut1 = random.randint(0, len(donor) - 1)
|
| 47 |
+
cut2 = random.randint(cut1, len(donor) - 1)
|
| 48 |
+
segment = donor[cut1:cut2+1]
|
| 49 |
+
seg_ids = {p.id for p in segment}
|
| 50 |
+
# Riempi con i PoI del receiver non giΓ nel segmento
|
| 51 |
+
filling = [p for p in receiver if p.id not in seg_ids]
|
| 52 |
+
child = filling[:cut1] + segment + filling[cut1:]
|
| 53 |
+
return child
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
Individual(genes=ox(g1, g2)),
|
| 57 |
+
Individual(genes=ox(g2, g1)),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def poi_aware_crossover(
|
| 62 |
+
parent1: Individual,
|
| 63 |
+
parent2: Individual,
|
| 64 |
+
categories: list[str] | None = None,
|
| 65 |
+
) -> tuple[Individual, Individual]:
|
| 66 |
+
"""
|
| 67 |
+
PoI-aware Crossover: scambia interi sottoinsiemi per categoria.
|
| 68 |
+
Utile per preservare "nicchie tematiche" (es. tour musei vs tour gastronomico).
|
| 69 |
+
"""
|
| 70 |
+
from core.models import PoICategory
|
| 71 |
+
|
| 72 |
+
if categories is None:
|
| 73 |
+
categories = [c.value for c in PoICategory]
|
| 74 |
+
|
| 75 |
+
def by_category(genes: list[PoI]) -> dict[str, list[PoI]]:
|
| 76 |
+
d: dict[str, list[PoI]] = {c: [] for c in categories}
|
| 77 |
+
for p in genes:
|
| 78 |
+
d[p.category.value].append(p)
|
| 79 |
+
return d
|
| 80 |
+
|
| 81 |
+
cat1 = by_category(parent1.genes)
|
| 82 |
+
cat2 = by_category(parent2.genes)
|
| 83 |
+
|
| 84 |
+
child1_genes, child2_genes = [], []
|
| 85 |
+
|
| 86 |
+
for cat in categories:
|
| 87 |
+
# Con probabilitΓ 0.5 scambia le categorie tra i figli
|
| 88 |
+
if random.random() < 0.5:
|
| 89 |
+
child1_genes.extend(cat1[cat])
|
| 90 |
+
child2_genes.extend(cat2[cat])
|
| 91 |
+
else:
|
| 92 |
+
child1_genes.extend(cat2[cat])
|
| 93 |
+
child2_genes.extend(cat1[cat])
|
| 94 |
+
|
| 95 |
+
# Rimuovi duplicati mantenendo l'ordine
|
| 96 |
+
def deduplicate(genes: list[PoI]) -> list[PoI]:
|
| 97 |
+
seen, result = set(), []
|
| 98 |
+
for p in genes:
|
| 99 |
+
if p.id not in seen:
|
| 100 |
+
seen.add(p.id)
|
| 101 |
+
result.append(p)
|
| 102 |
+
return result
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
Individual(genes=deduplicate(child1_genes)),
|
| 106 |
+
Individual(genes=deduplicate(child2_genes)),
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# MUTATION
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
def mutate(
|
| 115 |
+
individual: Individual,
|
| 116 |
+
poi_pool: list[PoI],
|
| 117 |
+
mut_prob: float = 0.15,
|
| 118 |
+
) -> Individual:
|
| 119 |
+
"""
|
| 120 |
+
Seleziona casualmente uno degli operatori di mutazione.
|
| 121 |
+
Applicato con probabilitΓ mut_prob.
|
| 122 |
+
"""
|
| 123 |
+
if random.random() > mut_prob or not individual.genes:
|
| 124 |
+
return individual
|
| 125 |
+
|
| 126 |
+
operator = random.choice([
|
| 127 |
+
swap_mutation,
|
| 128 |
+
insert_mutation,
|
| 129 |
+
reverse_segment_mutation,
|
| 130 |
+
lambda ind, pool: add_remove_mutation(ind, pool),
|
| 131 |
+
])
|
| 132 |
+
result = operator(individual.clone(), poi_pool)
|
| 133 |
+
result.invalidate_cache()
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def swap_mutation(individual: Individual, _pool: list[PoI]) -> Individual:
|
| 138 |
+
"""Scambia due PoI scelti casualmente nella sequenza."""
|
| 139 |
+
g = individual.genes
|
| 140 |
+
if len(g) < 2:
|
| 141 |
+
return individual
|
| 142 |
+
i, j = random.sample(range(len(g)), 2)
|
| 143 |
+
g[i], g[j] = g[j], g[i]
|
| 144 |
+
return individual
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def insert_mutation(individual: Individual, _pool: list[PoI]) -> Individual:
|
| 148 |
+
"""Rimuove un PoI da una posizione e lo inserisce altrove."""
|
| 149 |
+
g = individual.genes
|
| 150 |
+
if len(g) < 2:
|
| 151 |
+
return individual
|
| 152 |
+
i = random.randrange(len(g))
|
| 153 |
+
poi = g.pop(i)
|
| 154 |
+
j = random.randint(0, len(g))
|
| 155 |
+
g.insert(j, poi)
|
| 156 |
+
return individual
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def reverse_segment_mutation(individual: Individual, _pool: list[PoI]) -> Individual:
|
| 160 |
+
"""Inverte un sottosegmento casuale del tour (elimina incroci geografici)."""
|
| 161 |
+
g = individual.genes
|
| 162 |
+
if len(g) < 3:
|
| 163 |
+
return individual
|
| 164 |
+
i = random.randint(0, len(g) - 2)
|
| 165 |
+
j = random.randint(i + 1, len(g) - 1)
|
| 166 |
+
g[i:j+1] = reversed(g[i:j+1])
|
| 167 |
+
return individual
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def add_remove_mutation(individual: Individual, pool: list[PoI]) -> Individual:
|
| 171 |
+
"""
|
| 172 |
+
Con prob 0.70: aggiunge un PoI casuale non ancora nel tour (esplora).
|
| 173 |
+
Con prob 0.30: rimuove il PoI con il peggior score/durata (semplifica).
|
| 174 |
+
Il bias verso l'aggiunta contrasta la tendenza del GA a produrre
|
| 175 |
+
tour sempre piΓΉ corti dopo molte generazioni di mutazione.
|
| 176 |
+
"""
|
| 177 |
+
g = individual.genes
|
| 178 |
+
visited = {p.id for p in g}
|
| 179 |
+
|
| 180 |
+
if random.random() < 0.70: # era 0.50: bias verso aggiunta
|
| 181 |
+
candidates = [p for p in pool if p.id not in visited]
|
| 182 |
+
if candidates:
|
| 183 |
+
new_poi = random.choice(candidates)
|
| 184 |
+
pos = random.randint(0, len(g))
|
| 185 |
+
g.insert(pos, new_poi)
|
| 186 |
+
else:
|
| 187 |
+
if g:
|
| 188 |
+
worst = min(g, key=lambda p: p.score / (p.visit_duration + 1e-9))
|
| 189 |
+
g.remove(worst)
|
| 190 |
+
|
| 191 |
+
return individual
|
ga/repair.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ga/repair.py β Motore di riparazione genetica con profilo turista.
|
| 3 |
+
Garantisce:
|
| 4 |
+
1. Solo PoI di categorie ammesse dal profilo
|
| 5 |
+
2. Nessuna time window violata
|
| 6 |
+
3. Budget rispettato
|
| 7 |
+
4. Slot pasto presente se richiesto dal profilo
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
#import random
|
| 11 |
+
from config import ROUTE_DETOUR_FACTOR
|
| 12 |
+
from core.models import Individual, PoI, PoICategory
|
| 13 |
+
from core.distance import DistanceMatrix, haversine_km
|
| 14 |
+
from core.profile import TouristProfile
|
| 15 |
+
from config import (MAX_WAIT_MIN, GROUP_VISIT_OVERHEAD_PER_PERSON,
|
| 16 |
+
MEAL_RESERVE_MIN, EVENING_THRESHOLD, )
|
| 17 |
+
|
| 18 |
+
class RepairEngine:
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
dm: DistanceMatrix,
|
| 23 |
+
profile: TouristProfile,
|
| 24 |
+
all_pois: list[PoI],
|
| 25 |
+
start_time: int,
|
| 26 |
+
budget: int,
|
| 27 |
+
start_lat: float,
|
| 28 |
+
start_lon: float,
|
| 29 |
+
max_wait_min: int = MAX_WAIT_MIN, # attesa massima tollerata per un singolo PoI (minuti)
|
| 30 |
+
):
|
| 31 |
+
self.dm = dm
|
| 32 |
+
self.profile = profile
|
| 33 |
+
self.all_pois = all_pois
|
| 34 |
+
self.start_time = start_time
|
| 35 |
+
self.budget = budget
|
| 36 |
+
self.start_lat = start_lat
|
| 37 |
+
self.start_lon = start_lon
|
| 38 |
+
self.max_wait_min = max_wait_min
|
| 39 |
+
|
| 40 |
+
def repair(self, individual: Individual) -> Individual:
|
| 41 |
+
"""Pipeline: categoria β EDF β TW β budget β cap ristoranti β cap snack β pasto β TW finale."""
|
| 42 |
+
individual.invalidate_cache()
|
| 43 |
+
individual = self._filter_allowed_categories(individual)
|
| 44 |
+
individual = self._sort_by_earliest_deadline(individual)
|
| 45 |
+
individual = self.repair_time_windows(individual)
|
| 46 |
+
individual = self.repair_budget(individual)
|
| 47 |
+
individual = self._cap_restaurants(individual)
|
| 48 |
+
individual = self._cap_snacks(individual)
|
| 49 |
+
individual = self._ensure_meal_slots(individual)
|
| 50 |
+
# Passaggio finale: rimuove PoI diventati infeasible dopo l'inserimento
|
| 51 |
+
# del ristorante (es. l'ultimo monumento che ora arriva fuori TW)
|
| 52 |
+
individual = self.repair_time_windows(individual)
|
| 53 |
+
individual.invalidate_cache()
|
| 54 |
+
return individual
|
| 55 |
+
|
| 56 |
+
# ------------------------------------------------------------------
|
| 57 |
+
# 1. Rimuove PoI di categorie non ammesse dal profilo
|
| 58 |
+
# ------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
def _filter_allowed_categories(self, individual: Individual) -> Individual:
|
| 61 |
+
individual.genes = [
|
| 62 |
+
p for p in individual.genes
|
| 63 |
+
if self.profile.allows_category(p.category.value)
|
| 64 |
+
]
|
| 65 |
+
return individual
|
| 66 |
+
|
| 67 |
+
# ------------------------------------------------------------------
|
| 68 |
+
# 2. Ordina per Earliest Deadline First
|
| 69 |
+
# ------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
def _sort_by_earliest_deadline(self, individual: Individual) -> Individual:
|
| 72 |
+
"""
|
| 73 |
+
Ordina per earliest OPEN time: visita prima i PoI che aprono prima.
|
| 74 |
+
Evita che i ristoranti (open=720) finiscano in cima creando attese enormi.
|
| 75 |
+
"""
|
| 76 |
+
individual.genes.sort(key=lambda p: p.time_window.open)
|
| 77 |
+
return individual
|
| 78 |
+
|
| 79 |
+
# ------------------------------------------------------------------
|
| 80 |
+
# 3. Rimuove PoI con TW violata
|
| 81 |
+
# ------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
def repair_time_windows(self, individual: Individual) -> Individual:
|
| 84 |
+
"""
|
| 85 |
+
Simula il tour e rimuove i PoI che causano problemi:
|
| 86 |
+
- arrivo dopo la chiusura (infeasible)
|
| 87 |
+
- attesa all'apertura superiore a max_wait_min (tour non realistico)
|
| 88 |
+
Usa group_overhead per coerenza con FitnessEvaluator.
|
| 89 |
+
"""
|
| 90 |
+
group_extra = max(0, self.profile.group_size - 1) * GROUP_VISIT_OVERHEAD_PER_PERSON
|
| 91 |
+
|
| 92 |
+
valid = []
|
| 93 |
+
time_now = self.start_time
|
| 94 |
+
prev_lat = self.start_lat
|
| 95 |
+
prev_lon = self.start_lon
|
| 96 |
+
|
| 97 |
+
for poi in individual.genes:
|
| 98 |
+
travel = self._travel_min(prev_lat, prev_lon, poi)
|
| 99 |
+
arrival = time_now + travel
|
| 100 |
+
|
| 101 |
+
if arrival > poi.time_window.close:
|
| 102 |
+
continue # fuori orario
|
| 103 |
+
|
| 104 |
+
wait = max(0, poi.time_window.open - arrival)
|
| 105 |
+
if wait > self.max_wait_min:
|
| 106 |
+
continue # attesa inaccettabile
|
| 107 |
+
|
| 108 |
+
arrival = arrival + wait
|
| 109 |
+
departure = arrival + poi.visit_duration + group_extra
|
| 110 |
+
|
| 111 |
+
valid.append(poi)
|
| 112 |
+
time_now = departure
|
| 113 |
+
prev_lat = poi.lat
|
| 114 |
+
prev_lon = poi.lon
|
| 115 |
+
|
| 116 |
+
individual.genes = valid
|
| 117 |
+
return individual
|
| 118 |
+
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
# 4. Rimuove PoI finchΓ© budget rispettato (minor score/durata prima)
|
| 121 |
+
# ------------------------------------------------------------------
|
| 122 |
+
|
| 123 |
+
def repair_budget(self, individual: Individual) -> Individual:
|
| 124 |
+
"""
|
| 125 |
+
Rimuove PoI finchΓ© il budget Γ¨ rispettato, riservando tempo
|
| 126 |
+
solo per gli slot pasto SERALI (cena) non ancora coperti.
|
| 127 |
+
Il pranzo (β12:00) cade naturalmente nel flusso diurno e non
|
| 128 |
+
richiede una riserva esplicita nel budget.
|
| 129 |
+
"""
|
| 130 |
+
while True:
|
| 131 |
+
sched = self._simulate_schedule(individual.genes)
|
| 132 |
+
if not individual.genes:
|
| 133 |
+
break
|
| 134 |
+
|
| 135 |
+
# Conta solo slot SERALI non ancora coperti da un ristorante
|
| 136 |
+
meal_slots = self.profile.needs_meal_slot()
|
| 137 |
+
uncovered_evening = sum(
|
| 138 |
+
1 for (slot_open, slot_close) in meal_slots
|
| 139 |
+
if slot_open >= EVENING_THRESHOLD
|
| 140 |
+
and not any(
|
| 141 |
+
s["poi"].category == PoICategory.RESTAURANT
|
| 142 |
+
and slot_open <= s["arrival"] <= slot_close
|
| 143 |
+
for s in sched
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
effective_budget = self.budget - uncovered_evening * MEAL_RESERVE_MIN
|
| 148 |
+
total = sched[-1]["departure"] - self.start_time if sched else 0
|
| 149 |
+
|
| 150 |
+
if total <= effective_budget:
|
| 151 |
+
break
|
| 152 |
+
|
| 153 |
+
removable = [
|
| 154 |
+
p for p in individual.genes
|
| 155 |
+
if p.category != PoICategory.RESTAURANT
|
| 156 |
+
] or individual.genes
|
| 157 |
+
|
| 158 |
+
worst = min(removable, key=lambda p: p.score / (p.visit_duration + 1e-9))
|
| 159 |
+
individual.genes.remove(worst)
|
| 160 |
+
|
| 161 |
+
return individual
|
| 162 |
+
|
| 163 |
+
# ------------------------------------------------------------------
|
| 164 |
+
# 5. Limita i ristoranti al numero di slot pasto richiesti
|
| 165 |
+
# ------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
def _cap_restaurants(self, individual: Individual) -> Individual:
|
| 168 |
+
"""
|
| 169 |
+
Limita i ristoranti nel tour: al massimo 1 per slot pasto,
|
| 170 |
+
con TW compatibile con lo slot specifico.
|
| 171 |
+
|
| 172 |
+
- Per ogni slot (pranzo, cena) tiene il ristorante con score piΓΉ
|
| 173 |
+
alto la cui TW si sovrappone a quello slot.
|
| 174 |
+
- Rimuove tutti gli altri ristoranti (inclusi quelli di un slot
|
| 175 |
+
sbagliato β es. due ristoranti lunch quando serve un lunch e una cena).
|
| 176 |
+
- Se nessun pasto Γ¨ richiesto, rimuove tutti i ristoranti.
|
| 177 |
+
- BAR e GELATERIA non sono toccati da questo metodo.
|
| 178 |
+
"""
|
| 179 |
+
restaurants_in_tour = [
|
| 180 |
+
p for p in individual.genes
|
| 181 |
+
if p.category == PoICategory.RESTAURANT
|
| 182 |
+
]
|
| 183 |
+
if not restaurants_in_tour:
|
| 184 |
+
return individual
|
| 185 |
+
|
| 186 |
+
meal_slots = self.profile.needs_meal_slot()
|
| 187 |
+
|
| 188 |
+
if not meal_slots:
|
| 189 |
+
individual.genes = [
|
| 190 |
+
p for p in individual.genes
|
| 191 |
+
if p.category != PoICategory.RESTAURANT
|
| 192 |
+
]
|
| 193 |
+
return individual
|
| 194 |
+
|
| 195 |
+
to_keep: set[str] = set()
|
| 196 |
+
for (slot_open, slot_close) in meal_slots:
|
| 197 |
+
# Candidati: ristorante la cui TW si sovrappone allo slot temporale
|
| 198 |
+
# e non Γ¨ giΓ assegnato a un altro slot
|
| 199 |
+
candidates = [
|
| 200 |
+
r for r in restaurants_in_tour
|
| 201 |
+
if r.time_window.open <= slot_close
|
| 202 |
+
and r.time_window.close >= slot_open
|
| 203 |
+
and r.id not in to_keep
|
| 204 |
+
]
|
| 205 |
+
if candidates:
|
| 206 |
+
best = max(candidates, key=lambda r: r.score)
|
| 207 |
+
to_keep.add(best.id)
|
| 208 |
+
|
| 209 |
+
remove = {r.id for r in restaurants_in_tour if r.id not in to_keep}
|
| 210 |
+
individual.genes = [p for p in individual.genes if p.id not in remove]
|
| 211 |
+
return individual
|
| 212 |
+
|
| 213 |
+
def _cap_snacks(self, individual: Individual) -> Individual:
|
| 214 |
+
"""
|
| 215 |
+
Limita le soste snack (BAR, GELATERIA) ai massimi definiti nel profilo.
|
| 216 |
+
Mantiene le soste con score piΓΉ alto, rimuove le eccedenti.
|
| 217 |
+
"""
|
| 218 |
+
for cat, max_stops in [
|
| 219 |
+
(PoICategory.BAR, self.profile.max_bar_stops),
|
| 220 |
+
(PoICategory.GELATERIA, self.profile.max_gelateria_stops),
|
| 221 |
+
]:
|
| 222 |
+
in_tour = [p for p in individual.genes if p.category == cat]
|
| 223 |
+
if len(in_tour) <= max_stops:
|
| 224 |
+
continue
|
| 225 |
+
in_tour.sort(key=lambda p: p.score, reverse=True)
|
| 226 |
+
remove = {p.id for p in in_tour[max_stops:]}
|
| 227 |
+
individual.genes = [p for p in individual.genes if p.id not in remove]
|
| 228 |
+
return individual
|
| 229 |
+
|
| 230 |
+
# ------------------------------------------------------------------
|
| 231 |
+
# 6. Garantisce slot pasto se richiesto dal profilo
|
| 232 |
+
# ------------------------------------------------------------------
|
| 233 |
+
|
| 234 |
+
def _ensure_meal_slots(self, individual: Individual) -> Individual:
|
| 235 |
+
"""
|
| 236 |
+
Per ogni slot pasto richiesto dal profilo, garantisce un ristorante.
|
| 237 |
+
|
| 238 |
+
Strategia:
|
| 239 |
+
1. Prova ad AGGIUNGERE il ristorante senza sforare il budget.
|
| 240 |
+
2. Se non c'Γ¨ spazio, prova a SOSTITUIRE il PoI con il peggior
|
| 241 |
+
rapporto score/durata con il ristorante, se ciΓ² libera abbastanza
|
| 242 |
+
tempo da farci stare il pasto.
|
| 243 |
+
"""
|
| 244 |
+
meal_slots = self.profile.needs_meal_slot()
|
| 245 |
+
if not meal_slots:
|
| 246 |
+
return individual
|
| 247 |
+
|
| 248 |
+
restaurants = [
|
| 249 |
+
p for p in self.all_pois
|
| 250 |
+
if p.category == PoICategory.RESTAURANT
|
| 251 |
+
and self.profile.allows_category(p.category.value)
|
| 252 |
+
and p not in individual.genes
|
| 253 |
+
]
|
| 254 |
+
if not restaurants:
|
| 255 |
+
return individual
|
| 256 |
+
|
| 257 |
+
for (slot_open, slot_close) in meal_slots:
|
| 258 |
+
schedule = self._simulate_schedule(individual.genes)
|
| 259 |
+
already_covered = any(
|
| 260 |
+
stop["poi"].category == PoICategory.RESTAURANT
|
| 261 |
+
and slot_open <= stop["arrival"] <= slot_close
|
| 262 |
+
for stop in schedule
|
| 263 |
+
)
|
| 264 |
+
if already_covered:
|
| 265 |
+
continue
|
| 266 |
+
|
| 267 |
+
inserted = False
|
| 268 |
+
original_poi_ids = {s["poi"].id for s in schedule}
|
| 269 |
+
|
| 270 |
+
# Tolleranza di attesa: slot serali (β₯18:00) ammettono piΓΉ attesa
|
| 271 |
+
# (rientro in hotel, aperitivo, passeggiata pre-cena = comportamento normale)
|
| 272 |
+
|
| 273 |
+
slot_wait_tol = 90 if slot_open >= EVENING_THRESHOLD else self.max_wait_min + 15
|
| 274 |
+
|
| 275 |
+
# --- Tentativo 1: inserimento diretto ---
|
| 276 |
+
for rest in sorted(restaurants, key=lambda r: r.score, reverse=True):
|
| 277 |
+
if rest in individual.genes:
|
| 278 |
+
continue
|
| 279 |
+
for pos in range(len(individual.genes) + 1):
|
| 280 |
+
test_genes = individual.genes[:pos] + [rest] + individual.genes[pos:]
|
| 281 |
+
test_sched = self._simulate_schedule(test_genes)
|
| 282 |
+
if not test_sched:
|
| 283 |
+
continue
|
| 284 |
+
rest_stop = next((s for s in test_sched if s["poi"].id == rest.id), None)
|
| 285 |
+
if rest_stop is None:
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
arrival = rest_stop["arrival"]
|
| 289 |
+
wait_rest = rest_stop["wait"]
|
| 290 |
+
total_t = test_sched[-1]["departure"] - self.start_time
|
| 291 |
+
new_poi_ids = {s["poi"].id for s in test_sched}
|
| 292 |
+
|
| 293 |
+
if (slot_open <= arrival <= slot_close
|
| 294 |
+
and wait_rest <= slot_wait_tol
|
| 295 |
+
and total_t <= self.budget
|
| 296 |
+
and original_poi_ids.issubset(new_poi_ids)):
|
| 297 |
+
individual.genes.insert(pos, rest)
|
| 298 |
+
inserted = True
|
| 299 |
+
break
|
| 300 |
+
if inserted:
|
| 301 |
+
break
|
| 302 |
+
|
| 303 |
+
if inserted:
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
# --- Tentativo 2: sostituzione del PoI meno prezioso ---
|
| 307 |
+
# Ordina i candidati alla rimozione per minor valore (escludi ristoranti giΓ presenti)
|
| 308 |
+
removable = [
|
| 309 |
+
p for p in individual.genes
|
| 310 |
+
if p.category not in (PoICategory.RESTAURANT, PoICategory.BAR, PoICategory.GELATERIA)
|
| 311 |
+
]
|
| 312 |
+
if not removable:
|
| 313 |
+
continue
|
| 314 |
+
removable.sort(key=lambda p: p.score / (p.visit_duration + 1e-9))
|
| 315 |
+
|
| 316 |
+
for victim in removable:
|
| 317 |
+
for rest in sorted(restaurants, key=lambda r: r.score, reverse=True):
|
| 318 |
+
if rest in individual.genes:
|
| 319 |
+
continue
|
| 320 |
+
test_genes = [r if r.id != victim.id else rest for r in individual.genes]
|
| 321 |
+
test_sched = self._simulate_schedule(test_genes)
|
| 322 |
+
if not test_sched:
|
| 323 |
+
continue
|
| 324 |
+
rest_stop = next((s for s in test_sched if s["poi"].id == rest.id), None)
|
| 325 |
+
if rest_stop is None:
|
| 326 |
+
continue
|
| 327 |
+
arrival = rest_stop["arrival"]
|
| 328 |
+
wait_rest = rest_stop["wait"]
|
| 329 |
+
total_t = test_sched[-1]["departure"] - self.start_time
|
| 330 |
+
if (slot_open <= arrival <= slot_close
|
| 331 |
+
and wait_rest <= slot_wait_tol
|
| 332 |
+
and total_t <= self.budget):
|
| 333 |
+
idx = individual.genes.index(victim)
|
| 334 |
+
individual.genes[idx] = rest
|
| 335 |
+
inserted = True
|
| 336 |
+
break
|
| 337 |
+
if inserted:
|
| 338 |
+
break
|
| 339 |
+
|
| 340 |
+
individual.invalidate_cache()
|
| 341 |
+
return individual
|
| 342 |
+
|
| 343 |
+
# ------------------------------------------------------------------
|
| 344 |
+
# Helper interni
|
| 345 |
+
# ------------------------------------------------------------------
|
| 346 |
+
|
| 347 |
+
def _simulate_schedule(self, genes: list[PoI]) -> list[dict]:
|
| 348 |
+
"""
|
| 349 |
+
Simula lo schedule esattamente come FitnessEvaluator.decode():
|
| 350 |
+
- salta solo PoI con arrivo > time_window.close (impossibili)
|
| 351 |
+
- include TUTTE le attese senza soglia
|
| 352 |
+
- applica group_overhead per coerenza con FitnessEvaluator
|
| 353 |
+
|
| 354 |
+
Il filtraggio per max_wait_min avviene solo in repair_time_windows,
|
| 355 |
+
prima di questa simulazione.
|
| 356 |
+
"""
|
| 357 |
+
group_extra = max(0, self.profile.group_size - 1) * 5 # minuti extra per persona
|
| 358 |
+
|
| 359 |
+
stops = []
|
| 360 |
+
time_now = self.start_time
|
| 361 |
+
prev_lat = self.start_lat
|
| 362 |
+
prev_lon = self.start_lon
|
| 363 |
+
|
| 364 |
+
for poi in genes:
|
| 365 |
+
travel = self._travel_min(prev_lat, prev_lon, poi)
|
| 366 |
+
arrival = time_now + travel
|
| 367 |
+
if arrival > poi.time_window.close:
|
| 368 |
+
continue # impossibile: la TW Γ¨ chiusa
|
| 369 |
+
wait = max(0, poi.time_window.open - arrival)
|
| 370 |
+
arrival = arrival + wait
|
| 371 |
+
duration = poi.visit_duration + group_extra
|
| 372 |
+
departure = arrival + duration
|
| 373 |
+
stops.append({"poi": poi, "arrival": arrival, "departure": departure, "wait": wait})
|
| 374 |
+
time_now = departure
|
| 375 |
+
prev_lat = poi.lat
|
| 376 |
+
prev_lon = poi.lon
|
| 377 |
+
|
| 378 |
+
return stops
|
| 379 |
+
|
| 380 |
+
def _simulate_total_time(self, genes: list[PoI]) -> int:
|
| 381 |
+
"""Tempo totale del tour (minuti). Usato da repair_budget."""
|
| 382 |
+
sched = self._simulate_schedule(genes)
|
| 383 |
+
if not sched:
|
| 384 |
+
return self.budget + 1 # tour vuoto β forza rimozione
|
| 385 |
+
return sched[-1]["departure"] - self.start_time
|
| 386 |
+
|
| 387 |
+
def _travel_min(self, lat: float, lon: float, to: PoI) -> int:
|
| 388 |
+
km = haversine_km(lat, lon, to.lat, to.lon) * ROUTE_DETOUR_FACTOR
|
| 389 |
+
return self.profile.travel_time_min(km)
|
ga/seeding.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ga/seeding.py β Inizializzazione della popolazione con greedy seeding.
|
| 3 |
+
Rispetta il TouristProfile in ogni costruzione:
|
| 4 |
+
- Filtra le categorie non ammesse
|
| 5 |
+
- Usa effective_score (con boost tag) nel criterio di selezione
|
| 6 |
+
- Usa travel_time_min del profilo per i tempi
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
import random
|
| 10 |
+
from config import ROUTE_DETOUR_FACTOR
|
| 11 |
+
from core.models import Individual, PoI
|
| 12 |
+
from core.distance import DistanceMatrix, haversine_km
|
| 13 |
+
from core.profile import TouristProfile
|
| 14 |
+
from ga.repair import RepairEngine
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class GreedySeeder:
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
pois: list[PoI],
|
| 22 |
+
dm: DistanceMatrix,
|
| 23 |
+
repair: RepairEngine,
|
| 24 |
+
profile: TouristProfile,
|
| 25 |
+
start_time: int,
|
| 26 |
+
budget: int,
|
| 27 |
+
start_lat: float,
|
| 28 |
+
start_lon: float,
|
| 29 |
+
):
|
| 30 |
+
self.pois = pois
|
| 31 |
+
self.dm = dm
|
| 32 |
+
self.repair = repair
|
| 33 |
+
self.profile = profile
|
| 34 |
+
self.start_time = start_time
|
| 35 |
+
self.budget = budget
|
| 36 |
+
self.start_lat = start_lat
|
| 37 |
+
self.start_lon = start_lon
|
| 38 |
+
# Pool filtrato per categorie ammesse β usato in tutta la seeding
|
| 39 |
+
self.allowed_pois = [
|
| 40 |
+
p for p in pois
|
| 41 |
+
if profile.allows_category(p.category.value)
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
def build_population(self, pop_size: int) -> list[Individual]:
|
| 45 |
+
population = []
|
| 46 |
+
|
| 47 |
+
n_greedy = max(1, int(pop_size * 0.20))
|
| 48 |
+
n_perturbed = max(1, int(pop_size * 0.20))
|
| 49 |
+
n_random = pop_size - n_greedy - n_perturbed
|
| 50 |
+
|
| 51 |
+
for _ in range(n_greedy):
|
| 52 |
+
ind = self._greedy_construct(randomize=False, alpha=0.0)
|
| 53 |
+
ind = self.repair.repair(ind) # β cap snack/ristoranti anche sui greedy
|
| 54 |
+
population.append(ind)
|
| 55 |
+
|
| 56 |
+
for i in range(n_perturbed):
|
| 57 |
+
alpha = 0.15 + (i / n_perturbed) * 0.35
|
| 58 |
+
ind = self._greedy_construct(randomize=True, alpha=alpha)
|
| 59 |
+
ind = self.repair.repair(ind) # β idem
|
| 60 |
+
population.append(ind)
|
| 61 |
+
|
| 62 |
+
for _ in range(n_random):
|
| 63 |
+
shuffled = random.sample(self.allowed_pois, len(self.allowed_pois))
|
| 64 |
+
ind = Individual(genes=shuffled[:random.randint(1, len(shuffled))])
|
| 65 |
+
ind = self.repair.repair(ind)
|
| 66 |
+
population.append(ind)
|
| 67 |
+
|
| 68 |
+
return population
|
| 69 |
+
|
| 70 |
+
def _greedy_construct(self, randomize: bool = False, alpha: float = 0.0) -> Individual:
|
| 71 |
+
"""
|
| 72 |
+
Greedy con RCL. Usa group_overhead per coerenza con FitnessEvaluator.
|
| 73 |
+
Salta i ristoranti (li aggiunge _ensure_meal_slots) e riserva tempo
|
| 74 |
+
solo per gli slot pasto SERALI (β₯18:00), non per il pranzo β il pranzo
|
| 75 |
+
cade nel flusso naturale del tour diurno e viene inserito da
|
| 76 |
+
_ensure_meal_slots senza bisogno di riserva esplicita.
|
| 77 |
+
"""
|
| 78 |
+
group_extra = max(0, self.profile.group_size - 1) * 5
|
| 79 |
+
|
| 80 |
+
# Riserva tempo per ogni slot pasto che il greedy salta (tutti):
|
| 81 |
+
# - slot serali (β₯18:00): 90 min (cena dopo il tour diurno)
|
| 82 |
+
# - slot diurni (<18:00): 75 min (pranzo inserito nel mezzo del tour)
|
| 83 |
+
EVENING_RESERVE = 90
|
| 84 |
+
DAYTIME_RESERVE = 75
|
| 85 |
+
EVENING_THRESHOLD = 1080 # 18:00
|
| 86 |
+
|
| 87 |
+
total_reserve = sum(
|
| 88 |
+
EVENING_RESERVE if slot_open >= EVENING_THRESHOLD else DAYTIME_RESERVE
|
| 89 |
+
for (slot_open, _) in self.profile.needs_meal_slot()
|
| 90 |
+
)
|
| 91 |
+
effective_end = self.start_time + self.budget - total_reserve
|
| 92 |
+
|
| 93 |
+
tour = []
|
| 94 |
+
visited = set()
|
| 95 |
+
time_now = self.start_time
|
| 96 |
+
prev_lat = self.start_lat
|
| 97 |
+
prev_lon = self.start_lon
|
| 98 |
+
|
| 99 |
+
while True:
|
| 100 |
+
candidates = []
|
| 101 |
+
|
| 102 |
+
for poi in self.allowed_pois:
|
| 103 |
+
if poi.id in visited:
|
| 104 |
+
continue
|
| 105 |
+
if poi.category.value == "restaurant":
|
| 106 |
+
continue # ristoranti: aggiunti da _ensure_meal_slots
|
| 107 |
+
|
| 108 |
+
km = haversine_km(prev_lat, prev_lon, poi.lat, poi.lon) * ROUTE_DETOUR_FACTOR
|
| 109 |
+
travel_min = self.profile.travel_time_min(km)
|
| 110 |
+
arrival = time_now + travel_min
|
| 111 |
+
|
| 112 |
+
if arrival > poi.time_window.close:
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
actual_arrival = max(arrival, poi.time_window.open)
|
| 116 |
+
duration = poi.visit_duration + group_extra
|
| 117 |
+
finish = actual_arrival + duration
|
| 118 |
+
if finish > effective_end:
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
overhead = travel_min + max(0, poi.time_window.open - arrival)
|
| 122 |
+
eff_score = self.profile.effective_score(poi)
|
| 123 |
+
ratio = eff_score / (overhead + duration + 1e-9)
|
| 124 |
+
candidates.append((ratio, poi, actual_arrival, finish))
|
| 125 |
+
|
| 126 |
+
if not candidates:
|
| 127 |
+
break
|
| 128 |
+
|
| 129 |
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 130 |
+
|
| 131 |
+
if randomize and len(candidates) > 1 and random.random() < alpha:
|
| 132 |
+
rcl_size = max(1, int(len(candidates) * 0.20))
|
| 133 |
+
_, poi, _, finish = random.choice(candidates[:rcl_size])
|
| 134 |
+
else:
|
| 135 |
+
_, poi, _, finish = candidates[0]
|
| 136 |
+
|
| 137 |
+
tour.append(poi)
|
| 138 |
+
visited.add(poi.id)
|
| 139 |
+
prev_lat = poi.lat
|
| 140 |
+
prev_lon = poi.lon
|
| 141 |
+
time_now = finish
|
| 142 |
+
|
| 143 |
+
return Individual(genes=tour)
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.104.0
|
| 2 |
+
uvicorn>=0.24.0
|
| 3 |
+
streamlit>=1.28.0
|
| 4 |
+
pydantic>=2.0.0
|
| 5 |
+
pandas>=2.0.0
|
| 6 |
+
requests>=2.31.0
|
| 7 |
+
python-multipart>=0.0.22
|
| 8 |
+
huggingface-hub>=0.19.0
|
solver.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
solver.py β Entry point principale. Ciclo evolutivo NSGA-II per TOP-TW.
|
| 3 |
+
Uso:
|
| 4 |
+
python -m tour_ga.solver --city rome --budget 480 --generations 200
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import random
|
| 8 |
+
import time
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Callable
|
| 11 |
+
|
| 12 |
+
from core.models import Individual
|
| 13 |
+
from core.distance import DistanceMatrix
|
| 14 |
+
from core.fitness import FitnessEvaluator
|
| 15 |
+
from ga.operators import (
|
| 16 |
+
tournament_select, order_crossover, poi_aware_crossover, mutate
|
| 17 |
+
)
|
| 18 |
+
from ga.repair import RepairEngine
|
| 19 |
+
from ga.seeding import GreedySeeder
|
| 20 |
+
import config
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class SolverConfig:
|
| 25 |
+
pop_size: int = config.GA_POP_SIZE
|
| 26 |
+
max_generations: int = config.GA_MAX_GENERATIONS
|
| 27 |
+
cx_prob: float = config.GA_CX_PROB
|
| 28 |
+
mut_prob: float = config.GA_MUT_PROB
|
| 29 |
+
tournament_k: int = config.GA_TOURNAMENT_K
|
| 30 |
+
stagnation_limit: int = config.GA_STAGNATION_LIMIT
|
| 31 |
+
start_time: int = config.DEFAULT_START_TIME
|
| 32 |
+
budget: int = config.DEFAULT_BUDGET
|
| 33 |
+
start_lat: float = config.DEFAULT_START_LAT
|
| 34 |
+
start_lon: float = config.DEFAULT_START_LON
|
| 35 |
+
w_score: float = config.W_SCORE
|
| 36 |
+
w_dist: float = config.W_DIST
|
| 37 |
+
w_time: float = config.W_TIME
|
| 38 |
+
max_wait_min: int = config.GA_MAX_WAIT_MIN
|
| 39 |
+
ox_crossover_prob: float = config.GA_OX_CROSSOVER_PROB
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class NSGA2Solver:
|
| 43 |
+
"""
|
| 44 |
+
Implementazione NSGA-II adattata al problema TOP-TW.
|
| 45 |
+
Riceve un TouristProfile e lo propaga a tutti i componenti.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(self, pois, dm: DistanceMatrix, config: SolverConfig, profile=None):
|
| 49 |
+
from core.profile import TouristProfile # import locale per evitare dipendenze circolari
|
| 50 |
+
self.pois = pois
|
| 51 |
+
self.dm = dm
|
| 52 |
+
self.config = config
|
| 53 |
+
self.profile = profile or TouristProfile() # default: turista generico a piedi
|
| 54 |
+
|
| 55 |
+
# Inietta il profilo nella matrice distanze (per la velocitΓ )
|
| 56 |
+
self.dm.profile = self.profile
|
| 57 |
+
|
| 58 |
+
self.repair = RepairEngine(
|
| 59 |
+
dm=dm,
|
| 60 |
+
profile=self.profile,
|
| 61 |
+
all_pois=pois,
|
| 62 |
+
start_time=config.start_time,
|
| 63 |
+
budget=config.budget,
|
| 64 |
+
start_lat=config.start_lat,
|
| 65 |
+
start_lon=config.start_lon,
|
| 66 |
+
max_wait_min=config.max_wait_min,
|
| 67 |
+
)
|
| 68 |
+
self.evaluator = FitnessEvaluator(
|
| 69 |
+
dist_matrix=dm,
|
| 70 |
+
profile=self.profile,
|
| 71 |
+
start_time=config.start_time,
|
| 72 |
+
budget=config.budget,
|
| 73 |
+
start_lat=config.start_lat,
|
| 74 |
+
start_lon=config.start_lon,
|
| 75 |
+
w_score=config.w_score,
|
| 76 |
+
w_dist=config.w_dist,
|
| 77 |
+
w_time=config.w_time,
|
| 78 |
+
)
|
| 79 |
+
self.seeder = GreedySeeder(
|
| 80 |
+
pois=pois,
|
| 81 |
+
dm=dm,
|
| 82 |
+
repair=self.repair,
|
| 83 |
+
profile=self.profile,
|
| 84 |
+
start_time=config.start_time,
|
| 85 |
+
budget=config.budget,
|
| 86 |
+
start_lat=config.start_lat,
|
| 87 |
+
start_lon=config.start_lon,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
self.history: list[dict] = [] # statistiche per generazione
|
| 91 |
+
|
| 92 |
+
def solve(self, callback: Callable | None = None) -> list[Individual]:
|
| 93 |
+
"""
|
| 94 |
+
Esegue il ciclo NSGA-II e restituisce il fronte di Pareto finale.
|
| 95 |
+
callback(gen, pareto_front, stats) viene chiamata ogni generazione.
|
| 96 |
+
"""
|
| 97 |
+
cfg = self.config
|
| 98 |
+
t0 = time.perf_counter()
|
| 99 |
+
|
| 100 |
+
# --- Inizializzazione ---
|
| 101 |
+
population = self.seeder.build_population(cfg.pop_size)
|
| 102 |
+
population = self._evaluate_all(population)
|
| 103 |
+
population = self._nsga2_select(population + [], cfg.pop_size)
|
| 104 |
+
|
| 105 |
+
best_scalar = max(ind.fitness.scalar for ind in population)
|
| 106 |
+
stagnation = 0
|
| 107 |
+
|
| 108 |
+
for gen in range(cfg.max_generations):
|
| 109 |
+
# --- Generazione figli ---
|
| 110 |
+
offspring = []
|
| 111 |
+
while len(offspring) < cfg.pop_size:
|
| 112 |
+
p1 = tournament_select(population, cfg.tournament_k)
|
| 113 |
+
p2 = tournament_select(population, cfg.tournament_k)
|
| 114 |
+
|
| 115 |
+
# Alterna tra OX e PoI-aware crossover
|
| 116 |
+
if random.random() < cfg.cx_prob:
|
| 117 |
+
if random.random() < cfg.ox_crossover_prob:
|
| 118 |
+
c1, c2 = order_crossover(p1, p2)
|
| 119 |
+
else:
|
| 120 |
+
c1, c2 = poi_aware_crossover(p1, p2)
|
| 121 |
+
else:
|
| 122 |
+
c1, c2 = p1.clone(), p2.clone()
|
| 123 |
+
|
| 124 |
+
c1 = mutate(c1, self.seeder.allowed_pois, cfg.mut_prob)
|
| 125 |
+
c2 = mutate(c2, self.seeder.allowed_pois, cfg.mut_prob)
|
| 126 |
+
|
| 127 |
+
# Riparazione obbligatoria dopo ogni operatore
|
| 128 |
+
c1 = self.repair.repair(c1)
|
| 129 |
+
c2 = self.repair.repair(c2)
|
| 130 |
+
|
| 131 |
+
offspring.extend([c1, c2])
|
| 132 |
+
|
| 133 |
+
# --- Valutazione e selezione NSGA-II ---
|
| 134 |
+
offspring = self._evaluate_all(offspring)
|
| 135 |
+
combined = population + offspring
|
| 136 |
+
population = self._nsga2_select(combined, cfg.pop_size)
|
| 137 |
+
|
| 138 |
+
# --- Statistiche e criterio di stop ---
|
| 139 |
+
pareto = [ind for ind in population if ind.fitness.rank == 1]
|
| 140 |
+
new_best = max(ind.fitness.scalar for ind in population)
|
| 141 |
+
|
| 142 |
+
stats = {
|
| 143 |
+
"gen": gen + 1,
|
| 144 |
+
"pareto_size": len(pareto),
|
| 145 |
+
"best_scalar": round(new_best, 4),
|
| 146 |
+
"avg_score": round(
|
| 147 |
+
sum(ind.fitness.total_score for ind in population) / len(population), 3
|
| 148 |
+
),
|
| 149 |
+
"feasible_pct": round(
|
| 150 |
+
sum(1 for ind in population if ind.fitness.is_feasible) / len(population) * 100, 1
|
| 151 |
+
),
|
| 152 |
+
"elapsed_s": round(time.perf_counter() - t0, 2),
|
| 153 |
+
}
|
| 154 |
+
self.history.append(stats)
|
| 155 |
+
|
| 156 |
+
if callback:
|
| 157 |
+
callback(gen + 1, pareto, stats)
|
| 158 |
+
|
| 159 |
+
if new_best > best_scalar + 1e-6:
|
| 160 |
+
best_scalar = new_best
|
| 161 |
+
stagnation = 0
|
| 162 |
+
else:
|
| 163 |
+
stagnation += 1
|
| 164 |
+
|
| 165 |
+
if stagnation >= cfg.stagnation_limit:
|
| 166 |
+
print(f" Early stop a gen {gen+1}: stagnazione per {stagnation} generazioni.")
|
| 167 |
+
break
|
| 168 |
+
|
| 169 |
+
pareto_front = [ind for ind in population if ind.fitness.rank == 1]
|
| 170 |
+
return sorted(pareto_front, key=lambda x: -x.fitness.total_score)
|
| 171 |
+
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
# NSGA-II core: fast non-dominated sort + crowding distance
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def _evaluate_all(self, pop: list[Individual]) -> list[Individual]:
|
| 177 |
+
for ind in pop:
|
| 178 |
+
self.evaluator.evaluate(ind)
|
| 179 |
+
return pop
|
| 180 |
+
|
| 181 |
+
def _nsga2_select(
|
| 182 |
+
self, combined: list[Individual], target_size: int
|
| 183 |
+
) -> list[Individual]:
|
| 184 |
+
"""Selezione NSGA-II: ranking Pareto + crowding distance."""
|
| 185 |
+
fronts = self._fast_non_dominated_sort(combined)
|
| 186 |
+
|
| 187 |
+
next_pop: list[Individual] = []
|
| 188 |
+
for front in fronts:
|
| 189 |
+
if len(next_pop) + len(front) <= target_size:
|
| 190 |
+
next_pop.extend(front)
|
| 191 |
+
else:
|
| 192 |
+
self._assign_crowding_distance(front)
|
| 193 |
+
front.sort(key=lambda x: x.fitness.crowd, reverse=True)
|
| 194 |
+
next_pop.extend(front[:target_size - len(next_pop)])
|
| 195 |
+
break
|
| 196 |
+
|
| 197 |
+
return next_pop
|
| 198 |
+
|
| 199 |
+
def _fast_non_dominated_sort(
|
| 200 |
+
self, pop: list[Individual]
|
| 201 |
+
) -> list[list[Individual]]:
|
| 202 |
+
"""
|
| 203 |
+
Algoritmo NSGA-II O(MNΒ²) per la costruzione dei fronti.
|
| 204 |
+
M = numero obiettivi, N = dimensione popolazione.
|
| 205 |
+
"""
|
| 206 |
+
n = len(pop)
|
| 207 |
+
dom_count = [0] * n # quanti individui dominano i
|
| 208 |
+
dom_set = [[] for _ in range(n)] # individui dominati da i
|
| 209 |
+
fronts = [[]]
|
| 210 |
+
|
| 211 |
+
for i in range(n):
|
| 212 |
+
for j in range(n):
|
| 213 |
+
if i == j:
|
| 214 |
+
continue
|
| 215 |
+
fi, fj = pop[i].fitness, pop[j].fitness
|
| 216 |
+
if fi.dominates(fj):
|
| 217 |
+
dom_set[i].append(j)
|
| 218 |
+
elif fj.dominates(fi):
|
| 219 |
+
dom_count[i] += 1
|
| 220 |
+
if dom_count[i] == 0:
|
| 221 |
+
pop[i].fitness.rank = 1
|
| 222 |
+
fronts[0].append(pop[i])
|
| 223 |
+
|
| 224 |
+
rank = 1
|
| 225 |
+
current_front = fronts[0]
|
| 226 |
+
while current_front:
|
| 227 |
+
next_front = []
|
| 228 |
+
for ind in current_front:
|
| 229 |
+
idx_i = pop.index(ind)
|
| 230 |
+
for idx_j in dom_set[idx_i]:
|
| 231 |
+
dom_count[idx_j] -= 1
|
| 232 |
+
if dom_count[idx_j] == 0:
|
| 233 |
+
pop[idx_j].fitness.rank = rank + 1
|
| 234 |
+
next_front.append(pop[idx_j])
|
| 235 |
+
rank += 1
|
| 236 |
+
fronts.append(next_front)
|
| 237 |
+
current_front = next_front
|
| 238 |
+
|
| 239 |
+
return [f for f in fronts if f]
|
| 240 |
+
|
| 241 |
+
def _assign_crowding_distance(self, front: list[Individual]):
|
| 242 |
+
"""Calcola la crowding distance per il front dato."""
|
| 243 |
+
n = len(front)
|
| 244 |
+
if n == 0:
|
| 245 |
+
return
|
| 246 |
+
for ind in front:
|
| 247 |
+
ind.fitness.crowd = 0.0
|
| 248 |
+
|
| 249 |
+
objectives = [
|
| 250 |
+
lambda x: x.fitness.total_score, # massimizza
|
| 251 |
+
lambda x: -x.fitness.total_distance, # minimizza β negativo
|
| 252 |
+
lambda x: -x.fitness.total_time, # minimizza β negativo
|
| 253 |
+
]
|
| 254 |
+
for obj_fn in objectives:
|
| 255 |
+
sorted_f = sorted(front, key=obj_fn)
|
| 256 |
+
sorted_f[0].fitness.crowd = float('inf')
|
| 257 |
+
sorted_f[-1].fitness.crowd = float('inf')
|
| 258 |
+
f_min = obj_fn(sorted_f[0])
|
| 259 |
+
f_max = obj_fn(sorted_f[-1])
|
| 260 |
+
f_range = f_max - f_min or 1e-9
|
| 261 |
+
for i in range(1, n - 1):
|
| 262 |
+
sorted_f[i].fitness.crowd += (
|
| 263 |
+
obj_fn(sorted_f[i+1]) - obj_fn(sorted_f[i-1])
|
| 264 |
+
) / f_range
|
streamlit_ui.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
streamlit_ui.py β Streamlit UI for Tour Generator.
|
| 3 |
+
Allows uploading POIs and selecting profile to generate tours.
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import requests
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from huggingface_hub import CommitScheduler
|
| 13 |
+
|
| 14 |
+
DATASET_REPO_ID = "NextGenTech/tour-generator-logs"
|
| 15 |
+
|
| 16 |
+
LOG_DIR = Path("data")
|
| 17 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
LOG_FILE = LOG_DIR / "tour_results.jsonl"
|
| 19 |
+
|
| 20 |
+
scheduler = CommitScheduler(
|
| 21 |
+
repo_id=DATASET_REPO_ID,
|
| 22 |
+
repo_type="dataset",
|
| 23 |
+
folder_path=LOG_DIR,
|
| 24 |
+
path_in_repo="logs",
|
| 25 |
+
every=15
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
st.title("πΊοΈ Tour Generator")
|
| 29 |
+
st.markdown("Upload Points of Interest and select a user profile to generate an optimized tour using genetic algorithms.")
|
| 30 |
+
|
| 31 |
+
# Get available profiles
|
| 32 |
+
try:
|
| 33 |
+
response = requests.get("http://localhost:8000/profiles")
|
| 34 |
+
if response.status_code == 200:
|
| 35 |
+
available_profiles = response.json()["profiles"]
|
| 36 |
+
else:
|
| 37 |
+
available_profiles = ["cultural_walker", "foodie_transit", "family_mixed", "art_lover_car"]
|
| 38 |
+
except:
|
| 39 |
+
available_profiles = ["cultural_walker", "foodie_transit", "family_mixed", "art_lover_car"]
|
| 40 |
+
|
| 41 |
+
st.header("π Upload Points of Interest (POIs)")
|
| 42 |
+
pois_file = st.file_uploader(
|
| 43 |
+
"Upload POIs as CSV or JSON file",
|
| 44 |
+
type=['csv', 'json'],
|
| 45 |
+
help="CSV columns: id,name,lat,lon,score,visit_duration,time_window_open,time_window_close,category,tags\nJSON: list of POI objects"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
st.header("π€ Select User Profile")
|
| 49 |
+
profile_option = st.radio("Profile Type", ["Predefined", "Custom"])
|
| 50 |
+
|
| 51 |
+
profile_name: Optional[str] = None
|
| 52 |
+
profile_data: Optional[dict] = None
|
| 53 |
+
|
| 54 |
+
if profile_option == "Predefined":
|
| 55 |
+
profile_name = st.selectbox("Choose a predefined profile", available_profiles)
|
| 56 |
+
if profile_name:
|
| 57 |
+
try:
|
| 58 |
+
response = requests.get(f"http://localhost:8000/profiles/{profile_name}")
|
| 59 |
+
if response.status_code == 200:
|
| 60 |
+
profile_data = response.json()
|
| 61 |
+
st.subheader(f"Profile Details: {profile_name}")
|
| 62 |
+
st.json(profile_data)
|
| 63 |
+
except:
|
| 64 |
+
st.warning("Could not load profile details")
|
| 65 |
+
else:
|
| 66 |
+
profile_file = st.file_uploader(
|
| 67 |
+
"Upload custom profile JSON",
|
| 68 |
+
type=['json'],
|
| 69 |
+
help="JSON object with TouristProfile fields"
|
| 70 |
+
)
|
| 71 |
+
if profile_file:
|
| 72 |
+
try:
|
| 73 |
+
profile_data = json.load(profile_file)
|
| 74 |
+
st.json(profile_data)
|
| 75 |
+
except:
|
| 76 |
+
st.error("Invalid JSON file")
|
| 77 |
+
|
| 78 |
+
st.header("βοΈ Tour Parameters")
|
| 79 |
+
col1, col2 = st.columns(2)
|
| 80 |
+
with col1:
|
| 81 |
+
budget = st.number_input("Time Budget (minutes)", value=480, min_value=60, help="Total available time for the tour")
|
| 82 |
+
start_lat = st.number_input("Start Latitude", value=41.9028, format="%.4f")
|
| 83 |
+
with col2:
|
| 84 |
+
start_time = st.number_input("Start Time (minutes from midnight)", value=540, min_value=0, max_value=1439, help="e.g., 540 = 9:00 AM")
|
| 85 |
+
start_lon = st.number_input("Start Longitude", value=12.4964, format="%.4f")
|
| 86 |
+
|
| 87 |
+
if st.button("π Generate Tour", type="primary"):
|
| 88 |
+
if not pois_file:
|
| 89 |
+
st.error("Please upload a POIs file")
|
| 90 |
+
st.stop()
|
| 91 |
+
|
| 92 |
+
if profile_option == "Custom" and not profile_data:
|
| 93 |
+
st.error("Please upload a custom profile JSON")
|
| 94 |
+
st.stop()
|
| 95 |
+
|
| 96 |
+
# Prepare request
|
| 97 |
+
files = {'pois_file': pois_file}
|
| 98 |
+
data = {
|
| 99 |
+
'budget': budget,
|
| 100 |
+
'start_time': start_time,
|
| 101 |
+
'start_lat': start_lat,
|
| 102 |
+
'start_lon': start_lon,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if profile_name:
|
| 106 |
+
data['profile_name'] = profile_name
|
| 107 |
+
elif profile_data:
|
| 108 |
+
data['profile_json'] = json.dumps(profile_data)
|
| 109 |
+
|
| 110 |
+
with st.spinner("Generating tour... This may take a few moments."):
|
| 111 |
+
try:
|
| 112 |
+
response = requests.post("http://localhost:8000/generate_tour", files=files, data=data, timeout=60)
|
| 113 |
+
|
| 114 |
+
if response.status_code == 200:
|
| 115 |
+
result = response.json()
|
| 116 |
+
|
| 117 |
+
# Log the result
|
| 118 |
+
log_entry = {
|
| 119 |
+
"timestamp": str(pd.Timestamp.now()),
|
| 120 |
+
"profile": profile_name if profile_name else "custom",
|
| 121 |
+
"budget": budget,
|
| 122 |
+
"start_time": start_time,
|
| 123 |
+
"start_lat": start_lat,
|
| 124 |
+
"start_lon": start_lon,
|
| 125 |
+
"result": result
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
with scheduler.lock:
|
| 129 |
+
with LOG_FILE.open("a", encoding="utf-8") as f:
|
| 130 |
+
json.dump(log_entry, f, ensure_ascii=False)
|
| 131 |
+
f.write("\n")
|
| 132 |
+
|
| 133 |
+
st.success("β
Tour generated successfully!")
|
| 134 |
+
|
| 135 |
+
# Display summary
|
| 136 |
+
col1, col2, col3 = st.columns(3)
|
| 137 |
+
with col1:
|
| 138 |
+
st.metric("Total Score", f"{result['total_score']:.2f}")
|
| 139 |
+
with col2:
|
| 140 |
+
st.metric("Total Distance", f"{result['total_distance']:.1f} km")
|
| 141 |
+
with col3:
|
| 142 |
+
st.metric("Total Time", f"{result['total_time']} min")
|
| 143 |
+
|
| 144 |
+
st.write(f"**Feasible:** {'β
Yes' if result['is_feasible'] else 'β No'}")
|
| 145 |
+
|
| 146 |
+
# Display stops
|
| 147 |
+
st.header("π Tour Itinerary")
|
| 148 |
+
if result['stops']:
|
| 149 |
+
stops_df = pd.DataFrame(result['stops'])
|
| 150 |
+
stops_df['arrival_time'] = stops_df['arrival'].apply(lambda x: f"{x//60:02d}:{x%60:02d}")
|
| 151 |
+
stops_df['departure_time'] = stops_df['departure'].apply(lambda x: f"{x//60:02d}:{x%60:02d}")
|
| 152 |
+
stops_df['wait_min'] = stops_df['wait']
|
| 153 |
+
|
| 154 |
+
st.dataframe(
|
| 155 |
+
stops_df[['poi_name', 'arrival_time', 'departure_time', 'wait_min', 'travel_distance_km', 'travel_time_min']],
|
| 156 |
+
width="stretch"
|
| 157 |
+
)
|
| 158 |
+
else:
|
| 159 |
+
st.info("No stops in the generated tour")
|
| 160 |
+
|
| 161 |
+
else:
|
| 162 |
+
st.error(f"Error: {response.status_code} - {response.text}")
|
| 163 |
+
|
| 164 |
+
except requests.exceptions.RequestException as e:
|
| 165 |
+
st.error(f"Failed to connect to the API. Make sure the FastAPI server is running on localhost:8000\nError: {str(e)}")
|
| 166 |
+
|
| 167 |
+
st.markdown("---")
|
| 168 |
+
st.markdown("**Note:** Make sure to start the FastAPI server first: `python app.py` or `uvicorn app:app --reload`")
|