Tour_Generator_GA / core /fitness.py
GaetanoParente's picture
first commit
639f871
"""
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