File size: 16,194 Bytes
639f871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
"""
ga/repair.py — Motore di riparazione genetica con profilo turista.
Garantisce:
  1. Solo PoI di categorie ammesse dal profilo
  2. Nessuna time window violata
  3. Budget rispettato
  4. Slot pasto presente se richiesto dal profilo
"""
from __future__ import annotations
#import random
from config import ROUTE_DETOUR_FACTOR
from core.models import Individual, PoI, PoICategory
from core.distance import DistanceMatrix, haversine_km
from core.profile import TouristProfile
from config import (MAX_WAIT_MIN, GROUP_VISIT_OVERHEAD_PER_PERSON, 
                    MEAL_RESERVE_MIN, EVENING_THRESHOLD, )

class RepairEngine:

    def __init__(
        self,
        dm:          DistanceMatrix,
        profile:     TouristProfile,
        all_pois:    list[PoI],
        start_time:  int,
        budget:      int,
        start_lat:   float,
        start_lon:   float,
        max_wait_min: int = MAX_WAIT_MIN,   # attesa massima tollerata per un singolo PoI (minuti)
    ):
        self.dm           = dm
        self.profile      = profile
        self.all_pois     = all_pois
        self.start_time   = start_time
        self.budget       = budget
        self.start_lat    = start_lat
        self.start_lon    = start_lon
        self.max_wait_min = max_wait_min

    def repair(self, individual: Individual) -> Individual:
        """Pipeline: categoria → EDF → TW → budget → cap ristoranti → cap snack → pasto → TW finale."""
        individual.invalidate_cache()
        individual = self._filter_allowed_categories(individual)
        individual = self._sort_by_earliest_deadline(individual)
        individual = self.repair_time_windows(individual)
        individual = self.repair_budget(individual)
        individual = self._cap_restaurants(individual)
        individual = self._cap_snacks(individual)
        individual = self._ensure_meal_slots(individual)
        # Passaggio finale: rimuove PoI diventati infeasible dopo l'inserimento
        # del ristorante (es. l'ultimo monumento che ora arriva fuori TW)
        individual = self.repair_time_windows(individual)
        individual.invalidate_cache()
        return individual

    # ------------------------------------------------------------------
    # 1. Rimuove PoI di categorie non ammesse dal profilo
    # ------------------------------------------------------------------

    def _filter_allowed_categories(self, individual: Individual) -> Individual:
        individual.genes = [
            p for p in individual.genes
            if self.profile.allows_category(p.category.value)
        ]
        return individual

    # ------------------------------------------------------------------
    # 2. Ordina per Earliest Deadline First
    # ------------------------------------------------------------------

    def _sort_by_earliest_deadline(self, individual: Individual) -> Individual:
        """
        Ordina per earliest OPEN time: visita prima i PoI che aprono prima.
        Evita che i ristoranti (open=720) finiscano in cima creando attese enormi.
        """
        individual.genes.sort(key=lambda p: p.time_window.open)
        return individual

    # ------------------------------------------------------------------
    # 3. Rimuove PoI con TW violata
    # ------------------------------------------------------------------

    def repair_time_windows(self, individual: Individual) -> Individual:
        """
        Simula il tour e rimuove i PoI che causano problemi:
          - arrivo dopo la chiusura (infeasible)
          - attesa all'apertura superiore a max_wait_min (tour non realistico)
        Usa group_overhead per coerenza con FitnessEvaluator.
        """
        group_extra = max(0, self.profile.group_size - 1) * GROUP_VISIT_OVERHEAD_PER_PERSON

        valid    = []
        time_now = self.start_time
        prev_lat = self.start_lat
        prev_lon = self.start_lon

        for poi in individual.genes:
            travel  = self._travel_min(prev_lat, prev_lon, poi)
            arrival = time_now + travel

            if arrival > poi.time_window.close:
                continue   # fuori orario

            wait = max(0, poi.time_window.open - arrival)
            if wait > self.max_wait_min:
                continue   # attesa inaccettabile

            arrival   = arrival + wait
            departure = arrival + poi.visit_duration + group_extra

            valid.append(poi)
            time_now = departure
            prev_lat = poi.lat
            prev_lon = poi.lon

        individual.genes = valid
        return individual

    # ------------------------------------------------------------------
    # 4. Rimuove PoI finché budget rispettato (minor score/durata prima)
    # ------------------------------------------------------------------

    def repair_budget(self, individual: Individual) -> Individual:
        """
        Rimuove PoI finché il budget è rispettato, riservando tempo
        solo per gli slot pasto SERALI (cena) non ancora coperti.
        Il pranzo (≈12:00) cade naturalmente nel flusso diurno e non
        richiede una riserva esplicita nel budget.
        """
        while True:
            sched = self._simulate_schedule(individual.genes)
            if not individual.genes:
                break

            # Conta solo slot SERALI non ancora coperti da un ristorante
            meal_slots = self.profile.needs_meal_slot()
            uncovered_evening = sum(
                1 for (slot_open, slot_close) in meal_slots
                if slot_open >= EVENING_THRESHOLD
                and not any(
                    s["poi"].category == PoICategory.RESTAURANT
                    and slot_open <= s["arrival"] <= slot_close
                    for s in sched
                )
            )

            effective_budget = self.budget - uncovered_evening * MEAL_RESERVE_MIN
            total = sched[-1]["departure"] - self.start_time if sched else 0

            if total <= effective_budget:
                break

            removable = [
                p for p in individual.genes
                if p.category != PoICategory.RESTAURANT
            ] or individual.genes

            worst = min(removable, key=lambda p: p.score / (p.visit_duration + 1e-9))
            individual.genes.remove(worst)

        return individual

    # ------------------------------------------------------------------
    # 5. Limita i ristoranti al numero di slot pasto richiesti
    # ------------------------------------------------------------------

    def _cap_restaurants(self, individual: Individual) -> Individual:
        """
        Limita i ristoranti nel tour: al massimo 1 per slot pasto,
        con TW compatibile con lo slot specifico.

        - Per ogni slot (pranzo, cena) tiene il ristorante con score più
          alto la cui TW si sovrappone a quello slot.
        - Rimuove tutti gli altri ristoranti (inclusi quelli di un slot
          sbagliato — es. due ristoranti lunch quando serve un lunch e una cena).
        - Se nessun pasto è richiesto, rimuove tutti i ristoranti.
        - BAR e GELATERIA non sono toccati da questo metodo.
        """
        restaurants_in_tour = [
            p for p in individual.genes
            if p.category == PoICategory.RESTAURANT
        ]
        if not restaurants_in_tour:
            return individual

        meal_slots = self.profile.needs_meal_slot()

        if not meal_slots:
            individual.genes = [
                p for p in individual.genes
                if p.category != PoICategory.RESTAURANT
            ]
            return individual

        to_keep: set[str] = set()
        for (slot_open, slot_close) in meal_slots:
            # Candidati: ristorante la cui TW si sovrappone allo slot temporale
            # e non è già assegnato a un altro slot
            candidates = [
                r for r in restaurants_in_tour
                if r.time_window.open <= slot_close
                and r.time_window.close >= slot_open
                and r.id not in to_keep
            ]
            if candidates:
                best = max(candidates, key=lambda r: r.score)
                to_keep.add(best.id)

        remove = {r.id for r in restaurants_in_tour if r.id not in to_keep}
        individual.genes = [p for p in individual.genes if p.id not in remove]
        return individual

    def _cap_snacks(self, individual: Individual) -> Individual:
        """
        Limita le soste snack (BAR, GELATERIA) ai massimi definiti nel profilo.
        Mantiene le soste con score più alto, rimuove le eccedenti.
        """
        for cat, max_stops in [
            (PoICategory.BAR,       self.profile.max_bar_stops),
            (PoICategory.GELATERIA, self.profile.max_gelateria_stops),
        ]:
            in_tour = [p for p in individual.genes if p.category == cat]
            if len(in_tour) <= max_stops:
                continue
            in_tour.sort(key=lambda p: p.score, reverse=True)
            remove = {p.id for p in in_tour[max_stops:]}
            individual.genes = [p for p in individual.genes if p.id not in remove]
        return individual

    # ------------------------------------------------------------------
    # 6. Garantisce slot pasto se richiesto dal profilo
    # ------------------------------------------------------------------

    def _ensure_meal_slots(self, individual: Individual) -> Individual:
        """
        Per ogni slot pasto richiesto dal profilo, garantisce un ristorante.

        Strategia:
          1. Prova ad AGGIUNGERE il ristorante senza sforare il budget.
          2. Se non c'è spazio, prova a SOSTITUIRE il PoI con il peggior
             rapporto score/durata con il ristorante, se ciò libera abbastanza
             tempo da farci stare il pasto.
        """
        meal_slots = self.profile.needs_meal_slot()
        if not meal_slots:
            return individual

        restaurants = [
            p for p in self.all_pois
            if p.category == PoICategory.RESTAURANT
            and self.profile.allows_category(p.category.value)
            and p not in individual.genes
        ]
        if not restaurants:
            return individual

        for (slot_open, slot_close) in meal_slots:
            schedule = self._simulate_schedule(individual.genes)
            already_covered = any(
                stop["poi"].category == PoICategory.RESTAURANT
                and slot_open <= stop["arrival"] <= slot_close
                for stop in schedule
            )
            if already_covered:
                continue

            inserted = False
            original_poi_ids = {s["poi"].id for s in schedule}

            # Tolleranza di attesa: slot serali (≥18:00) ammettono più attesa
            # (rientro in hotel, aperitivo, passeggiata pre-cena = comportamento normale)
            
            slot_wait_tol = 90 if slot_open >= EVENING_THRESHOLD else self.max_wait_min + 15

            # --- Tentativo 1: inserimento diretto ---
            for rest in sorted(restaurants, key=lambda r: r.score, reverse=True):
                if rest in individual.genes:
                    continue
                for pos in range(len(individual.genes) + 1):
                    test_genes = individual.genes[:pos] + [rest] + individual.genes[pos:]
                    test_sched = self._simulate_schedule(test_genes)
                    if not test_sched:
                        continue
                    rest_stop = next((s for s in test_sched if s["poi"].id == rest.id), None)
                    if rest_stop is None:
                        continue

                    arrival   = rest_stop["arrival"]
                    wait_rest = rest_stop["wait"]
                    total_t   = test_sched[-1]["departure"] - self.start_time
                    new_poi_ids = {s["poi"].id for s in test_sched}

                    if (slot_open <= arrival <= slot_close
                            and wait_rest <= slot_wait_tol
                            and total_t <= self.budget
                            and original_poi_ids.issubset(new_poi_ids)):
                        individual.genes.insert(pos, rest)
                        inserted = True
                        break
                if inserted:
                    break

            if inserted:
                continue

            # --- Tentativo 2: sostituzione del PoI meno prezioso ---
            # Ordina i candidati alla rimozione per minor valore (escludi ristoranti già presenti)
            removable = [
                p for p in individual.genes
                if p.category not in (PoICategory.RESTAURANT, PoICategory.BAR, PoICategory.GELATERIA)
            ]
            if not removable:
                continue
            removable.sort(key=lambda p: p.score / (p.visit_duration + 1e-9))

            for victim in removable:
                for rest in sorted(restaurants, key=lambda r: r.score, reverse=True):
                    if rest in individual.genes:
                        continue
                    test_genes = [r if r.id != victim.id else rest for r in individual.genes]
                    test_sched = self._simulate_schedule(test_genes)
                    if not test_sched:
                        continue
                    rest_stop = next((s for s in test_sched if s["poi"].id == rest.id), None)
                    if rest_stop is None:
                        continue
                    arrival   = rest_stop["arrival"]
                    wait_rest = rest_stop["wait"]
                    total_t   = test_sched[-1]["departure"] - self.start_time
                    if (slot_open <= arrival <= slot_close
                            and wait_rest <= slot_wait_tol
                            and total_t <= self.budget):
                        idx = individual.genes.index(victim)
                        individual.genes[idx] = rest
                        inserted = True
                        break
                if inserted:
                    break

        individual.invalidate_cache()
        return individual

    # ------------------------------------------------------------------
    # Helper interni
    # ------------------------------------------------------------------

    def _simulate_schedule(self, genes: list[PoI]) -> list[dict]:
        """
        Simula lo schedule esattamente come FitnessEvaluator.decode():
          - salta solo PoI con arrivo > time_window.close (impossibili)
          - include TUTTE le attese senza soglia
          - applica group_overhead per coerenza con FitnessEvaluator

        Il filtraggio per max_wait_min avviene solo in repair_time_windows,
        prima di questa simulazione.
        """
        group_extra = max(0, self.profile.group_size - 1) * 5  # minuti extra per persona

        stops    = []
        time_now = self.start_time
        prev_lat = self.start_lat
        prev_lon = self.start_lon

        for poi in genes:
            travel  = self._travel_min(prev_lat, prev_lon, poi)
            arrival = time_now + travel
            if arrival > poi.time_window.close:
                continue  # impossibile: la TW è chiusa
            wait      = max(0, poi.time_window.open - arrival)
            arrival   = arrival + wait
            duration  = poi.visit_duration + group_extra
            departure = arrival + duration
            stops.append({"poi": poi, "arrival": arrival, "departure": departure, "wait": wait})
            time_now = departure
            prev_lat = poi.lat
            prev_lon = poi.lon

        return stops

    def _simulate_total_time(self, genes: list[PoI]) -> int:
        """Tempo totale del tour (minuti). Usato da repair_budget."""
        sched = self._simulate_schedule(genes)
        if not sched:
            return self.budget + 1   # tour vuoto → forza rimozione
        return sched[-1]["departure"] - self.start_time

    def _travel_min(self, lat: float, lon: float, to: PoI) -> int:
        km = haversine_km(lat, lon, to.lat, to.lon) * ROUTE_DETOUR_FACTOR
        return self.profile.travel_time_min(km)