218 lines
8.9 KiB
Python

"""Tests that the geometry base classes in :mod:`pyrate.plan.geometry.geospatial` work correctly."""
# Python standard
from copy import copy
from copy import deepcopy
from json import loads
# Typing
from typing import Any
from typing import Sequence
from typing import Tuple
# Generic testing
from unittest import TestCase
# Hypothesis testing
from hypothesis import given
from hypothesis import HealthCheck
from hypothesis import Phase
from hypothesis import settings
import hypothesis.strategies as st
# Package under test
from pyrate.plan.geometry import CartesianLocation
from pyrate.plan.geometry import CartesianPolygon
from pyrate.plan.geometry import CartesianRoute
from pyrate.plan.geometry import Direction
from pyrate.plan.geometry import Geospatial
from pyrate.plan.geometry import PolarLocation
from pyrate.plan.geometry import PolarPolygon
from pyrate.plan.geometry import PolarRoute
# Hypothesis testing
from pyrate.common.testing.strategies.geometry import geospatial_objects
_CARTESIAN_LOCATION_1 = CartesianLocation(5003.0, 139.231)
_CARTESIAN_LOCATION_2 = CartesianLocation(600.1, 139.231)
_POLAR_LOCATION_1 = PolarLocation(65.01, -180.0)
_POLAR_LOCATION_2 = PolarLocation(-80.3, -180.0)
class TestStringRepresentations(TestCase):
"""Makes sure that the string conversion with ``__str__`` and ``__repr__`` works."""
_GROUND_TRUTH: Sequence[Tuple[Geospatial, str]] = [
(
_CARTESIAN_LOCATION_1,
"CartesianLocation(east=5003.0, north=139.231)",
),
(
PolarLocation(65.01, -180.0),
"PolarLocation(latitude=65.00999999999999, longitude=-180.0)",
),
(
CartesianPolygon([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1]),
"CartesianPolygon(locations=[(5003.0, 139.231), (5003.0, 139.231), (5003.0, 139.231), "
"(5003.0, 139.231)])",
),
(
PolarPolygon([_POLAR_LOCATION_1, _POLAR_LOCATION_1, _POLAR_LOCATION_1]),
"PolarPolygon(locations=[PolarLocation(latitude=65.00999999999999, longitude=-180.0), "
"PolarLocation(latitude=65.00999999999999, longitude=-180.0), "
"PolarLocation(latitude=65.00999999999999, longitude=-180.0)])",
),
(
CartesianRoute([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_2, _CARTESIAN_LOCATION_1]),
"CartesianRoute(locations=[(5003.0, 139.231), (600.1, 139.231), (5003.0, 139.231)])",
),
(
PolarRoute([_POLAR_LOCATION_1, _POLAR_LOCATION_2, _POLAR_LOCATION_1, _POLAR_LOCATION_1]),
"PolarRoute(locations=[PolarLocation(latitude=65.00999999999999, longitude=-180.0), "
"PolarLocation(latitude=-80.3, longitude=-180.0), "
"PolarLocation(latitude=65.00999999999999, longitude=-180.0), "
"PolarLocation(latitude=65.00999999999999, longitude=-180.0)])",
),
]
def test_conversions(self) -> None:
"""Makes sure that all given geospatial objects can be converted."""
for geospatial, desired_str in TestStringRepresentations._GROUND_TRUTH:
with self.subTest(f"{type(geospatial)}.__str__"):
self.assertEqual(str(geospatial), desired_str)
with self.subTest(f"{type(geospatial)}.__repr__"):
self.assertEqual(repr(geospatial), desired_str)
class TestGeoJsonRepresentations(TestCase):
"""Makes sure that the conversion to GeoJSON via the common property ``__geo_interface__`` works."""
_GROUND_TRUTH: Sequence[Tuple[Geospatial, str]] = [
(
_CARTESIAN_LOCATION_1,
'{"type": "Feature", "geometry": {"type": "Point", '
'"coordinates": [5003.0, 139.231]}, "properties": {}}',
),
(
PolarLocation(65.01, -180.0),
'{"type": "Feature", "geometry": {"type": "Point", "coordinates": [-180.0, 65.01]}, '
'"properties": {}}',
),
(
CartesianPolygon([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1]),
'{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": '
"[[[5003.0, 139.231], [5003.0, 139.231], [5003.0, 139.231], [5003.0, 139.231]]]}, "
'"properties": {}}',
),
(
PolarPolygon([_POLAR_LOCATION_1, _POLAR_LOCATION_1, _POLAR_LOCATION_1]),
'{"type": "Feature", "geometry": {"type": "Polygon", '
'"coordinates": [[[-180.0, 65.01], [-180.0, 65.01], [-180.0, 65.01]]]}, "properties": {}}',
),
(
CartesianRoute([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_2, _CARTESIAN_LOCATION_1]),
'{"type": "Feature", "geometry": {"type": "LineString", "coordinates": '
'[[5003.0, 139.231], [600.1, 139.231], [5003.0, 139.231]]}, "properties": {}}',
),
(
PolarRoute([_POLAR_LOCATION_1, _POLAR_LOCATION_2, _POLAR_LOCATION_1]),
'{"type": "Feature", "geometry": {"type": "LineString", "coordinates": '
'[[-180.0, 65.01], [-180.0, -80.3], [-180.0, 65.01]]}, "properties": {}}',
),
]
def test_conversions(self) -> None:
"""Makes sure that all given geospatial objects can be converted."""
for geospatial, desired_geojson in TestGeoJsonRepresentations._GROUND_TRUTH:
for indent in (None, 1, 8):
with self.subTest(f"{type(geospatial)} with indent={indent}"):
geojson = geospatial.to_geo_json(indent=indent)
# load as JSON get get better error messages and become whitespace independent
self.assertDictEqual(loads(geojson), loads(desired_geojson))
class TestIdentifiers(TestCase):
"""Makes sure that identifiers are validated correctly.
The test is only performed on polar locations for simplicity and because validation is handled in the
abstract common parent class :class:`pyrate.plan.geometry.Geospatial` anyway.
"""
@given(st.integers(min_value=0, max_value=(2**63) - 1))
def test_on_locations_success(self, integer: int) -> None: # pylint: disable=no-self-use
"""Tests that valid identifiers are accepted."""
PolarLocation(latitude=0.0, longitude=0.0, identifier=integer)
@given(
st.one_of(
st.integers(max_value=-1), # negative numbers
st.integers(min_value=2**63), # very large numbers
)
)
def test_on_locations_rejected(self, integer: int) -> None:
"""Tests that invalid identifiers are rejected."""
with self.assertRaises(AssertionError):
PolarLocation(latitude=0.0, longitude=0.0, identifier=integer)
class TestEqualityMethods(TestCase):
"""Test the various equality methods."""
@given(geospatial_objects(stable=True))
@settings(
max_examples=200,
suppress_health_check=(HealthCheck.data_too_large,),
phases=(Phase.explicit, Phase.reuse, Phase.generate, Phase.target), # Do not shrink as it takes long
)
def test_equality_after_translation(self, geospatial: Any) -> None:
"""Tests that translated objects are only equal under sufficient tolerance."""
# We discard the second output since it differs between cartesian and polar objects
translated, _ = geospatial.translate(direction=Direction.North, distance=0.5)
# Try since generated primitives might cause an exception to be thrown
# e.g. if projected routes become length 0
try:
# They should not be equal
self.assertNotEqual(geospatial, translated)
self.assertFalse(geospatial.equals(translated))
self.assertFalse(geospatial.equals_exact(translated, tolerance=0.0))
if hasattr(geospatial, "equals_almost_congruent"):
self.assertFalse(
geospatial.equals_almost_congruent(translated, abs_tolerance=0.0, rel_tolerance=0.0)
)
# They should be equal within some tolerance (the tolerance needs to be large for the polar
# variants)
# TODO(Someone): re-enable; see #114
# self.assertTrue(geospatial.equals_exact(translated, tolerance=5))
# self.assertTrue(geospatial.almost_equals(translated, decimal=-1))
# We do not use `equals_almost_congruent` as it is not a per-coordinate difference and might cause
# a very large symmetric difference on very large objects
except ValueError:
pass
class TestCopyAndDeepcopy(TestCase):
"""Tests that all geometric objects can be deep-copied."""
@given(geospatial_objects())
@settings(max_examples=500)
def test_is_copyable(self, geospatial: Any) -> None:
"""Tests that copies can be made and are equal to the original."""
# Check copy
copied = copy(geospatial)
self.assertEqual(geospatial, copied)
# Check deepcopy
deep_copied = deepcopy(geospatial)
self.assertEqual(geospatial, deep_copied)