Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release to main #61

Merged
merged 4 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions api/v1/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from fastapi import APIRouter, HTTPException
from pytz import timezone
from shapely import Point
from shapely import get_coordinates
from tzfpy import get_tz

from middlewares.cache_middleware import configure_cache
Expand All @@ -17,8 +17,8 @@
photo_id_re = re.compile(r'view/(?P<id>\S+)\.')


def _get_timezone(position: Point) -> tuple[str | None, str | None]:
timezone_name: str | None = get_tz(position.x, position.y)
def _get_timezone(x: float, y: float) -> tuple[str | None, str | None]:
timezone_name: str | None = get_tz(x, y)
timezone_offset = None

if timezone_name:
Expand Down Expand Up @@ -78,9 +78,11 @@ async def get_node(node_id: int):
if aed is None:
raise HTTPException(404, f'Node {node_id} not found')

x, y = get_coordinates(aed.position)[0]

photo_dict = await _get_image_data(aed.tags)

timezone_name, timezone_offset = _get_timezone(aed.position)
timezone_name, timezone_offset = _get_timezone(x, y)
timezone_dict = {
'@timezone_name': timezone_name,
'@timezone_offset': timezone_offset,
Expand All @@ -97,8 +99,8 @@ async def get_node(node_id: int):
**timezone_dict,
'type': 'node',
'id': aed.id,
'lat': aed.position.y,
'lon': aed.position.x,
'lat': y,
'lon': x,
'tags': aed.tags,
'version': aed.version,
}
Expand Down
62 changes: 34 additions & 28 deletions api/v1/tile.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from collections.abc import Sequence
from itertools import chain
from math import atan, degrees, pi, sinh
from typing import Annotated

Expand All @@ -8,8 +7,7 @@
from anyio import create_task_group
from fastapi import APIRouter, Path, Response
from sentry_sdk import start_span, trace
from shapely import Point
from shapely.ops import transform
from shapely import get_coordinates, points, set_coordinates

