From 580c4f0b85d12574deb1560ec1e517c0ef704f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Mon, 25 Dec 2017 23:56:12 +0100 Subject: [PATCH] first implementation of wifi-based location --- src/c3nav/locale/de/LC_MESSAGES/django.po | 80 ++++++++++---------- src/c3nav/routing/api.py | 22 +++++- src/c3nav/routing/locator.py | 90 +++++++++++++++++++---- 3 files changed, 131 insertions(+), 61 deletions(-) diff --git a/src/c3nav/locale/de/LC_MESSAGES/django.po b/src/c3nav/locale/de/LC_MESSAGES/django.po index f8a6586b..4de6647e 100644 --- a/src/c3nav/locale/de/LC_MESSAGES/django.po +++ b/src/c3nav/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-12-24 05:03+0100\n" +"POT-Creation-Date: 2017-12-25 22:23+0100\n" "PO-Revision-Date: 2017-12-24 05:02+0100\n" "Last-Translator: Laura Klünder \n" "Language-Team: \n" @@ -306,7 +306,7 @@ msgid "back" msgstr "zurück" #: c3nav/control/templates/control/user.html:73 -#: c3nav/mapdata/models/access.py:77 c3nav/mapdata/models/geometry/space.py:345 +#: c3nav/mapdata/models/access.py:77 c3nav/mapdata/models/geometry/space.py:353 #: c3nav/site/models.py:17 msgid "author" msgstr "Autor" @@ -428,45 +428,37 @@ msgstr "Token erfolgreich invalidiert." msgid "You can only display your most recently created token." msgstr "Du kannst nur deinen zuletzt erstellten Code anzeigen." -#: c3nav/editor/forms.py:129 +#: c3nav/editor/forms.py:128 msgid "Redirecting Slugs (comma seperated)" msgstr "Umleitungs-Slugs (mit Komma getrennt)" -#: c3nav/editor/forms.py:158 +#: c3nav/editor/forms.py:157 #, python-format msgid "Can not add redirecting slug “%s”: it's the slug of this object." msgstr "" "Umleitungs-Slug „%s“ kann nicht hinzugefügt werden: Es ist der Slug dieses " "Objects." -#: c3nav/editor/forms.py:165 +#: c3nav/editor/forms.py:164 #, python-format msgid "Can not add redirecting slug “%s”: it is already used elsewhere." msgstr "" "Umleitungs-Slug „%s“ kann nicht hinzugefügt werden: Er wird bereits an " "anderer Stelle verwendet." -#: c3nav/editor/forms.py:172 +#: c3nav/editor/forms.py:171 msgid "Invalid JSON." msgstr "Invalides JSON." -#: c3nav/editor/forms.py:173 -msgid "Invalid Scan." -msgstr "Invalider Scan." - -#: c3nav/editor/forms.py:178 -msgid "Needs to be one scan at minimum." -msgstr "Muss mindestens 1 sein." - -#: c3nav/editor/forms.py:252 +#: c3nav/editor/forms.py:230 msgid "Final rejection" msgstr "Endgültige Ablehnung" -#: c3nav/editor/forms.py:260 +#: c3nav/editor/forms.py:238 msgid "create one way edges" msgstr "Kante nur in eine Richtung erstellen" -#: c3nav/editor/forms.py:261 +#: c3nav/editor/forms.py:239 msgid "activate next node after connecting" msgstr "nächsten Knoten nach dem Verbinden aktivieren" @@ -570,15 +562,15 @@ msgstr "Kartenupdate" msgid "Change Sets" msgstr "Änderungssets" -#: c3nav/editor/models/changeset.py:694 +#: c3nav/editor/models/changeset.py:698 msgid "Direct editing active" msgstr "Direktes Bearbeiten aktiv" -#: c3nav/editor/models/changeset.py:695 +#: c3nav/editor/models/changeset.py:699 msgid "No objects changed" msgstr "Keine Objekte geändert" -#: c3nav/editor/models/changeset.py:696 +#: c3nav/editor/models/changeset.py:700 #, python-format msgid "%(num)d object changed" msgid_plural "%(num)d objects changed" @@ -1471,7 +1463,7 @@ msgid "Doors" msgstr "Türen" #: c3nav/mapdata/models/geometry/level.py:156 -#: c3nav/mapdata/models/geometry/space.py:250 +#: c3nav/mapdata/models/geometry/space.py:258 msgid "altitude" msgstr "Bodenhöhe" @@ -1510,8 +1502,8 @@ msgstr "" "erstellt." #: c3nav/mapdata/models/geometry/space.py:23 -#: c3nav/mapdata/models/geometry/space.py:266 -#: c3nav/mapdata/models/geometry/space.py:303 +#: c3nav/mapdata/models/geometry/space.py:274 +#: c3nav/mapdata/models/geometry/space.py:311 msgid "space" msgstr "Raum" @@ -1590,65 +1582,65 @@ msgstr "Ort von Interesse" msgid "Points of Interest" msgstr "Orte von Interesse" -#: c3nav/mapdata/models/geometry/space.py:240 +#: c3nav/mapdata/models/geometry/space.py:248 msgid "Hole" msgstr "Loch" -#: c3nav/mapdata/models/geometry/space.py:241 +#: c3nav/mapdata/models/geometry/space.py:249 msgid "Holes" msgstr "Löcher" -#: c3nav/mapdata/models/geometry/space.py:253 +#: c3nav/mapdata/models/geometry/space.py:261 msgid "Altitude Marker" msgstr "Höhenmarker" -#: c3nav/mapdata/models/geometry/space.py:254 +#: c3nav/mapdata/models/geometry/space.py:262 msgid "Altitude Markers" msgstr "Höhenmarker" -#: c3nav/mapdata/models/geometry/space.py:267 -#: c3nav/mapdata/models/geometry/space.py:306 +#: c3nav/mapdata/models/geometry/space.py:275 +#: c3nav/mapdata/models/geometry/space.py:314 msgid "target space" msgstr "Zielraum" -#: c3nav/mapdata/models/geometry/space.py:269 -#: c3nav/mapdata/models/geometry/space.py:308 c3nav/mapdata/models/graph.py:44 +#: c3nav/mapdata/models/geometry/space.py:277 +#: c3nav/mapdata/models/geometry/space.py:316 c3nav/mapdata/models/graph.py:44 msgid "description" msgstr "Beschreibung" -#: c3nav/mapdata/models/geometry/space.py:272 +#: c3nav/mapdata/models/geometry/space.py:280 msgid "Leave description" msgstr "Verlassensbeschreibung" -#: c3nav/mapdata/models/geometry/space.py:273 +#: c3nav/mapdata/models/geometry/space.py:281 msgid "Leave descriptions" msgstr "Verlassensbeschreibungen" -#: c3nav/mapdata/models/geometry/space.py:304 +#: c3nav/mapdata/models/geometry/space.py:312 msgid "origin space" msgstr "Ursprungsraum" -#: c3nav/mapdata/models/geometry/space.py:311 +#: c3nav/mapdata/models/geometry/space.py:319 msgid "Cross description" msgstr "Durchschreitungsbeschreibung" -#: c3nav/mapdata/models/geometry/space.py:312 +#: c3nav/mapdata/models/geometry/space.py:320 msgid "Cross descriptions" msgstr "Durchschreitungsbeschreibungen" -#: c3nav/mapdata/models/geometry/space.py:346 +#: c3nav/mapdata/models/geometry/space.py:354 msgid "comment" msgstr "Kommentar" -#: c3nav/mapdata/models/geometry/space.py:347 +#: c3nav/mapdata/models/geometry/space.py:355 msgid "Measurement list" msgstr "Messungsliste" -#: c3nav/mapdata/models/geometry/space.py:350 +#: c3nav/mapdata/models/geometry/space.py:358 msgid "Wi-Fi Measurement" msgstr "WLAN Messung" -#: c3nav/mapdata/models/geometry/space.py:351 +#: c3nav/mapdata/models/geometry/space.py:359 msgid "Wi-Fi Measurements" msgstr "WLAN Messungen" @@ -1982,6 +1974,10 @@ msgstr "Unbekannter Startort." msgid "Unknown destination." msgstr "Unbekannter Zielort." +#: c3nav/routing/locator.py:129 +msgid "Invalid Scan." +msgstr "Invalider Scan." + #: c3nav/routing/models.py:20 c3nav/routing/models.py:21 #: c3nav/site/templates/site/map.html:129 msgid "Route options" @@ -2035,12 +2031,12 @@ msgstr "komplett vermeiden" msgid "avoid" msgstr "vermeiden" -#: c3nav/routing/models.py:135 +#: c3nav/routing/models.py:142 #, python-format msgid "Unknown route option: %s" msgstr "Unbekannte Routenoption: %s" -#: c3nav/routing/models.py:139 +#: c3nav/routing/models.py:146 #, python-format msgid "Invalid value for route option %s." msgstr "Invalider Wert für Routenoption %s." diff --git a/src/c3nav/routing/api.py b/src/c3nav/routing/api.py index 82e25b20..a51bb601 100644 --- a/src/c3nav/routing/api.py +++ b/src/c3nav/routing/api.py @@ -1,3 +1,5 @@ +import json + from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from rest_framework.decorators import list_route @@ -5,9 +7,10 @@ from rest_framework.response import Response from rest_framework.viewsets import ViewSet from c3nav.mapdata.models.access import AccessPermission -from c3nav.mapdata.utils.locations import visible_locations_for_request +from c3nav.mapdata.utils.locations import locations_for_request, visible_locations_for_request from c3nav.routing.exceptions import LocationUnreachable, NoRouteFound, NotYetRoutable from c3nav.routing.forms import RouteForm +from c3nav.routing.locator import Locator from c3nav.routing.models import RouteOptions from c3nav.routing.router import Router @@ -80,5 +83,18 @@ class RoutingViewSet(ViewSet): @list_route(methods=('POST', )) def locate(self, request, *args, **kwargs): - # todo: implement wifi location - return Response({'location': None}) + try: + scan_data = json.loads(request.body) + except json.JSONDecodeError: + return Response({ + 'errors': (_('Invalid JSON'), ), + }, status=400) + + try: + location = Locator.load().locate(scan_data, locations_for_request(request)) + except ValidationError: + return Response({ + 'errors': (_('Invalid scan data.'),), + }, status=400) + + return Response({'location': None if location is None else location.serialize()}) diff --git a/src/c3nav/routing/locator.py b/src/c3nav/routing/locator.py index a4147f20..e6cd0cfc 100644 --- a/src/c3nav/routing/locator.py +++ b/src/c3nav/routing/locator.py @@ -6,11 +6,14 @@ import threading from collections import deque, namedtuple from functools import reduce +import numpy as np from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from c3nav.mapdata.models import MapUpdate, Space +from c3nav.mapdata.utils.locations import CustomLocation +from c3nav.routing.router import Router class Locator: @@ -25,10 +28,12 @@ class Locator: stations = LocatorStations() spaces = {} for space in Space.objects.prefetch_related('wifi_measurements'): - spaces[space.pk] = LocatorSpace( + new_space = LocatorSpace( LocatorPoint.from_measurement(measurement, stations) for measurement in space.wifi_measurements.all() ) + if new_space.points: + spaces[space.pk] = new_space locator = cls(stations, spaces) pickle.dump(locator, open(cls.build_filename(update), 'wb')) @@ -56,18 +61,52 @@ class Locator: cls.cached = cls.load_nocache(update) return cls.cached + def locate(self, scan, permissions=None): + router = Router.load() + restrictions = router.get_restrictions(permissions) + + scan = LocatorPoint.validate_scan(scan, ignore_invalid_stations=True) + scan_values = LocatorPoint.convert_scan(scan, self.stations, create=False) + station_ids = frozenset(scan_values.keys()) + + spaces = tuple((pk, space, station_ids & space.stations_set) + for pk, space in self.spaces.items() + if pk not in restrictions.spaces) + spaces = tuple((pk, space, station_ids) for pk, space, station_ids in spaces if station_ids) + if not spaces: + return None + good_spaces = tuple((pk, space, station_ids) for pk, space, station_ids in spaces if len(station_ids) >= 3) + if not good_spaces: + for station_id in station_ids: + scan_values[station_id] = 0 + good_spaces = spaces + + good_spaces = sorted(good_spaces, key=lambda item: len(item[2]), reverse=True)[:10] + + best_location = None + best_score = float('inf') + + for space, station_ids in good_spaces: + point, score = space.get_best_point(scan_values, station_ids) + if score < best_score: + location = CustomLocation(router.spaces[space.pk].level, point.x, point.y) + best_location = location + best_score = score + + return best_location + class LocatorStations: def __init__(self): self.stations = [] self.stations_lookup = {} - def get_or_create(self, bssid, ssid, frequency): + def get(self, bssid, ssid, frequency, create=False): station_id = self.stations_lookup.get((bssid, ssid), None) if station_id is not None: station = self.stations[station_id] station.frequencies.add(frequency) - else: + elif create: station = LocatorStation(bssid, ssid, set((frequency, ))) station_id = len(self.stations) self.stations_lookup[(bssid, ssid)] = station_id @@ -78,31 +117,45 @@ class LocatorStations: class LocatorSpace: def __init__(self, points): self.points = tuple(points) - self.stations_set = reduce(operator.or_, (frozenset(point.values.keys()) for point in points), frozenset()) + self.stations_set = reduce(operator.or_, (frozenset(point.values.keys()) for point in self.points), frozenset()) self.stations = tuple(self.stations_set) self.stations_lookup = {station_id: i for i, station_id in enumerate(self.stations)} + self.levels = np.full((len(self.points), len(self.stations)), fill_value=-100, dtype=np.int8) + for i, point in enumerate(self.points): + for station_id, value in point.values.items(): + self.levels[i][self.stations_lookup[station_id]] = value + + def get_best_point(self, scan_values, station_ids): + stations = tuple(self.stations_lookup[station_id] for station_id in station_ids) + values = np.array(tuple(scan_values[station_id] for station_id in station_ids), dtype=np.int8) + scores = np.sum((self.levels[:, stations]-values)**2, axis=1) + best_point = np.argmin(scores).ravel()[0] + return self.points[best_point], scores[best_point] + class LocatorPoint(namedtuple('LocatorPoint', ('x', 'y', 'values'))): @classmethod def from_measurement(cls, measurement, stations: LocatorStations): return cls(x=measurement.geometry.x, y=measurement.geometry.y, - values=cls.convert_scans(measurement.data, stations)) + values=cls.convert_scans(measurement.data, stations, create=True)) @classmethod - def convert_scan(cls, scan, stations: LocatorStations): + def convert_scan(cls, scan, stations: LocatorStations, create=False): values = {} for scan_value in scan: - station_id = stations.get_or_create(scan_value['bssid'], scan_value['ssid'], scan_value['frequency']) - # todo: convert to something more or less linear - values[station_id] = scan_value['level'] + station_id = stations.get(bssid=scan_value['bssid'], ssid=scan_value['ssid'], + frequency=scan_value['frequency'], create=create) + if station_id is not None: + # todo: convert to something more or less linear + values[station_id] = scan_value['level'] return values @classmethod - def convert_scans(cls, scans, stations: LocatorStations): + def convert_scans(cls, scans, stations: LocatorStations, create=False): values_list = deque() for scan in scans: - values_list.append(cls.convert_scan(scan, stations)) + values_list.append(cls.convert_scan(scan, stations, create)) station_ids = reduce(operator.or_, (frozenset(values.keys()) for values in values_list), frozenset()) return { @@ -125,18 +178,23 @@ class LocatorPoint(namedtuple('LocatorPoint', ('x', 'y', 'values'))): allowed_keys = needed_keys | frozenset(('last', )) @classmethod - def validate_scans(cls, data): + def validate_scans(cls, data, ignore_invalid_stations=False): if not isinstance(data, list): raise cls.invalid_scan - for scan in data: - cls.validate_scan(scan) + return tuple(cls.validate_scan(scan) for scan in data) @classmethod - def validate_scan(cls, data): + def validate_scan(cls, data, ignore_invalid_stations=False): if not isinstance(data, list): raise cls.invalid_scan + cleaned_scan = deque() for scan_value in data: - cls.validate_scan_value(scan_value) + try: + cleaned_scan.append(cls.validate_scan_value(scan_value)) + except ValidationError: + if not ignore_invalid_stations: + raise + return tuple(cleaned_scan) @classmethod def validate_scan_value(cls, data):