File size: 6,743 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
"""
core/fitness.py — Valutazione fitness multi-obiettivo con profilo turista.
I tre obiettivi (score, distanza, tempo) vengono calcolati tenendo conto di:
  - tag_weights del profilo → boost/malus per score effettivo
  - transport_mode → velocità di spostamento corretta
  - want_lunch / want_dinner → penalità se manca il ristorante atteso
"""
from __future__ import annotations
from core.models import Individual, FitnessScore, TourSchedule, ScheduledStop, PoICategory
from core.distance import DistanceMatrix
from core.profile import TouristProfile
from config import (W_SCORE, W_DIST, W_TIME, PENALTY_BUDGET_OVERRUN, PENALTY_MEAL_MISSING,
                    GROUP_VISIT_OVERHEAD_PER_PERSON, ROUTE_DETOUR_FACTOR, MAX_DIST_TRANSIT_KM,
                    FITNESS_UTILIZATION_BONUS_FACTOR,
                    WAIT_PENALTY_FACTOR, SCORE_BOOST_CAP, MAX_DIST_WALK_KM)


class FitnessEvaluator:

    def __init__(
        self,
        dist_matrix:  DistanceMatrix,
        profile:      TouristProfile,
        start_time:   int,
        budget:       int,
        start_lat:    float,
        start_lon:    float,
        w_score:      float = W_SCORE,
        w_dist:       float = W_DIST,
        w_time:       float = W_TIME,
        penalty:      float = PENALTY_BUDGET_OVERRUN,
        meal_penalty: float = PENALTY_MEAL_MISSING,
    ):
        self.dm           = dist_matrix
        self.profile      = profile
        self.start_time   = start_time
        self.budget       = budget
        self.start_lat    = start_lat
        self.start_lon    = start_lon
        self.w_score      = w_score
        self.w_dist       = w_dist
        self.w_time       = w_time
        self.penalty      = penalty
        self.meal_penalty = meal_penalty

    def decode(self, individual: Individual) -> TourSchedule:
        if individual._schedule is not None:
            return individual._schedule

        schedule      = TourSchedule()
        time_now      = self.start_time
        prev_lat      = self.start_lat
        prev_lon      = self.start_lon
        total_dist_km = 0.0
        total_wait_min = 0
        feasible      = True

        for poi in individual.genes:
            km         = self._km(prev_lat, prev_lon, poi)
            travel_min = self.profile.travel_time_min(km)
            total_dist_km += km

            arrival = time_now + travel_min

            if arrival > poi.time_window.close:
                feasible = False
                wait = 0
            else:
                wait    = max(0, poi.time_window.open - arrival)
                arrival = max(arrival, poi.time_window.open)

            # Visita più lunga in gruppo: +5 min per persona extra
            duration  = poi.visit_duration + max(0, self.profile.group_size - 1) * GROUP_VISIT_OVERHEAD_PER_PERSON
            departure = arrival + duration
            time_now  = departure
            prev_lat  = poi.lat
            prev_lon  = poi.lon
            total_wait_min += wait   # accumula attese per penalità

            schedule.stops.append(ScheduledStop(
                poi=poi, arrival=arrival, departure=departure, wait=wait
            ))

        end_time = self.start_time + self.budget
        schedule.total_time     = time_now - self.start_time
        schedule.total_distance = round(total_dist_km, 2)
        schedule.total_wait     = total_wait_min
        schedule.is_feasible    = feasible and (time_now <= end_time)
        individual._schedule    = schedule
        return schedule

    def evaluate(self, individual: Individual) -> FitnessScore:
        schedule = self.decode(individual)
        end_time = self.start_time + self.budget

        # Score effettivo con boost da tag_weights del profilo
        total_score = sum(
            self.profile.effective_score(stop.poi)
            for stop in schedule.stops
        )

        time_over         = max(0, (self.start_time + schedule.total_time) - end_time)
        missing_meal_pen  = self._meal_coverage_penalty(schedule)

        # Normalizzazione basata sui PoI AMMESSI dal profilo (non sul totale)
        allowed_pois = [
            p for p in self.dm.pois
            if self.profile.allows_category(p.category.value)
        ]
        max_score = max(len(allowed_pois) * SCORE_BOOST_CAP, 1.0)
        max_dist  = MAX_DIST_TRANSIT_KM if self.profile.transport_mode.value in ("car", "transit") else MAX_DIST_WALK_KM

        norm_score  = total_score / max_score
        norm_dist   = min(schedule.total_distance / max_dist, 1.0)
        time_over_h = (time_over / 60) * self.penalty
        # Penalizza attese eccessive (oltre 5 min totali)
        total_wait   = getattr(schedule, 'total_wait', 0)
        wait_penalty = max(0, (total_wait - 5) / 60) * WAIT_PENALTY_FACTOR
        # Bonus per utilizzo del budget: incentiva tour più ricchi.
        # Senza questo termine, il GA converge a tour corti (meno distanza).
        # Il bonus cresce linearmente con i minuti usati, cappato al budget.
        utilization_bonus = min(schedule.total_time, self.budget) / self.budget * self.w_score * FITNESS_UTILIZATION_BONUS_FACTOR

        scalar = (
            self.w_score * norm_score
            + utilization_bonus         # premia l'uso del budget disponibile
            - self.w_dist * norm_dist
            - time_over_h
            - wait_penalty
            - missing_meal_pen
        )

        fitness = FitnessScore(
            total_score    = round(total_score, 4),
            total_distance = schedule.total_distance,
            total_time     = schedule.total_time,
            is_feasible    = schedule.is_feasible,
            scalar         = round(scalar, 6),
        )
        individual.fitness = fitness
        return fitness

    def _meal_coverage_penalty(self, schedule: TourSchedule) -> float:
        """
        Penalità per ogni slot pasto richiesto dal profilo ma non coperto.
        NON si applica se il profilo non include la categoria ristorante:
        non ha senso penalizzare chi ha esplicitamente escluso i ristoranti.
        """
        if "restaurant" not in self.profile.allowed_categories:
            return 0.0

        penalty = 0.0
        for (slot_open, slot_close) in self.profile.needs_meal_slot():
            covered = any(
                stop.poi.category == PoICategory.RESTAURANT
                and slot_open <= stop.arrival <= slot_close
                for stop in schedule.stops
            )
            if not covered:
                penalty += self.meal_penalty
        return penalty

    def _km(self, lat: float, lon: float, poi) -> float:
        from .distance import haversine_km
        return haversine_km(lat, lon, poi.lat, poi.lon) * ROUTE_DETOUR_FACTOR