from config import (
DEFAULT_CACHE_MAX_AGE,
Expand All @@ -33,17 +31,18 @@
router = APIRouter()


def _tile_to_point(z: int, x: int, y: int) -> Point:
def _tile_to_point(z: int, x: int, y: int) -> tuple[float, float]:
n = 2**z
lon_deg = x / n * 360.0 - 180.0
lat_rad = atan(sinh(pi * (1 - 2 * y / n)))
lat_deg = degrees(lat_rad)
return Point(lon_deg, lat_deg)
return lon_deg, lat_deg


def _tile_to_bbox(z: int, x: int, y: int) -> BBox:
p1 = _tile_to_point(z, x, y + 1)
p2 = _tile_to_point(z, x + 1, y)
p1_coords = _tile_to_point(z, x, y + 1)
p2_coords = _tile_to_point(z, x + 1, y)
p1, p2 = points((p1_coords, p2_coords))
return BBox(p1, p2)


Expand All @@ -65,31 +64,38 @@ async def get_tile(
return Response(content, headers=headers, media_type='application/vnd.mapbox-vector-tile')


def _mvt_rescale(x: float, y: float, x_min: float, y_min: float, x_span: float, y_span: float) -> tuple[int, int]:
x_mvt, y_mvt = MVT_TRANSFORMER.transform(np.array(x), np.array(y))

# subtract minimum boundary and scale to MVT extent
x_scaled = np.rint((x_mvt - x_min) / x_span * MVT_EXTENT).astype(int)
y_scaled = np.rint((y_mvt - y_min) / y_span * MVT_EXTENT).astype(int)
return x_scaled, y_scaled


def _mvt_encode(bbox: BBox, data: Sequence[dict]) -> bytes:
x_min, y_min = MVT_TRANSFORMER.transform(bbox.p1.x, bbox.p1.y)
x_max, y_max = MVT_TRANSFORMER.transform(bbox.p2.x, bbox.p2.y)
x_span = x_max - x_min
y_span = y_max - y_min

def _mvt_encode(bbox: BBox, layers: Sequence[dict]) -> bytes:
with start_span(description='Transforming MVT geometry'):
for feature in chain.from_iterable(d['features'] for d in data):
feature['geometry'] = transform(
func=lambda x, y: _mvt_rescale(x, y, x_min, y_min, x_span, y_span),
geom=feature['geometry'],
)
coords_range = []
coords = []

for layer in layers:
for feature in layer['features']:
feature_coords = get_coordinates(feature['geometry'])
coords_len = len(coords)
coords_range.append((coords_len, coords_len + len(feature_coords)))
coords.extend(feature_coords)

if coords:
bbox_coords = np.asarray((get_coordinates(bbox.p1)[0], get_coordinates(bbox.p2)[0]))
bbox_coords = np.asarray(MVT_TRANSFORMER.transform(bbox_coords[:, 0], bbox_coords[:, 1])).T
span = bbox_coords[1] - bbox_coords[0]

coords = np.asarray(coords)
coords = np.asarray(MVT_TRANSFORMER.transform(coords[:, 0], coords[:, 1])).T
coords = np.rint((coords - bbox_coords[0]) / span * MVT_EXTENT).astype(int)

i = 0
for layer in layers:
for feature in layer['features']:
feature_coords_range = coords_range[i]
feature_coords = coords[feature_coords_range[0] : feature_coords_range[1]]
feature['geometry'] = set_coordinates(feature['geometry'], feature_coords)
i += 1

with start_span(description='Encoding MVT'):
return mvt.encode(
data,
layers,
default_options={
'extents': MVT_EXTENT,
'check_winding_order': False,
Expand Down
2 changes: 1 addition & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from sentry_sdk.integrations.pymongo import PyMongoIntegration

NAME = 'openaedmap-backend'
VERSION = '2.7.4'
VERSION = '2.7.5'
CREATED_BY = f'{NAME} {VERSION}'
WEBSITE = 'https://openaedmap.org'

Expand Down
18 changes: 11 additions & 7 deletions models/aed_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,18 @@ def decide_access(accesses: Iterable[str]) -> str:
'no': 5,
}

min_access = '', float('inf')
min_access = ''
min_tier = float('inf')

for access in accesses:
if access == 'yes':
return 'yes' # early stopping
tier = tiered.get(access)

tier = tiered.get(access, float('inf'))
if tier < min_access[1]:
min_access = access, tier
if (tier is not None) and (tier < min_tier):
min_access = access
min_tier = tier

return min_access[0]
# early stopping
if min_tier == 0:
break

return min_access
65 changes: 36 additions & 29 deletions models/bbox.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import NamedTuple, Self

import numpy as np
from shapely import Point
from shapely import Point, get_coordinates, points
from shapely.geometry import Polygon


Expand All @@ -10,54 +10,61 @@ class BBox(NamedTuple):
p2: Point

def extend(self, percentage: float) -> Self:
lon_span = self.p2.x - self.p1.x
lat_span = self.p2.y - self.p1.y
lon_delta = lon_span * percentage
lat_delta = lat_span * percentage
p1_coords = get_coordinates(self.p1)[0]
p2_coords = get_coordinates(self.p2)[0]

new_p1_lon = max(-180, min(180, self.p1.x - lon_delta))
new_p1_lat = max(-90, min(90, self.p1.y - lat_delta))
new_p2_lon = max(-180, min(180, self.p2.x + lon_delta))
new_p2_lat = max(-90, min(90, self.p2.y + lat_delta))
spans = p2_coords - p1_coords
deltas = spans * percentage

return BBox(
p1=Point(new_p1_lon, new_p1_lat),
p2=Point(new_p2_lon, new_p2_lat),
)
p1_coords = np.clip(p1_coords - deltas, [-180, -90], [180, 90])
p2_coords = np.clip(p2_coords + deltas, [-180, -90], [180, 90])

p1, p2 = points((p1_coords, p2_coords))
return BBox(p1, p2)

@classmethod
def from_tuple(cls, bbox: tuple[float, float, float, float]) -> Self:
return cls(Point(bbox[0], bbox[1]), Point(bbox[2], bbox[3]))
p1, p2 = points(((bbox[0], bbox[1]), (bbox[2], bbox[3])))
return cls(p1, p2)

def to_tuple(self) -> tuple[float, float, float, float]:
return (self.p1.x, self.p1.y, self.p2.x, self.p2.y)
p1_x, p1_y = get_coordinates(self.p1)[0]
p2_x, p2_y = get_coordinates(self.p2)[0]

return (p1_x, p1_y, p2_x, p2_y)

def to_polygon(self, *, nodes_per_edge: int = 2) -> Polygon:
p1_x, p1_y = get_coordinates(self.p1)[0]
p2_x, p2_y = get_coordinates(self.p2)[0]

if nodes_per_edge <= 2:
return Polygon(
[
(self.p1.x, self.p1.y),
(self.p2.x, self.p1.y),
(self.p2.x, self.p2.y),
(self.p1.x, self.p2.y),
(self.p1.x, self.p1.y),
]
(
(p1_x, p1_y),
(p2_x, p1_y),
(p2_x, p2_y),
(p1_x, p2_y),
(p1_x, p1_y),
)
)

x_vals = np.linspace(self.p1.x, self.p2.x, nodes_per_edge)
y_vals = np.linspace(self.p1.y, self.p2.y, nodes_per_edge)
x_vals = np.linspace(p1_x, p2_x, nodes_per_edge)
y_vals = np.linspace(p1_y, p2_y, nodes_per_edge)

bottom_edge = np.column_stack((x_vals, np.full(nodes_per_edge, self.p1.y)))
top_edge = np.column_stack((x_vals, np.full(nodes_per_edge, self.p2.y)))
left_edge = np.column_stack((np.full(nodes_per_edge - 2, self.p1.x), y_vals[1:-1]))
right_edge = np.column_stack((np.full(nodes_per_edge - 2, self.p2.x), y_vals[1:-1]))
bottom_edge = np.column_stack((x_vals, np.full(nodes_per_edge, p1_y)))
top_edge = np.column_stack((x_vals, np.full(nodes_per_edge, p2_y)))
left_edge = np.column_stack((np.full(nodes_per_edge - 2, p1_x), y_vals[1:-1]))
right_edge = np.column_stack((np.full(nodes_per_edge - 2, p2_x), y_vals[1:-1]))

all_coords = np.concatenate((bottom_edge, right_edge, top_edge[::-1], left_edge[::-1]))

return Polygon(all_coords)

def correct_for_dateline(self) -> tuple[Self, ...]:
if self.p1.x > self.p2.x:
return (BBox(self.p1, Point(180, self.p2.y)), BBox(Point(-180, self.p1.y), self.p2))
b1_p1 = self.p1
b2_p2 = self.p2
b1_p2, b2_p1 = points(((180, self.p2.y), (-180, self.p1.y)))
return (BBox(b1_p1, b1_p2), BBox(b2_p1, b2_p2))
else:
return (self,)
27 changes: 17 additions & 10 deletions states/aed_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from cachetools import TTLCache
from pymongo import DeleteOne, ReplaceOne, UpdateOne
from sentry_sdk import start_span, start_transaction, trace
from shapely import Point
from shapely import Point, get_coordinates, points
from shapely.geometry import mapping
from shapely.geometry.base import BaseGeometry
from sklearn.cluster import Birch
Expand Down Expand Up @@ -238,12 +238,18 @@ async def get_aed_by_id(aed_id: int) -> AED | None:
@trace
async def get_all_aeds(filter: dict | None = None) -> Sequence[AED]:
cursor = AED_COLLECTION.find(filter, projection={'_id': False})
result = []
docs = await cursor.to_list(None)
if not docs:
return ()

async for doc in cursor:
doc['position'] = geometry_validator(doc['position'])
coords = tuple(doc['position']['coordinates'] for doc in docs)
positions = points(coords)
result = [None] * len(docs)

for i, doc, position in zip(range(len(docs)), docs, positions, strict=True):
doc['position'] = position
aed = AED.model_construct(**doc)
result.append(aed)
result[i] = aed

return result

Expand All @@ -258,22 +264,23 @@ async def get_aeds_within_geom(cls, geometry: BaseGeometry, group_eps: float | N
if len(aeds) <= 1 or group_eps is None:
return aeds

positions = tuple((aed.position.x, aed.position.y) for aed in aeds)
positions = tuple(get_coordinates(aed.position)[0] for aed in aeds)

# deterministic sampling
max_fit_samples = 7000
if len(positions) > max_fit_samples:
indices = np.linspace(0, len(positions), max_fit_samples, endpoint=False, dtype=int)
fit_positions = np.array(positions)[indices]
fit_positions = np.asarray(positions)[indices]
else:
fit_positions = positions

with start_span(description=f'Fitting model with {len(fit_positions)} samples'):
model = Birch(threshold=group_eps, n_clusters=None, compute_labels=False, copy=False)
model.fit(fit_positions)
center_points = points(model.subcluster_centers_)

with start_span(description=f'Processing {len(aeds)} samples'):
cluster_groups: tuple[list[AED]] = tuple([] for _ in range(len(model.subcluster_centers_)))
cluster_groups: tuple[list[AED]] = tuple([] for _ in range(len(center_points)))
result: list[AED | AEDGroup] = []

with start_span(description='Clustering'):
Expand All @@ -282,7 +289,7 @@ async def get_aeds_within_geom(cls, geometry: BaseGeometry, group_eps: float | N
for aed, cluster in zip(aeds, clusters, strict=True):
cluster_groups[cluster].append(aed)

for group, center in zip(cluster_groups, model.subcluster_centers_, strict=True):
for group, center_point in zip(cluster_groups, center_points, strict=True):
if len(group) == 0:
continue
if len(group) == 1:
Expand All @@ -291,7 +298,7 @@ async def get_aeds_within_geom(cls, geometry: BaseGeometry, group_eps: float | N

result.append(
AEDGroup(
position=Point(center[0], center[1]),
position=center_point,
count=len(group),
access=AEDGroup.decide_access(aed.access for aed in group),
)
Expand Down