Compare commits

..

7 Commits

Author SHA1 Message Date
2288a50b60 automatically save a trip when it is first created
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 2m38s
Run linting on the backend code / Build (pull_request) Successful in 28s
Run testing on the backend code / Build (pull_request) Failing after 5m22s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 24s
Build and release debug APK / Build APK (pull_request) Failing after 3m29s
2025-03-23 20:05:18 +01:00
02133a4abe quite a few UX improvements
Some checks failed
Run testing on the backend code / Build (pull_request) Failing after 4m43s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 25s
Build and release debug APK / Build APK (pull_request) Failing after 4m39s
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 3m30s
Run linting on the backend code / Build (pull_request) Successful in 28s
2025-03-23 20:00:24 +01:00
03cf58ce43 revamped onboarding
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 2m36s
Run linting on the backend code / Build (pull_request) Successful in 28s
Run testing on the backend code / Build (pull_request) Failing after 18m37s
Build and release debug APK / Build APK (pull_request) Failing after 4m42s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 31s
2025-02-25 19:18:44 +01:00
c4fddc1a57 account for changed itineraries once landmarks are marked as done or deleted
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m43s
Run linting on the backend code / Build (pull_request) Successful in 26s
Run testing on the backend code / Build (pull_request) Failing after 3m49s
Build and release debug APK / Build APK (pull_request) Failing after 3m35s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 26s
2025-02-16 12:41:06 +01:00
af5aa0097c reworked page layout inheritence 2025-02-16 11:40:25 +01:00
b82f9997a4 more pleasant progress handling, although somewhat flawed 2025-02-14 12:23:41 +01:00
1d5553f7f2 logger and launch cleanup 2025-02-14 12:14:45 +01:00
25 changed files with 143 additions and 783 deletions

View File

