GaetanoParente's picture
first commit
639f871
"""
app.py — FastAPI backend for Tour Generator.
Exposes API endpoints for generating tours.
"""
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from pydantic import BaseModel
import json
from typing import List, Optional
from core.models import PoI, PoICategory, TimeWindow
from core.profile import (
TouristProfile, MobilityLevel, TransportMode,
profile_cultural_walker, profile_foodie_transit,
profile_family_mixed, profile_art_lover_car
)
from core.distance import DistanceMatrix, haversine_km
from solver import NSGA2Solver, SolverConfig
import pandas as pd
app = FastAPI(title="Tour Generator API", description="API for generating optimized tours using genetic algorithms")
# Predefined profiles
PREDEFINED_PROFILES = {
"cultural_walker": profile_cultural_walker(),
"foodie_transit": profile_foodie_transit(),
"family_mixed": profile_family_mixed(),
"art_lover_car": profile_art_lover_car(),
}
class PoIModel(BaseModel):
id: str
name: str
lat: float
lon: float
score: float
visit_duration: int
time_window_open: int
time_window_close: int
category: str
tags: List[str] = []
class ProfileModel(BaseModel):
transport_mode: TransportMode = TransportMode.WALK
mobility: MobilityLevel = MobilityLevel.NORMAL
allowed_categories: List[str] = ["museum", "monument", "restaurant", "park", "viewpoint"]
want_lunch: bool = True
want_dinner: bool = True
lunch_time: int = 720
dinner_time: int = 1140
meal_window: int = 120
max_bar_stops: int = 2
max_gelateria_stops: int = 1
tag_weights: dict = {}
max_entry_fee: Optional[float] = None
group_size: int = 1
@app.post("/generate_tour")
async def generate_tour(
pois_file: Optional[UploadFile] = File(None),
pois_json: Optional[str] = Form(None),
profile_name: Optional[str] = Form(None),
profile_json: Optional[str] = Form(None),
budget: int = Form(480),
start_time: int = Form(540),
start_lat: float = Form(41.9028),
start_lon: float = Form(12.4964),
):
"""
Generate an optimized tour based on POIs and user profile.
- pois_file: Upload CSV or JSON file with POIs
- pois_json: JSON string with list of POIs
- profile_name: Name of predefined profile
- profile_json: JSON string with custom profile
- budget: Time budget in minutes
- start_time: Start time in minutes from midnight
- start_lat/lon: Starting coordinates
"""
# Load POIs
pois = []
if pois_file:
content = await pois_file.read()
if pois_file.filename.endswith('.csv'):
df = pd.read_csv(pd.io.common.BytesIO(content))
for _, row in df.iterrows():
pois.append(PoI(
id=str(row['id']),
name=str(row['name']),
lat=float(row['lat']),
lon=float(row['lon']),
score=float(row['score']),
visit_duration=int(row['visit_duration']),
time_window=TimeWindow(int(row['time_window_open']), int(row['time_window_close'])),
category=PoICategory(row['category']),
tags=str(row.get('tags', '')).split(',') if pd.notna(row.get('tags')) else []
))
elif pois_file.filename.endswith('.json'):
data = json.loads(content.decode('utf-8'))
for p in data:
pois.append(PoI(
id=p['id'],
name=p['name'],
lat=p['lat'],
lon=p['lon'],
score=p['score'],
visit_duration=p['visit_duration'],
time_window=TimeWindow(p['time_window']['open'], p['time_window']['close']),
category=PoICategory(p['category']),
tags=p.get('tags', [])
))
else:
raise HTTPException(status_code=400, detail="Unsupported file type for POIs. Use CSV or JSON.")
elif pois_json:
pois_data = json.loads(pois_json)
for p in pois_data:
pois.append(PoI(
id=p['id'],
name=p['name'],
lat=p['lat'],
lon=p['lon'],
score=p['score'],
visit_duration=p['visit_duration'],
time_window=TimeWindow(p['time_window']['open'], p['time_window']['close']),
category=PoICategory(p['category']),
tags=p.get('tags', [])
))
else:
raise HTTPException(status_code=400, detail="POIs not provided. Upload a file or provide JSON.")
# Load profile
if profile_name:
if profile_name in PREDEFINED_PROFILES:
profile = PREDEFINED_PROFILES[profile_name]
else:
raise HTTPException(status_code=400, detail=f"Invalid profile name. Available: {list(PREDEFINED_PROFILES.keys())}")
elif profile_json:
profile_data = json.loads(profile_json)
profile = TouristProfile(**profile_data)
else:
profile = TouristProfile() # default
# Create distance matrix
dm = DistanceMatrix(pois, profile)
# Config
config = SolverConfig(budget=budget, start_time=start_time, start_lat=start_lat, start_lon=start_lon)
# Solve
def cb(gen, pareto, stats):
if gen % 30 == 0 or gen == 1:
print(f" gen {gen:3d} | pareto={stats['pareto_size']:2d} | "
f"best={stats['best_scalar']:.4f} | feasible={stats['feasible_pct']:.0f}%")
solver = NSGA2Solver(pois, dm, config, profile)
population = solver.solve(callback=cb)
feasible = [x for x in population if x.fitness.is_feasible] or population
if not feasible:
raise HTTPException(status_code=500, detail="No solutions found")
# Get best tour (highest scalar fitness)
best = max(feasible, key=lambda individual: individual.fitness.scalar)
tour = solver.evaluator.decode(best)
if tour is None:
raise HTTPException(status_code=500, detail="Failed to generate schedule")
# Return as dict
stops_list = []
for i, s in enumerate(tour.stops):
if i == 0:
dist = haversine_km(start_lat, start_lon, s.poi.lat, s.poi.lon)
else:
dist = haversine_km(tour.stops[i-1].poi.lat, tour.stops[i-1].poi.lon, s.poi.lat, s.poi.lon)
time_min = profile.travel_time_min(dist)
stop_dict = {
"poi_id": s.poi.id,
"poi_name": s.poi.name,
"arrival": s.arrival,
"departure": s.departure,
"wait": s.wait,
"travel_distance_km": round(dist, 2),
"travel_time_min": time_min
}
stops_list.append(stop_dict)
return {
"total_score": best.fitness.total_score,
"total_distance": tour.total_distance,
"total_time": tour.total_time,
"is_feasible": tour.is_feasible,
"stops": stops_list
}
@app.get("/profiles")
def get_profiles():
"""Get list of available predefined profiles."""
return {"profiles": list(PREDEFINED_PROFILES.keys())}
@app.get("/profiles/{name}")
def get_profile(name: str):
"""Get details of a specific predefined profile."""
if name in PREDEFINED_PROFILES:
profile = PREDEFINED_PROFILES[name]
return {
"transport_mode": profile.transport_mode.value,
"mobility": profile.mobility.value,
"allowed_categories": profile.allowed_categories,
"want_lunch": profile.want_lunch,
"want_dinner": profile.want_dinner,
"lunch_time": profile.lunch_time,
"dinner_time": profile.dinner_time,
"meal_window": profile.meal_window,
"max_bar_stops": profile.max_bar_stops,
"max_gelateria_stops": profile.max_gelateria_stops,
"tag_weights": profile.tag_weights,
"max_entry_fee": profile.max_entry_fee,
"group_size": profile.group_size
}
else:
raise HTTPException(status_code=404, detail="Profile not found")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)