206 lines
8.8 KiB
Python
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)
|