Compare commits
23 Commits
backend/fe
...
96b0718081
Author | SHA1 | Date | |
---|---|---|---|
96b0718081 | |||
d9e5d9dac6 | |||
b0f9d31ee2 | |||
54bc9028ad | |||
37926e68ec | |||
e2d3d29956 | |||
6921ab57f8 | |||
f6d0cd5360 | |||
7a18830e99 | |||
ba14a0279e | |||
5a2c61d343 | |||
5e27dd9d79 | |||
d92001faaf | |||
73f0dc8361 | |||
05092e55f1 | |||
83be4b7616 | |||
8a9ec6b4d8 | |||
8c3145dfc9 | |||
2bf38119d6 | |||
ca711c614f | |||
357edf3000 | |||
444c47e3a4 | |||
da6ab207d9 |
6
backend/.gitignore
vendored
6
backend/.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
# osm-cache
|
||||
cache_XML/
|
||||
|
||||
# secrets
|
||||
*secrets.yaml
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -9,6 +12,9 @@ __pycache__/
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Pytest reports
|
||||
report.html
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
|
363
backend/landmarks.json
Normal file
363
backend/landmarks.json
Normal file
@@ -0,0 +1,363 @@
|
||||
[
|
||||
{
|
||||
"name": "Chinatown",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7554934,
|
||||
4.8444852
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 996515596,
|
||||
"attractiveness": 129,
|
||||
"n_tags": 0,
|
||||
"image_url": null,
|
||||
"website_url": null,
|
||||
"wiki_url": null,
|
||||
"keywords": {},
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "285d159c-68ee-4b37-8d71-f27ee3d38b02",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Galeries Lafayette",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7627107,
|
||||
4.8556833
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 1069872743,
|
||||
"attractiveness": 197,
|
||||
"n_tags": 11,
|
||||
"image_url": null,
|
||||
"website_url": "http://www.galerieslafayette.com/",
|
||||
"wiki_url": null,
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "28f1bc30-10d3-4944-8861-0ed9abca012d",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Muji",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7615971,
|
||||
4.8543781
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 1044165817,
|
||||
"attractiveness": 259,
|
||||
"n_tags": 14,
|
||||
"image_url": null,
|
||||
"website_url": "https://www.muji.com/fr/",
|
||||
"wiki_url": null,
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": "Muji",
|
||||
"uuid": "957f86a5-6c00-41a2-815d-d6f739052be4",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "HEMA",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7619133,
|
||||
4.8565239
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 1069872750,
|
||||
"attractiveness": 156,
|
||||
"n_tags": 9,
|
||||
"image_url": null,
|
||||
"website_url": "https://fr.westfield.com/lapartdieu/store/HEMA/www.hema.fr",
|
||||
"wiki_url": null,
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "8dae9d3e-e4c4-4e80-941d-0b106e22c85b",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Cordeliers",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7622752,
|
||||
4.8337998
|
||||
],
|
||||
"osm_type": "node",
|
||||
"osm_id": 5545183519,
|
||||
"attractiveness": 813,
|
||||
"n_tags": 0,
|
||||
"image_url": null,
|
||||
"website_url": null,
|
||||
"wiki_url": null,
|
||||
"keywords": {},
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "ba02adb5-e28f-4645-8c2d-25ead6232379",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Halles de Lyon Paul Bocuse",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7628282,
|
||||
4.8505601
|
||||
],
|
||||
"osm_type": "relation",
|
||||
"osm_id": 971529,
|
||||
"attractiveness": 272,
|
||||
"n_tags": 12,
|
||||
"image_url": null,
|
||||
"website_url": "https://www.halles-de-lyon-paulbocuse.com/",
|
||||
"wiki_url": "fr:Halles de Lyon-Paul Bocuse",
|
||||
"keywords": {
|
||||
"importance": "national",
|
||||
"height": null,
|
||||
"place_type": "marketplace",
|
||||
"date": null
|
||||
},
|
||||
"description": "Halles de Lyon Paul Bocuse is a marketplace of national importance.",
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "bbd50de3-aa91-425d-90c2-d4abfd1b4abe",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Grand Bazar",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7632141,
|
||||
4.8361975
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 82399951,
|
||||
"attractiveness": 93,
|
||||
"n_tags": 7,
|
||||
"image_url": null,
|
||||
"website_url": null,
|
||||
"wiki_url": null,
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "3de9131c-87c5-4efb-9fa8-064896fb8b29",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Shopping Area",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7673452,
|
||||
4.8438683
|
||||
],
|
||||
"osm_type": "node",
|
||||
"osm_id": 0,
|
||||
"attractiveness": 156,
|
||||
"n_tags": 0,
|
||||
"image_url": null,
|
||||
"website_url": null,
|
||||
"wiki_url": null,
|
||||
"keywords": {},
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "df2482a8-7e2e-4536-aad3-564899b2fa65",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Cour Oxyg\u00e8ne",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7620905,
|
||||
4.8568873
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 132673030,
|
||||
"attractiveness": 63,
|
||||
"n_tags": 5,
|
||||
"image_url": null,
|
||||
"website_url": null,
|
||||
"wiki_url": null,
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "ed134f76-9a02-4bee-9c10-78454f7bc4ce",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "P\u00f4le de Commerces et de Loisirs Confluence",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7410414,
|
||||
4.8171031
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 440270633,
|
||||
"attractiveness": 259,
|
||||
"n_tags": 14,
|
||||
"image_url": null,
|
||||
"website_url": "https://www.confluence.fr/",
|
||||
"wiki_url": null,
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "dd7e2f5f-0e60-4560-b903-e5ded4b6e36a",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Grand H\u00f4tel-Dieu",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7586955,
|
||||
4.8364597
|
||||
],
|
||||
"osm_type": "relation",
|
||||
"osm_id": 300128,
|
||||
"attractiveness": 546,
|
||||
"n_tags": 22,
|
||||
"image_url": null,
|
||||
"website_url": "https://grand-hotel-dieu.com",
|
||||
"wiki_url": "fr:H\u00f4tel-Dieu de Lyon",
|
||||
"keywords": {
|
||||
"importance": "international",
|
||||
"height": null,
|
||||
"place_type": "building",
|
||||
"date": "C17"
|
||||
},
|
||||
"description": "Grand H\u00f4tel-Dieu is an internationally famous building. It was constructed in C17.",
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "a91265a8-ffbd-44f7-a7ab-3ff75f08fbab",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Westfield La Part-Dieu",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.761331,
|
||||
4.855676
|
||||
],
|
||||
"osm_type": "way",
|
||||
"osm_id": 62338376,
|
||||
"attractiveness": 546,
|
||||
"n_tags": 22,
|
||||
"image_url": null,
|
||||
"website_url": "https://fr.westfield.com/lapartdieu",
|
||||
"wiki_url": "fr:La Part-Dieu (centre commercial)",
|
||||
"keywords": null,
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "7d60316f-d689-4fcf-be68-ffc09353b826",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
},
|
||||
{
|
||||
"name": "Ainay",
|
||||
"type": "shopping",
|
||||
"location": [
|
||||
45.7553105,
|
||||
4.8312084
|
||||
],
|
||||
"osm_type": "node",
|
||||
"osm_id": 5545126047,
|
||||
"attractiveness": 132,
|
||||
"n_tags": 0,
|
||||
"image_url": null,
|
||||
"website_url": null,
|
||||
"wiki_url": null,
|
||||
"keywords": {},
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"name_en": null,
|
||||
"uuid": "ad214f3d-a4b9-4078-876a-446caa7ab01c",
|
||||
"must_do": false,
|
||||
"must_avoid": false,
|
||||
"is_secondary": false,
|
||||
"time_to_reach_next": 0,
|
||||
"next_uuid": null,
|
||||
"is_viewpoint": false,
|
||||
"is_place_of_worship": false
|
||||
}
|
||||
]
|
File diff suppressed because one or more lines are too long
0
backend/src/landmarks/__init__.py
Normal file
0
backend/src/landmarks/__init__.py
Normal file
@@ -8,8 +8,8 @@ from pydantic import BaseModel
|
||||
|
||||
from ..overpass.overpass import Overpass, get_base_info
|
||||
from ..structs.landmark import Landmark
|
||||
from .get_time_distance import get_distance
|
||||
from .utils import create_bbox
|
||||
from ..utils.get_time_distance import get_distance
|
||||
from ..utils.bbox import create_bbox
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ class ClusterManager:
|
||||
out = out
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching clusters: {e}")
|
||||
self.logger.warning(f"Error fetching clusters: {e}")
|
||||
|
||||
if result is None :
|
||||
self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.")
|
||||
@@ -113,7 +113,7 @@ class ClusterManager:
|
||||
points = []
|
||||
for elem in result:
|
||||
osm_type = elem.get('type')
|
||||
|
||||
|
||||
# Get coordinates and append them to the points list
|
||||
_, coords = get_base_info(elem, osm_type)
|
||||
if coords is not None :
|
||||
@@ -146,7 +146,7 @@ class ClusterManager:
|
||||
self.valid = False
|
||||
|
||||
else :
|
||||
self.logger.debug(f"Detected 0 {cluster_type} clusters.")
|
||||
self.logger.debug(f"Found 0 {cluster_type} clusters.")
|
||||
self.valid = False
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ class ClusterManager:
|
||||
|
||||
# Define the bounding box for a given radius around the coordinates
|
||||
bbox = create_bbox(cluster.centroid, 300)
|
||||
|
||||
|
||||
# Query neighborhoods and shopping malls
|
||||
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"']
|
||||
|
||||
@@ -242,27 +242,25 @@ class ClusterManager:
|
||||
out = 'ids center tags'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching clusters: {e}")
|
||||
self.logger.warning(f"Error fetching clusters: {e}")
|
||||
continue
|
||||
|
||||
if result is None :
|
||||
self.logger.error(f"Error fetching clusters: {e}")
|
||||
self.logger.warning(f"Error fetching clusters: query result is None")
|
||||
continue
|
||||
|
||||
for elem in result:
|
||||
osm_type = elem.get('type')
|
||||
|
||||
id, coords, name = get_base_info(elem, osm_type, with_name=True)
|
||||
|
||||
# Get basic info
|
||||
id, coords, name = get_base_info(elem, elem.get('type'), with_name=True)
|
||||
if name is None or coords is None :
|
||||
continue
|
||||
|
||||
d = get_distance(cluster.centroid, coords)
|
||||
if d < min_dist :
|
||||
min_dist = d
|
||||
new_name = name # add name
|
||||
osm_type = osm_type # add type: 'way' or 'relation'
|
||||
osm_id = id # add OSM id
|
||||
new_name = name # add name
|
||||
osm_type = elem.get('type') # add type: 'way' or 'relation'
|
||||
osm_id = id # add OSM id
|
||||
|
||||
return Landmark(
|
||||
name=new_name,
|
@@ -4,10 +4,9 @@ import yaml
|
||||
|
||||
from ..structs.preferences import Preferences
|
||||
from ..structs.landmark import Landmark
|
||||
from .take_most_important import take_most_important
|
||||
from .cluster_manager import ClusterManager
|
||||
from ..overpass.overpass import Overpass, get_base_info
|
||||
from .utils import create_bbox
|
||||
from ..utils.bbox import create_bbox
|
||||
|
||||
from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH
|
||||
|
||||
@@ -23,7 +22,7 @@ class LandmarkManager:
|
||||
church_coeff: float # coeff to adjsut score of churches
|
||||
nature_coeff: float # coeff to adjust score of parks
|
||||
overall_coeff: float # coeff to adjust weight of tags
|
||||
n_important: int # number of important landmarks to consider
|
||||
# n_important: int # number of important landmarks to consider
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -42,7 +41,7 @@ class LandmarkManager:
|
||||
self.wikipedia_bonus = parameters['wikipedia_bonus']
|
||||
self.viewpoint_bonus = parameters['viewpoint_bonus']
|
||||
self.pay_bonus = parameters['pay_bonus']
|
||||
self.n_important = parameters['N_important']
|
||||
# self.n_important = parameters['N_important']
|
||||
|
||||
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
|
||||
parameters = yaml.safe_load(f)
|
||||
@@ -55,7 +54,12 @@ class LandmarkManager:
|
||||
self.logger.info('LandmakManager successfully initialized.')
|
||||
|
||||
|
||||
def generate_landmarks_list(self, center_coordinates: tuple[float, float], preferences: Preferences) -> tuple[list[Landmark], list[Landmark]]:
|
||||
def generate_landmarks_list(
|
||||
self,
|
||||
center_coordinates: tuple[float, float],
|
||||
preferences: Preferences,
|
||||
allow_clusters: bool = True
|
||||
) -> list[Landmark] :
|
||||
"""
|
||||
Generate and prioritize a list of landmarks based on user preferences.
|
||||
|
||||
@@ -63,16 +67,17 @@ class LandmarkManager:
|
||||
and current location. It scores and corrects these landmarks, removes duplicates, and then selects the most important
|
||||
landmarks based on a predefined criterion.
|
||||
|
||||
Args:
|
||||
center_coordinates (tuple[float, float]): The latitude and longitude of the center location around which to search.
|
||||
preferences (Preferences): The user's preference settings that influence the landmark selection.
|
||||
Parameters :
|
||||
center_coordinates (tuple[float, float]): The latitude and longitude of the center location around which to search.
|
||||
preferences (Preferences): The user's preference settings that influence the landmark selection.
|
||||
allow_clusters (bool, optional) : If set to False, no clusters will be fetched. Mainly used for the option to fetch landmarks nearby.
|
||||
|
||||
Returns:
|
||||
tuple[list[Landmark], list[Landmark]]:
|
||||
- A list of all existing landmarks.
|
||||
- A list of the most important landmarks based on the user's preferences.
|
||||
"""
|
||||
self.logger.debug('Starting to fetch landmarks...')
|
||||
self.logger.info(f'Starting to fetch landmarks around {center_coordinates}...')
|
||||
max_walk_dist = int((preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor)
|
||||
radius = min(max_walk_dist, int(self.max_bbox_side/2))
|
||||
|
||||
@@ -89,10 +94,11 @@ class LandmarkManager:
|
||||
all_landmarks.update(current_landmarks)
|
||||
self.logger.info(f'Found {len(current_landmarks)} sightseeing landmarks')
|
||||
|
||||
if allow_clusters :
|
||||
# special pipeline for historic neighborhoods
|
||||
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
|
||||
historic_clusters = neighborhood_manager.generate_clusters()
|
||||
all_landmarks.update(historic_clusters)
|
||||
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
|
||||
historic_clusters = neighborhood_manager.generate_clusters()
|
||||
all_landmarks.update(historic_clusters)
|
||||
|
||||
# list for nature
|
||||
if preferences.nature.score != 0:
|
||||
@@ -113,16 +119,19 @@ class LandmarkManager:
|
||||
landmark.duration = 30
|
||||
all_landmarks.update(current_landmarks)
|
||||
|
||||
# special pipeline for shopping malls
|
||||
shopping_manager = ClusterManager(bbox, 'shopping')
|
||||
shopping_clusters = shopping_manager.generate_clusters()
|
||||
all_landmarks.update(shopping_clusters)
|
||||
if allow_clusters :
|
||||
# special pipeline for shopping malls
|
||||
shopping_manager = ClusterManager(bbox, 'shopping')
|
||||
shopping_clusters = shopping_manager.generate_clusters()
|
||||
all_landmarks.update(shopping_clusters)
|
||||
|
||||
|
||||
landmarks_constrained = take_most_important(all_landmarks, self.n_important)
|
||||
# DETAILS HERE
|
||||
# self.logger.info(f'All landmarks generated : {len(all_landmarks)} landmarks around {center_coordinates}, and constrained to {len(landmarks_constrained)} most important ones.')
|
||||
self.logger.info(f'Found {len(all_landmarks)} landmarks in total.')
|
||||
|
||||
return all_landmarks, landmarks_constrained
|
||||
return sorted(all_landmarks, key=lambda x: x.attractiveness, reverse=True)
|
||||
|
||||
|
||||
def set_landmark_score(self, landmark: Landmark, landmarktype: str, preference_level: int) :
|
||||
"""
|
||||
@@ -197,7 +206,7 @@ class LandmarkManager:
|
||||
out = 'ids center tags'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching landmarks: {e}")
|
||||
self.logger.debug(f"Failed to fetch landmarks, proceeding without: {str(e)}")
|
||||
continue
|
||||
|
||||
return_list += self._to_landmarks(result, landmarktype, preference_level)
|
||||
@@ -236,6 +245,17 @@ class LandmarkManager:
|
||||
continue
|
||||
|
||||
tags = elem.get('tags')
|
||||
n_tags=len(tags)
|
||||
|
||||
# Skip this landmark if not suitable
|
||||
if tags.get('building:part') is not None :
|
||||
continue
|
||||
if tags.get('disused') is not None :
|
||||
continue
|
||||
if tags.get('boundary') is not None :
|
||||
continue
|
||||
if tags.get('shop') is not None and landmarktype != 'shopping' :
|
||||
continue
|
||||
|
||||
# Convert this to Landmark object
|
||||
landmark = Landmark(name=name,
|
||||
@@ -244,57 +264,36 @@ class LandmarkManager:
|
||||
osm_id=id,
|
||||
osm_type=osm_type,
|
||||
attractiveness=0,
|
||||
n_tags=len(tags))
|
||||
n_tags=n_tags)
|
||||
|
||||
# self.logger.debug('added landmark.')
|
||||
# Extract useful information for score calculation later down the road.
|
||||
landmark.image_url = tags.get('image')
|
||||
landmark.website_url = tags.get('website')
|
||||
landmark.wiki_url = tags.get('wikipedia')
|
||||
landmark.name_en = tags.get('name:en')
|
||||
|
||||
# Browse through tags to add information to landmark.
|
||||
for key, value in tags.items():
|
||||
|
||||
# Skip this landmark if not suitable.
|
||||
if key == 'building:part' and value == 'yes' :
|
||||
break
|
||||
if 'disused:' in key :
|
||||
break
|
||||
if 'boundary:' in key :
|
||||
break
|
||||
if 'shop' in key and landmarktype != 'shopping' :
|
||||
break
|
||||
# if value == 'apartments' :
|
||||
# break
|
||||
|
||||
# Fill in the other attributes.
|
||||
if key == 'image' :
|
||||
landmark.image_url = value
|
||||
if key == 'website' :
|
||||
landmark.website_url = value
|
||||
if value == 'place_of_worship' :
|
||||
# Check for place of worship
|
||||
if tags.get('place_of_worship') is not None :
|
||||
landmark.is_place_of_worship = True
|
||||
if key == 'wikipedia' :
|
||||
landmark.wiki_url = value
|
||||
if key == 'name:en' :
|
||||
landmark.name_en = value
|
||||
if 'building:' in key or 'pay' in key :
|
||||
landmark.n_tags -= 1
|
||||
landmark.name_en = tags.get('place_of_worship')
|
||||
|
||||
# Set the duration.
|
||||
if value in ['museum', 'aquarium', 'planetarium'] :
|
||||
landmark.duration = 60
|
||||
elif value == 'viewpoint' :
|
||||
landmark.is_viewpoint = True
|
||||
landmark.duration = 10
|
||||
elif value == 'cathedral' :
|
||||
landmark.is_place_of_worship = False
|
||||
landmark.duration = 10
|
||||
# Set the duration. Needed for the optimization.
|
||||
if tags.get('amenity') in ['aquarium', 'planetarium'] or tags.get('tourism') in ['aquarium', 'museum', 'zoo']:
|
||||
landmark.duration = 60
|
||||
elif tags.get('tourism') == 'viewpoint' :
|
||||
landmark.is_viewpoint = True
|
||||
landmark.duration = 10
|
||||
elif tags.get('building') == 'cathedral' :
|
||||
landmark.is_place_of_worship = False
|
||||
landmark.duration = 10
|
||||
|
||||
else:
|
||||
self.set_landmark_score(landmark, landmarktype, preference_level)
|
||||
landmarks.append(landmark)
|
||||
|
||||
continue
|
||||
# Compute the score and add landmark to the list.
|
||||
self.set_landmark_score(landmark, landmarktype, preference_level)
|
||||
landmarks.append(landmark)
|
||||
|
||||
return landmarks
|
||||
|
||||
|
||||
def dict_to_selector_list(d: dict) -> list:
|
||||
"""
|
||||
Convert a dictionary of key-value pairs to a list of Overpass query strings.
|
123
backend/src/landmarks/landmarks_router.py
Normal file
123
backend/src/landmarks/landmarks_router.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Main app for backend api"""
|
||||
import logging
|
||||
import time
|
||||
import random
|
||||
from fastapi import HTTPException, APIRouter
|
||||
|
||||
from ..structs.landmark import Landmark
|
||||
from ..structs.preferences import Preferences, Preference
|
||||
from .landmarks_manager import LandmarkManager
|
||||
|
||||
|
||||
# Setup the logger and the Landmarks Manager
|
||||
logger = logging.getLogger(__name__)
|
||||
manager = LandmarkManager()
|
||||
|
||||
|
||||
# Initialize the API router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/get/landmarks")
|
||||
def get_landmarks(
|
||||
preferences: Preferences,
|
||||
start: tuple[float, float],
|
||||
) -> list[Landmark]:
|
||||
"""
|
||||
Function that returns all available landmarks given some preferences and a start position.
|
||||
|
||||
Args:
|
||||
preferences : the preferences specified by the user as the post body
|
||||
start : the coordinates of the starting point
|
||||
Returns:
|
||||
list[Landmark] : The full list of fetched landmarks
|
||||
"""
|
||||
if preferences is None:
|
||||
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||
if (preferences.shopping.score == 0 and
|
||||
preferences.sightseeing.score == 0 and
|
||||
preferences.nature.score == 0) :
|
||||
raise HTTPException(status_code=406, detail="All preferences are 0.")
|
||||
if start is None:
|
||||
raise HTTPException(status_code=406, detail="Start coordinates not provided")
|
||||
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
|
||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||
|
||||
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Generate the landmarks from the start location
|
||||
landmarks = manager.generate_landmarks_list(
|
||||
center_coordinates = start,
|
||||
preferences = preferences
|
||||
)
|
||||
|
||||
if len(landmarks) == 0 :
|
||||
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
||||
|
||||
t_generate_landmarks = time.time() - start_time
|
||||
logger.info(f'Fetched {len(landmarks)} landmarks in \t: {round(t_generate_landmarks,3)} seconds')
|
||||
|
||||
return landmarks
|
||||
|
||||
|
||||
@router.post("/get-nearby/landmarks/{lat}/{lon}")
|
||||
def get_landmarks_nearby(
|
||||
lat: float,
|
||||
lon: float
|
||||
) -> list[Landmark] :
|
||||
"""
|
||||
Suggests nearby landmarks based on a given latitude and longitude.
|
||||
|
||||
This endpoint returns a curated list of up to 5 landmarks around the given geographical coordinates. It uses fixed preferences for
|
||||
sightseeing, shopping, and nature, with a maximum time constraint of 30 minutes to limit the number of landmarks returned.
|
||||
|
||||
Args:
|
||||
lat (float): Latitude of the user's current location.
|
||||
lon (float): Longitude of the user's current location.
|
||||
|
||||
Returns:
|
||||
list[Landmark]: A list of selected nearby landmarks.
|
||||
"""
|
||||
logger.info(f'Fetching landmarks nearby ({lat}, {lon}).')
|
||||
|
||||
# Define fixed preferences:
|
||||
prefs = Preferences(
|
||||
sightseeing = Preference(
|
||||
type='sightseeing',
|
||||
score=5
|
||||
),
|
||||
shopping = Preference(
|
||||
type='shopping',
|
||||
score=2
|
||||
),
|
||||
nature = Preference(
|
||||
type='nature',
|
||||
score=5
|
||||
),
|
||||
max_time_minute=30,
|
||||
detour_tolerance_minute=0,
|
||||
)
|
||||
|
||||
# Find the landmarks around the location
|
||||
landmarks_around = manager.generate_landmarks_list(
|
||||
center_coordinates = (lat, lon),
|
||||
preferences = prefs,
|
||||
allow_clusters=False,
|
||||
)
|
||||
|
||||
if len(landmarks_around) == 0 :
|
||||
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
||||
|
||||
# select 8 - 12 landmarks from there
|
||||
if len(landmarks_around) > 8 :
|
||||
n_imp = random.randint(2,5)
|
||||
rest = random.randint(8 - n_imp, min(12, len(landmarks_around))-n_imp)
|
||||
|
||||
print(f'len = {len(landmarks_around)}\nn_imp = {n_imp}\nrest = {rest}')
|
||||
landmarks_around = landmarks_around[:n_imp] + random.sample(landmarks_around[n_imp:], rest)
|
||||
|
||||
logger.info(f'Found {len(landmarks_around)} landmarks to suggest nearby ({lat}, {lon}).')
|
||||
# logger.debug('Suggested landmarks :\n\t' + '\n\t'.join(f'{landmark}' for landmark in landmarks_around))
|
||||
return landmarks_around
|
@@ -33,14 +33,14 @@ def configure_logging():
|
||||
# silence the chatty logs loki generates itself
|
||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING)
|
||||
# no need for time since it's added by loki or can be shown in kube logs
|
||||
logging_format = '%(name)s - %(levelname)s - %(message)s'
|
||||
logging_format = '%(name)-55s - %(levelname)-7s - %(message)s'
|
||||
|
||||
else:
|
||||
# if we are in a debug (local) session, set verbose and rich logging
|
||||
from rich.logging import RichHandler
|
||||
logging_handlers = [RichHandler()]
|
||||
logging_level = logging.DEBUG if is_debug else logging.INFO
|
||||
logging_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
logging_format = '%(asctime)s - %(name)-55s - %(levelname)-7s - %(message)s'
|
||||
|
||||
|
||||
|
||||
|
@@ -1,20 +1,18 @@
|
||||
"""Main app for backend api"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from .logging_config import configure_logging
|
||||
from .structs.landmark import Landmark, Toilets
|
||||
from .structs.preferences import Preferences
|
||||
from .structs.landmark import Landmark
|
||||
from .structs.linked_landmarks import LinkedLandmarks
|
||||
from .structs.trip import Trip
|
||||
from .utils.landmarks_manager import LandmarkManager
|
||||
from .utils.toilets_manager import ToiletsManager
|
||||
from .landmarks.landmarks_manager import LandmarkManager
|
||||
from .toilets.toilets_router import router as toilets_router
|
||||
from .optimization.optimization_router import router as optimization_router
|
||||
from .landmarks.landmarks_router import router as landmarks_router, get_landmarks_nearby
|
||||
from .optimization.optimizer import Optimizer
|
||||
from .optimization.refiner import Refiner
|
||||
from .overpass.overpass import fill_cache
|
||||
from .cache import client as cache_client
|
||||
|
||||
|
||||
@@ -37,109 +35,22 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.post("/trip/new")
|
||||
def new_trip(preferences: Preferences,
|
||||
start: tuple[float, float],
|
||||
end: tuple[float, float] | None = None,
|
||||
background_tasks: BackgroundTasks = None) -> Trip:
|
||||
"""
|
||||
Main function to call the optimizer.
|
||||
|
||||
Args:
|
||||
preferences : the preferences specified by the user as the post body
|
||||
start : the coordinates of the starting point
|
||||
end : the coordinates of the finishing point
|
||||
Returns:
|
||||
(uuid) : The uuid of the first landmark in the optimized route
|
||||
"""
|
||||
if preferences is None:
|
||||
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||
if (preferences.shopping.score == 0 and
|
||||
preferences.sightseeing.score == 0 and
|
||||
preferences.nature.score == 0) :
|
||||
raise HTTPException(status_code=406, detail="All preferences are 0.")
|
||||
if start is None:
|
||||
raise HTTPException(status_code=406, detail="Start coordinates not provided")
|
||||
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
|
||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||
if end is None:
|
||||
end = start
|
||||
logger.info("No end coordinates provided. Using start=end.")
|
||||
# Fetches the global list of landmarks given preferences and start/end coordinates. Two routes
|
||||
# Call with "/get/landmarks/" for main entry point of the trip generation pipeline.
|
||||
# Call with "/get-nearby/landmarks/" for the NEARBY feature.
|
||||
app.include_router(landmarks_router)
|
||||
|
||||
start_landmark = Landmark(name='start',
|
||||
type='start',
|
||||
location=(start[0], start[1]),
|
||||
osm_type='start',
|
||||
osm_id=0,
|
||||
attractiveness=0,
|
||||
duration=0,
|
||||
must_do=True,
|
||||
n_tags = 0)
|
||||
|
||||
end_landmark = Landmark(name='finish',
|
||||
type='finish',
|
||||
location=(end[0], end[1]),
|
||||
osm_type='end',
|
||||
osm_id=0,
|
||||
attractiveness=0,
|
||||
duration=0,
|
||||
must_do=True,
|
||||
n_tags=0)
|
||||
# Optimizes the trip given preferences. Second step in the main trip generation pipeline
|
||||
# Call with "/optimize/trip"
|
||||
app.include_router(optimization_router)
|
||||
|
||||
start_time = time.time()
|
||||
# Generate the landmarks from the start location
|
||||
landmarks, landmarks_short = manager.generate_landmarks_list(
|
||||
center_coordinates = start,
|
||||
preferences = preferences
|
||||
)
|
||||
|
||||
if len(landmarks) == 0 :
|
||||
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
||||
# Fetches toilets near given coordinates.
|
||||
# Call with "/get/toilets" for fetching toilets around coordinates
|
||||
app.include_router(toilets_router)
|
||||
|
||||
# insert start and finish to the landmarks list
|
||||
landmarks_short.insert(0, start_landmark)
|
||||
landmarks_short.append(end_landmark)
|
||||
|
||||
t_generate_landmarks = time.time() - start_time
|
||||
logger.info(f'Fetched {len(landmarks)} landmarks in \t: {round(t_generate_landmarks,3)} seconds')
|
||||
start_time = time.time()
|
||||
|
||||
# First stage optimization
|
||||
try:
|
||||
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
|
||||
|
||||
t_first_stage = time.time() - start_time
|
||||
start_time = time.time()
|
||||
|
||||
# Second stage optimization
|
||||
# TODO : only if necessary (not enough landmarks for ex.)
|
||||
try :
|
||||
refined_tour = refiner.refine_optimization(landmarks, base_tour,
|
||||
preferences.max_time_minute,
|
||||
preferences.detour_tolerance_minute)
|
||||
except TimeoutError as te :
|
||||
logger.error(f'Refiner failed : {str(te)} Using base tour.')
|
||||
refined_tour = base_tour
|
||||
except Exception as exc :
|
||||
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(exc)}") from exc
|
||||
|
||||
t_second_stage = time.time() - start_time
|
||||
|
||||
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
|
||||
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
|
||||
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
|
||||
linked_tour = LinkedLandmarks(refined_tour)
|
||||
|
||||
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
|
||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||
logger.info(f'Generated a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_generate_landmarks + t_first_stage + t_second_stage,3)} seconds.')
|
||||
logger.debug('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
|
||||
|
||||
background_tasks.add_task(fill_cache)
|
||||
|
||||
return trip
|
||||
|
||||
|
||||
#### For already existing trips/landmarks
|
||||
@@ -158,6 +69,7 @@ def get_trip(trip_uuid: str) -> Trip:
|
||||
trip = cache_client.get(f"trip_{trip_uuid}")
|
||||
return trip
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to fetch trip with UUID {trip_uuid}: {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail="Trip not found") from exc
|
||||
|
||||
|
||||
@@ -176,6 +88,7 @@ def get_landmark(landmark_uuid: str) -> Landmark:
|
||||
landmark = cache_client.get(f"landmark_{landmark_uuid}")
|
||||
return landmark
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to fetch landmark with UUID {landmark_uuid}: {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail="Landmark not found") from exc
|
||||
|
||||
|
||||
@@ -194,6 +107,7 @@ def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
||||
try:
|
||||
trip = cache_client.get(f'trip_{trip_uuid}')
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to update trip with UUID {trip_uuid} (trip not found): {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail='Trip not found') from exc
|
||||
|
||||
landmarks = []
|
||||
@@ -208,6 +122,7 @@ def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
||||
landmarks.append(landmark)
|
||||
next_uuid = landmark.next_uuid # Prepare for the next iteration
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to update trip with UUID {trip_uuid} : {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail=f'landmark {next_uuid} not found') from exc
|
||||
|
||||
# Re-link every thing and compute times again
|
||||
@@ -216,31 +131,3 @@ def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
||||
|
||||
return trip
|
||||
|
||||
|
||||
|
||||
@app.post("/toilets/new")
|
||||
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
|
||||
"""
|
||||
Endpoint to find toilets within a specified radius from a given location.
|
||||
|
||||
This endpoint expects the `location` and `radius` as **query parameters**, not in the request body.
|
||||
|
||||
Args:
|
||||
location (tuple[float, float]): The latitude and longitude of the location to search from.
|
||||
radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters.
|
||||
|
||||
Returns:
|
||||
list[Toilets]: A list of Toilets objects that meet the criteria.
|
||||
"""
|
||||
if location is None:
|
||||
raise HTTPException(status_code=406, detail="Coordinates not provided or invalid")
|
||||
if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180):
|
||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||
|
||||
toilets_manager = ToiletsManager(location, radius)
|
||||
|
||||
try :
|
||||
toilets_list = toilets_manager.generate_toilet_list()
|
||||
return toilets_list
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
||||
|
139
backend/src/optimization/optimization_router.py
Normal file
139
backend/src/optimization/optimization_router.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""API entry point for the trip optimization."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import yaml
|
||||
from fastapi import HTTPException, APIRouter, BackgroundTasks
|
||||
|
||||
from .optimizer import Optimizer
|
||||
from .refiner import Refiner
|
||||
from ..structs.landmark import Landmark
|
||||
from ..structs.preferences import Preferences
|
||||
from ..structs.linked_landmarks import LinkedLandmarks
|
||||
from ..structs.trip import Trip
|
||||
from ..overpass.overpass import fill_cache
|
||||
from ..cache import client as cache_client
|
||||
from ..constants import OPTIMIZER_PARAMETERS_PATH
|
||||
|
||||
|
||||
# Setup the Logger, Optimizer and Refiner
|
||||
logger = logging.getLogger(__name__)
|
||||
optimizer = Optimizer()
|
||||
refiner = Refiner(optimizer=optimizer)
|
||||
|
||||
|
||||
# Initialize the API router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/optimize/trip")
|
||||
def optimize_trip(
|
||||
preferences: Preferences,
|
||||
landmarks: list[Landmark],
|
||||
start: tuple[float, float],
|
||||
end: tuple[float, float] | None = None,
|
||||
background_tasks: BackgroundTasks = None
|
||||
) -> Trip:
|
||||
"""
|
||||
Main function to call the optimizer.
|
||||
|
||||
Args:
|
||||
preferences (Preferences) : the preferences specified by the user as the post body.
|
||||
start (tuple[float, float]) : the coordinates of the starting point.
|
||||
end tuple[float, float] : the coordinates of the finishing point.
|
||||
backgroud_tasks (BackgroundTasks) : necessary to fill the cache after the trip has been returned.
|
||||
Returns:
|
||||
(uuid) : The uuid of the first landmark in the optimized route
|
||||
"""
|
||||
if preferences is None:
|
||||
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||
if len(landmarks) == 0 :
|
||||
raise HTTPException(status_code=406, detail="No landmarks provided for computing the trip.")
|
||||
if (preferences.shopping.score == 0 and
|
||||
preferences.sightseeing.score == 0 and
|
||||
preferences.nature.score == 0) :
|
||||
raise HTTPException(status_code=406, detail="All preferences are 0.")
|
||||
if start is None:
|
||||
raise HTTPException(status_code=406, detail="Start coordinates not provided")
|
||||
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
|
||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||
if end is None:
|
||||
end = start
|
||||
logger.info("No end coordinates provided. Using start=end.")
|
||||
|
||||
# Start the timer
|
||||
start_time = time.time()
|
||||
|
||||
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
|
||||
|
||||
start_landmark = Landmark(
|
||||
name='start',
|
||||
type='start',
|
||||
location=(start[0], start[1]),
|
||||
osm_type='start',
|
||||
osm_id=0,
|
||||
attractiveness=0,
|
||||
duration=0,
|
||||
must_do=True,
|
||||
n_tags = 0
|
||||
)
|
||||
|
||||
end_landmark = Landmark(
|
||||
name='finish',
|
||||
type='finish',
|
||||
location=(end[0], end[1]),
|
||||
osm_type='end',
|
||||
osm_id=0,
|
||||
attractiveness=0,
|
||||
duration=0,
|
||||
must_do=True,
|
||||
n_tags=0
|
||||
)
|
||||
|
||||
# From the parameters load the length at which to truncate the landmarks list.
|
||||
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
|
||||
parameters = yaml.safe_load(f)
|
||||
n_important = parameters['N_important']
|
||||
|
||||
# Truncate to the most important landmarks for a shorter list
|
||||
landmarks_short = landmarks[:n_important]
|
||||
|
||||
# insert start and finish to the shorter landmarks list
|
||||
landmarks_short.insert(0, start_landmark)
|
||||
landmarks_short.append(end_landmark)
|
||||
|
||||
# First stage optimization
|
||||
try:
|
||||
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
|
||||
except Exception as exc:
|
||||
logger.error(f"Trip generation failed: {str(exc)}")
|
||||
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
|
||||
|
||||
t_first_stage = time.time() - start_time
|
||||
start_time = time.time()
|
||||
|
||||
# Second stage optimization
|
||||
try :
|
||||
refined_tour = refiner.refine_optimization(landmarks, base_tour,
|
||||
preferences.max_time_minute,
|
||||
preferences.detour_tolerance_minute)
|
||||
except Exception as exc :
|
||||
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
|
||||
refined_tour = base_tour
|
||||
|
||||
t_second_stage = time.time() - start_time
|
||||
|
||||
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
|
||||
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
|
||||
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
|
||||
linked_tour = LinkedLandmarks(refined_tour)
|
||||
|
||||
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
|
||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||
logger.info(f'Optimized a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_first_stage + t_second_stage,3)} seconds.')
|
||||
logger.info('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
|
||||
|
||||
background_tasks.add_task(fill_cache)
|
||||
|
||||
return trip
|
||||
|
@@ -257,7 +257,6 @@ class Optimizer:
|
||||
Returns:
|
||||
None: This function modifies the `prob` object by adding L-2 equality constraints in-place.
|
||||
"""
|
||||
# FIXME: weird 0 artifact in the coefficients popping up
|
||||
# Loop through rows 1 to L-2 to prevent stacked ones
|
||||
for i in range(1, L-1):
|
||||
# Add the constraint that sums across each "row" or "block" in the decision variables
|
||||
@@ -590,7 +589,7 @@ class Optimizer:
|
||||
try :
|
||||
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit+1, gapRel=self.gap_rel))
|
||||
except Exception as exc :
|
||||
raise Exception(f"No solution found: {exc}") from exc
|
||||
raise Exception(f"No solution found: {str(exc)}") from exc
|
||||
status = pl.LpStatus[prob.status]
|
||||
solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1)
|
||||
|
||||
@@ -598,7 +597,7 @@ class Optimizer:
|
||||
|
||||
# Raise error if no solution is found. FIXME: for now this throws the internal server error
|
||||
if status != 'Optimal' :
|
||||
self.logger.error("The problem is overconstrained, no solution on first try.")
|
||||
self.logger.warning("The problem is overconstrained, no solution on first try.")
|
||||
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
|
||||
|
||||
# If there is a solution, we're good to go, just check for connectiveness
|
||||
@@ -608,7 +607,7 @@ class Optimizer:
|
||||
while circles is not None :
|
||||
i += 1
|
||||
if i == self.max_iter :
|
||||
self.logger.error(f'Timeout: No solution found after {self.max_iter} iterations.')
|
||||
self.logger.warning(f'Timeout: No solution found after {self.max_iter} iterations.')
|
||||
raise TimeoutError(f"Optimization took too long. No solution found after {self.max_iter} iterations.")
|
||||
|
||||
for circle in circles :
|
||||
@@ -618,12 +617,13 @@ class Optimizer:
|
||||
try :
|
||||
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit, gapRel=self.gap_rel))
|
||||
except Exception as exc :
|
||||
raise Exception(f"No solution found: {exc}") from exc
|
||||
self.logger.warning("No solution found: {str(exc)")
|
||||
raise Exception(f"No solution found: {str(exc)}") from exc
|
||||
|
||||
solution = [pl.value(var) for var in x]
|
||||
|
||||
if pl.LpStatus[prob.status] != 'Optimal' :
|
||||
self.logger.error("The problem is overconstrained, no solution after {i} cycles.")
|
||||
self.logger.warning("The problem is overconstrained, no solution after {i} cycles.")
|
||||
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
|
||||
|
||||
circles = self.is_connected(solution)
|
||||
|
@@ -278,7 +278,7 @@ class Refiner :
|
||||
better_tour_poly = concave_hull(MultiPoint(coords)) # Create concave hull with "core" of tour leaving out start and finish
|
||||
xs, ys = better_tour_poly.exterior.xy
|
||||
"""
|
||||
ERROR HERE :
|
||||
FIXED : ERROR HERE :
|
||||
Exception has occurred: AttributeError
|
||||
'LineString' object has no attribute 'exterior'
|
||||
"""
|
||||
@@ -356,7 +356,7 @@ class Refiner :
|
||||
|
||||
# If unsuccessful optimization, use the base_tour.
|
||||
if new_tour is None:
|
||||
self.logger.warning("No solution found for the refined tour. Returning the initial tour.")
|
||||
self.logger.warning("Refiner failed: No solution found during second stage optimization.")
|
||||
new_tour = base_tour
|
||||
|
||||
# If only one landmark, return it.
|
||||
@@ -369,6 +369,7 @@ class Refiner :
|
||||
# Fix the tour using Polygons if the path looks weird.
|
||||
# Conditions : circular trip and invalid polygon.
|
||||
if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid :
|
||||
self.logger.debug("Tours might be funky, attempting to correct with polygons")
|
||||
better_tour = self.fix_using_polygon(better_tour)
|
||||
|
||||
return better_tour
|
||||
|
@@ -1,3 +1,4 @@
|
||||
"""Module defining the handling of cache data from Overpass requests."""
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
@@ -61,7 +62,7 @@ class JSONCache(CachingStrategyBase):
|
||||
return None
|
||||
|
||||
def set(self, key, value):
|
||||
"""Save the JSON data as an ElementTree to the cache."""
|
||||
"""Save the JSON data in the cache."""
|
||||
filename = self._filename(key)
|
||||
try:
|
||||
# Write the JSON data to the cache file
|
||||
@@ -94,7 +95,7 @@ class JSONCache(CachingStrategyBase):
|
||||
|
||||
def close(self):
|
||||
"""Cleanup method, if needed."""
|
||||
pass
|
||||
|
||||
|
||||
class CachingStrategy:
|
||||
"""
|
||||
@@ -107,6 +108,7 @@ class CachingStrategy:
|
||||
|
||||
@classmethod
|
||||
def use(cls, strategy_name='JSON', **kwargs):
|
||||
"""Define the caching strategy to use."""
|
||||
if cls.__strategy:
|
||||
cls.__strategy.close()
|
||||
|
||||
@@ -119,10 +121,12 @@ class CachingStrategy:
|
||||
|
||||
@classmethod
|
||||
def get(cls, key):
|
||||
"""Get the data from the cache."""
|
||||
return cls.__strategy.get(key)
|
||||
|
||||
@classmethod
|
||||
def set(cls, key, value):
|
||||
"""Save the data in the cache."""
|
||||
cls.__strategy.set(key, value)
|
||||
|
||||
@classmethod
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Module allowing connexion to overpass api and fectch data from OSM."""
|
||||
import os
|
||||
import time
|
||||
import urllib
|
||||
import math
|
||||
import logging
|
||||
@@ -59,19 +60,17 @@ class Overpass :
|
||||
return Overpass._filter_landmarks(cached_responses, bbox)
|
||||
|
||||
# If there is no cached data, fetch all from Overpass.
|
||||
elif not cached_responses :
|
||||
if not cached_responses :
|
||||
query_str = Overpass.build_query(bbox, osm_types, selector, conditions, out)
|
||||
self.logger.debug(f'Query string: {query_str}')
|
||||
return self.fetch_data_from_api(query_str)
|
||||
|
||||
# Hybrid cache: some data from Overpass, some data from cache.
|
||||
else :
|
||||
# Resize the bbox for smaller search area and build new query string.
|
||||
non_cached_bbox = Overpass._get_non_cached_bbox(non_cached_cells, bbox)
|
||||
query_str = Overpass.build_query(non_cached_bbox, osm_types, selector, conditions, out)
|
||||
self.logger.debug(f'Query string: {query_str}')
|
||||
non_cached_responses = self.fetch_data_from_api(query_str)
|
||||
return Overpass._filter_landmarks(cached_responses, bbox) + non_cached_responses
|
||||
# Resize the bbox for smaller search area and build new query string.
|
||||
non_cached_bbox = Overpass._get_non_cached_bbox(non_cached_cells, bbox)
|
||||
query_str = Overpass.build_query(non_cached_bbox, osm_types, selector, conditions, out)
|
||||
self.logger.debug(f'Query string: {query_str}')
|
||||
non_cached_responses = self.fetch_data_from_api(query_str)
|
||||
return Overpass._filter_landmarks(cached_responses, bbox) + non_cached_responses
|
||||
|
||||
|
||||
def fetch_data_from_api(self, query_str: str) -> List[dict]:
|
||||
@@ -96,9 +95,10 @@ class Overpass :
|
||||
return elements
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
self.logger.error(f"Error connecting to Overpass API: {e}")
|
||||
raise ConnectionError(f"Error connecting to Overpass API: {e}") from e
|
||||
self.logger.error(f"Error connecting to Overpass API: {str(e)}")
|
||||
raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
|
||||
except Exception as exc :
|
||||
self.logger.error(f"unexpected error while fetching data from Overpass: {str(exc)}")
|
||||
raise Exception(f'An unexpected error occured: {str(exc)}') from exc
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class Overpass :
|
||||
with urllib.request.urlopen(request) as response:
|
||||
|
||||
# Convert the HTTPResponse to a string and load data
|
||||
response_data = response.read().decode('utf-8')
|
||||
response_data = response.read().decode('utf-8')
|
||||
data = json.loads(response_data)
|
||||
|
||||
# Get elements and set cache
|
||||
@@ -122,7 +122,7 @@ class Overpass :
|
||||
self.caching_strategy.set(cache_key, elements)
|
||||
self.logger.debug(f'Cache set for {cache_key}')
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Error connecting to Overpass API: {e}") from e
|
||||
raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
|
||||
except Exception as exc :
|
||||
raise Exception(f'An unexpected error occured: {str(exc)}') from exc
|
||||
|
||||
@@ -153,7 +153,7 @@ class Overpass :
|
||||
- If no conditions are provided, the query will just use the `selector` to filter the OSM
|
||||
elements without additional constraints.
|
||||
"""
|
||||
query = '[out:json];('
|
||||
query = '[out:json][timeout:20];('
|
||||
|
||||
# convert the bbox to string.
|
||||
bbox_str = f"({','.join(map(str, bbox))})"
|
||||
@@ -309,9 +309,9 @@ class Overpass :
|
||||
if min_lat == float('inf') or min_lon == float('inf'):
|
||||
return None
|
||||
|
||||
return (max(min_lat, original_bbox[0]),
|
||||
max(min_lon, original_bbox[1]),
|
||||
min(max_lat, original_bbox[2]),
|
||||
return (max(min_lat, original_bbox[0]),
|
||||
max(min_lon, original_bbox[1]),
|
||||
min(max_lat, original_bbox[2]),
|
||||
min(max_lon, original_bbox[3]))
|
||||
|
||||
|
||||
@@ -388,8 +388,8 @@ def get_base_info(elem: dict, osm_type: OSM_TYPES, with_name=False) :
|
||||
if with_name :
|
||||
name = elem.get('tags', {}).get('name')
|
||||
return osm_id, coords, name
|
||||
else :
|
||||
return osm_id, coords
|
||||
|
||||
return osm_id, coords
|
||||
|
||||
|
||||
def fill_cache():
|
||||
@@ -399,18 +399,27 @@ def fill_cache():
|
||||
"""
|
||||
overpass = Overpass()
|
||||
|
||||
n_files = 0
|
||||
total = 0
|
||||
|
||||
overpass.logger.info('Trip successfully returned, starting to fill cache.')
|
||||
|
||||
with os.scandir(OSM_CACHE_DIR) as it:
|
||||
for entry in it:
|
||||
if entry.is_file() and entry.name.startswith('hollow_'):
|
||||
|
||||
total += 1
|
||||
try :
|
||||
# Read the whole file content as a string
|
||||
with open(entry.path, 'r') as f:
|
||||
with open(entry.path, 'r', encoding='utf-8') as f:
|
||||
# load data and fill the cache with the query and key
|
||||
json_data = json.load(f)
|
||||
overpass.fill_cache(json_data)
|
||||
n_files += 1
|
||||
time.sleep(1)
|
||||
# Now delete the file as the cache is filled
|
||||
os.remove(entry.path)
|
||||
|
||||
except Exception as exc :
|
||||
overpass.logger.error(f'An error occured while parsing file {entry.path} as .json file')
|
||||
overpass.logger.error(f'An error occured while parsing file {entry.path} as .json file: {str(exc)}')
|
||||
|
||||
overpass.logger.info(f"Successfully filled {n_files}/{total} cache files.")
|
||||
|
@@ -72,6 +72,7 @@ sightseeing:
|
||||
# - castle
|
||||
# - museum
|
||||
|
||||
|
||||
museums:
|
||||
tourism:
|
||||
- museum
|
||||
|
@@ -7,5 +7,4 @@ tag_exponent: 1.15
|
||||
image_bonus: 1.1
|
||||
viewpoint_bonus: 10
|
||||
wikipedia_bonus: 1.25
|
||||
N_important: 60
|
||||
pay_bonus: -1
|
||||
|
@@ -6,4 +6,5 @@ max_landmarks_refiner: 20
|
||||
overshoot: 0.0016
|
||||
time_limit: 1
|
||||
gap_rel: 0.025
|
||||
max_iter: 40
|
||||
max_iter: 80
|
||||
N_important: 60
|
||||
|
@@ -1,8 +1,7 @@
|
||||
"""Definition of the Landmark class to handle visitable objects across the world."""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from uuid import uuid4, UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Output to frontend
|
||||
@@ -50,7 +49,8 @@ class Landmark(BaseModel) :
|
||||
image_url : Optional[str] = None
|
||||
website_url : Optional[str] = None
|
||||
wiki_url : Optional[str] = None
|
||||
description : Optional[str] = None # TODO future
|
||||
# keywords: Optional[dict] = {}
|
||||
# description : Optional[str] = None
|
||||
duration : Optional[int] = 5
|
||||
name_en : Optional[str] = None
|
||||
|
||||
@@ -69,6 +69,7 @@ class Landmark(BaseModel) :
|
||||
is_viewpoint : Optional[bool] = False
|
||||
is_place_of_worship : Optional[bool] = False
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
String representation of the Landmark object.
|
||||
@@ -122,26 +123,3 @@ class Landmark(BaseModel) :
|
||||
return (self.uuid == value.uuid or
|
||||
self.osm_id == value.osm_id or
|
||||
(self.name == value.name and self.distance(value) < 0.001))
|
||||
|
||||
|
||||
class Toilets(BaseModel) :
|
||||
"""
|
||||
Model for toilets. When false/empty the information is either false either not known.
|
||||
"""
|
||||
location : tuple
|
||||
wheelchair : Optional[bool] = False
|
||||
changing_table : Optional[bool] = False
|
||||
fee : Optional[bool] = False
|
||||
opening_hours : Optional[str] = ""
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
String representation of the Toilets object.
|
||||
|
||||
Returns:
|
||||
str: A formatted string with the toilets location.
|
||||
"""
|
||||
return f'Toilets @{self.location}'
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from .landmark import Landmark
|
||||
from ..utils.get_time_distance import get_time
|
||||
from ..utils.description import description_and_keywords
|
||||
|
||||
class LinkedLandmarks:
|
||||
"""
|
||||
@@ -35,18 +36,23 @@ class LinkedLandmarks:
|
||||
Create the links between the landmarks in the list by setting their
|
||||
.next_uuid and the .time_to_next attributes.
|
||||
"""
|
||||
|
||||
# Mark secondary landmarks as such
|
||||
self.update_secondary_landmarks()
|
||||
|
||||
|
||||
for i, landmark in enumerate(self._landmarks[:-1]):
|
||||
# Set uuid of the next landmark
|
||||
landmark.next_uuid = self._landmarks[i + 1].uuid
|
||||
|
||||
# Adjust time to reach and total time
|
||||
time_to_next = get_time(landmark.location, self._landmarks[i + 1].location)
|
||||
landmark.time_to_reach_next = time_to_next
|
||||
self.total_time += time_to_next
|
||||
self.total_time += landmark.duration
|
||||
|
||||
# Fill in the keywords and description. GOOD IDEA, BAD EXECUTION, tags aren't available anymore at this stage
|
||||
# landmark.description, landmark.keywords = description_and_keywords(tags)
|
||||
|
||||
|
||||
self._landmarks[-1].next_uuid = None
|
||||
self._landmarks[-1].time_to_reach_next = 0
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Defines the Preferences used as input for trip generation."""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class Preference(BaseModel) :
|
||||
@@ -15,6 +15,13 @@ class Preference(BaseModel) :
|
||||
type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish']
|
||||
score: int # score could be from 1 to 5
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v):
|
||||
if v not in {'sightseeing', 'nature', 'shopping', 'start', 'finish'}:
|
||||
raise ValueError(f"Invalid type: {v}")
|
||||
return v
|
||||
|
||||
|
||||
# Input for optimization
|
||||
class Preferences(BaseModel) :
|
||||
|
26
backend/src/structs/toilets.py
Normal file
26
backend/src/structs/toilets.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Definition of the Toilets class."""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class Toilets(BaseModel) :
|
||||
"""
|
||||
Model for toilets. When false/empty the information is either false either not known.
|
||||
"""
|
||||
location : tuple
|
||||
wheelchair : Optional[bool] = False
|
||||
changing_table : Optional[bool] = False
|
||||
fee : Optional[bool] = False
|
||||
opening_hours : Optional[str] = ""
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
String representation of the Toilets object.
|
||||
|
||||
Returns:
|
||||
str: A formatted string with the toilets location.
|
||||
"""
|
||||
return f'Toilets @{self.location}'
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
@@ -19,30 +19,50 @@ def invalid_client():
|
||||
([48.8566, 2.3522], {}, 422),
|
||||
|
||||
# Invalid cases: incomplete preferences.
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "nature", "score": 5}, # no shopping
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 5}, # no shopping pref
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "nature", "score": 5}, # no nature
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 5}, # no nature pref
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"nature": {"type": "nature", "score": 5}, # no sightseeing
|
||||
([48.084588, 7.280405], {"nature": {"type": "nature", "score": 5}, # no sightseeing pref
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "nature", "score": 1}, # mixed up preferences types. TODO: i suggest reducing the complexity by remove the Preference object.
|
||||
"nature": {"type": "shopping", "score": 1},
|
||||
"shopping": {"type": "shopping", "score": 1},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"doesnotexist": {"type": "sightseeing", "score": 2}, # non-existing preferences types
|
||||
"nature": {"type": "nature", "score": 2},
|
||||
"shopping": {"type": "shopping", "score": 2},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 3}, # non-existing preferences types
|
||||
"nature": {"type": "doesntexisteither", "score": 3},
|
||||
"shopping": {"type": "shopping", "score": 3},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": -1}, # negative preference value
|
||||
"nature": {"type": "doesntexisteither", "score": 4},
|
||||
"shopping": {"type": "shopping", "score": 4},
|
||||
}, 422),
|
||||
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 10}, # too high preference value
|
||||
"nature": {"type": "doesntexisteither", "score": 4},
|
||||
"shopping": {"type": "shopping", "score": 4},
|
||||
}, 422),
|
||||
|
||||
# Invalid cases: unexisting coords
|
||||
([91, 181], {"sightseeing": {"type": "nature", "score": 5},
|
||||
([91, 181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
}, 422),
|
||||
([-91, 181], {"sightseeing": {"type": "nature", "score": 5},
|
||||
([-91, 181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
}, 422),
|
||||
([91, -181], {"sightseeing": {"type": "nature", "score": 5},
|
||||
([91, -181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
}, 422),
|
||||
([-91, -181], {"sightseeing": {"type": "nature", "score": 5},
|
||||
([-91, -181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
}, 422),
|
||||
@@ -53,8 +73,8 @@ def test_input(invalid_client, start, preferences, status_code): # pylint: dis
|
||||
Test new trip creation with different sets of preferences and locations.
|
||||
"""
|
||||
response = invalid_client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"/get/landmarks",
|
||||
json ={
|
||||
"preferences": preferences,
|
||||
"start": start
|
||||
}
|
||||
|
@@ -1,345 +0,0 @@
|
||||
"""Collection of tests to ensure correct implementation and track progress. """
|
||||
import time
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from .test_utils import load_trip_landmarks, log_trip_details
|
||||
from ..main import app
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Client used to call the app."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_turckheim(client, request): # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°1 : Custom test in Turckheim to ensure small villages are also supported.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 20
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 0},
|
||||
"shopping": {"type": "shopping", "score": 0},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [48.084588, 7.280405]
|
||||
# "start": [45.74445023349939, 4.8222687890538865]
|
||||
# "start": [45.75156398104873, 4.827154464827647]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert isinstance(landmarks, list) # check that the return type is a list
|
||||
assert len(landmarks) > 2 # check that there is something to visit
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
# assert 2!= 3
|
||||
|
||||
def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 120
|
||||
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [45.7576485, 4.8330241]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
|
||||
def test_cologne(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°3 : Custom test in Cologne to ensure proper decision making in crowded area.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 240
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [50.942352665, 6.957777972392]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
|
||||
|
||||
def test_strasbourg(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°4 : Custom test in Strasbourg to ensure proper decision making in crowded area.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 180
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [48.5846589226, 7.74078715721]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
|
||||
|
||||
def test_zurich(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°5 : Custom test in Zurich to ensure proper decision making in crowded area.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 180
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [47.377884227, 8.5395114066]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
|
||||
|
||||
def test_paris(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°6 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 200
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 0},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [48.85468881798671, 2.3423925755998374]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
|
||||
|
||||
def test_new_york(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°7 : Custom test in New York to ensure proper decision making in crowded area.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 600
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||
"nature": {"type": "nature", "score": 5},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [40.72592726802, -73.9920434795]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||
|
||||
|
||||
def test_shopping(client, request) : # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°8 : Custom test in Lyon centre to ensure shopping clusters are found.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
start_time = time.time() # Start timer
|
||||
duration_minutes = 240
|
||||
|
||||
response = client.post(
|
||||
"/trip/new",
|
||||
json={
|
||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 0},
|
||||
"nature": {"type": "nature", "score": 0},
|
||||
"shopping": {"type": "shopping", "score": 5},
|
||||
"max_time_minute": duration_minutes,
|
||||
"detour_tolerance_minute": 0},
|
||||
"start": [45.7576485, 4.8330241]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||
|
||||
# for elem in landmarks :
|
||||
# print(elem)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
46
backend/src/tests/test_nearby.py
Normal file
46
backend/src/tests/test_nearby.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Collection of tests to ensure correct implementation and track progress of the get_landmarks_nearby feature. """
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from ..main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Client used to call the app."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"location,status_code",
|
||||
[
|
||||
([45.7576485, 4.8330241], 200), # Lyon, France
|
||||
([41.4020572, 2.1818985], 200), # Barcelona, Spain
|
||||
([59.3293, 18.0686], 200), # Stockholm, Sweden
|
||||
([43.6532, -79.3832], 200), # Toronto, Canada
|
||||
([38.7223, -9.1393], 200), # Lisbon, Portugal
|
||||
([6.5244, 3.3792], 200), # Lagos, Nigeria
|
||||
([17.3850, 78.4867], 200), # Hyderabad, India
|
||||
([30.0444, 31.2357], 200), # Cairo, Egypt
|
||||
([50.8503, 4.3517], 200), # Brussels, Belgium
|
||||
([35.2271, -80.8431], 200), # Charlotte, USA
|
||||
([10.4806, -66.9036], 200), # Caracas, Venezuela
|
||||
([9.51074, -13.71118], 200), # Conakry, Guinea
|
||||
]
|
||||
)
|
||||
def test_nearby(client, location, status_code): # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°1 : Verify handling of invalid input.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
response = client.post(f"/get-nearby/landmarks/{location[0]}/{location[1]}")
|
||||
suggestions = response.json()
|
||||
|
||||
# checks :
|
||||
assert response.status_code == status_code # check for successful planning
|
||||
assert isinstance(suggestions, list) # check that the return type is a list
|
||||
assert len(suggestions) > 0
|
@@ -3,7 +3,7 @@
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from ..structs.landmark import Toilets
|
||||
from ..structs.toilets import Toilets
|
||||
from ..main import app
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def client():
|
||||
[
|
||||
({}, None, 422), # Invalid case: no location at all.
|
||||
([443], None, 422), # Invalid cases: invalid location.
|
||||
([443, 433], None, 422), # Invalid cases: invalid location.
|
||||
([443, 433], None, 422), # Invalid cases: invalid location.
|
||||
]
|
||||
)
|
||||
def test_invalid_input(client, location, radius, status_code): # pylint: disable=redefined-outer-name
|
||||
@@ -30,12 +30,13 @@ def test_invalid_input(client, location, radius, status_code): # pylint: disa
|
||||
request:
|
||||
"""
|
||||
response = client.post(
|
||||
"/toilets/new",
|
||||
"/get/toilets",
|
||||
params={
|
||||
"location": location,
|
||||
"radius": radius
|
||||
}
|
||||
)
|
||||
print(response.json())
|
||||
|
||||
# checks :
|
||||
assert response.status_code == status_code
|
||||
@@ -58,11 +59,12 @@ def test_no_toilets(client, location, status_code): # pylint: disable=redefin
|
||||
request:
|
||||
"""
|
||||
response = client.post(
|
||||
"/toilets/new",
|
||||
"/get/toilets",
|
||||
params={
|
||||
"location": location
|
||||
}
|
||||
)
|
||||
print(response.json())
|
||||
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
|
||||
|
||||
# checks :
|
||||
@@ -87,12 +89,14 @@ def test_toilets(client, location, status_code): # pylint: disable=redefined-
|
||||
request:
|
||||
"""
|
||||
response = client.post(
|
||||
"/toilets/new",
|
||||
"/get/toilets",
|
||||
params={
|
||||
"location": location,
|
||||
"radius" : 600
|
||||
}
|
||||
)
|
||||
|
||||
print(response.json())
|
||||
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
|
||||
|
||||
# checks :
|
||||
|
81
backend/src/tests/test_trip_generation.py
Normal file
81
backend/src/tests/test_trip_generation.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Collection of tests to ensure correct implementation and track progress."""
|
||||
import time
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from .test_utils import load_trip_landmarks, log_trip_details
|
||||
from ..structs.preferences import Preferences, Preference
|
||||
from ..main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Client used to call the app."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sightseeing, shopping, nature, max_time_minute, start_coords, end_coords",
|
||||
[
|
||||
# Edge cases
|
||||
(0, 0, 5, 240, [45.7576485, 4.8330241], None), # Lyon, Bellecour - test shopping only
|
||||
|
||||
# Realistic
|
||||
(5, 0, 0, 20, [48.0845881, 7.2804050], None), # Turckheim
|
||||
(5, 5, 5, 120, [45.7576485, 4.8330241], None), # Lyon, Bellecour
|
||||
(5, 2, 5, 240, [50.9423526, 6.9577780], None), # Cologne, centre
|
||||
(3, 5, 0, 180, [48.5846589226, 7.74078715721], None), # Strasbourg, centre
|
||||
(2, 4, 5, 180, [47.377884227, 8.5395114066], None), # Zurich, centre
|
||||
(5, 0, 5, 200, [48.85468881798671, 2.3423925755998374], None), # Paris, centre
|
||||
(5, 5, 5, 600, [40.72592726802, -73.9920434795], None), # New York, Lower Manhattan
|
||||
]
|
||||
)
|
||||
def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, start_coords, end_coords):
|
||||
|
||||
start_time = time.time() # Start timer
|
||||
|
||||
prefs = Preferences(
|
||||
sightseeing=Preference(type='sightseeing', score=sightseeing),
|
||||
shopping=Preference(type='shopping', score=shopping),
|
||||
nature=Preference(type='nature', score=nature),
|
||||
max_time_minute=max_time_minute,
|
||||
detour_tolerance_minute=0,
|
||||
)
|
||||
start = start_coords
|
||||
end = end_coords
|
||||
|
||||
# Step 1: request the list of landmarks in the vicinty of the starting point
|
||||
response = client.post(
|
||||
"/get/landmarks",
|
||||
json={
|
||||
"preferences": prefs.model_dump(),
|
||||
"start": start_coords,
|
||||
"end": end_coords,
|
||||
}
|
||||
)
|
||||
landmarks = response.json()
|
||||
|
||||
# Step 2: Feed the landmarks to the optimizer to compute the trip
|
||||
response = client.post(
|
||||
"/optimize/trip",
|
||||
json={
|
||||
"preferences": prefs.model_dump(),
|
||||
"landmarks": landmarks,
|
||||
"start": start,
|
||||
"end": end,
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
comp_time = time.time() - start_time
|
||||
|
||||
# Add details to report
|
||||
log_trip_details(request, landmarks, result['total_time'], prefs.max_time_minute)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert prefs.max_time_minute*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {prefs.max_time_minute}"
|
||||
assert prefs.max_time_minute*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {prefs.max_time_minute}"
|
@@ -1,10 +1,12 @@
|
||||
"""Helper methods for testing."""
|
||||
import time
|
||||
import logging
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..structs.landmark import Landmark
|
||||
from ..cache import client as cache_client
|
||||
from ..structs.landmark import Landmark
|
||||
from ..structs.preferences import Preferences, Preference
|
||||
|
||||
|
||||
def landmarks_to_osmid(landmarks: list[Landmark]) -> list[int] :
|
||||
@@ -39,7 +41,7 @@ def fetch_landmark(landmark_uuid: str):
|
||||
try:
|
||||
landmark = cache_client.get(f'landmark_{landmark_uuid}')
|
||||
if not landmark :
|
||||
logger.warning(f'Cache miss for landmark UUID: {landmark_uuid}')
|
||||
logger.error(f'Cache miss for landmark UUID: {landmark_uuid}')
|
||||
raise HTTPException(status_code=404, detail=f'Landmark with UUID {landmark_uuid} not found in cache.')
|
||||
|
||||
# Validate that the fetched data is a dictionary
|
||||
@@ -92,3 +94,34 @@ def log_trip_details(request, landmarks: list[Landmark], duration: int, target_d
|
||||
request.node.trip_details = trip_string
|
||||
request.node.trip_duration = str(duration) # result['total_time']
|
||||
request.node.target_duration = str(target_duration)
|
||||
|
||||
|
||||
|
||||
|
||||
def trip_params(
|
||||
sightseeing: int,
|
||||
shopping: int,
|
||||
nature: int,
|
||||
max_time_minute: int,
|
||||
start_coords: tuple[float, float] = None,
|
||||
end_coords: tuple[float, float] = None,
|
||||
):
|
||||
def decorator(test_func):
|
||||
@wraps(test_func)
|
||||
def wrapper(client, request):
|
||||
prefs = Preferences(
|
||||
sightseeing=Preference(type='sightseeing', score=sightseeing),
|
||||
shopping=Preference(type='shopping', score=shopping),
|
||||
nature=Preference(type='nature', score=nature),
|
||||
max_time_minute=max_time_minute,
|
||||
detour_tolerance_minute=0,
|
||||
)
|
||||
|
||||
start = start_coords
|
||||
end = end_coords
|
||||
|
||||
# Inject into test function
|
||||
return test_func(client, request, prefs, start, end)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
0
backend/src/toilets/__init__.py
Normal file
0
backend/src/toilets/__init__.py
Normal file
@@ -2,8 +2,8 @@
|
||||
import logging
|
||||
|
||||
from ..overpass.overpass import Overpass, get_base_info
|
||||
from ..structs.landmark import Toilets
|
||||
from .utils import create_bbox
|
||||
from ..structs.toilets import Toilets
|
||||
from ..utils.bbox import create_bbox
|
||||
|
||||
|
||||
# silence the overpass logger
|
||||
@@ -65,7 +65,7 @@ class ToiletsManager:
|
||||
try:
|
||||
result = self.overpass.fetch_data_from_api(query_str=query)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching landmarks: {e}")
|
||||
self.logger.error(f"Error fetching toilets: {e}")
|
||||
return None
|
||||
|
||||
toilets_list = self.to_toilets(result)
|
42
backend/src/toilets/toilets_router.py
Normal file
42
backend/src/toilets/toilets_router.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""API entry point for fetching toilet locations."""
|
||||
|
||||
from fastapi import HTTPException, APIRouter, Query
|
||||
|
||||
from .toilets_manager import ToiletsManager
|
||||
from ..structs.toilets import Toilets
|
||||
|
||||
|
||||
# Initialize the API router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/get/toilets")
|
||||
def get_toilets(
|
||||
location: tuple[float, float] = Query(...),
|
||||
radius: int = 500
|
||||
) -> list[Toilets] :
|
||||
"""
|
||||
Endpoint to find toilets within a specified radius from a given location.
|
||||
|
||||
This endpoint expects the `location` and `radius` as **query parameters**, not in the request body.
|
||||
|
||||
Args:
|
||||
location (tuple[float, float]): The latitude and longitude of the location to search from.
|
||||
radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters.
|
||||
|
||||
Returns:
|
||||
list[Toilets]: A list of Toilets objects that meet the criteria.
|
||||
"""
|
||||
if location is None:
|
||||
raise HTTPException(status_code=406, detail="Coordinates not provided or invalid")
|
||||
if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180):
|
||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||
|
||||
toilets_manager = ToiletsManager(location, radius)
|
||||
|
||||
try :
|
||||
toilets_list = toilets_manager.generate_toilet_list()
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
||||
|
||||
return toilets_list
|
@@ -24,4 +24,4 @@ def create_bbox(coords: tuple[float, float], radius: int):
|
||||
lon_min = lon - d_lon * 180 / m.pi
|
||||
lon_max = lon + d_lon * 180 / m.pi
|
||||
|
||||
return (lat_min, lon_min, lat_max, lon_max)
|
||||
return (lat_min, lon_min, lat_max, lon_max)
|
123
backend/src/utils/description.py
Normal file
123
backend/src/utils/description.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Add more information about the landmarks by writing a short description and keywords. """
|
||||
|
||||
|
||||
def description_and_keywords(tags: dict):
|
||||
"""
|
||||
Generates a description and a set of keywords for a given landmark based on its tags.
|
||||
|
||||
Params:
|
||||
tags (dict): A dictionary containing metadata about the landmark, including its name,
|
||||
importance, height, date of construction, and visitor information.
|
||||
|
||||
Returns:
|
||||
description (str): A string description of the landmark.
|
||||
keywords (dict): A dictionary of keywords with fields such as 'importance', 'height',
|
||||
'place_type', and 'date'.
|
||||
"""
|
||||
# Extract relevant fields
|
||||
name = tags.get('name')
|
||||
importance = tags.get('importance', None)
|
||||
n_visitors = tags.get('tourism:visitors', None)
|
||||
height = tags.get('height')
|
||||
place_type = get_place_type(tags)
|
||||
date = get_date(tags)
|
||||
|
||||
if place_type is None :
|
||||
return None, None
|
||||
|
||||
# Start the description.
|
||||
if importance is None :
|
||||
if len(tags.keys()) < 5 :
|
||||
return None, None
|
||||
if len(tags.keys()) < 10 :
|
||||
description = f"{name} is a well known {place_type}."
|
||||
elif len(tags.keys()) < 17 :
|
||||
importance = 'national'
|
||||
description = f"{name} is a {place_type} of national importance."
|
||||
else :
|
||||
importance = 'international'
|
||||
description = f"{name} is an internationally famous {place_type}."
|
||||
else :
|
||||
description = f"{name} is a {place_type} of {importance} importance."
|
||||
|
||||
if height is not None and date is not None :
|
||||
description += f" This {place_type} was constructed in {date} and is ca. {height} meters high."
|
||||
elif height is not None :
|
||||
description += f" This {place_type} stands ca. {height} meters tall."
|
||||
elif date is not None:
|
||||
description += f" It was constructed in {date}."
|
||||
|
||||
# Format the visitor number
|
||||
if n_visitors is not None :
|
||||
n_visitors = int(n_visitors)
|
||||
if n_visitors < 1000000 :
|
||||
description += f" It welcomes {int(n_visitors/1000)} thousand visitors every year."
|
||||
else :
|
||||
description += f" It welcomes {round(n_visitors/1000000, 1)} million visitors every year."
|
||||
|
||||
# Set the keywords.
|
||||
keywords = {"importance": importance,
|
||||
"height": height,
|
||||
"place_type": place_type,
|
||||
"date": date}
|
||||
|
||||
return description, keywords
|
||||
|
||||
|
||||
def get_place_type(tags):
|
||||
"""
|
||||
Determines the type of the place based on available tags such as 'amenity', 'building',
|
||||
'historic', and 'leisure'. The priority order is: 'historic' > 'building' (if not generic) >
|
||||
'amenity' > 'leisure'.
|
||||
|
||||
Params:
|
||||
tags (dict): A dictionary containing metadata about the place.
|
||||
|
||||
Returns:
|
||||
place_type (str): The determined type of the place, or None if no relevant type is found.
|
||||
"""
|
||||
amenity = tags.get('amenity', None)
|
||||
building = tags.get('building', None)
|
||||
historic = tags.get('historic', None)
|
||||
leisure = tags.get('leisure')
|
||||
|
||||
if historic and historic != "yes":
|
||||
return historic
|
||||
if building and building not in ["yes", "civic", "government", "apartments", "residential", "commericial", "industrial", "retail", "religious", "public", "service"]:
|
||||
return building
|
||||
if amenity:
|
||||
return amenity
|
||||
if leisure:
|
||||
return leisure
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_date(tags):
|
||||
"""
|
||||
Extracts the most relevant date from the available tags, prioritizing 'construction_date',
|
||||
'start_date', 'year_of_construction', and 'opening_date' in that order.
|
||||
|
||||
Params:
|
||||
tags (dict): A dictionary containing metadata about the place.
|
||||
|
||||
Returns:
|
||||
date (str): The most relevant date found, or None if no date is available.
|
||||
"""
|
||||
construction_date = tags.get('construction_date', None)
|
||||
opening_date = tags.get('opening_date', None)
|
||||
start_date = tags.get('start_date', None)
|
||||
year_of_construction = tags.get('year_of_construction', None)
|
||||
|
||||
# Prioritize based on availability
|
||||
if construction_date:
|
||||
return construction_date
|
||||
if start_date:
|
||||
return start_date
|
||||
if year_of_construction:
|
||||
return year_of_construction
|
||||
if opening_date:
|
||||
return opening_date
|
||||
|
||||
return None
|
@@ -1,17 +0,0 @@
|
||||
"""Helper function to return only the major landmarks from a large list."""
|
||||
from ..structs.landmark import Landmark
|
||||
|
||||
def take_most_important(landmarks: list[Landmark], n_important) -> list[Landmark]:
|
||||
"""
|
||||
Given a list of landmarks, return the n_important most important landmarks
|
||||
Args:
|
||||
landmarks: list[Landmark] - list of landmarks
|
||||
n_important: int - number of most important landmarks to return
|
||||
Returns:
|
||||
list[Landmark] - list of the n_important most important landmarks
|
||||
"""
|
||||
|
||||
# Sort landmarks by attractiveness (descending)
|
||||
sorted_landmarks = sorted(landmarks, key=lambda x: x.attractiveness, reverse=True)
|
||||
|
||||
return sorted_landmarks[:n_important]
|
1091
report.html
Normal file
1091
report.html
Normal file
File diff suppressed because it is too large
Load Diff
48
status
Normal file
48
status
Normal file
@@ -0,0 +1,48 @@
|
||||
error: wrong number of arguments, should be from 1 to 2
|
||||
usage: git config [<options>]
|
||||
|
||||
Config file location
|
||||
--[no-]global use global config file
|
||||
--[no-]system use system config file
|
||||
--[no-]local use repository config file
|
||||
--[no-]worktree use per-worktree config file
|
||||
-f, --[no-]file <file>
|
||||
use given config file
|
||||
--[no-]blob <blob-id> read config from given blob object
|
||||
|
||||
Action
|
||||
--[no-]get get value: name [value-pattern]
|
||||
--[no-]get-all get all values: key [value-pattern]
|
||||
--[no-]get-regexp get values for regexp: name-regex [value-pattern]
|
||||
--[no-]get-urlmatch get value specific for the URL: section[.var] URL
|
||||
--[no-]replace-all replace all matching variables: name value [value-pattern]
|
||||
--[no-]add add a new variable: name value
|
||||
--[no-]unset remove a variable: name [value-pattern]
|
||||
--[no-]unset-all remove all matches: name [value-pattern]
|
||||
--[no-]rename-section rename section: old-name new-name
|
||||
--[no-]remove-section remove a section: name
|
||||
-l, --[no-]list list all
|
||||
--[no-]fixed-value use string equality when comparing values to 'value-pattern'
|
||||
-e, --[no-]edit open an editor
|
||||
--[no-]get-color find the color configured: slot [default]
|
||||
--[no-]get-colorbool find the color setting: slot [stdout-is-tty]
|
||||
|
||||
Type
|
||||
-t, --[no-]type <type>
|
||||
value is given this type
|
||||
--bool value is "true" or "false"
|
||||
--int value is decimal number
|
||||
--bool-or-int value is --bool or --int
|
||||
--bool-or-str value is --bool or string
|
||||
--path value is a path (file or directory name)
|
||||
--expiry-date value is an expiry date
|
||||
|
||||
Other
|
||||
-z, --[no-]null terminate values with NUL byte
|
||||
--[no-]name-only show variable names only
|
||||
--[no-]includes respect include directives on lookup
|
||||
--[no-]show-origin show origin of config (file, standard input, blob, command line)
|
||||
--[no-]show-scope show scope of config (worktree, local, global, system, command)
|
||||
--[no-]default <value>
|
||||
with --get, use default value when missing entry
|
||||
|
Reference in New Issue
Block a user