File size: 8,259 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
"""
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)