ANN-route-predition/pyrate/tests/plan/graph/generate/test_graph_generation.py

206 lines
8.8 KiB
Python

"""Tests the generated graphs are well-formed."""
# Standard Library
from contextlib import redirect_stdout
from io import StringIO
from math import isclose
# Testing
import unittest
# Typing
from typing import cast
# Hypothesis testing
from hypothesis import given
import hypothesis.strategies as st
# Scientific (testing)
import numpy as np
import numpy.testing
# Own geography
from pyrate.plan.geometry.geospatial import MEAN_EARTH_CIRCUMFERENCE
from pyrate.plan.geometry.helpers import haversine_numpy
# Module under test
from pyrate.plan.graph.generate import angular_distance_for
from pyrate.plan.graph.generate import create_earth_graph
from pyrate.plan.graph.generate import great_circle_distance_distance_for
from pyrate.plan.graph.generate import min_required_frequency
EXAMPLE_DISTANCES_KILOMETERS = [100000, 100000.0, 5000, 250] # smaller values take too long
class TestGridGeneration(unittest.TestCase):
"""Tests that a grid can be created and pruned."""
@staticmethod
def _calculate_distances(latitudes: np.ndarray, longitudes: np.ndarray, edges: np.ndarray) -> np.ndarray:
"""Calculates the distance of all edges. The `edges` index into the coordinate arrays."""
entries = [
(latitudes[node_1], longitudes[node_1], latitudes[node_2], longitudes[node_2])
for node_1, node_2 in edges
]
return haversine_numpy(*np.transpose(entries))
def test_create_earth_grid(self) -> None:
"""Ensures that the generated earth grids are formed correctly."""
for distance_km in EXAMPLE_DISTANCES_KILOMETERS:
with self.subTest(f"Test with distance {distance_km} km"):
distance = distance_km * 1000
# create a grid
graph = create_earth_graph(min_required_frequency(distance, in_meters=True))
self.assertIsNotNone(graph.node_radius)
actual_distance: float = cast(float, graph.node_radius) * 2
# the actual_distance must be a upper-bounded by he requested distance
self.assertLessEqual(actual_distance, distance)
self.assertLessEqual(actual_distance, MEAN_EARTH_CIRCUMFERENCE / 2)
# the shapes of the returned arrays must match
self.assertEqual(
graph.latitudes_radians.shape,
graph.longitudes_radians.shape,
"latitude and longitude must have the same shape",
)
self.assertEqual(
graph.latitudes_degrees.shape,
graph.longitudes_degrees.shape,
"latitude and longitude must have the same shape",
)
self.assertEqual(
graph.latitudes_radians.shape,
graph.longitudes_degrees.shape,
"radians and degrees must have the same shape",
)
self.assertGreaterEqual(len(graph), 12) # as it is based on slicing an icosahedron
# the edges must be valid indices into the edges
self.assertTrue(
np.all(graph.edges[:, :] >= 0) and np.all(graph.edges[:, :] < len(graph)),
"some edges reference non-existent points",
)
# check the actual coordinate value
if (
np.any(graph.latitudes_radians < -np.pi / 2)
or np.any(graph.longitudes_radians < -np.pi)
or np.any(graph.latitudes_radians >= +np.pi / 2)
or np.any(graph.longitudes_radians >= +np.pi)
):
print(
"latitude < min / 2:",
np.compress(graph.latitudes_radians < -np.pi / 2, graph.latitudes_radians),
)
print(
"longitude < min:",
np.compress(graph.longitudes_radians < -np.pi, graph.longitudes_radians),
)
print(
"latitude >= max / 2:",
np.compress(graph.latitudes_radians >= +np.pi / 2, graph.latitudes_radians),
)
print(
"longitude >= max:",
np.compress(graph.longitudes_radians >= +np.pi, graph.longitudes_radians),
)
self.fail("some points are outside of the allowed range")
# check the distances along the edges
distances = TestGridGeneration._calculate_distances(
graph.latitudes_radians, graph.longitudes_radians, graph.edges
)
numpy.testing.assert_allclose(distances, actual_distance, atol=10, rtol=0.2)
mean = np.mean(distances)
self.assertTrue(isclose(mean, actual_distance, rel_tol=0.1, abs_tol=10.0))
standard_deviation = np.std(distances)
self.assertLessEqual(standard_deviation / mean, 0.075)
def test_print_status(self) -> None:
"""This tests that logging being enabled actually logs something and does not crash."""
stdout_logging = StringIO()
with redirect_stdout(stdout_logging):
create_earth_graph(6, print_status=True)
logged_lines = list(stdout_logging.getvalue().splitlines())
self.assertEqual(len(logged_lines), 6, "we expect 6 lines of messages")
def test_find_neighbors(self) -> None:
"""Tests that result of the neighbor search is correct."""
for distance_km in EXAMPLE_DISTANCES_KILOMETERS:
with self.subTest(f"Test with distance {distance_km} km"):
# create a grid & determine neighbors
graph = create_earth_graph(min_required_frequency(distance_km * 1000, in_meters=True))
neighbors = graph.neighbors
count_per_node = np.count_nonzero(neighbors >= 0, axis=1)
# check the resulting number of entries
self.assertEqual(
np.sum(count_per_node),
graph.edges.shape[0] * 2,
"each edge must generate two entries in the neighbor table",
)
self.assertEqual(
np.count_nonzero(count_per_node == 5),
12,
"exactly twelve nodes must have exactly five neighbors "
"(the corners of the original icosahedron)",
)
self.assertEqual(
np.count_nonzero(count_per_node == 6),
len(graph) - 12,
"all but twelve nodes must have exactly six neighbors",
)
# check the range of values
valid_index = np.logical_and(neighbors >= 0, neighbors < len(graph))
self.assertTrue(
np.all(np.logical_xor(neighbors == -1, valid_index)),
"any value i may either be -1 (=null) or a valid index with 0 <= i < num_nodes",
)
class TestHelperMethods(unittest.TestCase):
"""Tests that the helpers (e.g. for computing minimum required frequencies) work correctly."""
@given(st.floats(min_value=1e-6, allow_infinity=False, allow_nan=False), st.booleans())
def test_right_order_of_magnitude(self, desired_distance: float, in_meters: bool) -> None:
"""Asserts that commuting a frequency and converting it to units is correct w.r.t. to each other."""
frequency = min_required_frequency(desired_distance, in_meters)
if in_meters:
actual_distance = great_circle_distance_distance_for(frequency)
else:
actual_distance = angular_distance_for(frequency)
self.assertLessEqual(actual_distance, desired_distance)
if frequency > 1:
if in_meters:
actual_distance_one_rougher = great_circle_distance_distance_for(frequency - 1)
else:
actual_distance_one_rougher = angular_distance_for(frequency - 1)
self.assertGreaterEqual(actual_distance_one_rougher, desired_distance)
def test_specific_values(self) -> None:
"""Asserts that commuting a frequency works correct for specific hand-chosen values."""
# Taken from the implementation:
# The approximate angle between two edges on an icosahedron, in radians, about 63.4°
alpha = 1.1071487
# Contains pairs: (angular distance in radians, frequency)
table = [
(alpha + 1e-6, 1),
(alpha - 1e-9, 2),
(alpha / 9000.005, 9001),
]
for desired_angular_distance, desired_frequency in table:
computed_frequency = min_required_frequency(desired_angular_distance, in_meters=False)
self.assertEqual(desired_frequency, computed_frequency)