GaetanoParente commited on
Commit
639f871
Β·
1 Parent(s): c8fa249

first commit

Browse files
Files changed (23) hide show
  1. .gitignore +131 -0
  2. Dockerfile +18 -0
  3. README.md +544 -1
  4. __init__.py +0 -0
  5. app.py +225 -0
  6. config.py +216 -0
  7. core/__init__.py +30 -0
  8. core/distance.py +68 -0
  9. core/fitness.py +165 -0
  10. core/models.py +135 -0
  11. core/profile.py +275 -0
  12. data/__init__.py +0 -0
  13. data/custom_profile.json +25 -0
  14. data/pois.json +475 -0
  15. data/tour_results +3 -0
  16. demo_rome.py +134 -0
  17. ga/__init__.py +0 -0
  18. ga/operators.py +191 -0
  19. ga/repair.py +389 -0
  20. ga/seeding.py +143 -0
  21. requirements.txt +8 -0
  22. solver.py +264 -0
  23. 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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ '&copy; <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`")