30 Commits

Author SHA1 Message Date
ac99ef3930 Add renovate.json 2024-07-27 12:30:05 +00:00
35e0f3c400 Merge pull request 'Add readmes' (#12) from feature/documentation into main
Reviewed-on: remoll/fast-network-navigation#12
2024-07-27 12:10:12 +00:00
de5f1ec3d3 Merge pull request 'cleanup-backend' (#13) from cleanup-backend into main
Reviewed-on: remoll/fast-network-navigation#13
2024-07-27 12:09:54 +00:00
81c763587d Merge pull request 'style corrections, documentation, duplicate removal, flow improvement' (#11) from feature/backend-refactoring into cleanup-backend
Some checks failed
Build and push docker image / Build (pull_request) Failing after 10s
Reviewed-on: remoll/fast-network-navigation#11
2024-07-27 12:09:10 +00:00
3fa689fd16 a few docker-related fixes 2024-07-26 19:11:26 +02:00
2736a89f70 cleanup in view of docker builds 2024-07-26 13:13:36 +02:00
e50eedf099 add readmes with first pointers
Some checks failed
Build and push docker image / Build (pull_request) Failing after 2m15s
Build and release APK / Build APK (pull_request) Successful in 6m46s
Build web / Build Web (pull_request) Successful in 2m33s
2024-07-25 17:22:29 +02:00
2863c99d7c style corrections, documentation, duplicate removal, flow improvement 2024-07-25 17:15:18 +02:00
80b3d5b012 refactored landmark manager and clean up 2024-07-25 09:37:37 +02:00
d23050b811 fixed parameters folder 2024-07-24 10:34:34 +02:00
127ba8c028 fixed log 2024-07-21 10:16:13 +02:00
94fa735d54 cleaned up backend to use classes and yaml files 2024-07-20 23:16:35 +02:00
14a7f555df more coherent base types 2024-07-17 13:22:43 +02:00
4a291a69c9 Merge branch 'feature/backend/initial-deployment' 2024-07-17 12:47:20 +02:00
7d7a25e2f3 stage changes as reference implementation
All checks were successful
Build and push docker image / Build (pull_request) Successful in 2m50s
Build and release APK / Build APK (pull_request) Successful in 4m11s
Build web / Build Web (pull_request) Successful in 1m28s
2024-07-17 12:35:08 +02:00
f590ebb5ed Merge pull request 'Permafix-optimization-refiner' (#9) from Permafix-optimization-refiner into main
Reviewed-on: remoll/fast-network-navigation#9
2024-07-17 10:32:32 +00:00
b09ec2b083 changed bbox to meters
All checks were successful
Build and push docker image / Build (pull_request) Successful in 3m45s
Build and release APK / Build APK (pull_request) Successful in 6m57s
Build web / Build Web (pull_request) Successful in 2m33s
2024-07-17 12:30:47 +02:00
8d71cab8d5 osmnx does not behave 2024-07-17 12:00:40 +02:00
87df2f70e9 cleanup files 2024-07-17 11:59:42 +02:00
50bc8150c8 permafixed ? 2024-07-16 09:01:58 +02:00
25cc0fa300 (theoretically) functional deployment 2024-07-08 11:55:27 +02:00
8f23a4747d further cleanup 2024-07-08 11:55:00 +02:00
4896e95617 cleaned up 2024-07-08 02:13:13 +02:00
30ed2bb9ed permafixed the optimizer ??? 2024-07-08 02:01:42 +02:00
568e7bfbc4 upgraded optimizer 2024-07-08 01:20:17 +02:00
d4e964c5d4 fixed the optimizer_v4 2024-07-07 16:24:15 +02:00
f9c86261cb switch to osmnx 2024-07-07 14:49:10 +02:00
e71c92da40 added some ideas 2024-07-07 10:17:50 +02:00
006b80018a Added 2024-07-05 17:21:47 +02:00
49ce8527a3 cleanup path handling for easier dockerization 2024-06-30 18:42:59 +02:00
40 changed files with 2268 additions and 11980 deletions

View File

@@ -2,6 +2,8 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
paths:
- backend/**
name: Build and push docker image name: Build and push docker image
@@ -12,13 +14,13 @@ jobs:
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.kluster.moll.re registry: git.kluster.moll.re
username: ${{ gitea.repository_owner }} username: ${{ gitea.repository_owner }}
password: ${{ secrets.DOCKER_PUSH_TOKEN }} password: ${{ secrets.PACKAGE_REGISTRY_ACCESS }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -27,5 +29,5 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: backend context: backend
tags: git.kluster.moll.re/remoll/fast_network_navigation/backend:latest tags: git.kluster.moll.re/anydev/anyway-backend:latest
push: true push: true

View File

@@ -44,7 +44,7 @@ jobs:
- name: Add required secrets - name: Add required secrets
run: | run: |
echo ${{ secrets.ANDROID_SECRETS_BASE64 }} | base64 -d > ./android/secrets.properties echo ${{ secrets.ANDROID_SECRETS_PROPERTIES }} > ./android/secrets.properties
working-directory: ./frontend working-directory: ./frontend
- name: Sanity check - name: Sanity check

View File

@@ -1,34 +1,34 @@
on: # on:
pull_request: # pull_request:
branches: # branches:
- main # - main
paths: # paths:
- frontend/** # - frontend/**
name: Build web # name: Build web
jobs: # jobs:
build: # build:
name: Build Web # name: Build Web
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- name: Install prerequisites # - name: Install prerequisites
run: | # run: |
sudo apt-get update # sudo apt-get update
sudo apt-get install -y xz-utils # sudo apt-get install -y xz-utils
- uses: actions/checkout@v4 # - uses: actions/checkout@v4
- uses: https://github.com/subosito/flutter-action@v2 # - uses: https://github.com/subosito/flutter-action@v2
with: # with:
channel: stable # channel: stable
flutter-version: 3.19.6 # flutter-version: 3.19.6
cache: true # cache: true
- run: flutter pub get # - run: flutter pub get
working-directory: ./frontend # working-directory: ./frontend
- run: flutter build web # - run: flutter build web
working-directory: ./frontend # working-directory: ./frontend

46
.vscode/launch.json vendored
View File

@@ -4,25 +4,49 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
// backend - python using fastapi
{ {
"name": "frontend", "name": "Backend - debug",
"cwd": "frontend", "type": "debugpy",
"request": "launch", "request": "launch",
"type": "dart" "module": "uvicorn",
"env": {
"DEBUG": "true"
},
"args": [
"--app-dir",
"src",
"main:app",
"--reload",
],
"jinja": true,
"cwd": "${workspaceFolder}/backend"
}, },
{ {
"name": "frontend (profile mode)", "name": "Backend - tester",
"cwd": "frontend", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "src/tester.py",
"env": {
"DEBUG": "true"
},
"cwd": "${workspaceFolder}/backend"
},
// frontend - flutter app
{
"name": "Frontend - debug",
"type": "dart", "type": "dart",
"flutterMode": "profile" "request": "launch",
"program": "lib/main.dart",
"cwd": "${workspaceFolder}/frontend"
}, },
{ {
"name": "frontend (release mode)", "name": "Frontend - profile",
"cwd": "frontend",
"request": "launch",
"type": "dart", "type": "dart",
"flutterMode": "release" "request": "launch",
}, "program": "lib/main.dart",
"flutterMode": "profile",
"cwd": "${workspaceFolder}/frontend"
}
] ]
} }

View File

@@ -1,16 +1,40 @@
# fast_network_navigation # AnyWay - plan city trips your way
AnyWay is a mobile application that helps users plan city trips. The app allows users to specify their preferences and constraints, and then generates a personalized itinerary for them. The planning follows some guiding principles:
- **Personalization**:The user's preferences should be reflected in the choice of destinations.
- **Efficiency**:The itinerary should be optimized for the user's constraints.
- **Flexibility**: We aknowledge that tourism is a dynamic activity, and that users may want to change their plans on the go.
- **Discoverability**: Tourism is an inherently exploratory activity. Once a rough itinerary is generated, detours and spontaneous decisions should be encouraged.
## Architecture
This project is divided into two main components: a frontend and a backend. The frontend is a Flutter application that runs on Android and iOS. The backend is a python server that runs on a cloud provider. The two components communicate via a REST API. Since both components are very interdependent and share many data structures, we opted for a monorepo approach.
### Frontend
See the [frontend README](frontend/README.md) for more information. The application is centered around its map view, which displays the user's itinerary. This is based on the Google Maps API.
### Backend
See the [backend README](backend/README.md) for more information. The backend is responsible for generating the itinerary based on the user's preferences and constraints. Rather than using google maps, we use the OpenStreetMap API, which is much more flexible.
A new Flutter project.
## Getting Started ## Getting Started
Refer to the READMEs in the `frontend` and `backend` directories for instructions on how to run the application locally. Notable prerequisites include:
- Flutter SDK
- `google_maps_flutter` plugin
- Python 3
- `fastapi`
- `OSMPythonTools`
- `numpy, scipy`
- Docker
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project: ## Releases
The project is still in its early stages. We are currently working on a prototype that demonstrates the core functionality of the application.
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) Official releases will be made available on the Google Play Store and the Apple App Store.
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials, ## Roadmap
samples, guidance on mobile development, and a full API reference. See the [project board](https://todos.kluster.moll.re/share/L8vZJGU45Vx9RzzVqTzyCPrpRybXm1OAqaW7YkWb/auth?view=list):
<iframe src="https://todos.kluster.moll.re/share/L8vZJGU45Vx9RzzVqTzyCPrpRybXm1OAqaW7YkWb/auth?view=list" title="Todos" width="100%">

1
backend/.gitignore vendored
View File

@@ -1,5 +1,6 @@
# osm-cache # osm-cache
cache/ cache/
apicache/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@@ -1,4 +1,4 @@
FROM python:3 FROM python:3.11-slim
WORKDIR /app WORKDIR /app
COPY Pipfile Pipfile.lock . COPY Pipfile Pipfile.lock .
@@ -6,6 +6,12 @@ COPY Pipfile Pipfile.lock .
RUN pip install pipenv RUN pip install pipenv
RUN pipenv install --deploy --system RUN pipenv install --deploy --system
COPY . /src COPY src src
CMD ["pipenv", "run", "python", "/app/src/main.py"] EXPOSE 8000
# Set environment variables used by the deployment. These can be overridden by the user using this image.
ENV NUM_WORKERS=1
ENV OSM_CACHE_DIR=/cache
CMD fastapi run src/main.py --port 8000 --workers $NUM_WORKERS

View File

@@ -3,12 +3,14 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
name = "pypi" name = "pypi"
[dev-packages]
[packages] [packages]
numpy = "*" numpy = "*"
scipy = "*"
fastapi = "*" fastapi = "*"
osmpythontools = "*"
pydantic = "*" pydantic = "*"
geopy = "*"
shapely = "*" shapely = "*"
scipy = "*"
[dev-packages] osmpythontools = "*"
pywikibot = "*"

948
backend/Pipfile.lock generated

File diff suppressed because it is too large Load Diff

16
backend/README.md Normal file
View File

@@ -0,0 +1,16 @@
# Backend
This repository contains the backend code for the application. It utilizes FastAPI that allows to quickly create a RESTful API that exposes the endpoints of the route optimizer.
## Getting Started
- The code of the python application is located in the `src` directory.
- Package management is handled with `pipenv` and the dependencies are listed in the `Pipfile`.
- Since the application is aimed to be deployed in a container, the `Dockerfile` is provided to build the image.
### Deployment
To deploy the backend docker container, we use kubernetes. The deployment configuration is located under [https://git.kluster.moll.re/anydev/deployment-backend/](https://git.kluster.moll.re/anydev/deployment-backend/).
## Development
TBD

View File

@@ -1,11 +0,0 @@
'leisure'='park'
geological
'natural'='geyser'
'natural'='hot_spring'
'natural'='arch'
'natural'='volcano'
'natural'='stone'
'tourism'='alpine_hut'
'tourism'='viewpoint'
'tourism'='zoo'
'waterway'='waterfall'

View File

@@ -1,2 +0,0 @@
'shop'='department_store'
'shop'='mall'

View File

@@ -1,8 +0,0 @@
'tourism'='museum'
'tourism'='attraction'
'tourism'='gallery'
historic
'amenity'='planetarium'
'amenity'='place_of_worship'
'amenity'='fountain'
'water'='reflecting_pool'

27
backend/src/constants.py Normal file
View File

@@ -0,0 +1,27 @@
import logging.config
from pathlib import Path
import os
LOCATION_PREFIX = Path('src')
PARAMETERS_DIR = LOCATION_PREFIX / 'parameters'
AMENITY_SELECTORS_PATH = PARAMETERS_DIR / 'amenity_selectors.yaml'
LANDMARK_PARAMETERS_PATH = PARAMETERS_DIR / 'landmark_parameters.yaml'
OPTIMIZER_PARAMETERS_PATH = PARAMETERS_DIR / 'optimizer_parameters.yaml'
cache_dir_string = os.getenv('OSM_CACHE_DIR', './cache')
OSM_CACHE_DIR = Path(cache_dir_string)
import logging
import yaml
LOGGING_CONFIG = LOCATION_PREFIX / 'log_config.yaml'
config = yaml.safe_load(LOGGING_CONFIG.read_text())
logging.config.dictConfig(config)
# if we are in a debug session, set the log level to debug
if os.getenv('DEBUG', False):
logging.getLogger().setLevel(logging.DEBUG)

View File

@@ -1,365 +0,0 @@
import math as m
import json, os
from typing import List, Tuple, Optional
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from structs.landmarks import Landmark, LandmarkType
from structs.preferences import Preferences, Preference
SIGHTSEEING = LandmarkType(landmark_type='sightseeing')
NATURE = LandmarkType(landmark_type='nature')
SHOPPING = LandmarkType(landmark_type='shopping')
# Include the json here
# Create a list of all things to visit given some preferences and a city. Ready for the optimizer
def generate_landmarks(preferences: Preferences, coordinates: Tuple[float, float]) :
l_sights, l_nature, l_shop = get_amenities()
L = []
# List for sightseeing
if preferences.sightseeing.score != 0 :
L1 = get_landmarks(l_sights, SIGHTSEEING, coordinates=coordinates)
correct_score(L1, preferences.sightseeing)
L += L1
# List for nature
if preferences.nature.score != 0 :
L2 = get_landmarks(l_nature, NATURE, coordinates=coordinates)
correct_score(L2, preferences.nature)
L += L2
# List for shopping
if preferences.shopping.score != 0 :
L3 = get_landmarks(l_shop, SHOPPING, coordinates=coordinates)
correct_score(L3, preferences.shopping)
L += L3
L = remove_duplicates(L)
return L, take_most_important(L)
"""def generate_landmarks(preferences: Preferences, city_country: str = None, coordinates: Tuple[float, float] = None) -> Tuple[List[Landmark], List[Landmark]] :
l_sights, l_nature, l_shop = get_amenities()
L = []
# List for sightseeing
if preferences.sightseeing.score != 0 :
L1 = get_landmarks(l_sights, SIGHTSEEING, city_country=city_country, coordinates=coordinates)
correct_score(L1, preferences.sightseeing)
L += L1
# List for nature
if preferences.nature.score != 0 :
L2 = get_landmarks(l_nature, NATURE, city_country=city_country, coordinates=coordinates)
correct_score(L2, preferences.nature)
L += L2
# List for shopping
if preferences.shopping.score != 0 :
L3 = get_landmarks(l_shop, SHOPPING, city_country=city_country, coordinates=coordinates)
correct_score(L3, preferences.shopping)
L += L3
return remove_duplicates(L), take_most_important(L)
"""
# Helper function to gather the amenities list
def get_amenities() -> List[List[str]] :
# Get the list of amenities from the files
sightseeing = get_list('/amenities/sightseeing.am')
nature = get_list('/amenities/nature.am')
shopping = get_list('/amenities/shopping.am')
return sightseeing, nature, shopping
# Helper function to read a .am file and generate the corresponding list
def get_list(path: str) -> List[str] :
with open(os.path.dirname(os.path.abspath(__file__)) + path) as f :
content = f.readlines()
amenities = []
for line in content :
amenities.append(line.strip('\n'))
return amenities
# Take the most important landmarks from the list
def take_most_important(L: List[Landmark], N = 0) -> List[Landmark] :
# Read the parameters from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/landmarks_manager.params', "r") as f :
parameters = json.loads(f.read())
N_important = parameters['N important']
L_copy = []
L_clean = []
scores = [0]*len(L)
names = []
name_id = {}
for i, elem in enumerate(L) :
if elem.name not in names :
names.append(elem.name)
name_id[elem.name] = [i]
L_copy.append(elem)
else :
name_id[elem.name] += [i]
scores = []
for j in name_id[elem.name] :
scores.append(L[j].attractiveness)
best_id = max(range(len(scores)), key=scores.__getitem__)
t = name_id[elem.name][best_id]
if t == i :
for old in L_copy :
if old.name == elem.name :
old.attractiveness = L[t].attractiveness
scores = [0]*len(L_copy)
for i, elem in enumerate(L_copy) :
scores[i] = elem.attractiveness
res = sorted(range(len(scores)), key = lambda sub: scores[sub])[-(N_important-N):]
for i, elem in enumerate(L_copy) :
if i in res :
L_clean.append(elem)
return L_clean
# Remove duplicate elements and elements with low score
def remove_duplicates(L: List[Landmark]) -> List[Landmark] :
"""
Removes duplicate landmarks based on their names from the given list.
Parameters:
L (List[Landmark]): A list of Landmark objects.
Returns:
List[Landmark]: A list of unique Landmark objects based on their names.
"""
L_clean = []
names = []
for landmark in L :
if landmark.name in names:
continue
else :
names.append(landmark.name)
L_clean.append(landmark)
return L_clean
# Correct the score of a list of landmarks by taking into account preference settings
def correct_score(L: List[Landmark], preference: Preference) :
if len(L) == 0 :
return
if L[0].type != preference.type :
raise TypeError(f"LandmarkType {preference.type} does not match the type of Landmark {L[0].name}")
for elem in L :
elem.attractiveness = int(elem.attractiveness*preference.score/500) # arbitrary computation
# Function to count elements within a certain radius of a location
def count_elements_within_radius(coordinates: Tuple[float, float], radius: int) -> int:
lat = coordinates[0]
lon = coordinates[1]
alpha = (180*radius)/(6371000*m.pi)
bbox = {'latLower':lat-alpha,'lonLower':lon-alpha,'latHigher':lat+alpha,'lonHigher': lon+alpha}
# Build the query to find elements within the radius
radius_query = overpassQueryBuilder(bbox=[bbox['latLower'],bbox['lonLower'],bbox['latHigher'],bbox['lonHigher']],
elementType=['node', 'way', 'relation'])
try :
overpass = Overpass()
radius_result = overpass.query(radius_query)
return radius_result.countElements()
except :
return None
# Creates a bounding box around given coordinates
def create_bbox(coordinates: Tuple[float, float], side_length: int) -> Tuple[float, float, float, float]:
lat = coordinates[0]
lon = coordinates[1]
# Half the side length in km (since it's a square bbox)
half_side_length_km = side_length / 2.0
# Convert distance to degrees
lat_diff = half_side_length_km / 111 # 1 degree latitude is approximately 111 km
lon_diff = half_side_length_km / (111 * m.cos(m.radians(lat))) # Adjust for longitude based on latitude
# Calculate bbox
min_lat = lat - lat_diff
max_lat = lat + lat_diff
min_lon = lon - lon_diff
max_lon = lon + lon_diff
return min_lat, min_lon, max_lat, max_lon
def get_landmarks(list_amenity: list, landmarktype: LandmarkType, coordinates: Tuple[float, float]) -> List[Landmark] :
# Read the parameters from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/landmarks_manager.params', "r") as f :
parameters = json.loads(f.read())
tag_coeff = parameters['tag coeff']
park_coeff = parameters['park coeff']
church_coeff = parameters['church coeff']
radius = parameters['radius close to']
bbox_side = parameters['city bbox side']
# Create bbox around start location
bbox = create_bbox(coordinates, bbox_side)
# Initialize some variables
N = 0
L = []
overpass = Overpass()
for amenity in list_amenity :
query = overpassQueryBuilder(bbox=bbox, elementType=['way', 'relation'], selector=amenity, includeCenter=True, out='body')
result = overpass.query(query)
N += result.countElements()
for elem in result.elements():
name = elem.tag('name') # Add name
location = (elem.centerLat(), elem.centerLon()) # Add coordinates (lat, lon)
# skip if unprecise location
if name is None or location[0] is None:
continue
# skip if unused
if 'disused:leisure' in elem.tags().keys():
continue
# skip if part of another building
if 'building:part' in elem.tags().keys() and elem.tag('building:part') == 'yes':
continue
else :
osm_type = elem.type() # Add type : 'way' or 'relation'
osm_id = elem.id() # Add OSM id
elem_type = landmarktype # Add the landmark type as 'sightseeing
n_tags = len(elem.tags().keys()) # Add number of tags
# Add score of given landmark based on the number of surrounding elements. Penalty for churches as there are A LOT
if amenity == "'amenity'='place_of_worship'" :
score = int((count_elements_within_radius(location, radius) + n_tags*tag_coeff )*church_coeff)
elif amenity == "'leisure'='park'" :
score = int((count_elements_within_radius(location, radius) + n_tags*tag_coeff )*park_coeff)
else :
score = count_elements_within_radius(location, radius) + n_tags*tag_coeff
if score is not None :
# Generate the landmark and append it to the list
landmark = Landmark(name=name, type=elem_type, location=location, osm_type=osm_type, osm_id=osm_id, attractiveness=score, must_do=False, n_tags=n_tags)
L.append(landmark)
return L
"""def get_landmarks(list_amenity: list, landmarktype: LandmarkType, city_country: str = None, coordinates: Tuple[float, float] = None) -> List[Landmark] :
if city_country is None and coordinates is None :
raise ValueError("Either one of 'city_country' and 'coordinates' arguments must be specified")
if city_country is not None and coordinates is not None :
raise ValueError("Cannot specify both 'city_country' and 'coordinates' at the same time, please choose either one")
# Read the parameters from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/landmarks_manager.params', "r") as f :
parameters = json.loads(f.read())
tag_coeff = parameters['tag coeff']
park_coeff = parameters['park coeff']
church_coeff = parameters['church coeff']
radius = parameters['radius close to']
bbox_side = parameters['city bbox side']
# If city_country is specified :
if city_country is not None :
nominatim = Nominatim()
areaId = nominatim.query(city_country).areaId()
bbox = None
# If coordinates are specified :
elif coordinates is not None :
bbox = create_bbox(coordinates, bbox_side)
areaId = None
else :
raise ValueError("Argument number is not corresponding.")
# Initialize some variables
N = 0
L = []
overpass = Overpass()
for amenity in list_amenity :
query = overpassQueryBuilder(area=areaId, bbox=bbox, elementType=['way', 'relation'], selector=amenity, includeCenter=True, out='body')
result = overpass.query(query)
N += result.countElements()
for elem in result.elements():
name = elem.tag('name') # Add name
location = (elem.centerLat(), elem.centerLon()) # Add coordinates (lat, lon)
# skip if unprecise location
if name is None or location[0] is None:
continue
# skip if unused
if 'disused:leisure' in elem.tags().keys():
continue
# skip if part of another building
if 'building:part' in elem.tags().keys() and elem.tag('building:part') == 'yes':
continue
else :
osm_type = elem.type() # Add type : 'way' or 'relation'
osm_id = elem.id() # Add OSM id
elem_type = landmarktype # Add the landmark type as 'sightseeing
n_tags = len(elem.tags().keys()) # Add number of tags
# Add score of given landmark based on the number of surrounding elements. Penalty for churches as there are A LOT
if amenity == "'amenity'='place_of_worship'" :
score = int((count_elements_within_radius(location, radius) + n_tags*tag_coeff )*church_coeff)
elif amenity == "'leisure'='park'" :
score = int((count_elements_within_radius(location, radius) + n_tags*tag_coeff )*park_coeff)
else :
score = count_elements_within_radius(location, radius) + n_tags*tag_coeff
if score is not None :
# Generate the landmark and append it to the list
landmark = Landmark(name=name, type=elem_type, location=location, osm_type=osm_type, osm_id=osm_id, attractiveness=score, must_do=False, n_tags=n_tags)
L.append(landmark)
return L
"""

View File

@@ -0,0 +1,34 @@
version: 1
disable_existing_loggers: False
formatters:
simple:
format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
handlers:
console:
class: rich.logging.RichHandler
formatter: simple
# access:
# class: logging.FileHandler
# filename: logs/access.log
# level: INFO
# formatter: simple
loggers:
uvicorn.error:
level: INFO
handlers:
- console
propagate: no
# uvicorn.access:
# level: INFO
# handlers:
# - access
# propagate: no
root:
level: INFO
handlers:
- console
propagate: yes

View File

@@ -1,84 +1,64 @@
from optimizer import solve_optimization import logging
from refiner import refine_optimization
from landmarks_manager import generate_landmarks
from structs.landmarks import Landmark
from structs.landmarktype import LandmarkType
from structs.preferences import Preferences, Preference
from fastapi import FastAPI, Query, Body from fastapi import FastAPI, Query, Body
from typing import List
from structs.landmark import Landmark
from structs.preferences import Preferences
from structs.linked_landmarks import LinkedLandmarks
from utils.landmarks_manager import LandmarkManager
from utils.optimizer import Optimizer
from utils.refiner import Refiner
logger = logging.getLogger(__name__)
app = FastAPI() app = FastAPI()
manager = LandmarkManager()
optimizer = Optimizer()
refiner = Refiner(optimizer=optimizer)
# TODO: needs a global variable to store the landmarks accross function calls @app.post("/route/new")
# linked_tour = [] def get_route(preferences: Preferences, start: tuple[float, float], end: tuple[float, float] | None = None) -> str:
'''
Main function to call the optimizer.
# Assuming frontend is calling like this : :param preferences: the preferences specified by the user as the post body
#"http://127.0.0.1:8000/process?param1={param1}&param2={param2}" :param start: the coordinates of the starting point as a tuple of floats (as url query parameters)
@app.post("/optimizer_coords/{start_lat}/{start_lon}/{finish_lat}/{finish_lon}") :param end: the coordinates of the finishing point as a tuple of floats (as url query parameters)
def main1(start_lat: float, start_lon: float, preferences: Preferences = Body(...), finish_lat: float = None, finish_lon: float = None) -> List[Landmark]: :return: the uuid of the first landmark in the optimized route
'''
if preferences is None : if preferences is None:
raise ValueError("Please provide preferences in the form of a 'Preference' BaseModel class.") raise ValueError("Please provide preferences in the form of a 'Preference' BaseModel class.")
if bool(start_lat) ^ bool(start_lon) : if start is None:
raise ValueError("Please provide both latitude and longitude for the starting point") raise ValueError("Please provide the starting coordinates as a tuple of floats.")
if bool(finish_lat) ^ bool(finish_lon) : if end is None:
raise ValueError("Please provide both latitude and longitude for the finish point") end = start
logger.info("No end coordinates provided. Using start=end.")
start = Landmark(name='start', type=LandmarkType(landmark_type='start'), location=(start_lat, start_lon), osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) start_landmark = Landmark(name='start', type='start', location=(start[0], start[1]), osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
end_landmark = Landmark(name='end', type='finish', location=(end[0], end[1]), osm_type='end', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
if bool(finish_lat) and bool(finish_lon) :
finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(finish_lat, finish_lon), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
else :
finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(start_lat, start_lon), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
start = Landmark(name='start', type=LandmarkType(landmark_type='start'), location=(48.8375946, 2.2949904), osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.8375946, 2.2949904), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
# Generate the landmarks from the start location # Generate the landmarks from the start location
landmarks, landmarks_short = generate_landmarks(preferences=preferences, coordinates=start.location) landmarks, landmarks_short = manager.generate_landmarks_list(
center_coordinates = start,
preferences = preferences
)
# insert start and finish to the landmarks list # insert start and finish to the landmarks list
landmarks_short.insert(0, start) landmarks_short.insert(0, start_landmark)
landmarks_short.append(finish) landmarks_short.append(end_landmark)
# TODO infer these parameters from the preferences
# TODO use these parameters in another way
max_walking_time = 4 # hours max_walking_time = 4 # hours
detour = 30 # minutes detour = 30 # minutes
# First stage optimization # First stage optimization
base_tour = solve_optimization(landmarks_short, max_walking_time*60, True) base_tour = optimizer.solve_optimization(max_walking_time*60, landmarks_short)
# Second stage optimization # Second stage optimization
refined_tour = refine_optimization(landmarks, base_tour, max_walking_time*60+detour, True) refined_tour = refiner.refine_optimization(landmarks, base_tour, max_walking_time*60, detour)
linked_tour = LinkedLandmarks(refined_tour)
# TODO: should look something like this return linked_tour[0].uuid
# # set time to reach and transform into fully functional linked list
# linked_tour += link(refined_tour)
# return {
# 'city_name': 'Paris',
# 'n_stops': len(linked_tour),
# 'first_landmark_uuid': linked_tour[0].uuid,
# }
return refined_tour
# input city, country in the form of 'Paris, France'
@app.post("/test2/{city_country}")
def test2(city_country: str, preferences: Preferences = Body(...)) -> List[Landmark]:
landmarks = generate_landmarks(city_country, preferences)
max_steps = 9000000
visiting_order = solve_optimization(landmarks, max_steps, True)
@@ -86,5 +66,3 @@ def test2(city_country: str, preferences: Preferences = Body(...)) -> List[Landm
def get_landmark(landmark_uuid: str) -> Landmark: def get_landmark(landmark_uuid: str) -> Landmark:
#cherche dans linked_tour et retourne le landmark correspondant #cherche dans linked_tour et retourne le landmark correspondant
pass pass

View File

@@ -1,23 +0,0 @@
import fastapi
from dataclasses import dataclass
@dataclass
class Destination:
name: str
location: tuple
attractiveness: int
d = Destination()
def get_route() -> list[Destination]:
return {"route": "Hello World"}
endpoint = ("/get_route", get_route)
end
if __name__ == "__main__":
fastapi.run()

View File

@@ -1,415 +0,0 @@
import numpy as np
import json, os
from typing import List, Tuple
from scipy.optimize import linprog
from math import radians, sin, cos, acos
from shapely import Polygon
from structs.landmarks import Landmark
# Function to print the result
def print_res(L: List[Landmark], L_tot):
if len(L) == L_tot:
print('\nAll landmarks can be visited within max_steps, the following order is suggested : ')
else :
print('Could not visit all the landmarks, the following order is suggested : ')
dist = 0
for elem in L :
if elem.time_to_reach_next is not None :
print('- ' + elem.name + ', time to reach next = ' + str(elem.time_to_reach_next))
dist += elem.time_to_reach_next
else :
print('- ' + elem.name)
print("\nMinutes walked : " + str(dist))
print(f"Visited {len(L)-2} out of {L_tot-2} landmarks")
# Prevent the use of a particular solution
def prevent_config(resx, A_ub, b_ub) -> bool:
for i, elem in enumerate(resx):
resx[i] = round(elem)
N = len(resx) # Number of edges
L = int(np.sqrt(N)) # Number of landmarks
nonzeroind = np.nonzero(resx)[0] # the return is a little funky so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
vertices_visited = ind_a
vertices_visited.remove(0)
ones = [1]*L
h = [0]*N
for i in range(L) :
if i in vertices_visited :
h[i*L:i*L+L] = ones
A_ub = np.vstack((A_ub, h))
b_ub.append(len(vertices_visited)-1)
return A_ub, b_ub
# Prevent the possibility of a given solution bit
def break_cricle(circle_vertices: list, L: int, A_ub: list, b_ub: list) -> bool:
if L-1 in circle_vertices :
circle_vertices.remove(L-1)
h = [0]*L*L
for i in range(L) :
if i in circle_vertices :
h[i*L:i*L+L] = [1]*L
A_ub = np.vstack((A_ub, h))
b_ub.append(len(circle_vertices)-1)
return A_ub, b_ub
# Checks if the path is connected, returns a circle if it finds one and the RESULT
def is_connected(resx) -> bool:
# first round the results to have only 0-1 values
for i, elem in enumerate(resx):
resx[i] = round(elem)
N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
n_edges = resx.sum() # number of edges
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
ind_b = nonzero_tup[1].tolist()
edges = []
edges_visited = []
vertices_visited = []
edge1 = (ind_a[0], ind_b[0])
edges_visited.append(edge1)
vertices_visited.append(edge1[0])
for i, a in enumerate(ind_a) :
edges.append((a, ind_b[i])) # Create the list of edges
remaining = edges
remaining.remove(edge1)
break_flag = False
while len(remaining) > 0 and not break_flag:
for edge2 in remaining :
if edge2[0] == edge1[1] :
if edge1[1] in vertices_visited :
edges_visited.append(edge2)
break_flag = True
break
else :
vertices_visited.append(edge1[1])
edges_visited.append(edge2)
remaining.remove(edge2)
edge1 = edge2
elif edge1[1] == L-1 or edge1[1] in vertices_visited:
break_flag = True
break
vertices_visited.append(edge1[1])
if len(vertices_visited) == n_edges +1 :
return vertices_visited, []
else:
return vertices_visited, edges_visited
# Function that returns the distance in meters from one location to another
def get_distance(p1: Tuple[float, float], p2: Tuple[float, float], detour: float, speed: float) :
# Compute the straight-line distance in km
if p1 == p2 :
return 0, 0
else:
dist = 6371.01 * acos(sin(radians(p1[0]))*sin(radians(p2[0])) + cos(radians(p1[0]))*cos(radians(p2[0]))*cos(radians(p1[1]) - radians(p2[1])))
# Consider the detour factor for average cityto deterline walking distance (in km)
walk_dist = dist*detour
# Time to walk this distance (in minutes)
walk_time = walk_dist/speed*60
if walk_time > 15 :
walk_time = 5*round(walk_time/5)
else :
walk_time = round(walk_time)
return round(walk_dist, 1), walk_time
# Initialize A and c. Compute the distances from all landmarks to each other and store attractiveness
# We want to maximize the sightseeing : max(c) st. A*x < b and A_eq*x = b_eq
def init_ub_dist(landmarks: List[Landmark], max_steps: int):
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
detour = parameters['detour factor']
speed = parameters['average walking speed']
# Objective function coefficients. a*x1 + b*x2 + c*x3 + ...
c = []
# Coefficients of inequality constraints (left-hand side)
A_ub = []
for spot1 in landmarks :
dist_table = [0]*len(landmarks)
c.append(-spot1.attractiveness)
for j, spot2 in enumerate(landmarks) :
t = get_distance(spot1.location, spot2.location, detour, speed)[1]
dist_table[j] = t
A_ub += dist_table
c = c*len(landmarks)
return c, A_ub, [max_steps]
# Constraint to respect only one travel per landmark. Also caps the total number of visited landmarks
def respect_number(L:int, A_ub, b_ub):
ones = [1]*L
zeros = [0]*L
for i in range(L) :
h = zeros*i + ones + zeros*(L-1-i)
A_ub = np.vstack((A_ub, h))
b_ub.append(1)
# Read the parameters from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
max_landmarks = parameters['max landmarks']
A_ub = np.vstack((A_ub, ones*L))
b_ub.append(max_landmarks+1)
return A_ub, b_ub
# Constraint to not have d14 and d41 simultaneously. Does not prevent circular symmetry with more elements
def break_sym(L, A_ub, b_ub):
upper_ind = np.triu_indices(L,0,L)
up_ind_x = upper_ind[0]
up_ind_y = upper_ind[1]
for i, _ in enumerate(up_ind_x) :
l = [0]*L*L
if up_ind_x[i] != up_ind_y[i] :
l[up_ind_x[i]*L + up_ind_y[i]] = 1
l[up_ind_y[i]*L + up_ind_x[i]] = 1
A_ub = np.vstack((A_ub,l))
b_ub.append(1)
return A_ub, b_ub
# Constraint to not stay in position. Removes d11, d22, d33, etc.
def init_eq_not_stay(L: int):
l = [0]*L*L
for i in range(L) :
for j in range(L) :
if j == i :
l[j + i*L] = 1
l = np.array(np.array(l))
return [l], [0]
# Go through the landmarks and force the optimizer to use landmarks where attractiveness is set to -1
def respect_user_mustsee(landmarks: List[Landmark], A_eq: list, b_eq: list) :
L = len(landmarks)
for i, elem in enumerate(landmarks) :
if elem.must_do is True and elem.name not in ['finish', 'start']:
l = [0]*L*L
for j in range(L) : # sets the horizontal ones (go from)
l[j +i*L] = 1 # sets the vertical ones (go to) double check if good
for k in range(L-1) :
l[k*L+L-1] = 1
A_eq = np.vstack((A_eq,l))
b_eq.append(2)
return A_eq, b_eq
# Constraint to ensure start at start and finish at goal
def respect_start_finish(L: int, A_eq: list, b_eq: list):
ls = [1]*L + [0]*L*(L-1) # sets only horizontal ones for start (go from)
ljump = [0]*L*L
ljump[L-1] = 1 # Prevent start finish jump
lg = [0]*L*L
ll = [0]*L*(L-1) + [1]*L
for k in range(L-1) : # sets only vertical ones for goal (go to)
ll[k*L] = 1
if k != 0 : # Prevent the shortcut start -> finish
lg[k*L+L-1] = 1
A_eq = np.vstack((A_eq,ls))
A_eq = np.vstack((A_eq,ljump))
A_eq = np.vstack((A_eq,lg))
A_eq = np.vstack((A_eq,ll))
b_eq.append(1)
b_eq.append(0)
b_eq.append(1)
b_eq.append(0)
return A_eq, b_eq
# Constraint to tie the problem together. Necessary but not sufficient to avoid circles
def respect_order(N: int, A_eq, b_eq):
for i in range(N-1) : # Prevent stacked ones
if i == 0 or i == N-1: # Don't touch start or finish
continue
else :
l = [0]*N
l[i] = -1
l = l*N
for j in range(N) :
l[i*N + j] = 1
A_eq = np.vstack((A_eq,l))
b_eq.append(0)
return A_eq, b_eq
# Computes the time to reach from each landmark to the next
def link_list(order: List[int], landmarks: List[Landmark])->List[Landmark] :
# Read the parameters from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
detour_factor = parameters['detour factor']
speed = parameters['average walking speed']
L = []
j = 0
total_dist = 0
while j < len(order)-1 :
elem = landmarks[order[j]]
next = landmarks[order[j+1]]
d = get_distance(elem.location, next.location, detour_factor, speed)[1]
elem.time_to_reach_next = d
L.append(elem)
j += 1
total_dist += d
L.append(next)
return L, total_dist
def link_list_simple(ordered_visit: List[Landmark])-> List[Landmark] :
# Read the parameters from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
detour_factor = parameters['detour factor']
speed = parameters['average walking speed']
L = []
j = 0
total_dist = 0
while j < len(ordered_visit)-1 :
elem = ordered_visit[j]
next = ordered_visit[j+1]
elem.next_uuid = next.uuid
d = get_distance(elem.location, next.location, detour_factor, speed)[1]
elem.time_to_reach_next = d
L.append(elem)
j += 1
total_dist += d
L.append(next)
return L, total_dist
# Main optimization pipeline
def solve_optimization (landmarks :List[Landmark], max_steps: int, printing_details: bool) :
L = len(landmarks)
# SET CONSTRAINTS FOR INEQUALITY
c, A_ub, b_ub = init_ub_dist(landmarks, max_steps) # Add the distances from each landmark to the other
A_ub, b_ub = respect_number(L, A_ub, b_ub) # Respect max number of visits (no more possible stops than landmarks).
A_ub, b_ub = break_sym(L, A_ub, b_ub) # break the 'zig-zag' symmetry
# SET CONSTRAINTS FOR EQUALITY
A_eq, b_eq = init_eq_not_stay(L) # Force solution not to stay in same place
A_eq, b_eq = respect_user_mustsee(landmarks, A_eq, b_eq) # Check if there are user_defined must_see. Also takes care of start/goal
A_eq, b_eq = respect_start_finish(L, A_eq, b_eq) # Force start and finish positions
A_eq, b_eq = respect_order(L, A_eq, b_eq) # Respect order of visit (only works when max_steps is limiting factor)
# SET BOUNDS FOR DECISION VARIABLE (x can only be 0 or 1)
x_bounds = [(0, 1)]*L*L
# Solve linear programming problem
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3)
# Raise error if no solution is found
if not res.success :
raise ArithmeticError("No solution could be found, the problem is overconstrained. Please adapt your must_dos")
# If there is a solution, we're good to go, just check for connectiveness
else :
order, circle = is_connected(res.x)
i = 0
timeout = 80
while len(circle) != 0 and i < timeout:
A_ub, b_ub = prevent_config(res.x, A_ub, b_ub)
A_ub, b_ub = break_cricle(order, len(landmarks), A_ub, b_ub)
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3)
order, circle = is_connected(res.x)
if len(circle) == 0 :
break
print(i)
i += 1
if i == timeout :
raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.")
# Add the times to reach and stop optimizing
L, total_dist = link_list(order, landmarks)
if printing_details is True :
if i != 0 :
print(f"Neded to recompute paths {i} times because of unconnected loops...")
print_res(L, len(landmarks))
print("\nTotal score : " + str(int(-res.fun)))
return L

View File

@@ -0,0 +1,32 @@
nature:
leisure: park
geological: ''
natural:
- geyser
- hot_spring
- arch
- volcano
- stone
tourism:
- alpine_hut
- viewpoint
- zoo
waterway: waterfall
shopping:
shop:
- department_store
- mall
sightseeing:
tourism:
- museum
- attraction
- gallery
historic: ''
amenity:
- planetarium
- place_of_worship
- fountain
water:
- reflecting_pool

View File

@@ -0,0 +1,6 @@
city_bbox_side: 5000 #m
radius_close_to: 50
church_coeff: 0.8
park_coeff: 1.2
tag_coeff: 10
N_important: 40

View File

@@ -1,8 +0,0 @@
{
"city bbox side" : 10,
"radius close to" : 27.5,
"church coeff" : 0.6,
"park coeff" : 1.5,
"tag coeff" : 100,
"N important" : 40
}

View File

@@ -1,5 +0,0 @@
{
"detour factor" : 1.4,
"average walking speed" : 4.8,
"max landmarks" : 10
}

View File

@@ -0,0 +1,4 @@
detour_factor: 1.4
detour_corridor_width: 200
average_walking_speed: 4.8
max_landmarks: 7

View File

@@ -1,293 +0,0 @@
from collections import defaultdict
from heapq import heappop, heappush
from itertools import permutations
import os, json
from shapely import buffer, LineString, Point, Polygon, MultiPoint, convex_hull, concave_hull, LinearRing
from typing import List, Tuple
from math import pi
from structs.landmarks import Landmark
from landmarks_manager import take_most_important
from optimizer import solve_optimization, link_list_simple, print_res, get_distance
def create_corridor(landmarks: List[Landmark], width: float) :
corrected_width = (180*width)/(6371000*pi)
path = create_linestring(landmarks)
obj = buffer(path, corrected_width, join_style="mitre", cap_style="square", mitre_limit=2)
return obj
def create_linestring(landmarks: List[Landmark])->List[Point] :
points = []
for landmark in landmarks :
points.append(Point(landmark.location))
return LineString(points)
def is_in_area(area: Polygon, coordinates) -> bool :
point = Point(coordinates)
return point.within(area)
def is_close_to(location1: Tuple[float], location2: Tuple[float]):
"""Determine if two locations are close by rounding their coordinates to 3 decimals."""
absx = abs(location1[0] - location2[0])
absy = abs(location1[1] - location2[1])
return absx < 0.001 and absy < 0.001
#return (round(location1[0], 3), round(location1[1], 3)) == (round(location2[0], 3), round(location2[1], 3))
def rearrange(landmarks: List[Landmark]) -> List[Landmark]:
i = 1
while i < len(landmarks):
j = i+1
while j < len(landmarks):
if is_close_to(landmarks[i].location, landmarks[j].location) and landmarks[i].name not in ['start', 'finish'] and landmarks[j].name not in ['start', 'finish']:
# If they are not adjacent, move the j-th element to be adjacent to the i-th element
if j != i + 1:
landmarks.insert(i + 1, landmarks.pop(j))
break # Move to the next i-th element after rearrangement
j += 1
i += 1
return landmarks
"""
def find_shortest_path(landmarks: List[Landmark]) -> List[Landmark]:
# Read from data
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
detour = parameters['detour factor']
speed = parameters['average walking speed']
# Step 1: Build the graph
graph = defaultdict(list)
for i in range(len(landmarks)):
for j in range(len(landmarks)):
if i != j:
distance = get_distance(landmarks[i].location, landmarks[j].location, detour, speed)[1]
graph[i].append((distance, j))
# Step 2: Dijkstra's algorithm to find the shortest path from start to finish
start_idx = next(i for i, lm in enumerate(landmarks) if lm.name == 'start')
finish_idx = next(i for i, lm in enumerate(landmarks) if lm.name == 'finish')
distances = {i: float('inf') for i in range(len(landmarks))}
previous_nodes = {i: None for i in range(len(landmarks))}
distances[start_idx] = 0
priority_queue = [(0, start_idx)]
while priority_queue:
current_distance, current_index = heappop(priority_queue)
if current_distance > distances[current_index]:
continue
for neighbor_distance, neighbor_index in graph[current_index]:
distance = current_distance + neighbor_distance
if distance < distances[neighbor_index]:
distances[neighbor_index] = distance
previous_nodes[neighbor_index] = current_index
heappush(priority_queue, (distance, neighbor_index))
# Step 3: Backtrack from finish to start to find the path
path = []
current_index = finish_idx
while current_index is not None:
path.append(landmarks[current_index])
current_index = previous_nodes[current_index]
path.reverse()
return path
"""
"""
def total_path_distance(path: List[Landmark], detour, speed) -> float:
total_distance = 0
for i in range(len(path) - 1):
total_distance += get_distance(path[i].location, path[i + 1].location, detour, speed)[1]
return total_distance
"""
def find_shortest_path_through_all_landmarks(landmarks: List[Landmark]) -> List[Landmark]:
# Read from data
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
detour = parameters['detour factor']
speed = parameters['average walking speed']
# Step 1: Find 'start' and 'finish' landmarks
start_idx = next(i for i, lm in enumerate(landmarks) if lm.name == 'start')
finish_idx = next(i for i, lm in enumerate(landmarks) if lm.name == 'finish')
start_landmark = landmarks[start_idx]
finish_landmark = landmarks[finish_idx]
# Step 2: Create a list of unvisited landmarks excluding 'start' and 'finish'
unvisited_landmarks = [lm for i, lm in enumerate(landmarks) if i not in [start_idx, finish_idx]]
# Step 3: Initialize the path with the 'start' landmark
path = [start_landmark]
coordinates = [landmarks[start_idx].location]
current_landmark = start_landmark
# Step 4: Use nearest neighbor heuristic to visit all landmarks
while unvisited_landmarks:
nearest_landmark = min(unvisited_landmarks, key=lambda lm: get_distance(current_landmark.location, lm.location, detour, speed)[1])
path.append(nearest_landmark)
coordinates.append(nearest_landmark.location)
current_landmark = nearest_landmark
unvisited_landmarks.remove(nearest_landmark)
# Step 5: Finally add the 'finish' landmark to the path
path.append(finish_landmark)
coordinates.append(landmarks[finish_idx].location)
path_poly = Polygon(coordinates)
return path, path_poly
def get_minor_landmarks(all_landmarks: List[Landmark], visited_landmarks: List[Landmark], width: float) -> List[Landmark] :
second_order_landmarks = []
visited_names = []
area = create_corridor(visited_landmarks, width)
for visited in visited_landmarks :
visited_names.append(visited.name)
for landmark in all_landmarks :
if is_in_area(area, landmark.location) and landmark.name not in visited_names:
second_order_landmarks.append(landmark)
return take_most_important(second_order_landmarks, len(visited_landmarks))
"""def refine_optimization(landmarks: List[Landmark], base_tour: List[Landmark], max_time: int, print_infos: bool) -> List[Landmark] :
minor_landmarks = get_minor_landmarks(landmarks, base_tour, 200)
if print_infos : print("There are " + str(len(minor_landmarks)) + " minor landmarks around the predicted path")
full_set = base_tour[:-1] + minor_landmarks # create full set of possible landmarks (without finish)
full_set.append(base_tour[-1]) # add finish back
new_tour = solve_optimization(full_set, max_time, print_infos)
return new_tour"""
def refine_optimization(landmarks: List[Landmark], base_tour: List[Landmark], max_time: int, print_infos: bool) -> List[Landmark] :
# Read from the file
with open (os.path.dirname(os.path.abspath(__file__)) + '/parameters/optimizer.params', "r") as f :
parameters = json.loads(f.read())
max_landmarks = parameters['max landmarks']
if len(base_tour)-2 >= max_landmarks :
return base_tour
minor_landmarks = get_minor_landmarks(landmarks, base_tour, 200)
if print_infos : print("Using " + str(len(minor_landmarks)) + " minor landmarks around the predicted path")
# full set of visitable landmarks
full_set = base_tour[:-1] + minor_landmarks # create full set of possible landmarks (without finish)
full_set.append(base_tour[-1]) # add finish back
# get a new tour
new_tour = solve_optimization(full_set, max_time, False)
new_tour, new_dist = link_list_simple(new_tour)
"""#if base_tour[0].location == base_tour[-1].location :
if False :
coords = [] # Coordinates of the new tour
coords_dict = {} # maps the location of an element to the element itself. Used to access the elements back once we get the geometry
# Iterate through the new tour without finish
for elem in new_tour[:-1] :
coords.append(Point(elem.location))
coords_dict[elem.location] = elem # if start = goal, only finish remains
# Create a concave polygon using the coordinates
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
# reverse the xs and ys
xs.reverse()
ys.reverse()
better_tour = [] # List of ordered visit
name_index = {} # Maps the name of a landmark to its index in the concave polygon
# Loop through the polygon and generate the better (ordered) tour
for i,x in enumerate(xs[:-1]) :
better_tour.append(coords_dict[tuple((x,ys[i]))])
name_index[coords_dict[tuple((x,ys[i]))].name] = i
# Scroll the list to have start in front again
start_index = name_index['start']
better_tour = better_tour[start_index:] + better_tour[:start_index]
# Append the finish back and correct the time to reach
better_tour.append(new_tour[-1])
# Rearrange only if polygon
better_tour = rearrange(better_tour)
# Add the time to reach
better_tour = add_time_to_reach_simple(better_tour)
"""
"""
if not better_poly.is_simple :
coords_dict = {}
better_tour2 = []
for elem in better_tour :
coords_dict[elem.location] = elem
better_poly2 = better_poly.buffer(0)
new_coords = better_poly2.exterior.coords[:]
start_coords = base_tour[0].location
start_index = new_coords.
#for point in new_coords :
"""
better_tour, better_poly = find_shortest_path_through_all_landmarks(new_tour)
better_tour, better_dist = link_list_simple(better_tour)
if new_dist < better_dist :
final_tour = new_tour
else :
final_tour = better_tour
if print_infos :
print("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n")
print("\nRefined tour (result of second stage optimization): ")
print_res(final_tour, len(full_set))
return final_tour

View File

@@ -1,18 +1,14 @@
from typing import Optional from typing import Optional, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .landmarktype import LandmarkType
from uuid import uuid4 from uuid import uuid4
# Output to frontend # Output to frontend
class Landmark(BaseModel) : class Landmark(BaseModel) :
# Unique ID of a given landmark
uuid: str = Field(default_factory=uuid4) # TODO implement this ASAP
# Properties of the landmark # Properties of the landmark
name : str name : str
type: LandmarkType # De facto mapping depending on how the query was executed with overpass. Should still EXACTLY correspond to the preferences type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish']
location : tuple location : tuple
osm_type : str osm_type : str
osm_id : int osm_id : int
@@ -21,10 +17,22 @@ class Landmark(BaseModel) :
image_url : Optional[str] = None # TODO future image_url : Optional[str] = None # TODO future
description : Optional[str] = None # TODO future description : Optional[str] = None # TODO future
duration : Optional[int] = 0 # TODO future duration : Optional[int] = 0 # TODO future
# Unique ID of a given landmark
uuid: str = Field(default_factory=uuid4) # TODO implement this ASAP
# Additional properties depending on specific tour # Additional properties depending on specific tour
must_do : bool must_do : Optional[bool] = False
must_avoid : Optional[bool] = False
is_secondary : Optional[bool] = False # TODO future is_secondary : Optional[bool] = False # TODO future
time_to_reach_next : Optional[int] = 0 # TODO fix this in existing code time_to_reach_next : Optional[int] = 0 # TODO fix this in existing code
next_uuid : Optional[str] = None # TODO implement this ASAP next_uuid : Optional[str] = None # TODO implement this ASAP
def __hash__(self) -> int:
return self.uuid.int
def __str__(self) -> str:
time_to_next_str = f", time_to_next={self.time_to_reach_next}" if self.time_to_reach_next else ""
return f'Landmark({self.type}): [{self.name} @{self.location}, score={self.attractiveness}{time_to_next_str}]'

View File

@@ -1,4 +0,0 @@
from pydantic import BaseModel
class LandmarkType(BaseModel):
landmark_type: str

View File

@@ -0,0 +1,61 @@
import uuid
from .landmark import Landmark
from utils.get_time_separation import get_time
class LinkedLandmarks:
"""
A list of landmarks that are linked together, e.g. in a route.
Each landmark serves as a node in the linked list, but since we expect these to be consumed through the rest API, a pythonic reference to the next landmark is not well suited. Instead we use the uuid of the next landmark to reference the next landmark in the list. This is not very efficient, but appropriate for the expected use case ("short" trips with onyl few landmarks).
"""
_landmarks = list[Landmark]
total_time = int
uuid = str
def __init__(self, data: list[Landmark] = None) -> None:
"""
Initialize a new LinkedLandmarks object. This expects an ORDERED list of landmarks, where the first landmark is the starting point and the last landmark is the end point.
Args:
data (list[Landmark], optional): The list of landmarks that are linked together. Defaults to None.
"""
self.uuid = uuid.uuid4()
self._landmarks = data if data else []
self._link_landmarks()
def _link_landmarks(self) -> None:
"""
Create the links between the landmarks in the list by setting their .next_uuid and the .time_to_next attributes.
"""
self.total_time = 0
for i, landmark in enumerate(self._landmarks[:-1]):
landmark.next_uuid = self._landmarks[i + 1].uuid
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._landmarks[-1].next_uuid = None
self._landmarks[-1].time_to_reach_next = 0
def __getitem__(self, index: int) -> Landmark:
return self._landmarks[index]
def __str__(self) -> str:
return f"LinkedLandmarks, total time: {self.total_time} minutes, {len(self._landmarks)} stops: [{','.join([str(landmark) for landmark in self._landmarks])}]"
def asdict(self) -> dict:
"""
Convert the linked landmarks to a json serializable dictionary.
Returns:
dict: A dictionary representation of the linked landmarks.
"""
return {
'uuid': self.uuid,
'total_time': self.total_time,
'landmarks': [landmark.dict() for landmark in self._landmarks]
}

View File

@@ -1,9 +1,9 @@
from pydantic import BaseModel from pydantic import BaseModel
from .landmarktype import LandmarkType from typing import Optional, Literal
class Preference(BaseModel) : class Preference(BaseModel) :
name: str name: str
type: LandmarkType # should match the attributes of the Preferences class type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish']
score: int # score could be from 1 to 5 score: int # score could be from 1 to 5
# Input for optimization # Input for optimization
@@ -17,12 +17,5 @@ class Preferences(BaseModel) :
# Shopping (diriger plutôt vers des zones / rues commerçantes) # Shopping (diriger plutôt vers des zones / rues commerçantes)
shopping : Preference shopping : Preference
""" # Food (price low or high. Combien on veut dépenser pour manger à midi/soir) max_time_minute: Optional[int] = 6*60
food_budget : Preference detour_tolerance_minute: Optional[int] = 0
# Tolérance au détour (ce qui détermine (+ ou -) le chemin emprunté)
detour_tol : Preference"""

View File

@@ -1,116 +1,85 @@
import pandas as pd import logging
import yaml
from typing import List from utils.landmarks_manager import LandmarkManager
from landmarks_manager import generate_landmarks from utils.optimizer import Optimizer
from fastapi.encoders import jsonable_encoder from utils.refiner import Refiner
from structs.landmark import Landmark
from optimizer import solve_optimization from structs.linked_landmarks import LinkedLandmarks
from refiner import refine_optimization
from structs.landmarks import Landmark
from structs.landmarktype import LandmarkType
from structs.preferences import Preferences, Preference from structs.preferences import Preferences, Preference
# Helper function to create a .txt file with results logger = logging.getLogger(__name__)
def write_data(L: List[Landmark], file_name: str):
data = pd.DataFrame()
i = 0
for landmark in L :
data[i] = jsonable_encoder(landmark)
i += 1
data.to_json(file_name, indent = 2, force_ascii=False)
def test3(city_country: str) -> List[Landmark]:
def test(start_coords: tuple[float, float], finish_coords: tuple[float, float] = None) -> list[Landmark]:
manager = LandmarkManager()
optimizer = Optimizer()
refiner = Refiner(optimizer=optimizer)
preferences = Preferences( preferences = Preferences(
sightseeing=Preference( sightseeing=Preference(
name='sightseeing', name='sightseeing',
type=LandmarkType(landmark_type='sightseeing'), type='sightseeing',
score = 5), score = 5),
nature=Preference( nature=Preference(
name='nature', name='nature',
type=LandmarkType(landmark_type='nature'), type='nature',
score = 0),
shopping=Preference(
name='shopping',
type=LandmarkType(landmark_type='shopping'),
score = 5))
coordinates = None
landmarks, landmarks_short = generate_landmarks(preferences=preferences, city_country=city_country, coordinates=coordinates)
#write_data(landmarks)
start = Landmark(name='start', type=LandmarkType(landmark_type='start'), location=(48.2044576, 16.3870242), osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.2044576, 16.3870242), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
test = landmarks_short
test.insert(0, start)
test.append(finish)
max_walking_time = 2 # hours
visiting_list = solve_optimization(test, max_walking_time*60, True)
def test4(coordinates: tuple[float, float]) -> List[Landmark]:
preferences = Preferences(
sightseeing=Preference(
name='sightseeing',
type=LandmarkType(landmark_type='sightseeing'),
score = 5),
nature=Preference(
name='nature',
type=LandmarkType(landmark_type='nature'),
score = 5), score = 5),
shopping=Preference( shopping=Preference(
name='shopping', name='shopping',
type=LandmarkType(landmark_type='shopping'), type='shopping',
score = 5)) score = 5),
max_time_minute=180,
detour_tolerance_minute=30
)
# Create start and finish # Create start and finish
start = Landmark(name='start', type=LandmarkType(landmark_type='start'), location=coordinates, osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) if finish_coords is None :
finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=coordinates, osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) finish_coords = start_coords
start = Landmark(name='start', type='start', location=start_coords, osm_type='', osm_id=0, attractiveness=0, n_tags = 0)
finish = Landmark(name='finish', type='finish', location=finish_coords, osm_type='', osm_id=0, attractiveness=0, n_tags = 0)
#finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.8777055, 2.3640967), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) #finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.8777055, 2.3640967), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
#start = Landmark(name='start', type=LandmarkType(landmark_type='start'), location=(48.847132, 2.312359), osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) #start = Landmark(name='start', type=LandmarkType(landmark_type='start'), location=(48.847132, 2.312359), osm_type='start', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
#finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.843185, 2.344533), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) #finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.843185, 2.344533), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
#finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.847132, 2.312359), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0) #finish = Landmark(name='finish', type=LandmarkType(landmark_type='finish'), location=(48.847132, 2.312359), osm_type='finish', osm_id=0, attractiveness=0, must_do=True, n_tags = 0)
# Generate the landmarks from the start location # Generate the landmarks from the start location
landmarks, landmarks_short = generate_landmarks(preferences=preferences, coordinates=start.location) landmarks, landmarks_short = manager.generate_landmarks_list(
#write_data(landmarks, "landmarks.txt") center_coordinates = start_coords,
preferences = preferences
)
# Store data to file for debug purposes
# write_data(landmarks, "landmarks_Strasbourg.txt")
# Insert start and finish to the landmarks list # Insert start and finish to the landmarks list
landmarks_short.insert(0, start) landmarks_short.insert(0, start)
landmarks_short.append(finish) landmarks_short.append(finish)
# TODO use these parameters in another way
max_walking_time = 2 # hours
detour = 30 # minutes
# First stage optimization # First stage optimization
base_tour = solve_optimization(landmarks_short, max_walking_time*60, True) base_tour = optimizer.solve_optimization(max_time=preferences.max_time_minute, landmarks=landmarks_short)
# Second stage optimization # Second stage using linear optimization
refined_tour = refine_optimization(landmarks, base_tour, max_walking_time*60+detour, True) refined_tour = refiner.refine_optimization(all_landmarks=landmarks, base_tour=base_tour, max_time = preferences.max_time_minute, detour = preferences.detour_tolerance_minute)
return refined_tour linked_tour = LinkedLandmarks(refined_tour)
logger.info(f"Optimized route: {linked_tour}")
# with open('linked_tour.yaml', 'w') as f:
# yaml.dump(linked_tour.asdict(), f)
return linked_tour
test4(tuple((48.8344400, 2.3220540))) # Café Chez César #test(tuple((48.8344400, 2.3220540))) # Café Chez César
#test4(tuple((48.8375946, 2.2949904))) # Point random #test(tuple((48.8375946, 2.2949904))) # Point random
#test4(tuple((47.377859, 8.540585))) # Zurich HB #test(tuple((47.377859, 8.540585))) # Zurich HB
#test3('Vienna, Austria') #test(tuple((45.7576485, 4.8330241))) # Lyon Bellecour
test(tuple((48.5848435, 7.7332974))) # Strasbourg Gare
#test(tuple((48.2067858, 16.3692340))) # Vienne

View File

@@ -0,0 +1,39 @@
import yaml
from geopy.distance import geodesic
import constants
with constants.OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
DETOUR_FACTOR = parameters['detour_factor']
AVERAGE_WALKING_SPEED = parameters['average_walking_speed']
def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int:
"""
Calculate the time in minutes to travel from one location to another.
Args:
p1 (Tuple[float, float]): Coordinates of the starting location.
p2 (Tuple[float, float]): Coordinates of the destination.
detour (float): Detour factor affecting the distance.
speed (float): Walking speed in kilometers per hour.
Returns:
int: Time to travel from p1 to p2 in minutes.
"""
# Compute the straight-line distance in km
if p1 == p2 :
return 0
else:
dist = geodesic(p1, p2).kilometers
# Consider the detour factor for average cityto deterline walking distance (in km)
walk_dist = dist*DETOUR_FACTOR
# Time to walk this distance (in minutes)
walk_time = walk_dist/AVERAGE_WALKING_SPEED*60
return round(walk_time)

View File

@@ -0,0 +1,365 @@
import math as m
import yaml
import logging
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.cachingStrategy import CachingStrategy, JSON
from pywikibot import ItemPage, Site
from pywikibot import config
config.put_throttle = 0
config.maxlag = 0
from structs.preferences import Preferences, Preference
from structs.landmark import Landmark
from .take_most_important import take_most_important
import constants
SIGHTSEEING = 'sightseeing'
NATURE = 'nature'
SHOPPING = 'shopping'
class LandmarkManager:
logger = logging.getLogger(__name__)
city_bbox_side: int # bbox side in meters
radius_close_to: int # radius in meters
church_coeff: float # coeff to adjsut score of churches
park_coeff: float # coeff to adjust score of parks
tag_coeff: float # coeff to adjust weight of tags
N_important: int # number of important landmarks to consider
def __init__(self) -> None:
with constants.AMENITY_SELECTORS_PATH.open('r') as f:
self.amenity_selectors = yaml.safe_load(f)
with constants.LANDMARK_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.city_bbox_side = parameters['city_bbox_side']
self.radius_close_to = parameters['radius_close_to']
self.church_coeff = parameters['church_coeff']
self.park_coeff = parameters['park_coeff']
self.tag_coeff = parameters['tag_coeff']
self.N_important = parameters['N_important']
self.overpass = Overpass()
CachingStrategy.use(JSON, cacheDir=constants.OSM_CACHE_DIR)
def generate_landmarks_list(self, center_coordinates: tuple[float, float], preferences: Preferences) -> tuple[list[Landmark], list[Landmark]]:
"""
Generate and prioritize a list of landmarks based on user preferences.
This method fetches landmarks from various categories (sightseeing, nature, shopping) based on the user's preferences
and current location. It scores and corrects these landmarks, removes duplicates, and then selects the most important
landmarks based on a predefined criterion.
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.
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.
"""
L = []
bbox = self.create_bbox(center_coordinates)
# list for sightseeing
if preferences.sightseeing.score != 0:
score_function = lambda loc, n_tags: int((self.count_elements_close_to(loc) + ((n_tags**1.2)*self.tag_coeff) )*self.church_coeff)
L1 = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], SIGHTSEEING, score_function)
self.correct_score(L1, preferences.sightseeing)
L += L1
# list for nature
if preferences.nature.score != 0:
score_function = lambda loc, n_tags: int((self.count_elements_close_to(loc) + ((n_tags**1.2)*self.tag_coeff) )*self.park_coeff)
L2 = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], NATURE, score_function)
self.correct_score(L2, preferences.nature)
L += L2
# list for shopping
if preferences.shopping.score != 0:
score_function = lambda loc, n_tags: int(self.count_elements_close_to(loc) + ((n_tags**1.2)*self.tag_coeff))
L3 = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], SHOPPING, score_function)
self.correct_score(L3, preferences.shopping)
L += L3
L = self.remove_duplicates(L)
L_constrained = take_most_important(L, self.N_important)
self.logger.info(f'Generated {len(L)} landmarks around {center_coordinates}, and constrained to {len(L_constrained)} most important ones.')
return L, L_constrained
def remove_duplicates(self, landmarks: list[Landmark]) -> list[Landmark]:
"""
Removes duplicate landmarks based on their names from the given list. Only retains the landmark with highest score
Parameters:
landmarks (list[Landmark]): A list of Landmark objects.
Returns:
list[Landmark]: A list of unique Landmark objects based on their names.
"""
L_clean = []
names = []
for landmark in landmarks:
if landmark.name in names:
continue
else:
names.append(landmark.name)
L_clean.append(landmark)
return L_clean
def correct_score(self, landmarks: list[Landmark], preference: Preference):
"""
Adjust the attractiveness score of each landmark in the list based on user preferences.
This method updates the attractiveness of each landmark by scaling it according to the user's preference score.
The score adjustment is computed using a simple linear transformation based on the preference score.
Args:
landmarks (list[Landmark]): A list of landmarks whose scores need to be corrected.
preference (Preference): The user's preference settings that influence the attractiveness score adjustment.
Raises:
TypeError: If the type of any landmark in the list does not match the expected type in the preference.
"""
if len(landmarks) == 0:
return
if landmarks[0].type != preference.type:
raise TypeError(f"LandmarkType {preference.type} does not match the type of Landmark {landmarks[0].name}")
for elem in landmarks:
elem.attractiveness = int(elem.attractiveness*preference.score/5) # arbitrary computation
def count_elements_close_to(self, coordinates: tuple[float, float]) -> int:
"""
Count the number of OpenStreetMap elements (nodes, ways, relations) within a specified radius of the given location.
This function constructs a bounding box around the specified coordinates based on the radius. It then queries
OpenStreetMap data to count the number of elements within that bounding box.
Args:
coordinates (tuple[float, float]): The latitude and longitude of the location to search around.
Returns:
int: The number of elements (nodes, ways, relations) within the specified radius. Returns 0 if no elements
are found or if an error occurs during the query.
"""
lat = coordinates[0]
lon = coordinates[1]
radius = self.radius_close_to
alpha = (180*radius) / (6371000*m.pi)
bbox = {'latLower':lat-alpha,'lonLower':lon-alpha,'latHigher':lat+alpha,'lonHigher': lon+alpha}
# Build the query to find elements within the radius
radius_query = overpassQueryBuilder(
bbox=[bbox['latLower'],
bbox['lonLower'],
bbox['latHigher'],
bbox['lonHigher']],
elementType=['node', 'way', 'relation']
)
try:
radius_result = self.overpass.query(radius_query)
N_elem = radius_result.countWays() + radius_result.countRelations()
self.logger.debug(f"There are {N_elem} ways/relations within 50m")
if N_elem is None:
return 0
return N_elem
except:
return 0
def create_bbox(self, coordinates: tuple[float, float]) -> tuple[float, float, float, float]:
"""
Create a bounding box around the given coordinates.
Args:
coordinates (tuple[float, float]): The latitude and longitude of the center of the bounding box.
Returns:
tuple[float, float, float, float]: The minimum latitude, minimum longitude, maximum latitude, and maximum longitude
defining the bounding box.
"""
lat = coordinates[0]
lon = coordinates[1]
# Half the side length in km (since it's a square bbox)
half_side_length_km = self.city_bbox_side / 2 / 1000
# Convert distance to degrees
lat_diff = half_side_length_km / 111 # 1 degree latitude is approximately 111 km
lon_diff = half_side_length_km / (111 * m.cos(m.radians(lat))) # Adjust for longitude based on latitude
# Calculate bbox
min_lat = lat - lat_diff
max_lat = lat + lat_diff
min_lon = lon - lon_diff
max_lon = lon + lon_diff
return min_lat, min_lon, max_lat, max_lon
def fetch_landmarks(self, bbox: tuple, amenity_selector: dict, landmarktype: str, score_function: callable) -> list[Landmark]:
"""
Fetches landmarks of a specified type from OpenStreetMap (OSM) within a bounding box centered on given coordinates.
Args:
bbox (tuple[float, float, float, float]): The bounding box coordinates (min_lat, min_lon, max_lat, max_lon).
amenity_selector (dict): The Overpass API query selector for the desired landmark type.
landmarktype (str): The type of the landmark (e.g., 'sightseeing', 'nature', 'shopping').
score_function (callable): The function to compute the score of the landmark based on its attributes.
Returns:
list[Landmark]: A list of Landmark objects that were fetched and filtered based on the provided criteria.
Notes:
- Landmarks are fetched using Overpass API queries.
- Selectors are translated from the dictionary to the Overpass query format. (e.g., 'amenity'='place_of_worship')
- Landmarks are filtered based on various conditions including tags and type.
- Scores are assigned to landmarks based on their attributes and surrounding elements.
"""
return_list = []
# caution, when applying a list of selectors, overpass will search for elements that match ALL selectors simultaneously
# we need to split the selectors into separate queries and merge the results
for sel in dict_to_selector_list(amenity_selector):
self.logger.debug(f"Current selector: {sel}")
query = overpassQueryBuilder(
bbox = bbox,
elementType = ['way', 'relation'],
selector = sel,
# conditions = [],
includeCenter = True,
out = 'body'
)
try:
result = self.overpass.query(query)
except Exception as e:
self.logger.error(f"Error fetching landmarks: {e}")
return
for elem in result.elements():
name = elem.tag('name') # Add name
location = (elem.centerLat(), elem.centerLon()) # Add coordinates (lat, lon)
# TODO: exclude these from the get go
# skip if unprecise location
if name is None or location[0] is None:
continue
# skip if unused
if 'disused:leisure' in elem.tags().keys():
continue
# skip if part of another building
if 'building:part' in elem.tags().keys() and elem.tag('building:part') == 'yes':
continue
osm_type = elem.type() # Add type: 'way' or 'relation'
osm_id = elem.id() # Add OSM id
elem_type = landmarktype # Add the landmark type as 'sightseeing,
n_tags = len(elem.tags().keys()) # Add number of tags
# remove specific tags
skip = False
for tag in elem.tags().keys():
if "pay" in tag:
n_tags -= 1 # discard payment options for tags
if "disused" in tag:
skip = True # skip disused amenities
break
if "wikipedia" in tag:
n_tags += 3 # wikipedia entries count more
if tag == "wikidata":
Q = elem.tag('wikidata')
site = Site("wikidata", "wikidata")
item = ItemPage(site, Q)
item.get()
n_languages = len(item.labels)
n_tags += n_languages/10
if elem_type != "nature":
if "leisure" in tag and elem.tag('leisure') == "park":
elem_type = "nature"
if landmarktype != SHOPPING:
if "shop" in tag:
skip = True
break
if tag == "building" and elem.tag('building') in ['retail', 'supermarket', 'parking']:
skip = True
break
if skip:
continue
score = score_function(location, n_tags)
if score != 0:
# Generate the landmark and append it to the list
landmark = Landmark(
name=name,
type=elem_type,
location=location,
osm_type=osm_type,
osm_id=osm_id,
attractiveness=score,
must_do=False,
n_tags=int(n_tags)
)
return_list.append(landmark)
self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
return return_list
def dict_to_selector_list(d: dict) -> list:
"""
Convert a dictionary of key-value pairs to a list of Overpass query strings.
Args:
d (dict): A dictionary of key-value pairs representing the selector.
Returns:
list: A list of strings representing the Overpass query selectors.
"""
return_list = []
for key, value in d.items():
if type(value) == list:
val = '|'.join(value)
return_list.append(f'{key}~"{val}"')
elif type(value) == str and len(value) == 0:
return_list.append(f'{key}')
else:
return_list.append(f'{key}={value}')
return return_list

View File

@@ -0,0 +1,519 @@
import yaml, logging
import numpy as np
from scipy.optimize import linprog
from collections import defaultdict, deque
from geopy.distance import geodesic
from structs.landmark import Landmark
from .get_time_separation import get_time
import constants
class Optimizer:
logger = logging.getLogger(__name__)
detour: int = None # accepted max detour time (in minutes)
detour_factor: float # detour factor of straight line vs real distance in cities
average_walking_speed: float # average walking speed of adult
max_landmarks: int # max number of landmarks to visit
def __init__(self) :
# load parameters from file
with constants.OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.detour_factor = parameters['detour_factor']
self.average_walking_speed = parameters['average_walking_speed']
self.max_landmarks = parameters['max_landmarks']
# Prevent the use of a particular solution
def prevent_config(self, resx):
"""
Prevent the use of a particular solution by adding constraints to the optimization.
Args:
resx (list[float]): List of edge weights.
Returns:
Tuple[list[int], list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector.
"""
for i, elem in enumerate(resx):
resx[i] = round(elem)
N = len(resx) # Number of edges
L = int(np.sqrt(N)) # Number of landmarks
nonzeroind = np.nonzero(resx)[0] # the return is a little funky so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
vertices_visited = ind_a
vertices_visited.remove(0)
ones = [1]*L
h = [0]*N
for i in range(L) :
if i in vertices_visited :
h[i*L:i*L+L] = ones
return h, [len(vertices_visited)-1]
# Prevents the creation of the same circle (both directions)
def prevent_circle(self, circle_vertices: list, L: int) :
"""
Prevent circular paths by by adding constraints to the optimization.
Args:
circle_vertices (list): List of vertices forming a circle.
L (int): Number of landmarks.
Returns:
Tuple[np.ndarray, list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector.
"""
l1 = [0]*L*L
l2 = [0]*L*L
for i, node in enumerate(circle_vertices[:-1]) :
next = circle_vertices[i+1]
l1[node*L + next] = 1
l2[next*L + node] = 1
s = circle_vertices[0]
g = circle_vertices[-1]
l1[g*L + s] = 1
l2[s*L + g] = 1
return np.vstack((l1, l2)), [0, 0]
def is_connected(self, resx) :
"""
Determine the order of visits and detect any circular paths in the given configuration.
Args:
resx (list): List of edge weights.
Returns:
Tuple[list[int], Optional[list[list[int]]]]: A tuple containing the visit order and a list of any detected circles.
"""
# first round the results to have only 0-1 values
for i, elem in enumerate(resx):
resx[i] = round(elem)
N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
ind_b = nonzero_tup[1].tolist()
# Step 1: Create a graph representation
graph = defaultdict(list)
for a, b in zip(ind_a, ind_b):
graph[a].append(b)
# Step 2: Function to perform BFS/DFS to extract journeys
def get_journey(start):
journey_nodes = []
visited = set()
stack = deque([start])
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
journey_nodes.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
stack.append(neighbor)
return journey_nodes
# Step 3: Extract all journeys
all_journeys_nodes = []
visited_nodes = set()
for node in ind_a:
if node not in visited_nodes:
journey_nodes = get_journey(node)
all_journeys_nodes.append(journey_nodes)
visited_nodes.update(journey_nodes)
for l in all_journeys_nodes :
if 0 in l :
order = l
all_journeys_nodes.remove(l)
break
if len(all_journeys_nodes) == 0 :
return order, None
return order, all_journeys_nodes
def init_ub_dist(self, landmarks: list[Landmark], max_steps: int):
"""
Initialize the objective function coefficients and inequality constraints for the optimization problem.
This function computes the distances between all landmarks and stores their attractiveness to maximize sightseeing.
The goal is to maximize the objective function subject to the constraints A*x < b and A_eq*x = b_eq.
Args:
landmarks (list[Landmark]): List of landmarks.
max_steps (int): Maximum number of steps allowed.
Returns:
Tuple[list[float], list[float], list[int]]: Objective function coefficients, inequality constraint coefficients, and the right-hand side of the inequality constraint.
"""
# Objective function coefficients. a*x1 + b*x2 + c*x3 + ...
c = []
# Coefficients of inequality constraints (left-hand side)
A_ub = []
for spot1 in landmarks :
dist_table = [0]*len(landmarks)
c.append(-spot1.attractiveness)
for j, spot2 in enumerate(landmarks) :
t = get_time(spot1.location, spot2.location)
dist_table[j] = t
closest = sorted(dist_table)[:22]
for i, dist in enumerate(dist_table) :
if dist not in closest :
dist_table[i] = 32700
A_ub += dist_table
c = c*len(landmarks)
return c, A_ub, [max_steps]
def respect_number(self, L: int):
"""
Generate constraints to ensure each landmark is visited only once and cap the total number of visited landmarks.
Args:
L (int): Number of landmarks.
Returns:
Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
ones = [1]*L
zeros = [0]*L
A = ones + zeros*(L-1)
b = [1]
for i in range(L-1) :
h_new = zeros*i + ones + zeros*(L-1-i)
A = np.vstack((A, h_new))
b.append(1)
A = np.vstack((A, ones*L))
b.append(self.max_landmarks+1)
return A, b
# Constraint to not have d14 and d41 simultaneously. Does not prevent cyclic paths with more elements
def break_sym(self, L):
"""
Generate constraints to prevent simultaneous travel between two landmarks in both directions.
Args:
L (int): Number of landmarks.
Returns:
Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
upper_ind = np.triu_indices(L,0,L)
up_ind_x = upper_ind[0]
up_ind_y = upper_ind[1]
A = [0]*L*L
b = [1]
for i, _ in enumerate(up_ind_x[1:]) :
l = [0]*L*L
if up_ind_x[i] != up_ind_y[i] :
l[up_ind_x[i]*L + up_ind_y[i]] = 1
l[up_ind_y[i]*L + up_ind_x[i]] = 1
A = np.vstack((A,l))
b.append(1)
return A, b
def init_eq_not_stay(self, L: int):
"""
Generate constraints to prevent staying in the same position (e.g., removing d11, d22, d33, etc.).
Args:
L (int): Number of landmarks.
Returns:
Tuple[list[np.ndarray], list[int]]: Equality constraint coefficients and the right-hand side of the equality constraints.
"""
l = [0]*L*L
for i in range(L) :
for j in range(L) :
if j == i :
l[j + i*L] = 1
l = np.array(np.array(l), dtype=np.int8)
return [l], [0]
def respect_user_must_do(self, landmarks: list[Landmark]) :
"""
Generate constraints to ensure that landmarks marked as 'must_do' are included in the optimization.
Args:
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_do'.
Returns:
Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
L = len(landmarks)
A = [0]*L*L
b = [0]
for i, elem in enumerate(landmarks[1:]) :
if elem.must_do is True and elem.name not in ['finish', 'start']:
l = [0]*L*L
l[i*L:i*L+L] = [1]*L # set mandatory departures from landmarks tagged as 'must_do'
A = np.vstack((A,l))
b.append(1)
return A, b
def respect_user_must_avoid(self, landmarks: list[Landmark]) :
"""
Generate constraints to ensure that landmarks marked as 'must_avoid' are skipped in the optimization.
Args:
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_avoid'.
Returns:
Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
L = len(landmarks)
A = [0]*L*L
b = [0]
for i, elem in enumerate(landmarks[1:]) :
if elem.must_avoid is True and elem.name not in ['finish', 'start']:
l = [0]*L*L
l[i*L:i*L+L] = [1]*L
A = np.vstack((A,l))
b.append(0) # prevent departures from landmarks tagged as 'must_do'
return A, b
# Constraint to ensure start at start and finish at goal
def respect_start_finish(self, L: int):
"""
Generate constraints to ensure that the optimization starts at the designated start landmark and finishes at the goal landmark.
Args:
L (int): Number of landmarks.
Returns:
Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
l_start = [1]*L + [0]*L*(L-1) # sets departures only for start (horizontal ones)
l_start[L-1] = 0 # prevents the jump from start to finish
l_goal = [0]*L*L # sets arrivals only for finish (vertical ones)
l_L = [0]*L*(L-1) + [1]*L # prevents arrivals at start and departures from goal
for k in range(L-1) : # sets only vertical ones for goal (go to)
l_L[k*L] = 1
if k != 0 :
l_goal[k*L+L-1] = 1
A = np.vstack((l_start, l_goal))
b = [1, 1]
A = np.vstack((A,l_L))
b.append(0)
return A, b
def respect_order(self, L: int):
"""
Generate constraints to tie the optimization problem together and prevent stacked ones, although this does not fully prevent circles.
Args:
L (int): Number of landmarks.
Returns:
Tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
A = [0]*L*L
b = [0]
for i in range(L-1) : # Prevent stacked ones
if i == 0 or i == L-1: # Don't touch start or finish
continue
else :
l = [0]*L
l[i] = -1
l = l*L
for j in range(L) :
l[i*L + j] = 1
A = np.vstack((A,l))
b.append(0)
return A, b
def link_list(self, order: list[int], landmarks: list[Landmark])->list[Landmark] :
"""
Compute the time to reach from each landmark to the next and create a list of landmarks with updated travel times.
Args:
order (list[int]): List of indices representing the order of landmarks to visit.
landmarks (list[Landmark]): List of all landmarks.
Returns:
list[Landmark]]: The updated linked list of landmarks with travel times
"""
L = []
j = 0
while j < len(order)-1 :
# get landmarks involved
elem = landmarks[order[j]]
next = landmarks[order[j+1]]
# get attributes
elem.time_to_reach_next = get_time(elem.location, next.location)
elem.must_do = True
elem.location = (round(elem.location[0], 5), round(elem.location[1], 5))
elem.next_uuid = next.uuid
L.append(elem)
j += 1
next.location = (round(next.location[0], 5), round(next.location[1], 5))
next.must_do = True
L.append(next)
return L
# Main optimization pipeline
def solve_optimization(
self,
max_time: int,
landmarks: list[Landmark],
) -> list[Landmark]:
"""
Main optimization pipeline to solve the landmark visiting problem.
This method sets up and solves a linear programming problem with constraints to find an optimal tour of landmarks,
considering user-defined must-visit landmarks, start and finish points, and ensuring no cycles are present.
Args:
max_time (int): Maximum time allowed for the tour in minutes.
landmarks (list[Landmark]): List of landmarks to visit.
Returns:
list[Landmark]: The optimized tour of landmarks with updated travel times, or None if no valid solution is found.
"""
L = len(landmarks)
# SET CONSTRAINTS FOR INEQUALITY
c, A_ub, b_ub = self.init_ub_dist(landmarks, max_time) # Add the distances from each landmark to the other
A, b = self.respect_number(L) # Respect max number of visits (no more possible stops than landmarks).
A_ub = np.vstack((A_ub, A), dtype=np.int16)
b_ub += b
A, b = self.break_sym(L) # break the 'zig-zag' symmetry
A_ub = np.vstack((A_ub, A), dtype=np.int16)
b_ub += b
# SET CONSTRAINTS FOR EQUALITY
A_eq, b_eq = self.init_eq_not_stay(L) # Force solution not to stay in same place
A, b = self.respect_user_must_do(landmarks) # Check if there are user_defined must_see. Also takes care of start/goal
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
A, b = self.respect_user_must_avoid(landmarks) # Check if there are user_defined must_see. Also takes care of start/goal
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
A, b = self.respect_start_finish(L) # Force start and finish positions
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
A, b = self.respect_order(L) # Respect order of visit (only works when max_steps is limiting factor)
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
# SET BOUNDS FOR DECISION VARIABLE (x can only be 0 or 1)
x_bounds = [(0, 1)]*L*L
# Solve linear programming problem
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3)
# Raise error if no solution is found
if not res.success :
raise ArithmeticError("No solution could be found, the problem is overconstrained. Please adapt your must_dos")
# If there is a solution, we're good to go, just check for connectiveness
order, circles = self.is_connected(res.x)
#nodes, edges = is_connected(res.x)
i = 0
timeout = 80
while circles is not None and i < timeout:
A, b = self.prevent_config(res.x)
A_ub = np.vstack((A_ub, A))
b_ub += b
#A_ub, b_ub = prevent_circle(order, len(landmarks), A_ub, b_ub)
for circle in circles :
A, b = self.prevent_circle(circle, L)
A_eq = np.vstack((A_eq, A))
b_eq += b
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3)
if not res.success :
raise ArithmeticError("Solving failed because of overconstrained problem")
return None
order, circles = self.is_connected(res.x)
#nodes, edges = is_connected(res.x)
if circles is None :
break
# print(i)
i += 1
if i == timeout :
raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.")
#sort the landmarks in the order of the solution
tour = [landmarks[i] for i in order]
self.logger.debug(f"Re-optimized {i} times, score: {int(-res.fun)}")
return tour

View File

@@ -0,0 +1,340 @@
import yaml, logging
from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
from math import pi
from structs.landmark import Landmark
from . import take_most_important, get_time_separation
from .optimizer import Optimizer
import constants
class Refiner :
logger = logging.getLogger(__name__)
detour_factor: float # detour factor of straight line vs real distance in cities
detour_corridor_width: float # width of the corridor around the path
average_walking_speed: float # average walking speed of adult
max_landmarks: int # max number of landmarks to visit
optimizer: Optimizer # optimizer object
def __init__(self, optimizer: Optimizer) :
self.optimizer = optimizer
# load parameters from file
with constants.OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.detour_factor = parameters['detour_factor']
self.detour_corridor_width = parameters['detour_corridor_width']
self.average_walking_speed = parameters['average_walking_speed']
self.max_landmarks = parameters['max_landmarks'] + 4
def create_corridor(self, landmarks: list[Landmark], width: float) :
"""
Create a corridor around the path connecting the landmarks.
Args:
landmarks (list[Landmark]): the landmark path around which to create the corridor
width (float): Width of the corridor in meters.
Returns:
Geometry: A buffered geometry object representing the corridor around the path.
"""
corrected_width = (180*width)/(6371000*pi)
path = self.create_linestring(landmarks)
obj = buffer(path, corrected_width, join_style="mitre", cap_style="square", mitre_limit=2)
return obj
def create_linestring(self, tour: list[Landmark]) -> LineString :
"""
Create a `LineString` object from a tour.
Args:
tour (list[Landmark]): An ordered sequence of landmarks that represents the visiting order.
Returns:
LineString: A `LineString` object representing the path through the landmarks.
"""
points = []
for landmark in tour :
points.append(Point(landmark.location))
return LineString(points)
# Check if some coordinates are in area. Used for the corridor
def is_in_area(self, area: Polygon, coordinates) -> bool :
"""
Check if a given point is within a specified area.
Args:
area (Polygon): The polygon defining the area.
coordinates (tuple[float, float]): The coordinates of the point to check.
Returns:
bool: True if the point is within the area, otherwise False.
"""
point = Point(coordinates)
return point.within(area)
# Function to determine if two landmarks are close to each other
def is_close_to(self, location1: tuple[float], location2: tuple[float]):
"""
Determine if two locations are close to each other by rounding their coordinates to 3 decimal places.
Args:
location1 (tuple[float, float]): The coordinates of the first location.
location2 (tuple[float, float]): The coordinates of the second location.
Returns:
bool: True if the locations are within 0.001 degrees of each other, otherwise False.
"""
absx = abs(location1[0] - location2[0])
absy = abs(location1[1] - location2[1])
return absx < 0.001 and absy < 0.001
#return (round(location1[0], 3), round(location1[1], 3)) == (round(location2[0], 3), round(location2[1], 3))
def rearrange(self, tour: list[Landmark]) -> list[Landmark]:
"""
Rearrange landmarks to group nearby visits together.
This function reorders landmarks so that nearby landmarks are adjacent to each other in the list,
while keeping 'start' and 'finish' landmarks in their original positions.
Args:
tour (list[Landmark]): Ordered list of landmarks to be rearranged.
Returns:
list[Landmark]: The rearranged list of landmarks with grouped nearby visits.
"""
i = 1
while i < len(tour):
j = i+1
while j < len(tour):
if self.is_close_to(tour[i].location, tour[j].location) and tour[i].name not in ['start', 'finish'] and tour[j].name not in ['start', 'finish']:
# If they are not adjacent, move the j-th element to be adjacent to the i-th element
if j != i + 1:
tour.insert(i + 1, tour.pop(j))
break # Move to the next i-th element after rearrangement
j += 1
i += 1
return tour
def find_shortest_path_through_all_landmarks(self, landmarks: list[Landmark]) -> tuple[list[Landmark], Polygon]:
"""
Find the shortest path through all landmarks using a nearest neighbor heuristic.
This function constructs a path that starts from the 'start' landmark, visits all other landmarks in the order
of their proximity, and ends at the 'finish' landmark. It returns both the ordered list of landmarks and a
polygon representing the path.
Args:
landmarks (list[Landmark]): list of all landmarks including 'start' and 'finish'.
Returns:
tuple[list[Landmark], Polygon]: A tuple where the first element is the list of landmarks in the order they
should be visited, and the second element is a `Polygon` representing
the path connecting all landmarks.
"""
# Step 1: Find 'start' and 'finish' landmarks
start_idx = next(i for i, lm in enumerate(landmarks) if lm.type == 'start')
finish_idx = next(i for i, lm in enumerate(landmarks) if lm.type == 'finish')
start_landmark = landmarks[start_idx]
finish_landmark = landmarks[finish_idx]
# Step 2: Create a list of unvisited landmarks excluding 'start' and 'finish'
unvisited_landmarks = [lm for i, lm in enumerate(landmarks) if i not in [start_idx, finish_idx]]
# Step 3: Initialize the path with the 'start' landmark
path = [start_landmark]
coordinates = [landmarks[start_idx].location]
current_landmark = start_landmark
# Step 4: Use nearest neighbor heuristic to visit all landmarks
while unvisited_landmarks:
nearest_landmark = min(unvisited_landmarks, key=lambda lm: get_time_separation.get_time(current_landmark.location, lm.location))
path.append(nearest_landmark)
coordinates.append(nearest_landmark.location)
current_landmark = nearest_landmark
unvisited_landmarks.remove(nearest_landmark)
# Step 5: Finally add the 'finish' landmark to the path
path.append(finish_landmark)
coordinates.append(landmarks[finish_idx].location)
path_poly = Polygon(coordinates)
return path, path_poly
# Returns a list of minor landmarks around the planned path to enhance experience
def get_minor_landmarks(self, all_landmarks: list[Landmark], visited_landmarks: list[Landmark], width: float) -> list[Landmark] :
"""
Identify landmarks within a specified corridor that have not been visited yet.
This function creates a corridor around the path defined by visited landmarks and then finds landmarks that fall
within this corridor. It returns a list of these landmarks, excluding those already visited, sorted by their importance.
Args:
all_landmarks (list[Landmark]): list of all available landmarks.
visited_landmarks (list[Landmark]): list of landmarks that have already been visited.
width (float): Width of the corridor around the visited landmarks.
Returns:
list[Landmark]: list of important landmarks within the corridor that have not been visited yet.
"""
second_order_landmarks = []
visited_names = []
area = self.create_corridor(visited_landmarks, width)
for visited in visited_landmarks :
visited_names.append(visited.name)
for landmark in all_landmarks :
if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
second_order_landmarks.append(landmark)
return take_most_important.take_most_important(second_order_landmarks, len(visited_landmarks))
# Try fix the shortest path using shapely
def fix_using_polygon(self, tour: list[Landmark])-> list[Landmark] :
"""
Improve the tour path using geometric methods to ensure it follows a more optimal shape.
This function creates a polygon from the given tour and attempts to refine it using a concave hull. It reorders
the landmarks to fit within this refined polygon and adjusts the tour to ensure the 'start' landmark is at the
beginning. It also checks if the final polygon is simple and rearranges the tour if necessary.
Args:
tour (list[Landmark]): list of landmarks representing the current tour path.
Returns:
list[Landmark]: Refined list of landmarks in the order of visit to produce a better tour path.
"""
coords = []
coords_dict = {}
for landmark in tour :
coords.append(landmark.location)
if landmark.name != 'finish' :
coords_dict[landmark.location] = landmark
tour_poly = Polygon(coords)
better_tour_poly = tour_poly.buffer(0)
try :
xs, ys = better_tour_poly.exterior.xy
if len(xs) != len(tour) :
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
except :
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
# reverse the xs and ys
xs.reverse()
ys.reverse()
better_tour = [] # list of ordered visit
name_index = {} # Maps the name of a landmark to its index in the concave polygon
# Loop through the polygon and generate the better (ordered) tour
for i,x in enumerate(xs[:-1]) :
y = ys[i]
better_tour.append(coords_dict[tuple((x,y))])
name_index[coords_dict[tuple((x,y))].name] = i
# Scroll the list to have start in front again
start_index = name_index['start']
better_tour = better_tour[start_index:] + better_tour[:start_index]
# Append the finish back and correct the time to reach
better_tour.append(tour[-1])
# Rearrange only if polygon still not simple
if not better_tour_poly.is_simple :
better_tour = self.rearrange(better_tour)
return better_tour
def refine_optimization(
self,
all_landmarks: list[Landmark],
base_tour: list[Landmark],
max_time: int,
detour: int
) -> list[Landmark]:
"""
This is the second stage of the optimization. It refines the initial tour path by considering additional minor landmarks and optimizes the path.
This method evaluates the need for further optimization based on the initial tour. If a detour is required
it adds minor landmarks around the initial predicted path and solves a new optimization problem to find a potentially better
tour. It then links the new tour and adjusts it using a nearest neighbor heuristic and polygon-based methods to
ensure a valid path. The final tour is chosen based on the shortest distance.
Args:
all_landmarks (list[Landmark]): The full list of landmarks available for the optimization.
base_tour (list[Landmark]): The initial tour path to be refined.
max_time (int): The maximum time available for the tour in minutes.
detour (int): The maximum detour time allowed for the tour in minutes.
Returns:
list[Landmark]: The refined list of landmarks representing the optimized tour path.
"""
# No need to refine if no detour is taken
if detour == 0:
return base_tour
minor_landmarks = self.get_minor_landmarks(all_landmarks, base_tour, self.detour_corridor_width)
self.logger.info(f"Using {len(minor_landmarks)} minor landmarks around the predicted path")
# full set of visitable landmarks
full_set = base_tour[:-1] + minor_landmarks # create full set of possible landmarks (without finish)
full_set.append(base_tour[-1]) # add finish back
# get a new tour
new_tour = self.optimizer.solve_optimization(
max_time = max_time + detour,
landmarks = full_set
)
if new_tour is None:
self.logger.warning("No solution found for the refined tour. Returning the initial tour.")
new_tour = base_tour
# Find shortest path using the nearest neighbor heuristic
better_tour, better_poly = self.find_shortest_path_through_all_landmarks(new_tour)
# Fix the tour using Polygons if the path looks weird
if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid :
better_tour = self.fix_using_polygon(better_tour)
return better_tour

View File

@@ -0,0 +1,38 @@
from structs.landmark import Landmark
def take_most_important(landmarks: list[Landmark], N_important) -> list[Landmark] :
L = len(landmarks)
L_copy = []
L_clean = []
scores = [0]*len(landmarks)
names = []
name_id = {}
for i, elem in enumerate(landmarks) :
if elem.name not in names :
names.append(elem.name)
name_id[elem.name] = [i]
L_copy.append(elem)
else :
name_id[elem.name] += [i]
scores = []
for j in name_id[elem.name] :
scores.append(L[j].attractiveness)
best_id = max(range(len(scores)), key=scores.__getitem__)
t = name_id[elem.name][best_id]
if t == i :
for old in L_copy :
if old.name == elem.name :
old.attractiveness = L[t].attractiveness
scores = [0]*len(L_copy)
for i, elem in enumerate(L_copy) :
scores[i] = elem.attractiveness
res = sorted(range(len(scores)), key = lambda sub: scores[sub])[-(N_important-L):]
for i, elem in enumerate(L_copy) :
if i in res :
L_clean.append(elem)
return L_clean

17
frontend/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Frontend
This is the frontend of the project. It is a Flutter application that is designed to run on both Android and iOS devices. The frontend is responsible for displaying the user interface and handling user input. It communicates with the backend to retrieve and send data.
## Getting Started
The flutter application is divided into multiple chunks of code.
- the `lib` directory contains the main code of the application.
- the `android` and `ios` directories contain platform-specific code.
- the root directory contains configuration files and metadata.
To run the application, you need to have the Flutter SDK installed. You can find instructions on how to do this [here](https://flutter.dev/docs/get-started/install).
Once you have the Flutter SDK installed, you can locally install the dependencies by running:
```bash
flutter pub get
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,482 +0,0 @@
{
"0":{
"name":"Atelier Brancusi",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8614029,
2.3519903
],
"osm_type":"way",
"osm_id":55503399,
"attractiveness":13,
"must_do":false,
"n_tags":13,
"time_to_reach":0
},
"1":{
"name":"Musée de Cluny",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8506604,
2.3437398
],
"osm_type":"way",
"osm_id":56640163,
"attractiveness":18,
"must_do":false,
"n_tags":18,
"time_to_reach":0
},
"2":{
"name":"Musée du Luxembourg",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8485965,
2.3340156
],
"osm_type":"way",
"osm_id":170226810,
"attractiveness":15,
"must_do":false,
"n_tags":15,
"time_to_reach":0
},
"3":{
"name":"Musée national Eugène Delacroix",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8546662,
2.3353836
],
"osm_type":"way",
"osm_id":395785603,
"attractiveness":15,
"must_do":false,
"n_tags":15,
"time_to_reach":0
},
"4":{
"name":"Hôtel de Sully",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.854635,
2.3638126
],
"osm_type":"relation",
"osm_id":403146,
"attractiveness":14,
"must_do":false,
"n_tags":13,
"time_to_reach":0
},
"5":{
"name":"Hôtel de la Monnaie",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.856403,
2.3388625
],
"osm_type":"relation",
"osm_id":967664,
"attractiveness":11,
"must_do":false,
"n_tags":11,
"time_to_reach":0
},
"6":{
"name":"Musée Bourdelle",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8432078,
2.3186583
],
"osm_type":"relation",
"osm_id":1212876,
"attractiveness":23,
"must_do":false,
"n_tags":23,
"time_to_reach":0
},
"7":{
"name":"Musée Carnavalet",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8576819,
2.3627147
],
"osm_type":"relation",
"osm_id":2405955,
"attractiveness":25,
"must_do":false,
"n_tags":25,
"time_to_reach":0
},
"8":{
"name":"Pont Neuf",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8565248,
2.3408132
],
"osm_type":"way",
"osm_id":53574149,
"attractiveness":16,
"must_do":false,
"n_tags":15,
"time_to_reach":0
},
"9":{
"name":"Jardin du Luxembourg",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8467137,
2.3363649
],
"osm_type":"way",
"osm_id":128206209,
"attractiveness":35,
"must_do":false,
"n_tags":23,
"time_to_reach":0
},
"10":{
"name":"Cathédrale Notre-Dame de Paris",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8529372,
2.3498701
],
"osm_type":"way",
"osm_id":201611261,
"attractiveness":55,
"must_do":false,
"n_tags":54,
"time_to_reach":0
},
"11":{
"name":"Maison à l'enseigne du Faucheur",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8558553,
2.3568266
],
"osm_type":"way",
"osm_id":1123456865,
"attractiveness":13,
"must_do":false,
"n_tags":11,
"time_to_reach":0
},
"12":{
"name":"Maison à l'enseigne du Mouton",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8558431,
2.3568914
],
"osm_type":"way",
"osm_id":1123456866,
"attractiveness":13,
"must_do":false,
"n_tags":11,
"time_to_reach":0
},
"13":{
"name":"Palais de Justice de Paris",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8556537,
2.3446072
],
"osm_type":"relation",
"osm_id":536982,
"attractiveness":24,
"must_do":false,
"n_tags":24,
"time_to_reach":0
},
"14":{
"name":"Mémorial des Martyrs de la Déportation",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8517365,
2.3524734
],
"osm_type":"relation",
"osm_id":9396191,
"attractiveness":21,
"must_do":false,
"n_tags":21,
"time_to_reach":0
},
"15":{
"name":"Fontaine des Innocents",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8606368,
2.3480233
],
"osm_type":"way",
"osm_id":52469222,
"attractiveness":16,
"must_do":false,
"n_tags":15,
"time_to_reach":0
},
"16":{
"name":"Hôtel de Sens",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8535257,
2.3588733
],
"osm_type":"way",
"osm_id":55541122,
"attractiveness":21,
"must_do":false,
"n_tags":20,
"time_to_reach":0
},
"17":{
"name":"Hôtel d'Ourscamp",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8555465,
2.3571206
],
"osm_type":"way",
"osm_id":55620201,
"attractiveness":10,
"must_do":false,
"n_tags":9,
"time_to_reach":0
},
"18":{
"name":"Hôtel de Choiseul-Praslin",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8478008,
2.3203873
],
"osm_type":"way",
"osm_id":65756922,
"attractiveness":12,
"must_do":false,
"n_tags":12,
"time_to_reach":0
},
"19":{
"name":"Chapelle Notre-Dame-des-Anges",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8462836,
2.3235558
],
"osm_type":"way",
"osm_id":219378497,
"attractiveness":16,
"must_do":false,
"n_tags":16,
"time_to_reach":0
},
"20":{
"name":"Fontaine du Palmier",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8575005,
2.3472864
],
"osm_type":"way",
"osm_id":261092850,
"attractiveness":23,
"must_do":false,
"n_tags":14,
"time_to_reach":0
},
"21":{
"name":"Église Saint-Séverin",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8520913,
2.3457237
],
"osm_type":"way",
"osm_id":19740659,
"attractiveness":15,
"must_do":false,
"n_tags":25,
"time_to_reach":0
},
"22":{
"name":"Église Orthodoxe Roumaine des Saints Archanges",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.849488,
2.3471975
],
"osm_type":"way",
"osm_id":55366403,
"attractiveness":10,
"must_do":false,
"n_tags":16,
"time_to_reach":0
},
"23":{
"name":"Église Saint-Merri",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8590777,
2.3508448
],
"osm_type":"way",
"osm_id":55742120,
"attractiveness":16,
"must_do":false,
"n_tags":26,
"time_to_reach":0
},
"24":{
"name":"Église Saint-Germain des Prés",
"type":{
"landmark_type":"sightseeing"
},
"location":[
48.8539667,
2.334463
],
"osm_type":"way",
"osm_id":62287173,
"attractiveness":12,
"must_do":false,
"n_tags":21,
"time_to_reach":0
},
"25":{
"name":"Square de la Tour Saint-Jacques",
"type":{
"landmark_type":"nature"
},
"location":[
48.857913,
2.3487608
],
"osm_type":"way",
"osm_id":15895172,
"attractiveness":19,
"must_do":false,
"n_tags":12,
"time_to_reach":0
},
"26":{
"name":"Square Jean XXIII",
"type":{
"landmark_type":"nature"
},
"location":[
48.8523775,
2.3503406
],
"osm_type":"way",
"osm_id":20444455,
"attractiveness":23,
"must_do":false,
"n_tags":15,
"time_to_reach":0
},
"27":{
"name":"Square Félix Desruelles",
"type":{
"landmark_type":"nature"
},
"location":[
48.8536974,
2.3345069
],
"osm_type":"way",
"osm_id":62287123,
"attractiveness":12,
"must_do":false,
"n_tags":7,
"time_to_reach":0
},
"28":{
"name":"Jardin des Rosiers Joseph-Migneret",
"type":{
"landmark_type":"nature"
},
"location":[
48.8572946,
2.3602516
],
"osm_type":"way",
"osm_id":365878540,
"attractiveness":14,
"must_do":false,
"n_tags":8,
"time_to_reach":0
},
"29":{
"name":"Jardin du Père-Armand-David",
"type":{
"landmark_type":"nature"
},
"location":[
48.8478674,
2.3220972
],
"osm_type":"way",
"osm_id":588324415,
"attractiveness":10,
"must_do":false,
"n_tags":7,
"time_to_reach":0
}
}

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}