File size: 11,594 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
"""
core/profile.py — Profilo del turista con tutte le preferenze personali.
È l'oggetto centrale che attraversa TUTTO il pipeline GA:
  DistanceMatrix → velocità di spostamento per modalità
  GreedySeeder   → whitelist categorie + slot pasto garantito
  RepairEngine   → blacklist categorie + rimozione violazioni profilo
  FitnessEvaluator → boost/malus score per tag di interesse
  GeneticOperators → add/remove usa solo PoI ammessi dal profilo
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from config import (MIXED_THRESHOLD_M, TRANSIT_WALK_THRESHOLD_KM, SCORE_BOOST_CAP,
                    TRANSIT_OVERHEAD_MIN, CAR_OVERHEAD_MIN, SPEED_KMH,
                    MIXED_LONG_SPEED_KMH, WANT_LUNCH, WANT_DINNER, LUNCH_TIME,
                    DINNER_TIME, MEAL_WINDOW, MAX_BAR_STOPS, MAX_GELATERIA_STOPS)


class TransportMode(Enum):
    WALK    = "walk"      # tutto a piedi (centro storico)
    CAR     = "car"       # auto / taxi
    TRANSIT = "transit"   # bus + metro
    MIXED   = "mixed"     # a piedi <MIXED_THRESHOLD_M, transit/auto oltre


class MobilityLevel(Enum):
    NORMAL  = "normal"    # nessuna limitazione
    LIMITED = "limited"   # difficoltà con scale, distanze lunghe → penalizza PoI lontani


# Soglia in metri sotto la quale si va a piedi anche in modalità MIXED
#MIXED_THRESHOLD_M = 600

# Soglia al di sotto della quale, anche in modalità TRANSIT, si cammina:
# prendere un mezzo per 300m è spesso più lento che andare a piedi.
#TRANSIT_WALK_THRESHOLD_KM = 0.40

# Overhead fisso per ogni tratta in mezzo pubblico (minuti):
# comprende cammino alla fermata + attesa + cammino dalla fermata.
# Roma: metro ha frequenza ~5 min, bus ~8-12 min → media ~10 min overhead.
#TRANSIT_OVERHEAD_MIN = 10

# Overhead per auto/taxi: parcheggio + spostamento a piedi dal parcheggio.
#CAR_OVERHEAD_MIN = 5

# Velocità medie di percorrenza (escluso overhead)
# SPEED_TABLE: dict[tuple[TransportMode, MobilityLevel], float] = {
#     (TransportMode.WALK,    MobilityLevel.NORMAL):  4.5,
#     (TransportMode.WALK,    MobilityLevel.LIMITED): 3.0,
#     (TransportMode.CAR,     MobilityLevel.NORMAL):  25.0,
#     (TransportMode.CAR,     MobilityLevel.LIMITED): 25.0,
#     (TransportMode.TRANSIT, MobilityLevel.NORMAL):  20.0,  # velocità effettiva metro/bus Roma
#     (TransportMode.TRANSIT, MobilityLevel.LIMITED): 16.0,
#     (TransportMode.MIXED,   MobilityLevel.NORMAL):  4.5,   # usata per segmenti brevi
#     (TransportMode.MIXED,   MobilityLevel.LIMITED): 3.0,
# }

# MIXED_LONG_SPEED: dict[MobilityLevel, float] = {
#     MobilityLevel.NORMAL:  20.0,
#     MobilityLevel.LIMITED: 16.0,
# }


@dataclass
class TouristProfile:
    """
    Preferenze complete del turista.
    Tutti i campi hanno un default sensato per un turista generico.
    """

    # --- Trasporto ---
    transport_mode: TransportMode = TransportMode.WALK
    mobility:       MobilityLevel = MobilityLevel.NORMAL

    # --- Categorie ammesse ---
    # Se una categoria non è in questa lista, i suoi PoI vengono
    # completamente ignorati in seeding, repair e mutation.
    allowed_categories: list[str] = field(default_factory=lambda: [
        "museum", "monument", "restaurant", "park", "viewpoint"
    ])

    # --- Preferenze pasti ---
    want_lunch:  bool = WANT_LUNCH
    want_dinner: bool = WANT_DINNER
    lunch_time:  int  = LUNCH_TIME
    dinner_time: int  = DINNER_TIME
    meal_window: int  = MEAL_WINDOW

    # --- Soste snack (bar, gelateria) ---
    # Numero massimo di soste snack per tipo nel tour.
    # None = nessun limite (utile per profili food-focused).
    max_bar_stops:       int = MAX_BAR_STOPS
    max_gelateria_stops: int = MAX_GELATERIA_STOPS

    # --- Interessi tematici (tag) ---
    # Ogni tag elencato riceve un boost moltiplicativo allo score del PoI.
    # Es. {"arte": 1.5, "antico": 1.3} → i musei d'arte valgono 50% di più.
    tag_weights: dict[str, float] = field(default_factory=dict)

    # --- Budget economico ---
    max_entry_fee: Optional[float] = None   # euro; None = nessun limite

    # --- Gruppo ---
    group_size: int = 1   # utile per entry fee totale e ritmo di visita

    def __post_init__(self):
        # Normalizza le categorie in minuscolo
        self.allowed_categories = [c.lower() for c in self.allowed_categories]
            # Coerci transport_mode da stringa a enum se necessario
        if isinstance(self.transport_mode, str):
            self.transport_mode = TransportMode(self.transport_mode.lower())

        # Coerci mobility da stringa a enum se necessario
        if isinstance(self.mobility, str):
            self.mobility = MobilityLevel(self.mobility.lower())

    def allows_category(self, category_value: str) -> bool:
        """Restituisce True se la categoria è ammessa dal profilo."""
        return category_value.lower() in self.allowed_categories

    def effective_score(self, poi) -> float:
        """
        Score del PoI moltiplicato per i boost dei tag di interesse.
        Un PoI senza tag corrispondenti mantiene lo score base.
        """
        boost = 1.0
        for tag in poi.tags:
            if tag in self.tag_weights:
                boost += self.tag_weights[tag] - 1.0  # additive boost
        return min(poi.score * boost, SCORE_BOOST_CAP)  
    
    def travel_speed_kmh(self, dist_km: float) -> float:
        """Velocità di percorrenza pura (senza overhead fisso)."""
        if self.transport_mode == TransportMode.MIXED:
            dist_m = dist_km * 1000
            if dist_m <= MIXED_THRESHOLD_M:
                return SPEED_KMH[(TransportMode.WALK.value, self.mobility.value)]
            else:
                return MIXED_LONG_SPEED_KMH[self.mobility]
        return SPEED_KMH.get((self.transport_mode.value, self.mobility.value))

    def travel_time_min(self, dist_km: float) -> int:
        """
        Tempo di percorrenza realistico in minuti.

        Modello per modalità:
          WALK    → dist / v_walk
          CAR     → dist / v_car  + CAR_OVERHEAD (parcheggio)
          TRANSIT → se dist < soglia: a piedi (prendere il mezzo non conviene)
                    altrimenti: dist / v_transit + TRANSIT_OVERHEAD (attesa + fermata)
          MIXED   → a piedi se dist < MIXED_THRESHOLD, altrimenti come TRANSIT
        """
        mode = self.transport_mode
        walk_speed = SPEED_KMH[(TransportMode.WALK.value, self.mobility.value)]

        if mode == TransportMode.WALK:
            return max(1, int((dist_km / walk_speed) * 60))

        if mode == TransportMode.CAR:
            speed = SPEED_KMH.get((TransportMode.CAR.value, self.mobility.value))
            return max(3, int((dist_km / speed) * 60) + CAR_OVERHEAD_MIN)

        if mode == TransportMode.TRANSIT:
            if dist_km < TRANSIT_WALK_THRESHOLD_KM:
                # Distanza troppo corta: a piedi è più veloce del mezzo
                return max(1, int((dist_km / walk_speed) * 60))
            speed = SPEED_KMH[(TransportMode.TRANSIT.value, self.mobility.value)]
            ride  = int((dist_km / speed) * 60)
            return ride + TRANSIT_OVERHEAD_MIN

        if mode == TransportMode.MIXED:
            dist_m = dist_km * 1000
            if dist_m <= MIXED_THRESHOLD_M:
                return max(1, int((dist_km / walk_speed) * 60))
            long_speed = MIXED_LONG_SPEED_KMH[self.mobility.value]
            ride = int((dist_km / long_speed) * 60)
            return ride + TRANSIT_OVERHEAD_MIN

        # Fallback
        return max(1, int((dist_km / walk_speed) * 60))

    def needs_meal_slot(self) -> list[tuple[int, int]]:
        """
        Restituisce la lista di finestre temporali in cui il profilo
        richiede un ristorante nel tour.
        Es. [(660, 780), (1080, 1200)] per pranzo+cena.
        """
        slots = []
        if self.want_lunch:
            slots.append((
                self.lunch_time - self.meal_window,
                self.lunch_time + self.meal_window
            ))
        if self.want_dinner:
            slots.append((
                self.dinner_time - self.meal_window,
                self.dinner_time + self.meal_window
            ))
        return slots

    def summary(self) -> str:
        lines = [
            f"Trasporto : {self.transport_mode.value}  |  Mobilità: {self.mobility.value}",
            f"Categorie : {', '.join(self.allowed_categories)}",
            f"Pranzo    : {'sì (' + str(self.lunch_time//60) + ':00)' if self.want_lunch else 'no'}  |  "
            f"Cena: {'sì (' + str(self.dinner_time//60) + ':00)' if self.want_dinner else 'no'}",
        ]
        if self.tag_weights:
            tw = ", ".join(f"{k}×{v}" for k, v in self.tag_weights.items())
            lines.append(f"Interessi : {tw}")
        if self.max_entry_fee is not None:
            lines.append(f"Budget biglietti: €{self.max_entry_fee:.0f} max a PoI")
        return "\n".join(lines)


# ---------------------------------------------------------------------------
# Factory: profili predefiniti pronti all'uso
# ---------------------------------------------------------------------------
#TODO: rivedere la definizione di tutti i profili e i pesi, facendo attenzione alle allowed_categories e alle tag_weights per coerenza interna.
def profile_cultural_walker() -> TouristProfile:
    """Turista culturale a piedi, interessato ad arte e storia. Include una sosta pranzo."""
    return TouristProfile(
        transport_mode     = TransportMode.WALK,
        allowed_categories = ["museum", "monument", "viewpoint", "restaurant", "bar", "gelateria"],
        want_lunch         = True,
        want_dinner        = False,
        tag_weights        = {"arte": 1.4, "antico": 1.3, "rinascimento": 1.5, "unesco": 1.2},
        max_bar_stops      = 1,
        max_gelateria_stops= 1
    )


def profile_foodie_transit() -> TouristProfile:
    """Turista gastronomico con mezzi pubblici, ristoranti inclusi."""
    return TouristProfile(
        transport_mode     = TransportMode.TRANSIT,
        allowed_categories = ["restaurant", "monument", "bar", "gelateria", "viewpoint", "park"],
        want_lunch         = True,
        want_dinner        = True,
        tag_weights        = {"cucina_romana": 1.6, "offal": 0.5, "vivace": 1.2},
        max_bar_stops      = 2,
        max_gelateria_stops= 2
    )


def profile_family_mixed() -> TouristProfile:
    """Famiglia con bambini: percorso misto, evita musei pesanti."""
    return TouristProfile(
        transport_mode     = TransportMode.MIXED,
        mobility           = MobilityLevel.LIMITED,
        allowed_categories = ["monument", "park", "viewpoint", "restaurant", "bar", "gelateria"],
        want_lunch         = True,
        want_dinner        = False,
        group_size         = 4,
        tag_weights        = {"fotogenico": 1.3, "vivace": 1.2},
        max_entry_fee      = 15.0,
        max_bar_stops      = 1,
        max_gelateria_stops= 1
    )


def profile_art_lover_car() -> TouristProfile:
    """Appassionato d'arte con auto: vuole visitare musei anche lontani."""
    return TouristProfile(
        transport_mode     = TransportMode.CAR,
        allowed_categories = ["museum", "monument", "bar", "gelateria"],
        want_lunch         = True,
        want_dinner        = False,
        tag_weights        = {"arte": 1.6, "scultura": 1.5, "rinascimento": 1.4, "antico": 1.1},
        max_entry_fee      = 30.0,
        max_bar_stops      = 1,
        max_gelateria_stops= 1
        
    )