"""This module implements abstractions for geospatial, polygonal shapes in WGS84 and local cartesian
coordinates using shapely."""
#
# Copyright (c) Simon Kohaut, Honda Research Institute Europe GmbH
#
# This file is part of ProMis and licensed under the BSD 3-Clause License.
# You should have received a copy of the BSD 3-Clause License along with ProMis.
# If not, see https://opensource.org/license/bsd-3-clause/.
#
# Standard Library
from abc import ABC
from pickle import dump, load
from typing import TypeVar
# Third Party
from geojson import Feature, FeatureCollection, dumps
from numpy import ndarray
from requests import post
from shapely import STRtree
# ProMis (need to avoid circular imports)
import promis.geo
from promis.geo.geospatial import Geospatial
from promis.geo.location import PolarLocation
from promis.geo.polygon import CartesianPolygon, PolarPolygon
#: Helper to define <Polar|Cartesian>Map operatios within base class
DerivedMap = TypeVar("DerivedMap", bound="Map")
[docs]
class Map(ABC):
"""A base class for maps.
Args:
origin: The origin point of this map
features: A list of features that should be contained by this map
"""
def __init__(
self,
origin: PolarLocation,
features: "list[promis.geo.CartesianGeometry | promis.geo.PolarGeometry] | None" = None,
) -> None:
# Attributes setup
self.origin = origin
self.features = features or []
[docs]
@staticmethod
def load(path) -> "Map":
with open(path, "rb") as file:
return load(file)
[docs]
def save(self, path):
with open(path, "wb") as file:
dump(self, file)
[docs]
def location_types(self) -> list[str]:
"""Get all location types contained in this map.
Returns:
A list of all location types contained in this map
"""
return list(set([feature.location_type for feature in self.features]))
[docs]
def is_valid(self) -> bool:
"""Whether this map contains only valid polygonal shapes according to :mod:`shapely`.
Quite expensive, not cached. Invalid features might cross themselves or have zero area.
Other tools might still refuse it, like *GEOS*.
"""
for feature in self.features:
if isinstance(feature, PolarPolygon | CartesianPolygon) and not feature.is_valid():
return False
return True
[docs]
def filter(self, location_type: str) -> DerivedMap:
"""Get a map with only features of the given type.
Args:
location_type: The type of locations to filter for
Returns:
A map that only contains features of the given type
"""
return type(self)(
self.origin,
[feature for feature in self.features if feature.location_type == location_type],
)
[docs]
def to_rtree(self) -> STRtree | None:
"""Convert this map into a Shapely STRtree for efficient spatial queries.
Returns:
The Shapely STRtree containing this map's features
"""
# Construct an STR tree with the geometry of this map
return STRtree([feature.geometry for feature in self.features])
[docs]
def to_geo_json(
self, location_type: str | None = None, indent: int | str | None = None, **kwargs
) -> str:
"""Constructs the GeoJSON string representing this map as a FeatureCollection.
For more information on GeoJSON, see :func:`promis.geo.geospatial.Geospatial.to_geo_json`.
"""
return dumps(
FeatureCollection(
[
Feature(
geometry=feature,
id=feature.identifier,
)
for feature in self.features
if location_type is None or feature.location_type == location_type
],
indent=indent,
**kwargs,
)
)
@staticmethod
def _sample_features(features: list[Geospatial]) -> list[Geospatial]:
return [feature.sample()[0] for feature in features]
[docs]
def sample(self, number_of_samples: int = 1) -> list[DerivedMap]:
"""Sample random maps given this maps's feature's uncertainty.
Args:
number_of_samples: How many samples to draw
Returns:
The set of sampled maps with the individual features being sampled according
to their uncertainties and underlying sample methods
"""
return [
type(self)(self.origin, Map._sample_features(self.features)) for _ in range(number_of_samples)
]
[docs]
def apply_covariance(self, covariance: ndarray | dict | None):
"""Set the covariance matrix of all features.
Args:
covariance: The covariance matrix to set for all featuers or a dictionary
mapping location_type to covariance matrix
"""
# TODO: investigae how the covariance of the final relation is computed
if isinstance(covariance, dict):
for feature in self.features:
if feature.location_type in covariance.keys():
feature.covariance = covariance[feature.location_type]
else:
for feature in self.features:
feature.covariance = covariance
@property
def all_location_types(self) -> set[str]:
"""Get all location types contained in this map.
Returns:
A set of all location types contained in this map
"""
return {feature.location_type for feature in self.features}
def __len__(self) -> int:
return len(self.features)
[docs]
class PolarMap(Map):
"""A map containing geospatial objects based on WGS84 coordinates.
Args:
origin: The origin point of this map
features: A list of features that should be contained by this map
"""
def __init__(
self,
origin: PolarLocation,
features: "list[promis.geo.PolarGeometry] | None" = None,
) -> None:
super().__init__(origin, features)
self.features: list[promis.geo.PolarGeometry]
[docs]
def to_cartesian(self) -> "CartesianMap":
"""Projects this map to a cartesian representation according to its global reference.
Returns:
The cartesian representation of this map with the given reference point being the same
"""
cartesian_features = [feature.to_cartesian(self.origin) for feature in self.features]
return CartesianMap(self.origin, cartesian_features)
[docs]
def send_to_gui(self, url: str ="http://localhost:8000/add_geojson_map", timeout: int = 10):
"""Send an HTTP POST-request to the GUI backend to add all feature in the map to gui.
Args:
url: url of the backend
timeout: request timeout in second
Raise:
:class:`~requests.HTTPError`: When the HTTP request returned an unsuccessful status code
:class:`~requests.ConnectionError`: If the request fails due to connection issues
"""
if self.features is None:
return
data = "["
for feature in self.features:
data += feature.to_geo_json()
data += ','
data = data[:-1]
data += ']'
r = post(url=url, data=data, timeout=timeout)
r.raise_for_status()
[docs]
class CartesianMap(Map):
"""A map containing geospatial objects based on local coordinates with a global reference point.
Args:
origin: The origin point of this map
features: A list of features that should be contained by this map
"""
def __init__(
self,
origin: PolarLocation,
features: "list[promis.geo.CartesianGeometry] | None" = None,
) -> None:
super().__init__(origin, features)
self.features: list[promis.geo.CartesianGeometry]
[docs]
def to_polar(self) -> PolarMap:
"""Projects this map to a polar representation according to the map's global reference.
Returns:
The cartesian representation of this map with the given reference point being the same
"""
polar_features = [feature.to_polar(self.origin) for feature in self.features]
return PolarMap(self.origin, polar_features)