Spaces:
Sleeping
Sleeping
| """ | |
| 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 |