@ -1,30 +0,0 @@
# License
## Proprietary License
All code and resources in this repository are the property of AnyDev. The software and related documentation are provided solely for use with services provided by AnyDev. Redistribution, modification, or use of this software outside of its intended service is strictly prohibited without explicit permission.
### Copyright © 2024 AnyDev
All rights reserved.
### Restrictions
- You may not modify, distribute, copy, or reverse engineer any part of this codebase.
- This software is licensed for use solely in conjunction with services provided by AnyDev.
- Any commercial use of this software is strictly prohibited without explicit written consent from AnyDev.
## Third-Party Dependencies
This project uses third-party dependencies, which are subject to their respective licenses.
- Python backend dependencies: fastapi, pydantic, numpy, shapely, etc. Licensed under their respective licenses.
- Flutter frontend dependencies: Cupertino Icons, sliding_up_panel, http, etc. Licensed under their respective licenses.
Please refer to each project's documentation for the specific terms and conditions.
## OpenStreetMap Data Usage
This project uses data derived from **OpenStreetMap**. OpenStreetMap data is available under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). We comply with the ODbL license, and some of the data displayed in the service may be derived from OpenStreetMap sources. We do not redistribute raw OpenStreetMap data; instead, it is processed and transformed before being used in our services.
More information about OpenStreetMap data usage can be found [here](https://www.openstreetmap.org/copyright).

3
backend/.gitignore vendored
View File

@ -1,9 +1,6 @@
# osm-cache
cache_XML/
# secrets
*secrets.yaml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

View File

@ -1,363 +0,0 @@
[
{
"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

View File

@ -1,16 +1,17 @@
"""Main app for backend api"""
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from .logging_config import configure_logging
from .structs.landmark import Landmark
from .structs.landmark import Landmark, Toilets
from .structs.preferences import Preferences
from .structs.linked_landmarks import LinkedLandmarks
from .structs.trip import Trip
from .landmarks.landmarks_manager import LandmarkManager
from .toilets.toilet_routes import router as toilets_router
from .utils.landmarks_manager import LandmarkManager
from .utils.toilets_manager import ToiletsManager
from .optimization.optimizer import Optimizer
from .optimization.refiner import Refiner
from .overpass.overpass import fill_cache
@ -36,14 +37,10 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan)
app.include_router(toilets_router)
@app.post("/trip/new")
def new_trip(preferences: Preferences,
start: tuple[float, float],
end: tuple[float, float] | None = None,
end: tuple[float, float] | None = None,
background_tasks: BackgroundTasks = None) -> Trip:
"""
Main function to call the optimizer.
@ -69,8 +66,6 @@ def new_trip(preferences: Preferences,
end = start
logger.info("No end coordinates provided. Using start=end.")
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]),
@ -92,7 +87,6 @@ def new_trip(preferences: Preferences,
n_tags=0)
start_time = time.time()
# Generate the landmarks from the start location
landmarks, landmarks_short = manager.generate_landmarks_list(
center_coordinates = start,
@ -114,7 +108,6 @@ def new_trip(preferences: Preferences,
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
@ -126,21 +119,22 @@ def new_trip(preferences: Preferences,
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)}")
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)
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)
@ -163,7 +157,6 @@ 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
@ -182,45 +175,32 @@ 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
@app.post("/trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}")
def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
@app.post("/toilets/new")
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
"""
Updates the reaching times of a given trip when removing a landmark.
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:
landmark_uuid (str) : unique identifier for a Landmark.
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:
(Landmark) : the corresponding Landmark.
list[Toilets]: A list of Toilets objects that meet the criteria.
"""
# First, fetch the trip in the cache.
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
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")
landmarks = []
next_uuid = trip.first_landmark_uuid
toilets_manager = ToiletsManager(location, radius)
# Extract landmarks
try :
while next_uuid is not None:
landmark = cache_client.get(f'landmark_{next_uuid}')
# Filter out the removed landmark.
if next_uuid != removed_landmark_uuid :
landmarks.append(landmark)
next_uuid = landmark.next_uuid # Prepare for the next iteration
toilets_list = toilets_manager.generate_toilet_list()
return toilets_list
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
linked_tour = LinkedLandmarks(landmarks)
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
return trip
raise HTTPException(status_code=404, detail="No toilets found") from exc

View File

@ -257,6 +257,7 @@ 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
@ -589,15 +590,15 @@ 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: {str(exc)}") from exc
raise Exception(f"No solution found: {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)
self.logger.debug("First results are out. Looking out for circles and correcting...")
self.logger.debug("First results are out. Looking out for circles and correcting.")
# Raise error if no solution is found. FIXME: for now this throws the internal server error
if status != 'Optimal' :
self.logger.warning("The problem is overconstrained, no solution on first try.")
self.logger.error("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
@ -607,7 +608,7 @@ class Optimizer:
while circles is not None :
i += 1
if i == self.max_iter :
self.logger.warning(f'Timeout: No solution found after {self.max_iter} iterations.')
self.logger.error(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 :
@ -617,13 +618,12 @@ class Optimizer:
try :
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit, gapRel=self.gap_rel))
except Exception as exc :
self.logger.warning("No solution found: {str(exc)")
raise Exception(f"No solution found: {str(exc)}") from exc
raise Exception(f"No solution found: {exc}") from exc
solution = [pl.value(var) for var in x]
if pl.LpStatus[prob.status] != 'Optimal' :
self.logger.warning("The problem is overconstrained, no solution after {i} cycles.")
self.logger.error("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)

View File

@ -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
"""
FIXED : ERROR HERE :
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("Refiner failed: No solution found during second stage optimization.")
self.logger.warning("No solution found for the refined tour. Returning the initial tour.")
new_tour = base_tour
# If only one landmark, return it.
@ -369,7 +369,6 @@ 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

View File

@ -1,4 +1,3 @@
"""Module defining the handling of cache data from Overpass requests."""
import os
import json
import hashlib
@ -62,7 +61,7 @@ class JSONCache(CachingStrategyBase):
return None
def set(self, key, value):
"""Save the JSON data in the cache."""
"""Save the JSON data as an ElementTree to the cache."""
filename = self._filename(key)
try:
# Write the JSON data to the cache file
@ -95,7 +94,7 @@ class JSONCache(CachingStrategyBase):
def close(self):
"""Cleanup method, if needed."""
pass
class CachingStrategy:
"""
@ -108,7 +107,6 @@ class CachingStrategy:
@classmethod
def use(cls, strategy_name='JSON', **kwargs):
"""Define the caching strategy to use."""
if cls.__strategy:
cls.__strategy.close()
@ -121,12 +119,10 @@ 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

View File

@ -1,6 +1,5 @@
"""Module allowing connexion to overpass api and fectch data from OSM."""
import os
import time
import urllib
import math
import logging
@ -53,24 +52,24 @@ class Overpass :
# Retrieve cached data and identify missing cache entries
cached_responses, non_cached_cells = self._retrieve_cached_data(overlapping_cells, osm_types, selector, conditions, out)
self.logger.debug(f'Cache hit for {len(overlapping_cells)-len(non_cached_cells)}/{len(overlapping_cells)} quadrants.')
self.logger.info(f'Cache hit for {len(overlapping_cells)-len(non_cached_cells)}/{len(overlapping_cells)} quadrants.')
# If there is no missing data, return the cached responses after filtering.
if not non_cached_cells :
return Overpass._filter_landmarks(cached_responses, bbox)
# If there is no cached data, fetch all from Overpass.
if not cached_responses :
elif 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)
# 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
# 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)
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]:
@ -95,10 +94,9 @@ class Overpass :
return elements
except urllib.error.URLError as e:
self.logger.error(f"Error connecting to Overpass API: {str(e)}")
raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
self.logger.error(f"Error connecting to Overpass API: {e}")
raise ConnectionError(f"Error connecting to Overpass API: {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 +112,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 +120,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: {str(e)}") from e
raise ConnectionError(f"Error connecting to Overpass API: {e}") from e
except Exception as exc :
raise Exception(f'An unexpected error occured: {str(exc)}') from exc
@ -153,7 +151,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][timeout:20];('
query = '[out:json];('
# convert the bbox to string.
bbox_str = f"({','.join(map(str, bbox))})"
@ -309,9 +307,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 +386,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
return osm_id, coords
else :
return osm_id, coords
def fill_cache():
@ -399,25 +397,18 @@ def fill_cache():
"""
overpass = Overpass()
n_files = 0
total = 0
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', encoding='utf-8') as f:
with open(entry.path, 'r') 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: {str(exc)}')
overpass.logger.info(f"Successfully filled {n_files}/{total} cache files.")
overpass.logger.error(f'An error occured while parsing file {entry.path} as .json file')

View File

@ -72,7 +72,6 @@ sightseeing:
# - castle
# - museum
museums:
tourism:
- museum

View File

@ -1,11 +1,12 @@
max_bbox_side: 4000 #m
radius_close_to: 50
church_coeff: 0.75
nature_coeff: 1.6
church_coeff: 0.55
nature_coeff: 1.4
overall_coeff: 10
tag_exponent: 1.15
image_bonus: 1.1
viewpoint_bonus: 10
viewpoint_bonus: 5
wikipedia_bonus: 1.25
name_bonus: 3
N_important: 60
pay_bonus: -1

View File

@ -5,5 +5,5 @@ max_landmarks: 10
max_landmarks_refiner: 20
overshoot: 0.0016
time_limit: 1
gap_rel: 0.025
max_iter: 80
gap_rel: 0.05
max_iter: 40

View File

@ -1,7 +1,8 @@
"""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, Field
from pydantic import BaseModel, ConfigDict, Field
# Output to frontend
@ -49,8 +50,7 @@ class Landmark(BaseModel) :
image_url : Optional[str] = None
website_url : Optional[str] = None
wiki_url : Optional[str] = None
keywords: Optional[dict] = {}
description : Optional[str] = None
description : Optional[str] = None # TODO future
duration : Optional[int] = 5
name_en : Optional[str] = None
@ -69,7 +69,6 @@ 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.
@ -123,3 +122,26 @@ 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)

View File

@ -1,26 +0,0 @@
"""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)

View File

@ -31,9 +31,9 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
"shopping": {"type": "shopping", "score": 0},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [48.084588, 7.280405]
# "start": [48.084588, 7.280405]
# "start": [45.74445023349939, 4.8222687890538865]
# "start": [45.75156398104873, 4.827154464827647]
"start": [45.75156398104873, 4.827154464827647]
}
)
result = response.json()
@ -46,6 +46,8 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
# 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
@ -340,4 +342,4 @@ def test_shopping(client, request) : # pylint: disable=redefined-outer-name
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}"
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"

