"""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)