View File

@ -3,7 +3,7 @@
from fastapi.testclient import TestClient
import pytest
from ..structs.toilets import Toilets
from ..structs.landmark import Toilets
from ..main import app

View File

@ -1,6 +1,7 @@
"""Helper methods for testing."""
import logging
from fastapi import HTTPException
from pydantic import ValidationError
from ..structs.landmark import Landmark
from ..cache import client as cache_client
@ -38,7 +39,7 @@ def fetch_landmark(landmark_uuid: str):
try:
landmark = cache_client.get(f'landmark_{landmark_uuid}')
if not landmark :
logger.error(f'Cache miss for landmark UUID: {landmark_uuid}')
logger.warning(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

View File

@ -1,38 +0,0 @@
"""Defines the endpoint for fetching toilet locations."""
from fastapi import HTTPException, APIRouter, Query
from ..structs.toilets import Toilets
from .toilets_manager import ToiletsManager
# Define the API router
router = APIRouter()
@router.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()
except KeyError as exc:
raise HTTPException(status_code=404, detail="No toilets found") from exc
return toilets_list

View File

@ -1,6 +1,6 @@
"""Find clusters of interest to add more general areas of visit to the tour."""
import logging
from typing import Literal, Tuple
from typing import Literal
import numpy as np
from sklearn.cluster import DBSCAN
@ -8,8 +8,8 @@ from pydantic import BaseModel
from ..overpass.overpass import Overpass, get_base_info
from ..structs.landmark import Landmark
from ..utils.get_time_distance import get_distance
from ..utils.bbox import create_bbox
from .get_time_distance import get_distance
from .utils import create_bbox
@ -33,7 +33,7 @@ class Cluster(BaseModel):
"""
type: Literal['street', 'area']
importance: int
centroid: Tuple[float, float]
centroid: tuple
# start: Optional[list] = None # for later use if we want to have streets as well
# end: Optional[list] = None
@ -103,7 +103,7 @@ class ClusterManager:
out = out
)
except Exception as e:
self.logger.warning(f"Error fetching clusters: {e}")
self.logger.error(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 :
@ -178,12 +178,11 @@ class ClusterManager:
# Calculate the centroid as the mean of the points
centroid = np.mean(current_cluster, axis=0)
centroid = tuple((round(centroid[0], 7), round(centroid[1], 7)))
if self.cluster_type == 'shopping' :
score = len(current_cluster)*3
score = len(current_cluster)*2
else :
score = len(current_cluster)*15
score = len(current_cluster)*8
locations.append(Cluster(
type='area',
centroid=centroid,
@ -216,18 +215,18 @@ class ClusterManager:
"""
# Define the bounding box for a given radius around the coordinates
bbox = create_bbox(cluster.centroid, 300)
bbox = create_bbox(cluster.centroid, 1000)
# Query neighborhoods and shopping malls
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"']
if self.cluster_type == 'shopping' :
selectors.append('"shop"="mall"')
new_name = 'Shopping Area'
t = 30
t = 40
else :
new_name = 'Neighborhood'
t = 20
t = 15
min_dist = float('inf')
osm_id = 0
@ -239,28 +238,30 @@ class ClusterManager:
result = self.overpass.send_query(bbox = bbox,
osm_types = osm_types,
selector = sel,
out = 'ids center tags'
out = 'ids center'
)
except Exception as e:
self.logger.warning(f"Error fetching clusters: {e}")
self.logger.error(f"Error fetching clusters: {e}")
continue
if result is None :
self.logger.warning(f"Error fetching clusters: query result is None")
self.logger.error(f"Error fetching clusters: {e}")
continue
for elem in result:
# Get basic info
id, coords, name = get_base_info(elem, elem.get('type'), with_name=True)
osm_type = elem.get('type')
id, coords, name = get_base_info(elem, osm_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 = elem.get('type') # add type: 'way' or 'relation'
osm_id = id # add OSM id
new_name = name
osm_type = osm_type # Add type: 'way' or 'relation'
osm_id = id # Add OSM id
return Landmark(
name=new_name,

View File

@ -4,10 +4,10 @@ import yaml
from ..structs.preferences import Preferences
from ..structs.landmark import Landmark
from ..utils.take_most_important import take_most_important
from .take_most_important import take_most_important
from .cluster_manager import ClusterManager
from ..overpass.overpass import Overpass, get_base_info
from ..utils.bbox import create_bbox
from .utils import create_bbox
from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH
@ -39,6 +39,7 @@ class LandmarkManager:
self.overall_coeff = parameters['overall_coeff']
self.tag_exponent = parameters['tag_exponent']
self.image_bonus = parameters['image_bonus']
self.name_bonus = parameters['name_bonus']
self.wikipedia_bonus = parameters['wikipedia_bonus']
self.viewpoint_bonus = parameters['viewpoint_bonus']
self.pay_bonus = parameters['pay_bonus']
@ -146,8 +147,6 @@ class LandmarkManager:
score *= self.wikipedia_bonus
if landmark.is_place_of_worship :
score *= self.church_coeff
if landmark.is_viewpoint :
score *= self.viewpoint_bonus
if landmarktype == 'nature' :
score *= self.nature_coeff
@ -197,12 +196,12 @@ class LandmarkManager:
out = 'ids center tags'
)
except Exception as e:
self.logger.debug(f"Failed to fetch landmarks, proceeding without: {str(e)}")
self.logger.error(f"Error fetching landmarks: {e}")
continue
return_list += self._to_landmarks(result, landmarktype, preference_level)
# self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
return return_list
@ -246,6 +245,8 @@ class LandmarkManager:
attractiveness=0,
n_tags=len(tags))
# self.logger.debug('added landmark.')
# Browse through tags to add information to landmark.
for key, value in tags.items():
@ -266,7 +267,7 @@ class LandmarkManager:
landmark.image_url = value
if key == 'website' :
landmark.website_url = value
if value == 'place_of_worship' :
if key == 'place_of_worship' :
landmark.is_place_of_worship = True
if key == 'wikipedia' :
landmark.wiki_url = value
@ -275,7 +276,6 @@ class LandmarkManager:
if 'building:' in key or 'pay' in key :
landmark.n_tags -= 1
# Set the duration.
if value in ['museum', 'aquarium', 'planetarium'] :
landmark.duration = 60
@ -286,138 +286,14 @@ class LandmarkManager:
landmark.is_place_of_worship = False
landmark.duration = 10
landmark.description, landmark.keywords = self.description_and_keywords(tags)
self.set_landmark_score(landmark, landmarktype, preference_level)
landmarks.append(landmark)
else:
self.set_landmark_score(landmark, landmarktype, preference_level)
landmarks.append(landmark)
continue
return landmarks
def description_and_keywords(self, 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 = self.get_place_type(tags)
date = self.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(self, data):
"""
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:
data (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 = data.get('amenity', None)
building = data.get('building', None)
historic = data.get('historic', None)
leisure = data.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(self, data):
"""
Extracts the most relevant date from the available tags, prioritizing 'construction_date',
'start_date', 'year_of_construction', and 'opening_date' in that order.
Params:
data (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 = data.get('construction_date', None)
opening_date = data.get('opening_date', None)
start_date = data.get('start_date', None)
year_of_construction = data.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
def dict_to_selector_list(d: dict) -> list:
"""
Convert a dictionary of key-value pairs to a list of Overpass query strings.

View File

@ -2,8 +2,8 @@
import logging
from ..overpass.overpass import Overpass, get_base_info
from ..structs.toilets import Toilets
from ..utils.bbox import create_bbox
from ..structs.landmark import Toilets
from .utils 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 toilets: {e}")
self.logger.error(f"Error fetching landmarks: {e}")
return None
toilets_list = self.to_toilets(result)

View File

@ -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)

48
status
View File

@ -1,48 +0,0 @@
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