diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 65565ed..7a1288a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,8 +44,10 @@ repos: hooks: - id: flake8 exclude: ^(docs|scripts|tests)/ + # E203 whitespace before ':' args: - --max-line-length=120 + - --ignore=E203 additional_dependencies: - flake8-bugbear - flake8-comprehensions @@ -59,7 +61,7 @@ repos: additional_dependencies: [ numpy, poetry==1.1.11 ] - repo: https://github.com/asottile/pyupgrade - rev: v2.36.0 + rev: v2.37.3 hooks: - id: pyupgrade diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e9e798c..31b5a20 100755 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog ========= + +TODO pending major release: remove separate prepare step?! initialise in one step during initialisation +TODO Numba JIT compilation of utils. line speed profiling for highest impact of refactoring +TODO improve A* implementation (away from OOP) + + +2.3.0 (2022-08-18) +------------------- + +* major overhaul of all functionality from OOP to functional programming/numpy based + +internal: + +* added test cases + + + + 2.2.3 (2022-10-11) ------------------- diff --git a/Makefile b/Makefile index f065ce4..ad35000 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ hook: @pre-commit install @pre-commit run --all-files -hookupdate: +hook2: @pre-commit autoupdate clean: diff --git a/docs/conf.py b/docs/conf.py index 50a65ad..bfdef07 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,7 +110,7 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -# TODO https://github.com/adamchainz/django-mysql/blob/master/docs/conf.py +# https://github.com/adamchainz/django-mysql/blob/master/docs/conf.py # -- Options for LaTeX output ------------------------------------------ # -- Options for manual page output ------------------------------------ diff --git a/extremitypathfinder/command_line.py b/extremitypathfinder/command_line.py index 5dd7400..4c2b8b1 100644 --- a/extremitypathfinder/command_line.py +++ b/extremitypathfinder/command_line.py @@ -1,7 +1,7 @@ import argparse from extremitypathfinder import PolygonEnvironment -from extremitypathfinder.global_settings import BOUNDARY_JSON_KEY, HOLES_JSON_KEY +from extremitypathfinder.configs import BOUNDARY_JSON_KEY, HOLES_JSON_KEY from extremitypathfinder.helper_fcts import read_json JSON_HELP_MSG = ( diff --git a/extremitypathfinder/global_settings.py b/extremitypathfinder/configs.py similarity index 78% rename from extremitypathfinder/global_settings.py rename to extremitypathfinder/configs.py index 4bbe47b..9382d26 100644 --- a/extremitypathfinder/global_settings.py +++ b/extremitypathfinder/configs.py @@ -6,8 +6,8 @@ PATH_TYPE = List[COORDINATE_TYPE] LENGTH_TYPE = Optional[float] INPUT_NUMERICAL_TYPE = Union[float, int] -INPUT_COORD_TYPE = Tuple[INPUT_NUMERICAL_TYPE, INPUT_NUMERICAL_TYPE] -OBSTACLE_ITER_TYPE = Iterable[INPUT_COORD_TYPE] +InputCoords = Tuple[INPUT_NUMERICAL_TYPE, INPUT_NUMERICAL_TYPE] +OBSTACLE_ITER_TYPE = Iterable[InputCoords] INPUT_COORD_LIST_TYPE = Union[np.ndarray, List] DEFAULT_PICKLE_NAME = "environment.pickle" diff --git a/extremitypathfinder/extremitypathfinder.py b/extremitypathfinder/extremitypathfinder.py index 52b5226..7991048 100644 --- a/extremitypathfinder/extremitypathfinder.py +++ b/extremitypathfinder/extremitypathfinder.py @@ -1,27 +1,30 @@ import pickle from copy import deepcopy -from typing import Iterable, List, Optional, Set, Tuple +from typing import Dict, List, Optional, Tuple import numpy as np -# TODO possible to allow polygon consisting of 2 vertices only(=barrier)? lots of functions need at least 3 vertices atm -from extremitypathfinder.global_settings import ( +from extremitypathfinder.configs import ( DEFAULT_PICKLE_NAME, INPUT_COORD_LIST_TYPE, - INPUT_COORD_TYPE, LENGTH_TYPE, OBSTACLE_ITER_TYPE, PATH_TYPE, + InputCoords, ) -from extremitypathfinder.helper_classes import DirectedHeuristicGraph, Edge, Polygon, PolygonVertex, Vertex +from extremitypathfinder.helper_classes import DirectedHeuristicGraph from extremitypathfinder.helper_fcts import ( check_data_requirements, + compute_extremity_idxs, + compute_graph, convert_gridworld, find_visible, - find_within_range, - inside_polygon, + get_repr_n_dists, + is_within_map, ) +# TODO possible to allow polygon consisting of 2 vertices only(=barrier)? lots of functions need at least 3 vertices atm + # is not a helper function to make it an importable part of the package def load_pickle(path=DEFAULT_PICKLE_NAME): @@ -42,36 +45,32 @@ class PolygonEnvironment: `__" """ - boundary_polygon: Polygon = None - holes: List[Polygon] = None + nr_edges: int prepared: bool = False - graph: DirectedHeuristicGraph = None - temp_graph: DirectedHeuristicGraph = None # for storing and plotting the graph during a query - _all_extremities: Optional[Set[PolygonVertex]] = None - - @property - def polygons(self) -> Iterable[Polygon]: - yield self.boundary_polygon - yield from self.holes + holes: List[np.ndarray] + extremity_indices: List[int] + reprs_n_distances: Dict[int, np.ndarray] + graph: DirectedHeuristicGraph + temp_graph: Optional[DirectedHeuristicGraph] = None # for storing and plotting the graph during a query + boundary_polygon: np.ndarray + coords: np.ndarray + edge_vertex_idxs: np.ndarray + extremity_mask: np.ndarray + vertex_edge_idxs: np.ndarray @property - def all_vertices(self) -> List[PolygonVertex]: - for p in self.polygons: - yield from p.vertices + def nr_edges(self) -> int: + return self.nr_vertices @property - def all_extremities(self) -> Set[PolygonVertex]: - if self._all_extremities is None: - self._all_extremities = set() - for p in self.polygons: - # only extremities that are actually within the map should be considered - self._all_extremities |= set(filter(lambda e: self.within_map(e.coordinates), p.extremities)) - return self._all_extremities + def all_extremities(self) -> List[Tuple]: + coords = self.coords + return [tuple(coords[i]) for i in self.extremity_indices] @property - def all_edges(self) -> Iterable[Edge]: - for p in self.polygons: - yield from p.edges + def all_vertices(self) -> List[Tuple]: + coords = self.coords + return [tuple(coords[i]) for i in range(self.nr_vertices)] def store( self, @@ -98,14 +97,64 @@ def store( """ self.prepared = False # loading the map - boundary_coordinates = np.array(boundary_coordinates) - list_of_hole_coordinates = [np.array(hole_coords) for hole_coords in list_of_hole_coordinates] + boundary_coordinates = np.array(boundary_coordinates, dtype=float) + list_of_hole_coordinates = [np.array(hole_coords, dtype=float) for hole_coords in list_of_hole_coordinates] if validate: check_data_requirements(boundary_coordinates, list_of_hole_coordinates) - self.boundary_polygon = Polygon(boundary_coordinates, is_hole=False) + self.boundary_polygon = boundary_coordinates + # IMPORTANT: make a copy of the list instead of linking to the same list (python!) - self.holes = [Polygon(coordinates, is_hole=True) for coordinates in list_of_hole_coordinates] + self.holes = list_of_hole_coordinates + + list_of_polygons = [boundary_coordinates] + list_of_hole_coordinates + nr_total_pts = sum(map(len, list_of_polygons)) + vertex_edge_idxs = np.empty((nr_total_pts, 2), dtype=int) + # TODO required? inverse of the other. get_neighbours function + edge_vertex_idxs = np.empty((nr_total_pts, 2), dtype=int) + edge_idx = 0 + offset = 0 + extremity_idxs = set() + for poly in list_of_polygons: + + poly_extr_idxs = compute_extremity_idxs(poly) + poly_extr_idxs = {i + offset for i in poly_extr_idxs} + extremity_idxs |= poly_extr_idxs + + nr_coords = len(poly) + v1 = -1 % nr_coords + # TODO col 1 is just np.arange?! + for v2 in range(nr_coords): + v1_idx = v1 + offset + v2_idx = v2 + offset + edge_vertex_idxs[edge_idx, 0] = v1_idx + edge_vertex_idxs[edge_idx, 1] = v2_idx + vertex_edge_idxs[v1_idx, 1] = edge_idx + vertex_edge_idxs[v2_idx, 0] = edge_idx + # move to the next vertex/edge + v1 = v2 + edge_idx += 1 + + offset = edge_idx + + # assert edge_idx == nr_total_pts + + coords = np.concatenate(list_of_polygons, axis=0) + # Attention: only consider extremities that are actually within the map + extremity_idxs = [i for i in extremity_idxs if self.within_map(coords[i])] + + mask = np.full(nr_total_pts, False, dtype=bool) + for i in extremity_idxs: + mask[i] = True + + self.nr_vertices = nr_total_pts + self.edge_vertex_idxs = edge_vertex_idxs + self.vertex_edge_idxs = vertex_edge_idxs + self.coords = coords + self.extremity_indices = extremity_idxs + self.extremity_mask = mask + + self.reprs_n_distances = {i: get_repr_n_dists(i, coords) for i in extremity_idxs} def store_grid_world( self, @@ -135,155 +184,61 @@ def export_pickle(self, path: str = DEFAULT_PICKLE_NAME): pickle.dump(self, f) print("done.\n") - def translate(self, new_origin: Vertex): - """shifts the coordinate system to a new origin - - computing the angle representations, shifted coordinates and distances for all vertices - respective to the query point (lazy!) - - :param new_origin: the origin of the coordinate system to be shifted to - """ - for p in self.polygons: - p.translate(new_origin) - - def prepare(self): # TODO include in storing functions? + def prepare(self): """Computes a visibility graph optimized (=reduced) for path planning and stores it Computes all directly reachable extremities based on visibility and their distance to each other + pre-procesing of the map. pre-computation for faster shortest path queries + optimizes graph further at construction time + + NOTE: initialise the graph with all extremities. + even if a node has no edges (visibility to other extremities, dangling node), + it must still be included! .. note:: - Multiple polygon vertices might have identical coordinates. + Multiple polygon vertices might have identical coords_rel. They must be treated as distinct vertices here, since their attached edges determine visibility. In the created graph however, these nodes must be merged at the end to avoid ambiguities! .. note:: Pre computing the shortest paths between all directly reachable extremities and storing them in the graph would not be an advantage, because then the graph is fully connected. - A star would visit every node in the graph at least once (-> disadvantage!). + A* would visit every node in the graph at least once (-> disadvantage!). """ - if self.prepared: raise ValueError("this environment is already prepared. load new polygons first.") - # preprocessing the map - # construct graph of visible (=directly reachable) extremities - # and optimize graph further at construction time - # NOTE: initialise the graph with all extremities. - # even if a node has no edges (visibility to other extremities), it should still be included! - self.graph = DirectedHeuristicGraph(self.all_extremities) - - extremities_to_check = self.all_extremities.copy() - - # have to run for all (also last one!), because existing edges might get deleted every loop - while len(extremities_to_check) > 0: - # extremities are always visible to each other (bi-directional relation -> undirected graph) - # -> do not check extremities which have been checked already - # (would only give the same result when algorithms are correct) - # the extremity itself must not be checked when looking for visible neighbours - query_extremity: PolygonVertex = extremities_to_check.pop() - - self.translate(new_origin=query_extremity) - - visible_vertices = set() - candidate_extremities = extremities_to_check.copy() - # remove the extremities with the same coordinates as the query extremity - candidate_extremities.difference_update( - {c for c in candidate_extremities if c.get_angle_representation() is None} - ) - - # these vertices all belong to a polygon - n1, n2 = query_extremity.get_neighbours() - # ATTENTION: polygons may intersect -> neighbouring extremities must NOT be visible from each other! - - # eliminate all vertices 'behind' the query point from the candidate set - # since the query vertex is an extremity the 'outer' angle is < 180 degree - # then the difference between the angle representation of the two edges has to be < 2.0 - # all vertices between the angle of the two neighbouring edges ('outer side') - # are not visible (no candidates!) - # ATTENTION: vertices with the same angle representation might be visible and must NOT be deleted! - repr1 = n1.get_angle_representation() - repr2 = n2.get_angle_representation() - repr_diff = abs(repr1 - repr2) - candidate_extremities.difference_update( - find_within_range( - repr1, - repr2, - repr_diff, - candidate_extremities, - angle_range_less_180=True, - equal_repr_allowed=False, - ) - ) - - # as shown in [1, Ch. II 4.4.2 "Property One"]: Starting from any point lying "in front of" an extremity e, - # such that both adjacent edges are visible, one will never visit e, because everything is - # reachable on a shorter path without e (except e itself). - # An extremity e1 lying in the area "in front of" extremity e hence is never the next vertex - # in a shortest path coming from e. - # And also in reverse: when coming from e1 everything else than e itself can be reached faster - # without visiting e. - # -> e1 and e do not have to be connected in the graph. - # IMPORTANT: this condition only holds for building the basic visibility graph without start and goal node! - # When a query point (start/goal) happens to be an extremity, edges to the (visible) extremities in front - # MUST be added to the graph! - # Find extremities which fulfill this condition for the given query extremity - repr1 = (repr1 + 2.0) % 4.0 # rotate 180 deg - repr2 = (repr2 + 2.0) % 4.0 - # IMPORTANT: the true angle diff does not change, but the repr diff does! compute again - repr_diff = abs(repr1 - repr2) - # IMPORTANT: check all extremities here, not just current candidates - # do not check extremities with equal coordinates (also query extremity itself!) - # and with the same angle representation (those edges must not get deleted from graph!) - temp_candidates = set( - filter( - lambda e: e.get_angle_representation() is not None, - self.all_extremities, - ) - ) - lie_in_front = find_within_range( - repr1, - repr2, - repr_diff, - temp_candidates, - angle_range_less_180=True, - equal_repr_allowed=False, - ) - # "thin out" the graph -> optimisation - # already existing edges in the graph to the extremities in front have to be removed - self.graph.remove_multiple_undirected_edges(query_extremity, lie_in_front) - # do not consider when looking for visible extremities (NOTE: they might actually be visible!) - candidate_extremities.difference_update(lie_in_front) - - # all edges except the neighbouring edges (handled above!) have to be checked - edges_to_check = set(self.all_edges) - edges_to_check.remove(query_extremity.edge1) - edges_to_check.remove(query_extremity.edge2) - - visible_vertices.update(find_visible(candidate_extremities, edges_to_check)) - self.graph.add_multiple_undirected_edges(query_extremity, visible_vertices) - - self.graph.make_clean() # join all nodes with the same coordinates + nr_extremities = len(self.extremity_indices) + if nr_extremities == 0: + self.graph = DirectedHeuristicGraph() + return + + self.graph = compute_graph( + self.nr_edges, + self.extremity_indices, + self.reprs_n_distances, + self.coords, + self.edge_vertex_idxs, + self.extremity_mask, + self.vertex_edge_idxs, + ) self.prepared = True - def within_map(self, coords: INPUT_COORD_TYPE): + def within_map(self, coords: InputCoords): """checks if the given coordinates lie within the boundary polygon and outside of all holes :param coords: numerical tuple representing coordinates :return: whether the given coordinate is a valid query point """ - # - x, y = coords - if not inside_polygon(x, y, self.boundary_polygon.coordinates, border_value=True): - return False - for hole in self.holes: - if inside_polygon(x, y, hole.coordinates, border_value=False): - return False - return True + boundary = self.boundary_polygon + holes = self.holes + p = np.array(coords, dtype=float) + return is_within_map(p, boundary, holes) def find_shortest_path( self, - start_coordinates: INPUT_COORD_TYPE, - goal_coordinates: INPUT_COORD_TYPE, + start_coordinates: InputCoords, + goal_coordinates: InputCoords, free_space_after: bool = True, verify: bool = True, ) -> Tuple[PATH_TYPE, LENGTH_TYPE]: @@ -291,7 +246,7 @@ def find_shortest_path( :param start_coordinates: a (x,y) coordinate tuple representing the start node :param goal_coordinates: a (x,y) coordinate tuple representing the goal node - :param free_space_after: whether the created temporary search graph self.temp_graph + :param free_space_after: whether the created temporary search graph graph should be deleted after the query :param verify: whether it should be checked if start and goal points really lie inside the environment. if points close to or on polygon edges should be accepted as valid input, set this to ``False``. @@ -308,130 +263,121 @@ def find_shortest_path( raise ValueError("start point does not lie within the map") if verify and not self.within_map(goal_coordinates): raise ValueError("goal point does not lie within the map") - if start_coordinates == goal_coordinates: + + coords_start = np.array(start_coordinates) + coords_goal = np.array(goal_coordinates) + if np.array_equal(coords_start, coords_goal): # start and goal are identical and can be reached instantly return [start_coordinates, goal_coordinates], 0.0 - # could check if start and goal nodes have identical coordinates with one of the vertices - # optimisations for visibility test can be made in this case: - # for extremities the visibility has already been (except for in front) computed - # BUT: too many cases possible: e.g. multiple vertices identical to query point... - # -> always create new query vertices - # include start and goal vertices in the graph - start_vertex = Vertex(start_coordinates) - goal_vertex = Vertex(goal_coordinates) + nr_edges = self.nr_edges + vertex_edge_idxs = self.vertex_edge_idxs + edge_vertex_idxs = self.edge_vertex_idxs + # temporarily extend data structures + extremity_mask = np.append(self.extremity_mask, (False, False)) + coords = np.append(self.coords, (coords_start, coords_goal), axis=0) + idx_start = self.nr_vertices + idx_goal = self.nr_vertices + 1 - # check the goal node first (earlier termination possible) - self.translate(new_origin=goal_vertex) # do before checking angle representations! - # IMPORTANT: manually translate the start vertex, because it is not part of any polygon - # and hence does not get translated automatically - start_vertex.mark_outdated() + # start and goal nodes could be identical with one ore more of the vertices + # BUT: this is an edge case -> compute visibility as usual and later try to merge with the graph + # create temporary graph + # DirectedHeuristicGraph implements __deepcopy__() to not change the original precomputed self.graph + # but to still not create real copies of vertex instances! + graph = deepcopy(self.graph) + # TODO make more performant, avoid real copy + # graph = self.graph + + # check the goal node first (earlier termination possible) + idx_origin = idx_goal # the visibility of only the graphs nodes has to be checked (not all extremities!) # points with the same angle representation should not be considered visible # (they also cause errors in the algorithms, because their angle repr is not defined!) - candidates = set( - filter( - lambda n: n.get_angle_representation() is not None, - self.graph.get_all_nodes(), - ) - ) - # IMPORTANT: check if the start node is visible from the goal node! + # IMPORTANT: also check if the start node is visible from the goal node! # NOTE: all edges are being checked, it is computationally faster to compute all visibilities in one go - candidates.add(start_vertex) + candidate_idxs = self.graph.all_nodes + candidate_idxs.add(idx_start) + edge_idxs2check = set(range(nr_edges)) + vert_idx2repr, vert_idx2dist = get_repr_n_dists(idx_origin, coords) + candidate_idxs = {i for i in candidate_idxs if not vert_idx2dist[i] == 0.0} + visible_idxs = find_visible( + idx_origin, + candidate_idxs, + edge_idxs2check, + extremity_mask, + coords, + vertex_edge_idxs, + edge_vertex_idxs, + vert_idx2repr, + vert_idx2dist, + ) + visibles_n_distances_map = {i: vert_idx2dist[i] for i in visible_idxs} - visibles_n_distances_goal = find_visible(candidates, edges_to_check=set(self.all_edges)) - if len(visibles_n_distances_goal) == 0: + if len(visibles_n_distances_map) == 0: # The goal node does not have any neighbours. Hence there is not possible path to the goal. return [], None - # create temporary graph TODO make more performant, avoid real copy - # DirectedHeuristicGraph implements __deepcopy__() to not change the original precomputed self.graph - # but to still not create real copies of vertex instances! - self.temp_graph = deepcopy(self.graph) - - # IMPORTANT geometrical property of this problem: it is always shortest to directly reach a node - # instead of visiting other nodes first (there is never an advantage through reduced edge weight) - # -> when goal is directly reachable, there can be no other shorter path to it. Terminate - - for v, d in visibles_n_distances_goal: - if v == start_vertex: + for i, d in visibles_n_distances_map.items(): + if i == idx_start: + # IMPORTANT geometrical property of this problem: it is always shortest to directly reach a node + # instead of visiting other nodes first (there is never an advantage through reduced edge weight) + # -> when goal is directly reachable, there can be no other shorter path to it. Terminate return [start_coordinates, goal_coordinates], d # add unidirectional edges to the temporary graph # add edges in the direction: extremity (v) -> goal - self.temp_graph.add_directed_edge(v, goal_vertex, d) + graph.add_directed_edge(i, idx_goal, d) - self.translate(new_origin=start_vertex) # do before checking angle representations! + idx_origin = idx_start # the visibility of only the graphs nodes have to be checked # the goal node does not have to be considered, because of the earlier check - candidates = set( - filter( - lambda n: n.get_angle_representation() is not None, - self.graph.get_all_nodes(), - ) + edge_idxs2check = set(range(nr_edges)) # new copy + vert_idx2repr, vert_idx2dist = get_repr_n_dists(idx_origin, coords) + candidate_idxs = {i for i in self.graph.get_all_nodes() if not vert_idx2dist[i] == 0.0} + visible_idxs = find_visible( + idx_origin, + candidate_idxs, + edge_idxs2check, + extremity_mask, + coords, + vertex_edge_idxs, + edge_vertex_idxs, + vert_idx2repr, + vert_idx2dist, ) - visibles_n_distances_start = find_visible(candidates, edges_to_check=set(self.all_edges)) - if len(visibles_n_distances_start) == 0: - # The start node does not have any neighbours. Hence there is not possible path to the goal. + + if len(visible_idxs) == 0: + # The start node does not have any neighbours. Hence there is no possible path to the goal. return [], None # add edges in the direction: start -> extremity - self.temp_graph.add_multiple_directed_edges(start_vertex, visibles_n_distances_start) - - # also here unnecessary edges in the graph can be deleted when start or goal lie in front of visible extremities - # IMPORTANT: when a query point happens to coincide with an extremity, edges to the (visible) extremities - # in front MUST be added to the graph! Handled by always introducing new (non extremity, non polygon) vertices. - - # for every extremity that is visible from either goal or start - # NOTE: edges are undirected! self.temp_graph.get_neighbours_of(start_vertex) == set() - # neighbours_start = self.temp_graph.get_neighbours_of(start_vertex) - neighbours_start = {n for n, d in visibles_n_distances_start} - # the goal vertex might be marked visible, it is not an extremity -> skip - neighbours_start.discard(goal_vertex) - neighbours_goal = self.temp_graph.get_neighbours_of(goal_vertex) - for vertex in neighbours_start | neighbours_goal: - # assert type(vertex) == PolygonVertex and vertex.is_extremity - - # check only if point is visible - temp_candidates = set() - if vertex in neighbours_start: - temp_candidates.add(start_vertex) - - if vertex in neighbours_goal: - temp_candidates.add(goal_vertex) - - if len(temp_candidates) > 0: - self.translate(new_origin=vertex) - # IMPORTANT: manually translate the goal and start vertices - start_vertex.mark_outdated() - goal_vertex.mark_outdated() - - n1, n2 = vertex.get_neighbours() - repr1 = (n1.get_angle_representation() + 2.0) % 4.0 # rotated 180 deg - repr2 = (n2.get_angle_representation() + 2.0) % 4.0 - repr_diff = abs(repr1 - repr2) - - # IMPORTANT: special case: - # here the nodes must stay connected if they have the same angle representation! - lie_in_front = find_within_range( - repr1, - repr2, - repr_diff, - temp_candidates, - angle_range_less_180=True, - equal_repr_allowed=False, - ) - self.temp_graph.remove_multiple_undirected_edges(vertex, lie_in_front) - - # NOTE: exploiting property 2 from [1] here would be more expensive than beneficial - vertex_path, distance = self.temp_graph.modified_a_star(start_vertex, goal_vertex) + visibles_n_distances_map = {i: vert_idx2dist[i] for i in visible_idxs} + graph.add_multiple_directed_edges(idx_start, visibles_n_distances_map) + + # Note: also here unnecessary edges in the graph could be deleted when start or goal lie + # optimising the graph here however is more expensive than beneficial, + # as it is only being used for a single query + # ATTENTION: update to new coordinates + graph.coord_map = {i: coords[i] for i in graph.all_nodes} + graph.join_identical() + + vertex_id_path, distance = graph.modified_a_star(idx_start, idx_goal, coords_goal) + + # clean up + # TODO re-use the same graph + # graph.remove_node(idx_start) + # graph.remove_node(idx_goal) if free_space_after: - del self.temp_graph # free the memory + del graph # free the memory + + else: + self.temp_graph = graph # extract the coordinates from the path - return [tuple(v.coordinates) for v in vertex_path], distance + vertex_path = [tuple(coords[i]) for i in vertex_id_path] + return vertex_path, distance if __name__ == "__main__": diff --git a/extremitypathfinder/helper_classes.py b/extremitypathfinder/helper_classes.py index a25fdc0..c439df1 100644 --- a/extremitypathfinder/helper_classes.py +++ b/extremitypathfinder/helper_classes.py @@ -1,269 +1,8 @@ import heapq -from typing import Dict, List, Optional, Set +from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple import numpy as np -# TODO find a way to avoid global variable, wrap all in a different kind of 'coordinate system environment'? -# problem: lazy evaluation, passing the origin every time is not an option -# placeholder for temporarily storing the origin of the current coordinate system -origin = None - - -class AngleRepresentation(object): - """ - a class automatically computing a representation for the angle from the origin to a given vector - value in [0.0 : 4.0[ - every quadrant contains angle measures from 0.0 to 1.0 - there are 4 quadrants (counter clockwise numbering) - 0 / 360 degree -> 0.0 - 90 degree -> 1.0 - 180 degree -> 2.0 - 270 degree -> 3.0 - ... - Useful for comparing angles without actually computing expensive trigonometrical functions - This representation does not grow directly proportional to its represented angle, - but it its bijective and monotonous: - rep(p1) > rep(p2) <=> angle(p1) > angle(p2) - rep(p1) = rep(p2) <=> angle(p1) = angle(p2) - angle(p): counter clockwise angle between the two line segments (0,0)'--(1,0)' and (0,0)'--p - with (0,0)' being the vector representing the origin - - """ - - # prevent dynamic attribute assignment (-> safe memory) - # __slots__ = ['quadrant', 'angle_measure', 'value'] - __slots__ = ["value"] - - def __init__(self, np_vector): - # 2D vector: (dx, dy) = np_vector - norm = np.linalg.norm(np_vector) - if norm == 0.0: - # make sure norm is not 0! - raise ValueError("received null vector:", np_vector, norm) - - dx_positive = np_vector[0] >= 0 - dy_positive = np_vector[1] >= 0 - - if dx_positive and dy_positive: - quadrant = 0.0 - angle_measure = np_vector[1] / norm - - elif not dx_positive and dy_positive: - quadrant = 1.0 - angle_measure = -np_vector[0] / norm - - elif not dx_positive and not dy_positive: - quadrant = 2.0 - angle_measure = -np_vector[1] / norm - - else: - quadrant = 3.0 - angle_measure = np_vector[0] / norm - - self.value = quadrant + angle_measure - - def __str__(self): - return str(self.value) - - def __repr__(self): - return self.__str__() - - -class Vertex(object): - # defining static attributes on class to safe memory - __slots__ = [ - "coordinates", - "is_extremity", - "is_outdated", - "coordinates_translated", - "angle_representation", - "distance_to_origin", - ] - - def __init__(self, coordinates): - self.coordinates = np.array(coordinates) - self.is_extremity: bool = False - - # a container for temporally storing shifted coordinates - self.coordinates_translated = None - self.angle_representation: Optional[AngleRepresentation] = None - self.distance_to_origin: float = 0.0 - - # for lazy evaluation: often the angle representations dont have to be computed for every vertex! - self.is_outdated: bool = True - - def __gt__(self, other): - # ordering needed for priority queue. multiple vertices possibly have the same priority. - return id(self) > id(other) - - def __str__(self): - return str(tuple(self.coordinates)) - - def __repr__(self): - return self.__str__() - - def evaluate(self): - global origin - # store the coordinate value of the point relative to the new origin vector - self.coordinates_translated = self.coordinates - origin.coordinates - self.distance_to_origin = np.linalg.norm(self.coordinates_translated) - if self.distance_to_origin == 0.0: - # the coordinates of the origin and this vertex are equal - # an angle is not defined in this case! - self.angle_representation = None - else: - self.angle_representation = AngleRepresentation(self.coordinates_translated) - - self.is_outdated = False - - def get_coordinates_translated(self): - if self.is_outdated: - self.evaluate() - return self.coordinates_translated - - def get_angle_representation(self): - if self.is_outdated: - self.evaluate() - try: - return self.angle_representation.value - except AttributeError: - return None - - def get_distance_to_origin(self): - if self.is_outdated: - self.evaluate() - return self.distance_to_origin - - def mark_outdated(self): - self.is_outdated = True - - -class PolygonVertex(Vertex): - # __slots__ declared in parents are available in child classes. However, child subclasses will get a __dict__ - # and __weakref__ unless they also define __slots__ (which should only contain names of any additional slots). - __slots__ = ["edge1", "edge2", "neighbour1", "neighbour2"] - - def __init__(self, *args, **kwargs): - super(PolygonVertex, self).__init__(*args, **kwargs) - self.edge1: Optional[Edge] = None - self.edge2: Optional[Edge] = None - self.neighbour1: Optional[PolygonVertex] = None - self.neighbour2: Optional[PolygonVertex] = None - - def get_neighbours(self): - return self.neighbour1, self.neighbour2 - - def declare_extremity(self): - self.is_extremity = True - - def set_edge1(self, e1): - self.edge1 = e1 - # ordering is important! the numbering convention has to stay intact! - self.neighbour1 = e1.vertex1 - - def set_edge2(self, e2): - self.edge2 = e2 - # ordering is important! the numbering convention has to stay intact! - self.neighbour2 = e2.vertex2 - - -class Edge(object): - __slots__ = ["vertex1", "vertex2"] - - def __init__(self, vertex1, vertex2): - self.vertex1: PolygonVertex = vertex1 - self.vertex2: PolygonVertex = vertex2 - - def __str__(self): - return self.vertex1.__str__() + "-->" + self.vertex2.__str__() - - def __repr__(self): - return self.__str__() - - -class Polygon(object): - __slots__ = ["vertices", "edges", "coordinates", "is_hole", "_extremities"] - - def __init__(self, coordinate_list, is_hole): - # store just the coordinates separately from the vertices in the format suiting the inside_polygon() function - self.coordinates = np.array(coordinate_list) - - self.is_hole: bool = is_hole - - if len(coordinate_list) < 3: - raise ValueError( - "This is not a valid polygon:", - coordinate_list, - "# edges:", - len(coordinate_list), - ) - - self.vertices: List[PolygonVertex] = [PolygonVertex(coordinate) for coordinate in coordinate_list] - - self.edges: List[Edge] = [] - vertex1 = self.vertices[-1] - for vertex2 in self.vertices: - edge = Edge(vertex1, vertex2) - # ordering is important! the numbering convention has to stay intact! - vertex1.set_edge2(edge) - vertex2.set_edge1(edge) - self.edges.append(edge) - vertex1 = vertex2 - - self._extremities: Optional[List[PolygonVertex]] = None - - def _find_extremities(self): - """ - identify all protruding points = vertices with an inside angle of > 180 degree ('extremities') - expected edge numbering: - outer boundary polygon: counter clockwise - holes: clockwise - :return: - """ - self._extremities = [] - # extremity_indices = [] - # extremity_index = -1 - v1 = self.vertices[-2] - p1 = v1.coordinates - v2 = self.vertices[-1] - p2 = v2.coordinates - - for v3 in self.vertices: - - p3 = v3.coordinates - # since consequent vertices are not permitted to be equal, - # the angle representation of the difference is well defined - if (AngleRepresentation(p3 - p2).value - AngleRepresentation(p1 - p2).value) % 4 < 2.0: - # basic idea: - # - translate the coordinate system to have p2 as origin - # - compute the angle representations of both vectors representing the edges - # - "rotate" the coordinate system (equal to deducting) so that the p1p2 representation is 0 - # - check in which quadrant the p2p3 representation lies - # %4 because the quadrant has to be in [0,1,2,3] (representation in [0:4[) - # if the representation lies within quadrant 0 or 1 (<2.0), the inside angle - # (for boundary polygon inside, for holes outside) between p1p2p3 is > 180 degree - # then p2 = extremity - v2.declare_extremity() - self._extremities.append(v2) - - # move to the next point - # vertex1=vertex2 - v2 = v3 - p1 = p2 - p2 = p3 - - @property - def extremities(self) -> List[PolygonVertex]: - if self._extremities is None: - self._find_extremities() - return self._extremities - - def translate(self, new_origin: Vertex): - global origin - origin = new_origin - for vertex in self.vertices: - vertex.mark_outdated() - class SearchState(object): __slots__ = ["node", "distance", "neighbours", "path", "cost_so_far", "priority"] @@ -271,7 +10,6 @@ class SearchState(object): def __init__(self, node, distance, neighbour_generator, path, cost_so_far, cost_estim): self.node = node self.distance = distance - # TODO self.neighbours = neighbour_generator self.path = path self.cost_so_far: float = cost_so_far @@ -298,26 +36,36 @@ def get(self): return s.node, s.neighbours, s.distance, s.path, s.cost_so_far -# TODO often empty sets in self.neighbours +def get_distance_to_origin(coords_origin: np.ndarray, coords_v: np.ndarray) -> float: + coords_rel = coords_v - coords_origin + return np.linalg.norm(coords_rel, ord=2) + + +NodeId = int + + class DirectedHeuristicGraph(object): - __slots__ = ["all_nodes", "distances", "goal_node", "heuristic", "neighbours"] + __slots__ = ["all_nodes", "distances", "goal_coords", "heuristic", "neighbours", "coord_map", "merged_id_mapping"] - def __init__(self, all_nodes: Optional[Set[Vertex]] = None): - self.distances: Dict = {} - self.neighbours: Dict = {} + def __init__(self, coord_map: Optional[Dict[NodeId, np.ndarray]] = None): + self.distances: Dict[Tuple[NodeId, NodeId], float] = {} + self.neighbours: Dict[NodeId, Set[NodeId]] = {} - if all_nodes is None: + if coord_map is None: all_nodes = set() - self.all_nodes: Set[Vertex] = all_nodes.copy() # independent copy required! + else: + all_nodes = set(coord_map.keys()) - # TODO use same set as extremities of env, but different for copy! + self.all_nodes: Set[NodeId] = set(all_nodes) # independent copy required! + self.coord_map: Dict[NodeId, np.ndarray] = coord_map + self.merged_id_mapping: Dict[NodeId, NodeId] = {} # the heuristic must NEVER OVERESTIMATE the actual cost (here: actual shortest distance) # <=> must always be lowest for node with the POSSIBLY lowest cost # <=> heuristic is LOWER BOUND for the cost # the heuristic here: distance to the goal node (is always the shortest possible distance!) - self.heuristic: dict = {} - self.goal_node: Optional[Vertex] = None + self.heuristic: Dict[NodeId, float] = {} + self.goal_coords: Optional[np.ndarray] = None def __deepcopy__(self, memodict=None): # returns an independent copy (nodes can be added without changing the original graph), @@ -328,39 +76,29 @@ def __deepcopy__(self, memodict=None): independent_copy.all_nodes = self.all_nodes.copy() return independent_copy - def get_all_nodes(self): + def get_all_nodes(self) -> Set[NodeId]: return self.all_nodes - def get_neighbours(self): - return self.neighbours.items() + def get_neighbours(self) -> Iterable: + yield from self.neighbours.items() - def get_neighbours_of(self, node): + def get_neighbours_of(self, node: NodeId) -> Set[NodeId]: return self.neighbours.get(node, set()) - def get_distance(self, node1, node2): + def get_distance(self, node1: NodeId, node2: NodeId) -> float: # directed edges: just one direction is being stored return self.distances[(node1, node2)] - def get_heuristic(self, node): - global origin # use origin contains the current goal node + def get_heuristic(self, node: NodeId) -> float: # lazy evaluation: h = self.heuristic.get(node, None) if h is None: # has been reset, compute again - h = np.linalg.norm(node.coordinates - origin.coordinates) + h = get_distance_to_origin(self.goal_coords, self.coord_map[node]) self.heuristic[node] = h return h - def set_goal_node(self, goal_node): - assert goal_node in self.all_nodes # has no outgoing edges -> no neighbours - # IMPORTANT: while using heuristic graph (a star), do not change origin! - global origin - origin = goal_node # use origin for temporally storing the goal node - self.goal_node = goal_node - # reset heuristic for all - self.heuristic.clear() - - def neighbours_of(self, node1): + def gen_neighbours_and_dist(self, node1: NodeId) -> Iterator[Tuple[NodeId, float, float]]: # optimisation: # return the neighbours ordered after their cost estimate: distance+ heuristic (= current-next + next-goal) # -> when the goal is reachable return it first (-> a star search terminates) @@ -376,73 +114,88 @@ def entry_generator(neighbours, distances): # yield node, distance, cost_estimate= distance + heuristic yield from out_sorted - def add_directed_edge(self, node1, node2, distance): + def add_directed_edge(self, node1: NodeId, node2: NodeId, distance: float): assert node1 != node2 # no self loops allowed! self.neighbours.setdefault(node1, set()).add(node2) self.distances[(node1, node2)] = distance self.all_nodes.add(node1) self.all_nodes.add(node2) - def add_undirected_edge(self, node1, node2, distance): + def add_undirected_edge(self, node1: NodeId, node2: NodeId, distance: float): assert node1 != node2 # no self loops allowed! self.add_directed_edge(node1, node2, distance) self.add_directed_edge(node2, node1, distance) - def add_multiple_undirected_edges(self, node1, node_distance_iter): - for node2, distance in node_distance_iter: + def add_multiple_undirected_edges(self, node1: NodeId, node_distance_map: Dict[NodeId, float]): + for node2, distance in node_distance_map.items(): self.add_undirected_edge(node1, node2, distance) - def add_multiple_directed_edges(self, node1, node_distance_iter): - for node2, distance in node_distance_iter: + def add_multiple_directed_edges(self, node1: NodeId, node_distance_iter: Dict[NodeId, float]): + for node2, distance in node_distance_iter.items(): self.add_directed_edge(node1, node2, distance) - def remove_directed_edge(self, n1, n2): - neighbours = self.neighbours.get(n1) - if neighbours is not None: - neighbours.discard(n2) - self.distances.pop((n1, n2), None) - # ATTENTION: even if there are no neighbours left and a node is hence dangling (not reachable), - # the node must still be kept, since with the addition of start and goal nodes during a query - # the node might become reachable! + def remove_directed_edge(self, n1: NodeId, n2: NodeId): + neighbours = self.get_neighbours_of(n1) + neighbours.discard(n2) + self.distances.pop((n1, n2), None) + # ATTENTION: even if there are no neighbours left and a node is hence dangling (not reachable), + # the node must still be kept, since with the addition of start and goal nodes during a query + # the node might become reachable! - def remove_undirected_edge(self, node1, node2): + def remove_undirected_edge(self, node1: NodeId, node2: NodeId): # should work even if edge does not exist yet self.remove_directed_edge(node1, node2) self.remove_directed_edge(node2, node1) - def remove_multiple_undirected_edges(self, node1, node2_iter): + def remove_multiple_undirected_edges(self, node1: NodeId, node2_iter: Iterable[NodeId]): for node2 in node2_iter: self.remove_undirected_edge(node1, node2) - def make_clean(self): + def join_identical(self): # for shortest path computations all graph nodes should be unique - self.join_identical() + # join all nodes with the same coordinates # leave dangling nodes! (they might become reachable by adding start and and goal node!) - - def join_identical(self): - # join all nodes with the same coordinates, - nodes_to_check = self.get_all_nodes().copy() + nodes_to_check = self.all_nodes.copy() while len(nodes_to_check) > 1: n1 = nodes_to_check.pop() - coordinates1 = n1.coordinates - same_nodes = {n for n in nodes_to_check if np.allclose(coordinates1, n.coordinates)} + coordinates1 = self.coord_map[n1] + same_nodes = {n for n in nodes_to_check if np.allclose(coordinates1, self.coord_map[n])} nodes_to_check.difference_update(same_nodes) for n2 in same_nodes: - # print('removing duplicate node', n2) - neighbours_n1 = self.neighbours[n1] - neighbours_n2 = self.neighbours.pop(n2) - for n3 in neighbours_n2: - d = self.distances.pop((n2, n3)) - self.distances.pop((n3, n2)) - self.neighbours[n3].remove(n2) - # do not allow self loops! - if n3 != n1 and n3 not in neighbours_n1: - # and add all the new edges to node 1 - self.add_undirected_edge(n1, n3, d) - - self.all_nodes.remove(n2) - - def modified_a_star(self, start: Vertex, goal: Vertex): + self.merge_nodes(n1, n2) + + def remove_node(self, n: NodeId): + self.all_nodes.discard(n) + # also deletes all edges + # outgoing + neighbours = self.neighbours.pop(n, set()) + for n1 in neighbours: + self.distances.pop((n, n1), None) + self.distances.pop((n1, n), None) + # incoming + for n1 in self.all_nodes: + neighbours = self.neighbours.get(n1, set()) + neighbours.discard(n) + self.distances.pop((n, n1), None) + self.distances.pop((n1, n), None) + + def merge_nodes(self, n1: NodeId, n2: NodeId): + # print('removing duplicate node', n2) + neighbours_n1 = self.neighbours.get(n1, set()) + neighbours_n2 = self.neighbours.pop(n2, {}) + for n3 in neighbours_n2: + d = self.distances.pop((n2, n3)) + self.distances.pop((n3, n2), None) + self.neighbours.get(n3, set()).discard(n2) + # do not allow self loops! + if n3 != n1 and n3 not in neighbours_n1: + # and add all the new edges to node 1 + self.add_undirected_edge(n1, n3, d) + + self.remove_node(n2) + self.merged_id_mapping[n2] = n1 # mapping from -> to + + def modified_a_star(self, start: int, goal: int, goal_coords: np.ndarray) -> Tuple[List[int], Optional[float]]: """implementation of the popular A* algorithm with optimisations for this special use case IMPORTANT: geometrical property of this problem (and hence also the extracted graph): @@ -476,7 +229,11 @@ def modified_a_star(self, start: Vertex, goal: Vertex): ([], None) if there is no possible path. """ - def enqueue_neighbours(): + # apply mapping in case start or goal got merged with another node + start = self.merged_id_mapping.get(start, start) + goal = self.merged_id_mapping.get(goal, goal) + + def enqueue(neighbours: Iterator): try: next_node, distance, cost_estim = next(neighbours) except StopIteration: @@ -485,14 +242,14 @@ def enqueue_neighbours(): state = SearchState(next_node, distance, neighbours, path, cost_so_far, cost_estim) search_state_queue.put(state) - self.set_goal_node(goal) # lazy update of the heuristic + self.goal_coords = goal_coords # lazy update of the heuristic search_state_queue = SearchStateQueue() current_node = start - neighbours = self.neighbours_of(current_node) + neighbours = self.gen_neighbours_and_dist(current_node) cost_so_far = 0.0 path = [start] - enqueue_neighbours() + enqueue(neighbours) visited_nodes = set() while not search_state_queue.is_empty(): @@ -508,7 +265,7 @@ def enqueue_neighbours(): # print('neighbours:', heuristic_graph.get_neighbours_of(current_node)) # there could still be other neighbours left in this generator: - enqueue_neighbours() + enqueue(neighbours) if current_node in visited_nodes: # this node has already been visited. there is no need to consider @@ -533,8 +290,8 @@ def enqueue_neighbours(): return path, cost_so_far # also consider the neighbours of the current node - neighbours = self.neighbours_of(current_node) - enqueue_neighbours() + neighbours = self.gen_neighbours_and_dist(current_node) + enqueue(neighbours) # goal is not reachable return [], None diff --git a/extremitypathfinder/helper_fcts.py b/extremitypathfinder/helper_fcts.py index 193e2ce..ea86cbf 100644 --- a/extremitypathfinder/helper_fcts.py +++ b/extremitypathfinder/helper_fcts.py @@ -1,29 +1,94 @@ import json +import math from itertools import combinations -from typing import List +from typing import Dict, Iterable, List, Optional, Set, Tuple import numpy as np +import numpy.linalg + +from extremitypathfinder.configs import BOUNDARY_JSON_KEY, HOLES_JSON_KEY +from extremitypathfinder.helper_classes import DirectedHeuristicGraph + + +def compute_repr_n_dist(np_vector: np.ndarray) -> Tuple[float, float]: + """computing representation for the angle from the origin to a given vector + + value in [0.0 : 4.0[ + every quadrant contains angle measures from 0.0 to 1.0 + there are 4 quadrants (counter clockwise numbering) + 0 / 360 degree -> 0.0 + 90 degree -> 1.0 + 180 degree -> 2.0 + 270 degree -> 3.0 + ... + Useful for comparing angles without actually computing expensive trigonometrical functions + This representation does not grow directly proportional to its represented angle, + but it its bijective and monotonous: + rep(p1) > rep(p2) <=> angle(p1) > angle(p2) + rep(p1) = rep(p2) <=> angle(p1) = angle(p2) + angle(p): counter clockwise angle between the two line segments (0,0)'--(1,0)' and (0,0)'--p + with (0,0)' being the vector representing the origin + + :param np_vector: + :return: + """ + dx, dy = np_vector + distance = math.sqrt(dx**2 + dy**2) # l-2 norm + if distance == 0.0: + angle_measure = np.nan + else: + # 2D vector: (dx, dy) = np_vector + dx_positive = dx >= 0 + dy_positive = dy >= 0 + + if dx_positive and dy_positive: + quadrant = 0.0 + angle_measure = dy + + elif not dx_positive and dy_positive: + quadrant = 1.0 + angle_measure = -dx + + elif not dx_positive and not dy_positive: + quadrant = 2.0 + angle_measure = -dy + + else: + quadrant = 3.0 + angle_measure = dx -# TODO numba precompilation of some parts possible?! do line speed profiling first! speed impact -from extremitypathfinder.global_settings import BOUNDARY_JSON_KEY, HOLES_JSON_KEY -from extremitypathfinder.helper_classes import AngleRepresentation, PolygonVertex + # normalise angle measure to [0; 1] + angle_measure /= distance + angle_measure += quadrant + return angle_measure, distance -def inside_polygon(x, y, coords, border_value): + +def get_repr_n_dists(orig_idx: int, coords: np.ndarray) -> np.ndarray: + coords_orig = coords[orig_idx] + coords_translated = coords - coords_orig + repr_n_dists = np.apply_along_axis(compute_repr_n_dist, axis=1, arr=coords_translated) + return repr_n_dists.T + + +def inside_polygon(p: np.ndarray, coords: np.ndarray, border_value: bool) -> bool: # should return the border value for point equal to any polygon vertex # TODO overflow possible with large values when comparing slopes, change procedure - for c in coords[:]: - if np.all(c == [x, y]): - return border_value - # and if the point p lies on any polygon edge - p = np.array([x, y]) p1 = coords[-1, :] for p2 in coords[:]: - if abs(AngleRepresentation(p1 - p).value - AngleRepresentation(p2 - p).value) == 2.0: + if np.array_equal(p2, p): + return border_value + rep_p1_p, _ = compute_repr_n_dist(p1 - p) + rep_p2_p, _ = compute_repr_n_dist(p2 - p) + if abs(rep_p1_p - rep_p2_p) == 2.0: return border_value p1 = p2 + # regular point in polygon algorithm + # TODO use optimised implementation + x, y = p + contained = False # the edge from the last to the first point is checked first i = -1 @@ -62,11 +127,20 @@ def inside_polygon(x, y, coords, border_value): return contained +def is_within_map(p: np.ndarray, boundary: np.ndarray, holes: Iterable[np.ndarray]) -> bool: + if not inside_polygon(p, boundary, border_value=True): + return False + for hole in holes: + if inside_polygon(p, hole, border_value=False): + return False + return True + + def no_identical_consequent_vertices(coords): p1 = coords[-1] for p2 in coords: # TODO adjust allowed difference: rtol, atol - if np.allclose(p1, p2): + if np.array_equal(p1, p2): return False p1 = p2 @@ -77,7 +151,7 @@ def get_intersection_status(p1, p2, q1, q2): # return: # 0: no intersection # 1: intersection in ]p1;p2[ - # TODO 4 different possibilities + # TODO support 2 remaining possibilities # 2: intersection directly in p1 or p2 # 3: intersection directly in q1 or q2 # solve the set of equations @@ -104,7 +178,7 @@ def get_intersection_status(p1, p2, q1, q2): # special case of has_intersection() -def lies_behind(p1, p2, v): +def lies_behind_inner(p1: np.ndarray, p2: np.ndarray, v: np.ndarray) -> bool: # solve the set of equations # (p2-p1) lambda + (p1) = (v) mu # in matrix form A x = b: @@ -128,6 +202,14 @@ def lies_behind(p1, p2, v): return x[1] < 1.0 +def lies_behind(idx_p1: int, idx_p2: int, idx_v: int, idx_orig: int, coords: np.ndarray) -> bool: + coords_origin = coords[idx_orig] + coords_p1_rel = coords[idx_p1] - coords_origin + coords_p2_rel = coords[idx_p2] - coords_origin + coords_v_rel = coords[idx_v] - coords_origin + return lies_behind_inner(coords_p1_rel, coords_p2_rel, coords_v_rel) + + def no_self_intersection(coords): polygon_length = len(coords) # again_check = [] @@ -152,7 +234,7 @@ def no_self_intersection(coords): return True -def has_clockwise_numbering(coords): +def has_clockwise_numbering(coords: np.ndarray) -> bool: """tests if a polygon has clockwise vertex numbering approach: Sum over the edges, (x2 − x1)(y2 + y1). If the result is positive the curve is clockwise. from: @@ -186,14 +268,17 @@ def check_polygon(polygon): raise ValueError("A polygon must not intersect itself.") -# TODO test -# todo - polygons must not intersect each other def check_data_requirements(boundary_coords: np.ndarray, list_hole_coords: List[np.ndarray]): """ensures that all the following conditions on the polygons are fulfilled: - basic polygon requirements (s. above) - edge numbering has to follow this convention (for easier computations): * outer boundary polygon: counter clockwise * holes: clockwise + + TODO test + todo - polygons must not intersect each other + TODO data rectification + :param boundary_coords: :param list_hole_coords: :return: @@ -206,10 +291,15 @@ def check_data_requirements(boundary_coords: np.ndarray, list_hole_coords: List[ if not has_clockwise_numbering(hole_coords): raise ValueError("Vertex numbering of hole polygon must be clockwise.") - # TODO data rectification - -def find_within_range(repr1, repr2, repr_diff, vertex_set, angle_range_less_180, equal_repr_allowed): +def find_within_range( + vert_idx2repr: np.ndarray, + repr1: float, + repr2: float, + candidate_idxs: Set[int], + angle_range_less_180: bool, + equal_repr_allowed: bool, +) -> Set[int]: """ filters out all vertices whose representation lies within the range between the two given angle representations @@ -217,75 +307,399 @@ def find_within_range(repr1, repr2, repr_diff, vertex_set, angle_range_less_180, - query angle (range) is < 180deg or not (>= 180deg) :param repr1: :param repr2: - :param repr_diff: abs(repr1-repr2) - :param vertex_set: + :param representations: :param angle_range_less_180: whether the angle between repr1 and repr2 is < 180 deg :param equal_repr_allowed: whether vertices with the same representation should also be returned :return: """ + if len(candidate_idxs) == 0: + return set() - if len(vertex_set) == 0: - return vertex_set - + repr_diff = abs(repr1 - repr2) if repr_diff == 0.0: return set() - min_repr_val = min(repr1, repr2) - max_repr_val = max(repr1, repr2) # = min_angle + angle_diff + min_repr = min(repr1, repr2) + max_repr = max(repr1, repr2) # = min_angle + angle_diff - def lies_within(vertex): - # vertices with the same representation will not NOT be returned! - return min_repr_val < vertex.get_angle_representation() < max_repr_val - - def lies_within_eq(vertex): - # vertices with the same representation will be returned! - return min_repr_val <= vertex.get_angle_representation() <= max_repr_val + def repr_within(r): + # Note: vertices with the same representation will not NOT be returned! + return min_repr < r < max_repr + # depending on the angle the included range is clockwise or anti-clockwise + # (from min_repr to max_val or the other way around) # when the range contains the 0.0 value (transition from 3.99... -> 0.0) # it is easier to check if a representation does NOT lie within this range - # -> filter_fct = not_within - def not_within(vertex): - # vertices with the same representation will NOT be returned! - return not (min_repr_val <= vertex.get_angle_representation() <= max_repr_val) - - def not_within_eq(vertex): - # vertices with the same representation will be returned! - return not (min_repr_val < vertex.get_angle_representation() < max_repr_val) - - if equal_repr_allowed: - lies_within_fct = lies_within_eq - not_within_fct = not_within_eq - else: - lies_within_fct = lies_within - not_within_fct = not_within + # -> invert filter condition + # special case: angle == 180deg + on_line_inv = repr_diff == 2.0 and repr1 >= repr2 + # which range to filter is determined by the order of the points + # since the polygons follow a numbering convention, + # the 'left' side of p1-p2 always lies inside the map + # -> filter out everything on the right side (='outside') + inversion_condition = on_line_inv or ((repr_diff < 2.0) ^ angle_range_less_180) + + def within_filter_func(r: float) -> bool: + repr_eq = r == min_repr or r == max_repr + if repr_eq and equal_repr_allowed: + return True + if repr_eq and not equal_repr_allowed: + return False - if repr_diff < 2.0: - # angle < 180 deg - if angle_range_less_180: - filter_fct = lies_within_fct - else: - # the actual range to search is from min_val to max_val, but clockwise! - filter_fct = not_within_fct - - elif repr_diff == 2.0: - # angle == 180deg - # which range to filter is determined by the order of the points - # since the polygons follow a numbering convention, - # the 'left' side of p1-p2 always lies inside the map - # -> filter out everything on the right side (='outside') - if repr1 < repr2: - filter_fct = lies_within_fct - else: - filter_fct = not_within_fct + res = repr_within(r) + if inversion_condition: + res = not res + return res + + idxs_within = {i for i in candidate_idxs if within_filter_func(vert_idx2repr[i])} + return idxs_within + + +def get_neighbour_idxs(i: int, vertex_edge_idxs: np.ndarray, edge_vertex_idxs: np.ndarray) -> Tuple[int, int]: + edge_idx1, edge_idx2 = vertex_edge_idxs[i] + neigh_idx1 = edge_vertex_idxs[edge_idx1, 0] + neigh_idx2 = edge_vertex_idxs[edge_idx2, 1] + return neigh_idx1, neigh_idx2 + + +def skip_edge( + node: int, + edge2discard: int, + candidates: Set[int], + edge_vertex_idxs: np.ndarray, + extremity_mask: np.ndarray, + vertex_edge_idxs: np.ndarray, +): + # (note: not identical, does not belong to the same polygon!) + # mark this vertex as not visible (would otherwise add 0 distance edge in the graph) + candidates.discard(node) + # no points lie truly "behind" this edge as there is no "direction of sight" defined + # <-> angle representation/range undefined for just this single edge + # however if one considers the point neighbouring in the other direction (<-> two edges) + # these two neighbouring edges define an invisible angle range + # -> simply move the pointer + v1, v2 = get_neighbour_idxs(node, vertex_edge_idxs, edge_vertex_idxs) + range_less_180 = extremity_mask[node] + # do not check the other neighbouring edge of vertex1 in the future (has been considered already) + edge_idx = vertex_edge_idxs[node][edge2discard] + return edge_idx, range_less_180, v1, v2 + + +def find_candidates_behind( + origin: int, v1: int, v2: int, candidates: Set[int], distances: np.ndarray, coords: np.ndarray +) -> Set[int]: + dist_v1 = distances[v1] + dist_v2 = distances[v2] + max_distance = max(dist_v1, dist_v2) + idxs_behind = set() + # for all remaining vertices v it has to be tested if the line segment from query point (=origin) to v + # has an intersection with the current edge p1---p2 + for idx in candidates: + # if a candidate is farther away from the query point than both vertices of the edge, + # it surely lies behind the edge + # ATTENTION: even if a candidate is closer to the query point than both vertices of the edge, + # it still needs to be checked! + dist2orig = distances[idx] + further_away = dist2orig > max_distance + if further_away or lies_behind(v1, v2, idx, origin, coords): + idxs_behind.add(idx) + # vertex lies in front of this edge + return idxs_behind + + +def clean_visibles(visible_idxs: Set[int], cand_idx2repr: np.ndarray, vert_idx2dist: np.ndarray) -> Set[int]: + # in case some vertices have the same representation, only return (link) the closest vertex + if len(visible_idxs) <= 1: + return visible_idxs + + cleaned = set() + visible_idxs_sorted = sorted(visible_idxs, key=lambda i: cand_idx2repr[i]) + min_dist = np.inf + first_idx = visible_idxs_sorted[0] + rep_prev = cand_idx2repr[first_idx] + selected_idx = 0 + for i in visible_idxs_sorted: + rep = cand_idx2repr[i] + if rep != rep_prev: + cleaned.add(selected_idx) + min_dist = np.inf + rep_prev = rep + + dist = vert_idx2dist[i] + if dist < min_dist: + selected_idx = i + min_dist = dist + + cleaned.add(selected_idx) + return cleaned + + +def find_visible( + origin: int, + candidates: Set[int], + edges_to_check: Set[int], + extremity_mask: np.ndarray, + coords: np.ndarray, + vertex_edge_idxs: np.ndarray, + edge_vertex_idxs: np.ndarray, + representations: np.ndarray, + distances: np.ndarray, +) -> Set[int]: + """ + query_vertex: a vertex for which the visibility to the vertices should be checked. + also non extremity vertices, polygon vertices and vertices with the same coordinates are allowed. + query point also might lie directly on an edge! (angle = 180deg) + :param candidates: the set of all vertex ids which should be checked for visibility. + IMPORTANT: is being manipulated, so has to be a copy! + IMPORTANT: must not contain the query vertex! + :param edges_to_check: the set of edges which determine visibility + :return: a set of tuples of all vertices visible from the query vertex and the corresponding distance + """ + if len(candidates) == 0: + return candidates - else: - # angle > 180deg - if angle_range_less_180: - filter_fct = not_within_fct - else: - filter_fct = lies_within_fct + visibles = set() + # goal: eliminating all vertices lying 'behind' any edge + while len(candidates) > 0: + try: + edge = edges_to_check.pop() + except KeyError: + break # no more edges left to check + + v1, v2 = edge_vertex_idxs[edge] + lies_on_edge = False + range_less_180 = False + + if distances[v1] == 0.0: + # vertex1 of the edge has the same coordinates as the query vertex + # -> the origin lies on the edge + lies_on_edge = True + edge, range_less_180, v1, v2 = skip_edge( + node=v1, + edge2discard=0, + candidates=candidates, + edge_vertex_idxs=edge_vertex_idxs, + extremity_mask=extremity_mask, + vertex_edge_idxs=vertex_edge_idxs, + ) + edges_to_check.discard(edge) + + elif distances[v2] == 0.0: + # same for vertex2 of the edge + # NOTE: it is unsupported that v1 as well as v2 have the same coordinates as the query vertex + # (edge with length 0) + lies_on_edge = True + edge, range_less_180, v1, v2 = skip_edge( + node=v2, + edge2discard=1, + candidates=candidates, + edge_vertex_idxs=edge_vertex_idxs, + extremity_mask=extremity_mask, + vertex_edge_idxs=vertex_edge_idxs, + ) + edges_to_check.discard(edge) + + repr1 = representations[v1] + repr2 = representations[v2] + repr_diff = abs(repr1 - repr2) + if repr_diff == 2.0: + # angle == 180deg -> on the edge + lies_on_edge = True + + if lies_on_edge: + # when the query vertex lies on an edge (or vertex) no behind/in front checks must be performed! + # the neighbouring edges are visible for sure + # attention: only add to visible set if vertex was a candidate! + try: + candidates.remove(v1) + visibles.add(v1) + except KeyError: + pass + try: + candidates.remove(v2) + visibles.add(v2) + except KeyError: + pass + + # all the candidates between the two vertices v1 v2 are not visible for sure + # candidates with the same representation must not be deleted, because they can be visible! + invisible_candidate_idxs = find_within_range( + representations, repr1, repr2, candidates, angle_range_less_180=range_less_180, equal_repr_allowed=False + ) + candidates.difference_update(invisible_candidate_idxs) + continue + + # case: a 'regular' edge + # eliminate all candidates which are blocked by the edge + # that means inside the angle range spanned by the edge and actually behind it + cand_idxs_tmp = candidates.copy() + # the vertices belonging to the edge itself (its vertices) must not be checked. + # use discard() instead of remove() to not raise an error (they might not be candidates) + cand_idxs_tmp.discard(v1) + cand_idxs_tmp.discard(v2) + + # for all candidate edges check if there are any candidate vertices (besides the ones belonging to the edge) + # within this angle range + # the "view range" of an edge from a query point (spanned by the two vertices of the edge) + # is always < 180deg when the edge is not running through the query point (=180 deg) + # candidates with the same representation as v1 or v2 should be considered. + # they can be visible, but should be ruled out if they lie behind any edge! + cand_idxs_tmp = find_within_range( + representations, repr1, repr2, cand_idxs_tmp, angle_range_less_180=True, equal_repr_allowed=True + ) + + idxs_behind = find_candidates_behind(origin, v1, v2, cand_idxs_tmp, distances, coords) + # vertices behind any edge are not visible + candidates.difference_update(idxs_behind) + + # all edges have been checked + # all remaining vertices were not concealed behind any edge and hence are visible + visibles.update(candidates) + return clean_visibles(visibles, representations, distances) + + +def find_visible_and_in_front( + origin: int, + nr_edges: int, + coords: np.ndarray, + candidates: Set[int], + extremities: Iterable[int], + extremity_mask: np.ndarray, + distances: np.ndarray, + representations: np.ndarray, + vertex_edge_idxs: np.ndarray, + edge_vertex_idxs: np.ndarray, +): + # vertices all belong to a polygon + n1, n2 = get_neighbour_idxs(origin, vertex_edge_idxs, edge_vertex_idxs) + # ATTENTION: polygons may intersect -> neighbouring extremities must NOT be visible from each other! + # eliminate all vertices 'behind' the query point from the candidate set + # since the query vertex is an extremity the 'outer' angle is < 180 degree + # then the difference between the angle representation of the two edges has to be < 2.0 + # all vertices between the angle of the two neighbouring edges ('outer side') + # are not visible (no candidates!) + # ATTENTION: vertices with the same angle representation might be visible and must NOT be deleted! + n1_repr = representations[n1] + n2_repr = representations[n2] + idxs_behind = find_within_range( + representations, n1_repr, n2_repr, candidates, angle_range_less_180=True, equal_repr_allowed=False + ) + # do not consider points found to lie behind + candidates.difference_update(idxs_behind) + # as shown in [1, Ch. II 4.4.2 "Property One"]: Starting from any point lying "in front of" an extremity e, + # such that both adjacent edges are visible, one will never visit e, because everything is + # reachable on a shorter path without e (except e itself). + # An extremity e1 lying in the area "in front of" extremity e hence is never the next vertex + # in a shortest path coming from e. + # And also in reverse: when coming from e1 everything else than e itself can be reached faster + # without visiting e. + # -> e1 and e do not have to be connected in the graph. + # IMPORTANT: this condition only holds for building the basic visibility graph without start and goal node! + # When a query point (start/goal) happens to be an extremity, edges to the (visible) extremities in front + # MUST be added to the graph! + # Find extremities which fulfill this condition for the given query extremity + # IMPORTANT: check all extremities here, not just current candidates + # do not check extremities with equal coords_rel (also query extremity itself!) + # and with the same angle representation (those edges must not get deleted from graph!) + idxs_in_front = find_within_range( + representations, + repr1=angle_rep_inverse(n1_repr), + repr2=angle_rep_inverse(n2_repr), + candidate_idxs=set(extremities), + angle_range_less_180=True, + equal_repr_allowed=False, + ) + # do not consider points lying in front when looking for visible extremities, + # even if they are actually be visible. + candidates.difference_update(idxs_in_front) + # all edges have to be checked, except the 2 neighbouring edges (handled above!) + edge_idxs2check = set(range(nr_edges)) + edge_idxs2check.difference_update(vertex_edge_idxs[origin]) + visible_idxs = find_visible( + origin, + candidates, + edge_idxs2check, + extremity_mask, + coords, + vertex_edge_idxs, + edge_vertex_idxs, + representations, + distances, + ) + return idxs_in_front, visible_idxs + + +def compute_graph( + nr_edges: int, + extremity_indices: Iterable[int], + reprs_n_distances: Dict[int, np.ndarray], + coords: np.ndarray, + edge_vertex_idxs: np.ndarray, + extremity_mask: np.ndarray, + vertex_edge_idxs: np.ndarray, +) -> DirectedHeuristicGraph: + # IMPORTANT: add all extremities (even if they turn out to be dangling in the end) + extremity_coord_map = {i: coords[i] for i in extremity_indices} + graph = DirectedHeuristicGraph(extremity_coord_map) + for extr_ptr, origin_idx in enumerate(extremity_indices): + vert_idx2repr, vert_idx2dist = reprs_n_distances[origin_idx] + # optimisation: extremities are always visible to each other + # (bi-directional relation -> undirected edges in the graph) + # -> do not check extremities which have been checked already + # (must give the same result when algorithms are correct) + # the origin extremity itself must also not be checked when looking for visible neighbours + candidate_idxs = set(extremity_indices[extr_ptr + 1 :]) + idxs_in_front, visible_idxs = find_visible_and_in_front( + origin_idx, + nr_edges, + coords, + candidate_idxs, + extremity_indices, + extremity_mask, + vert_idx2dist, + vert_idx2repr, + vertex_edge_idxs, + edge_vertex_idxs, + ) + # "thin out" the graph: + # remove already existing edges in the graph to the extremities in front + graph.remove_multiple_undirected_edges(origin_idx, idxs_in_front) + + visible_vertex2dist_map = {i: vert_idx2dist[i] for i in visible_idxs} + graph.add_multiple_undirected_edges(origin_idx, visible_vertex2dist_map) + graph.join_identical() # join all nodes with the same coordinates + return graph + + +def try_extraction(json_data, key): + try: + extracted_data = json_data[key] + except KeyError as e: + raise ValueError(f"The expected key {key} was not found in the JSON file:\n{e}") + return extracted_data + + +def convert2polygon(json_list): + return [tuple(coord_pair_list) for coord_pair_list in json_list] + + +def read_json(path2json_file): + """ + Parse data from a JSON file and save as lists of tuples for both boundary and holes. + NOTE: The format of the JSON file is explained in the command line script (argparse definition) - return set(filter(filter_fct, vertex_set)) + :param path2json_file: The path to the input json file + :return: The parsed lists of boundaries and holes + """ + # parse data from the input file + with open(path2json_file, "r") as json_file: + json_data = json_file.read() + json_loaded = json.loads(json_data) + boundary_data = try_extraction(json_loaded, BOUNDARY_JSON_KEY) + holes_data = try_extraction(json_loaded, HOLES_JSON_KEY) + boundary_coordinates = convert2polygon(boundary_data) + list_of_holes = [convert2polygon(hole_data) for hole_data in holes_data] + return boundary_coordinates, list_of_holes def convert_gridworld(size_x: int, size_y: int, obstacle_iter: iter, simplify: bool = True) -> (list, list): @@ -403,7 +817,7 @@ def construct_polygon(start_pos, boundary_detect_fct, cntr_clockwise_wanted: boo if cntr_clockwise_wanted: # make edge numbering counter clockwise! edge_list.reverse() - return np.array(edge_list) + return np.array(edge_list, dtype=float) # build the boundary polygon # start at the lowest and leftmost unblocked grid cell @@ -420,9 +834,17 @@ def construct_polygon(start_pos, boundary_detect_fct, cntr_clockwise_wanted: boo # just the obstacles inside the boundary polygon are part of holes # shift coordinates by +(0.5,0.5) for correct detection # the border value does not matter here - unchecked_obstacles = [ - o for o in obstacle_iter if inside_polygon(o[0] + 0.5, o[1] + 0.5, boundary_edges, border_value=True) - ] + + def get_unchecked_obstacles(obstacles: Iterable, poly: np.ndarray, required_val: bool = True) -> List: + unchecked_obstacles = [] + for o in obstacles: + p = o + 0.5 + if inside_polygon(p, poly, border_value=True) == required_val: + unchecked_obstacles.append(o) + + return unchecked_obstacles + + unchecked_obstacles = get_unchecked_obstacles(obstacle_iter, boundary_edges) hole_list = [] while len(unchecked_obstacles) > 0: @@ -431,9 +853,7 @@ def construct_polygon(start_pos, boundary_detect_fct, cntr_clockwise_wanted: boo # detect which of the obstacles still do not belong to any hole: # delete the obstacles which are included in the just constructed hole - unchecked_obstacles = [ - o for o in unchecked_obstacles if not inside_polygon(o[0] + 0.5, o[1] + 0.5, hole, border_value=True) - ] + unchecked_obstacles = get_unchecked_obstacles(unchecked_obstacles, hole, required_val=False) if simplify: # TODO @@ -444,209 +864,50 @@ def construct_polygon(start_pos, boundary_detect_fct, cntr_clockwise_wanted: boo return boundary_edges, hole_list -def find_visible(vertex_candidates, edges_to_check): - """ - # IMPORTANT: self.translate(new_origin=query_vertex) always has to be called before! - (for computing the angle representations wrt. the query vertex) - query_vertex: a vertex for which the visibility to the vertices should be checked. - also non extremity vertices, polygon vertices and vertices with the same coordinates are allowed. - query point also might lie directly on an edge! (angle = 180deg) - :param vertex_candidates: the set of all vertices which should be checked for visibility. - IMPORTANT: is being manipulated, so has to be a copy! - IMPORTANT: must not contain the query vertex! - :param edges_to_check: the set of edges which determine visibility - :return: a set of tuples of all vertices visible from the query vertex and the corresponding distance - """ - - visible_vertices = set() - if len(vertex_candidates) == 0: - return visible_vertices - - # used for increasing the priority of "closer" edges - priority_edges = set() - # goal: eliminating all vertices lying 'behind' any edge - # TODO improvement in combination with priority: process edges roughly in sequence, but still allow jumps - # would follow closer edges more often which have a bigger chance to eliminate candidates -> speed up - while len(vertex_candidates) > 0 and len(edges_to_check) > 0: - # check prioritized items first - try: - edge = priority_edges.pop() - edges_to_check.remove(edge) - except KeyError: - edge = edges_to_check.pop() - - lies_on_edge = False - range_less_180 = False - - v1, v2 = edge.vertex1, edge.vertex2 - if v1.get_distance_to_origin() == 0.0: - # vertex1 has the same coordinates as the query vertex -> on the edge - lies_on_edge = True - # (but does not belong to the same polygon, not identical!) - # mark this vertex as not visible (would otherwise add 0 distance edge in the graph) - vertex_candidates.discard(v1) - # its angle representation is not defined (no line segment from vertex1 to query vertex!) - range_less_180 = v1.is_extremity - # do not check the other neighbouring edge of vertex1 in the future - e1 = v1.edge1 - edges_to_check.discard(e1) - priority_edges.discard(e1) - # everything between its two neighbouring edges is not visible for sure - v1, v2 = v1.get_neighbours() - - elif v2.get_distance_to_origin() == 0.0: - lies_on_edge = True - vertex_candidates.discard(v2) - range_less_180 = v2.is_extremity - e1 = v2.edge2 - edges_to_check.discard(e1) - priority_edges.discard(e1) - v1, v2 = v2.get_neighbours() - - repr1 = v1.get_angle_representation() - repr2 = v2.get_angle_representation() - - repr_diff = abs(repr1 - repr2) - if repr_diff == 2.0: - # angle == 180deg -> on the edge - lies_on_edge = True - - if lies_on_edge: - # when the query vertex lies on an edge (or vertex) no behind/in front checks must be performed! - # the neighbouring edges are visible for sure - try: - vertex_candidates.remove(v1) - visible_vertices.add(v1) - except KeyError: - pass - try: - vertex_candidates.remove(v2) - visible_vertices.add(v2) - except KeyError: - pass - - # all the candidates between the two vertices v1 v2 are not visible for sure - # candidates with the same representation should not be deleted, because they can be visible! - vertex_candidates.difference_update( - find_within_range( - repr1, - repr2, - repr_diff, - vertex_candidates, - angle_range_less_180=range_less_180, - equal_repr_allowed=False, - ) - ) - continue - - # case: a 'regular' edge - # eliminate all candidates which are blocked by the edge - # that means inside the angle range spanned by the edge and actually behind it - vertices_to_check = vertex_candidates.copy() - # the vertices belonging to the edge itself (its vertices) must not be checked. - # use discard() instead of remove() to not raise an error (they might not be candidates) - vertices_to_check.discard(v1) - vertices_to_check.discard(v2) - if len(vertices_to_check) == 0: - continue - - # assert repr1 is not None - # assert repr2 is not None - - # for all candidate edges check if there are any candidate vertices (besides the ones belonging to the edge) - # within this angle range - # the "view range" of an edge from a query point (spanned by the two vertices of the edge) - # is always < 180deg when the edge is not running through the query point (=180 deg) - # candidates with the same representation as v1 or v2 should be considered. - # they can be visible, but should be ruled out if they lie behind any edge! - vertices_to_check = find_within_range( - repr1, - repr2, - repr_diff, - vertices_to_check, - angle_range_less_180=True, - equal_repr_allowed=True, - ) - if len(vertices_to_check) == 0: - continue - - # if a candidate is farther away from the query point than both vertices of the edge, - # it surely lies behind the edge - # ATTENTION: even if a candidate is closer to the query point than both vertices of the edge, - # it still needs to be checked! - v1_dist = v1.get_distance_to_origin() - v2_dist = v2.get_distance_to_origin() - max_distance = max(v1_dist, v2_dist) - vertices_behind = {v for v in vertices_to_check if v.get_distance_to_origin() > max_distance} - # they do not have to be checked, no intersection computation necessary - # TODO improvement: increase the neighbouring edges' priorities when there were extremities behind - vertices_to_check.difference_update(vertices_behind) - - vertices_in_front = set() - # for all remaining vertices v it has to be tested if the line segment from query point (=origin) to v - # has an intersection with the current edge p1---p2 - p1 = v1.get_coordinates_translated() - p2 = v2.get_coordinates_translated() - for vertex in vertices_to_check: - if lies_behind(p1, p2, vertex.get_coordinates_translated()): - vertices_behind.add(vertex) - else: - vertices_in_front.add(vertex) - - # vertices behind any edge are not visible - vertex_candidates.difference_update(vertices_behind) - # if there are no more candidates left. immediately quit checking edges - if len(vertex_candidates) == 0: - break - - # check the neighbouring edges of all vertices which lie in front of the edge next first - # (prioritize them) - # they lie in front and hence will eliminate other vertices faster - # the fewer vertex candidates remain, the faster the procedure - # TODO possible improvement: increase priority every time and draw highest priority items - # but this involves sorting (expensive for large polygons!) - # idea: work with a list of sets, add new set for higher priority, no real sorting, but still managing! - # test speed impact. - for e in vertices_in_front: - # only add the neighbour edges to the priority set if they still have to be checked! - if isinstance(e, PolygonVertex): - # only vertices belonging to polygons have neighbours - priority_edges.update(edges_to_check.intersection({e.edge1, e.edge2})) - - # all edges have been checked - # all remaining vertices were not concealed behind any edge and hence are visible - visible_vertices.update(vertex_candidates) - - # return a set of tuples: (vertex, distance) - return {(e, e.get_distance_to_origin()) for e in visible_vertices} - - -def try_extraction(json_data, key): - try: - extracted_data = json_data[key] - except KeyError as e: - raise ValueError(f"The expected key {key} was not found in the JSON file:\n{e}") - return extracted_data - - -def convert2polygon(json_list): - return [tuple(coord_pair_list) for coord_pair_list in json_list] - - -def read_json(path2json_file): - """ - Parse data from a JSON file and save as lists of tuples for both boundary and holes. - NOTE: The format of the JSON file is explained in the command line script (argparse definition) - - :param path2json_file: The path to the input json file - :return: The parsed lists of boundaries and holes +def angle_rep_inverse(repr: Optional[float]) -> Optional[float]: + if repr is None: + repr_inv = None + else: + repr_inv = (repr + 2.0) % 4.0 + return repr_inv + + +def compute_extremity_idxs(coordinates: np.ndarray) -> List[int]: + """identify all protruding points = vertices with an inside angle of > 180 degree ('extremities') + expected edge numbering: + outer boundary polygon: counter clockwise + holes: clockwise + + basic idea: + - translate the coordinate system to have p2 as origin + - compute the angle representations of both vectors representing the edges + - "rotate" the coordinate system (equal to deducting) so that the p1p2 representation is 0 + - check in which quadrant the p2p3 representation lies + %4 because the quadrant has to be in [0,1,2,3] (representation in [0:4[) + if the representation lies within quadrant 0 or 1 (<2.0), the inside angle + (for boundary polygon inside, for holes outside) between p1p2p3 is > 180 degree + then p2 = extremity + :param coordinates: + :return: """ - # parse data from the input file - with open(path2json_file, "r") as json_file: - json_data = json_file.read() - json_loaded = json.loads(json_data) - boundary_data = try_extraction(json_loaded, BOUNDARY_JSON_KEY) - holes_data = try_extraction(json_loaded, HOLES_JSON_KEY) - boundary_coordinates = convert2polygon(boundary_data) - list_of_holes = [convert2polygon(hole_data) for hole_data in holes_data] - return boundary_coordinates, list_of_holes + nr_coordinates = len(coordinates) + extr_idxs = [] + p1 = coordinates[-2] + p2 = coordinates[-1] + for i, p3 in enumerate(coordinates): + # since consequent vertices are not permitted to be equal, + # the angle representation of the difference is well defined + diff_p3_p2 = p3 - p2 + diff_p1_p2 = p1 - p2 + repr_p3_p2, _ = compute_repr_n_dist(diff_p3_p2) + repr_p1_p2, _ = compute_repr_n_dist(diff_p1_p2) + rep_diff = repr_p3_p2 - repr_p1_p2 + if rep_diff % 4.0 < 2.0: # inside angle > 180 degree + # p2 is an extremity + idx_p2 = (i - 1) % nr_coordinates + extr_idxs.append(idx_p2) + + # move to the next point + p1 = p2 + p2 = p3 + return extr_idxs diff --git a/extremitypathfinder/plotting.py b/extremitypathfinder/plotting.py index da4caaf..9610a84 100644 --- a/extremitypathfinder/plotting.py +++ b/extremitypathfinder/plotting.py @@ -3,6 +3,7 @@ from os.path import abspath, exists, join import matplotlib.pyplot as plt +import numpy as np from matplotlib.patches import Polygon from extremitypathfinder.extremitypathfinder import PolygonEnvironment @@ -18,7 +19,6 @@ } SHOW_PLOTS = False -# TODO avoid global variable PLOTTING_DIR = "all_plots" @@ -34,7 +34,7 @@ def export_plot(fig, file_name): def mark_points(vertex_iter, **kwargs): try: - coordinates = [v.coordinates.tolist() for v in vertex_iter] + coordinates = [v.tolist() for v in vertex_iter] except AttributeError: coordinates = list(vertex_iter) coords_zipped = list(zip(*coordinates)) @@ -43,12 +43,8 @@ def mark_points(vertex_iter, **kwargs): def draw_edge(v1, v2, c, alpha, **kwargs): - if type(v1) == tuple: - x1, y1 = v1 - x2, y2 = v2 - else: - x1, y1 = v1.coordinates - x2, y2 = v2.coordinates + x1, y1 = v1 + x2, y2 = v2 plt.plot([x1, x2], [y1, y2], color=c, alpha=alpha, **kwargs) @@ -59,18 +55,21 @@ def draw_polygon(ax, coords, **kwargs): def draw_boundaries(map, ax): - # TODO outside light grey + # TODO outside dark grey # TODO fill holes light grey - draw_polygon(ax, map.boundary_polygon.coordinates) + draw_polygon(ax, map.boundary_polygon) for h in map.holes: - draw_polygon(ax, h.coordinates, facecolor="grey", fill=True) + draw_polygon(ax, h, facecolor="grey", fill=True) mark_points(map.all_vertices, c="black", s=15) mark_points(map.all_extremities, c="red", s=50) -def draw_internal_graph(map, ax): - for start, all_goals in map.graph.get_neighbours(): +def draw_internal_graph(map: PolygonEnvironment, ax): + graph = map.graph + for start_idx, all_goal_idxs in graph.neighbours.items(): + start = graph.coord_map[start_idx] + all_goals = [graph.coord_map[i] for i in all_goal_idxs] for goal in all_goals: draw_edge(start, goal, c="red", alpha=0.2, linewidth=2) @@ -78,27 +77,28 @@ def draw_internal_graph(map, ax): def set_limits(map, ax): ax.set_xlim( ( - min(map.boundary_polygon.coordinates[:, 0]) - 1, - max(map.boundary_polygon.coordinates[:, 0]) + 1, + min(map.boundary_polygon[:, 0]) - 1, + max(map.boundary_polygon[:, 0]) + 1, ) ) ax.set_ylim( ( - min(map.boundary_polygon.coordinates[:, 1]) - 1, - max(map.boundary_polygon.coordinates[:, 1]) + 1, + min(map.boundary_polygon[:, 1]) - 1, + max(map.boundary_polygon[:, 1]) + 1, ) ) def draw_path(vertex_path): # start, path and goal in green - if vertex_path: - mark_points(vertex_path, c="g", alpha=0.9, s=50) - mark_points([vertex_path[0], vertex_path[-1]], c="g", s=100) - v1 = vertex_path[0] - for v2 in vertex_path[1:]: - draw_edge(v1, v2, c="g", alpha=1.0) - v1 = v2 + if not vertex_path: + return + mark_points(vertex_path, c="g", alpha=0.9, s=50) + mark_points([vertex_path[0], vertex_path[-1]], c="g", s=100) + v1 = vertex_path[0] + for v2 in vertex_path[1:]: + draw_edge(v1, v2, c="g", alpha=1.0) + v1 = v2 def draw_loaded_map(map): @@ -125,23 +125,35 @@ def draw_prepared_map(map): def draw_with_path(map, temp_graph, vertex_path): fig, ax = plt.subplots() - start, goal = vertex_path[0], vertex_path[-1] + coords_map = temp_graph.coord_map + all_nodes = temp_graph.all_nodes draw_boundaries(map, ax) draw_internal_graph(map, ax) set_limits(map, ax) - # additionally draw: - # new edges yellow - if start in temp_graph.get_all_nodes(): - for n2, _d in temp_graph.neighbours_of(start): - draw_edge(start, n2, c="y", alpha=0.7) - - all_nodes = temp_graph.get_all_nodes() - if goal in all_nodes: - # edges only run towards goal - for n1 in all_nodes: - if goal in temp_graph.get_neighbours_of(n1): - draw_edge(n1, goal, c="y", alpha=0.7) + if len(vertex_path) > 0: + # additionally draw: + # new edges yellow + start, goal = vertex_path[0], vertex_path[-1] + goal_idx = None + start_idx = None + for i, c in coords_map.items(): + if np.array_equal(c, goal): + goal_idx = i + if np.array_equal(c, start): + start_idx = i + + if start_idx is not None: + for n_idx in temp_graph.get_neighbours_of(start_idx): + n = coords_map[n_idx] + draw_edge(start, n, c="y", alpha=0.7) + + if goal_idx is not None: + # edges only run towards goal + for n_idx in all_nodes: + if goal_idx in temp_graph.get_neighbours_of(n_idx): + n = coords_map[n_idx] + draw_edge(n, goal, c="y", alpha=0.7) # start, path and goal in green draw_path(vertex_path) @@ -151,12 +163,13 @@ def draw_with_path(map, temp_graph, vertex_path): plt.show() -def draw_only_path(map, vertex_path): +def draw_only_path(map, vertex_path, start_coordinates, goal_coordinates): fig, ax = plt.subplots() draw_boundaries(map, ax) set_limits(map, ax) draw_path(vertex_path) + mark_points([start_coordinates, goal_coordinates], c="g", s=100) export_plot(fig, "path_plot") if SHOW_PLOTS: @@ -166,14 +179,15 @@ def draw_only_path(map, vertex_path): def draw_graph(map, graph): fig, ax = plt.subplots() - all_nodes = graph.get_all_nodes() + all_node_idxs = graph.get_all_nodes() + all_nodes = [graph.coord_map[i] for i in all_node_idxs] mark_points(all_nodes, c="black", s=30) - for n in all_nodes: - x, y = n.coordinates - neighbours = graph.get_neighbours_of(n) - for n2 in neighbours: - x2, y2 = n2.coordinates + for i in all_node_idxs: + x, y = graph.coord_map[i] + neighbour_idxs = graph.get_neighbours_of(i) + for n2_idx in neighbour_idxs: + x2, y2 = graph.coord_map[n2_idx] dx, dy = x2 - x, y2 - y plt.arrow( x, @@ -216,15 +230,17 @@ def prepare(self): super().prepare() draw_prepared_map(self) - def find_shortest_path(self, *args, **kwargs): + def find_shortest_path(self, start_coordinates, goal_coordinates, *args, **kwargs): """Also draws the computed shortest path.""" # important to not delete the temp graph! for plotting - vertex_path, distance = super().find_shortest_path(*args, free_space_after=False, **kwargs) + vertex_path, distance = super().find_shortest_path( + start_coordinates, goal_coordinates, *args, free_space_after=False, **kwargs + ) + draw_only_path(self, vertex_path, start_coordinates, goal_coordinates) if self.temp_graph: # in some cases (e.g. direct path possible) no graph is being created! draw_graph(self, self.temp_graph) draw_with_path(self, self.temp_graph, vertex_path) - draw_only_path(self, vertex_path) del self.temp_graph # free the memory # extract the coordinates from the path diff --git a/poetry.lock b/poetry.lock index 83247b6..7943694 100644 --- a/poetry.lock +++ b/poetry.lock @@ -16,17 +16,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.4.0" +version = "22.1.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "babel" @@ -84,7 +84,7 @@ python-versions = ">=3.6" [[package]] name = "distlib" -version = "0.3.4" +version = "0.3.5" description = "Distribution utilities" category = "dev" optional = false @@ -100,19 +100,19 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.7.1" +version = "3.8.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] -testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +docs = ["furo (>=2022.6.21)", "sphinx (>=5.1.1)", "sphinx-autodoc-typehints (>=1.19.1)"] +testing = ["covdefaults (>=2.2)", "coverage (>=6.4.2)", "pytest (>=7.1.2)", "pytest-cov (>=3)", "pytest-timeout (>=2.1)"] [[package]] name = "fonttools" -version = "4.34.4" +version = "4.36.0" description = "Tools to manipulate font files" category = "dev" optional = false @@ -134,7 +134,7 @@ woff = ["zopfli (>=0.1.4)", "brotlicffi (>=0.8.0)", "brotli (>=1.0.1)"] [[package]] name = "identify" -version = "2.5.1" +version = "2.5.3" description = "File identification library for Python" category = "dev" optional = false @@ -199,7 +199,7 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "kiwisolver" -version = "1.4.3" +version = "1.4.4" description = "A fast implementation of the Cassowary constraint solver" category = "dev" optional = false @@ -215,7 +215,7 @@ python-versions = ">=3.7" [[package]] name = "matplotlib" -version = "3.5.2" +version = "3.5.3" description = "Python plotting package" category = "dev" optional = false @@ -230,7 +230,7 @@ packaging = ">=20.0" pillow = ">=6.2.0" pyparsing = ">=2.2.1" python-dateutil = ">=2.7" -setuptools_scm = ">=4" +setuptools_scm = ">=4,<7" [[package]] name = "nodeenv" @@ -242,7 +242,7 @@ python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.* [[package]] name = "numpy" -version = "1.23.1" +version = "1.23.2" description = "NumPy is the fundamental package for array computing with Python." category = "main" optional = false @@ -292,12 +292,12 @@ optional = false python-versions = ">=3.6" [package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] [[package]] name = "pre-commit" -version = "2.19.0" +version = "2.20.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -321,12 +321,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pygments" -version = "2.12.0" +version = "2.13.0" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false python-versions = ">=3.6" +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pyparsing" version = "3.0.9" @@ -372,7 +375,7 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2022.1" +version = "2022.2.1" description = "World timezone definitions, modern and historical" category = "dev" optional = false @@ -406,16 +409,15 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools-scm" -version = "7.0.4" +version = "6.4.2" description = "the blessed package to manage your versions by scm tags" category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" [package.dependencies] packaging = ">=20.0" tomli = ">=1.0.0" -typing-extensions = "*" [package.extras] test = ["pytest (>=6.2)", "virtualenv (>20)"] @@ -482,7 +484,7 @@ docutils = "<0.18" sphinx = ">=1.6" [package.extras] -dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] [[package]] name = "sphinxcontrib-applehelp" @@ -493,8 +495,8 @@ optional = false python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] [[package]] name = "sphinxcontrib-devhelp" @@ -505,8 +507,8 @@ optional = false python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] [[package]] name = "sphinxcontrib-htmlhelp" @@ -517,8 +519,8 @@ optional = false python-versions = ">=3.6" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest", "html5lib"] +test = ["html5lib", "pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] [[package]] name = "sphinxcontrib-jsmath" @@ -529,7 +531,7 @@ optional = false python-versions = ">=3.5" [package.extras] -test = ["pytest", "flake8", "mypy"] +test = ["mypy", "flake8", "pytest"] [[package]] name = "sphinxcontrib-qthelp" @@ -540,8 +542,8 @@ optional = false python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] [[package]] name = "sphinxcontrib-serializinghtml" @@ -552,8 +554,8 @@ optional = false python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] [[package]] name = "toml" @@ -593,17 +595,9 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2, docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "psutil (>=5.6.1)", "pathlib2 (>=2.3.3)"] -[[package]] -name = "typing-extensions" -version = "4.3.0" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" -optional = false -python-versions = ">=3.7" - [[package]] name = "urllib3" -version = "1.26.10" +version = "1.26.11" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false @@ -616,269 +610,96 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.15.1" +version = "20.16.3" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" [package.dependencies] -distlib = ">=0.3.1,<1" -filelock = ">=3.2,<4" -platformdirs = ">=2,<3" -six = ">=1.9.0,<2" +distlib = ">=0.3.5,<1" +filelock = ">=3.4.1,<4" +platformdirs = ">=2.4,<3" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"] +testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] [[package]] name = "zipp" -version = "3.8.0" +version = "3.8.1" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[extras] +docs = [] +numba = [] +plot = [] +test = [] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "16ab09eb70efbe3fc8d946786d03da08acc22c6a1fc2101838e6f04ea27a4c73" +content-hash = "b17494c876d4a16b89fcbd219cd9c8b5c5eb2b1af590f6db5bf40add8a826263" [metadata.files] -alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, -] +alabaster = [] atomicwrites = [] -attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, -] +attrs = [] babel = [] -certifi = [ - {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, - {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, -] -cfgv = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, -] +certifi = [] +cfgv = [] charset-normalizer = [] colorama = [] cycler = [] -distlib = [ - {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, - {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, -] -docutils = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, -] -filelock = [ - {file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"}, - {file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"}, -] +distlib = [] +docutils = [] +filelock = [] fonttools = [] -identify = [ - {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"}, - {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"}, -] -idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, -] +identify = [] +idna = [] imagesize = [] importlib-metadata = [] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -jinja2 = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] +iniconfig = [] +jinja2 = [] kiwisolver = [] -markupsafe = [ - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, - {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, -] +markupsafe = [] matplotlib = [] nodeenv = [] numpy = [] -packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] +packaging = [] pillow = [] -platformdirs = [ - {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, - {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -pre-commit = [ - {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"}, - {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"}, -] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] -pygments = [ - {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, - {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, -] -pyparsing = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, -] -pytest = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, -] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] -pytz = [ - {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, - {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, -] -pyyaml = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, -] +platformdirs = [] +pluggy = [] +pre-commit = [] +py = [] +pygments = [] +pyparsing = [] +pytest = [] +python-dateutil = [] +pytz = [] +pyyaml = [] requests = [] setuptools-scm = [] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -snowballstemmer = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] -sphinx = [ - {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, - {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, -] -sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-1.0.0-py2.py3-none-any.whl", hash = "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8"}, - {file = "sphinx_rtd_theme-1.0.0.tar.gz", hash = "sha256:eec6d497e4c2195fa0e8b2016b337532b8a699a68bcb22a512870e16925c6a5c"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, - {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, - {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, -] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] +six = [] +snowballstemmer = [] +sphinx = [] +sphinx-rtd-theme = [] +sphinxcontrib-applehelp = [] +sphinxcontrib-devhelp = [] +sphinxcontrib-htmlhelp = [] +sphinxcontrib-jsmath = [] +sphinxcontrib-qthelp = [] +sphinxcontrib-serializinghtml = [] +toml = [] +tomli = [] tox = [] -typing-extensions = [] urllib3 = [] virtualenv = [] -zipp = [ - {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, - {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, -] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml index 7b21b03..7a44ea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "extremitypathfinder" -version = "2.2.3" +version = "2.3.0" license = "MIT" readme = "README.rst" repository = "https://github.com/jannikmi/extremitypathfinder" @@ -51,6 +51,12 @@ Sphinx = "^4.3.1" sphinx-rtd-theme = "^1.0.0" matplotlib = "^3.5.2" +[tool.poetry.extras] +numba = ["numba"] +plot = ["matplotlib"] +test = ["pytest", "tox"] +docs = ["Sphinx", "sphinx-rtd-theme"] + [build-system] requires = ["poetry-core>=1.0.7", "poetry==1.1.11"] build-backend = "poetry.core.masonry.api" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helper_classes_test.py b/tests/helper_classes_test.py deleted file mode 100755 index 4fbb2a0..0000000 --- a/tests/helper_classes_test.py +++ /dev/null @@ -1,159 +0,0 @@ -import unittest - -import numpy as np -import pytest -from helpers import proto_test_case - -from extremitypathfinder import helper_classes -from extremitypathfinder.helper_classes import AngleRepresentation, Polygon, Vertex - -helper_classes.origin = Vertex((-5.0, -5.0)) - - -class HelperClassesTest(unittest.TestCase): - def test_angle_repr(self): - with pytest.raises(ValueError): - AngleRepresentation(np.array([0.0, 0.0])) - - # - # def quadrant_test_fct(input): - # np_2D_coord_vector = np.array(input) - # return AngleRepresentation(np_2D_coord_vector).quadrant - # - # data = [ - # ([1.0, 0.0], 0.0), - # ([0.0, 1.0], 0.0), - # ([-1.0, 0.0], 1.0), - # ([0.0, -1.0], 3.0), - # - # ([2.0, 0.0], 0.0), - # ([0.0, 2.0], 0.0), - # ([-2.0, 0.0], 1.0), - # ([0.0, -2.0], 3.0), - # - # ([1.0, 1.0], 0.0), - # ([-1.0, 1.0], 1.0), - # ([-1.0, -1.0], 2.0), - # ([1.0, -1.0], 3.0), - # - # ([1.0, 0.00001], 0.0), - # ([0.00001, 1.0], 0.0), - # ([-1.0, 0.00001], 1.0), - # ([0.00001, -1.0], 3.0), - # - # ([1.0, -0.00001], 3.0), - # ([-0.00001, 1.0], 1.0), - # ([-1.0, -0.00001], 2.0), - # ([-0.00001, -1.0], 2.0), - # ] - # - # proto_test_case(data, quadrant_test_fct) - - # TODO test: - # randomized - # every quadrant contains angle measures from 0.0 to 1.0 - # angle %360! - # rep(p1) > rep(p2) <=> angle(p1) > angle(p2) - # rep(p1) = rep(p2) <=> angle(p1) = angle(p2) - # repr value in [0.0 : 4.0] - - def value_test_fct(input): - np_2D_coord_vector = np.array(input) - return AngleRepresentation(np_2D_coord_vector).value - - data = [ - ([1.0, 0.0], 0.0), - ([0.0, 1.0], 1.0), - ([-1.0, 0.0], 2.0), - ([0.0, -1.0], 3.0), - ([2.0, 0.0], 0.0), - ([0.0, 2.0], 1.0), - ([-2.0, 0.0], 2.0), - ([0.0, -2.0], 3.0), - ] - - proto_test_case(data, value_test_fct) - - def test_vertex_translation(self): - data = [ - ([1.0, 1.0], [6.0, 6.0]), - ([0.0, 0.0], [5.0, 5.0]), - ([-12.0, -3.0], [-7.0, 2.0]), - ([-4.0, 5.0], [1.0, 10.0]), - ([3.0, -2.0], [8.0, 3.0]), - ] - - def translation_test_fct(input): - return Vertex(input).get_coordinates_translated().tolist() - - proto_test_case(data, translation_test_fct) - - def test_vertex_angle_repr(self): - data = [ - ([0.0, -5.0], 0.0), - ([-5.0, 0.0], 1.0), - ([-6.0, -5.0], 2.0), - ([-5.0, -6.0], 3.0), - ] - - def angle_repr_test_fct(input): - return Vertex(input).get_angle_representation() - - proto_test_case(data, angle_repr_test_fct) - - def test_vertex_distance_to_origin(self): - data = [ - ([0.0, 0.0], np.sqrt(50)), - ([-5.0, 0.0], 5.0), - ([0.0, -5], 5.0), - ([-3.0, -2.0], np.sqrt(13)), - ([2.0, 5.0], np.sqrt(149)), - ] - - def dist_to_origin_test_fct(input): - return Vertex(input).get_distance_to_origin() - - proto_test_case(data, dist_to_origin_test_fct) - - def test_polygon_bad_input(self): - with pytest.raises(ValueError) as err: - Polygon([(0, 0), (1, 1)], is_hole=False) - assert "not a valid polygon" in str(err) - - def test_polygon_extremities(self): - data = [ - ( - [ - (0, 0), - (10, 0), - (9, 5), - (10, 10), - (0, 10), - ], - [(9, 5)], - ), - ( - [ - (0, 0), - (-2, -2), - (-3, -2.5), - (-3, -4), - (2, -3), - (1, 2.5), - (0, -1), - ], - [(0, -1), (-2, -2)], - ), - ] - - def find_extremities_test_fct(input): - extremities = Polygon(input, is_hole=False).extremities - return [tuple(e.coordinates) for e in extremities] - - proto_test_case(data, find_extremities_test_fct) - - -if __name__ == "__main__": - suite = unittest.TestLoader().loadTestsFromTestCase(HelperClassesTest) - unittest.TextTestRunner(verbosity=2).run(suite) - # unittest.main() diff --git a/tests/helper_fcts_test.py b/tests/helper_fcts_test.py index 2e1deed..eb08e34 100755 --- a/tests/helper_fcts_test.py +++ b/tests/helper_fcts_test.py @@ -1,22 +1,202 @@ -import unittest +""" +TODO test find_visible(), ... +TODO test if relation is really bidirectional (y in find_visible(x,y) <=> x in find_visible(y,x)) +TODO test input data validation +""" from os.path import abspath, join, pardir +from typing import Dict, Set import numpy as np -from helpers import proto_test_case +import pytest from extremitypathfinder import PolygonEnvironment -from extremitypathfinder.helper_classes import AngleRepresentation -from extremitypathfinder.helper_fcts import has_clockwise_numbering, inside_polygon, read_json - - -# TODO test find_visible(), ... -# TODO test invalid data detection -class HelperFctsTest(unittest.TestCase): - def value_test_fct(input): - np_2D_coord_vector = np.array(input) - return AngleRepresentation(np_2D_coord_vector).value +from extremitypathfinder.helper_fcts import ( + clean_visibles, + compute_extremity_idxs, + compute_repr_n_dist, + has_clockwise_numbering, + inside_polygon, + read_json, +) +from tests.helpers import proto_test_case + + +def test_inside_polygon(): + for border_value in [True, False]: + + def test_fct(input): + polygon_test_case = np.array([(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]) + p = np.array(input, dtype=float) + return inside_polygon(p, polygon_test_case, border_value) + + p_test_cases = [ + # (x,y), + # inside + (0.0, 0.0), + # # outside + (-2.0, 2.0), + (0, 2.0), + (2.0, 2.0), + (-2.0, 0), + (2.0, 0), + (-2.0, -2.0), + (0, -2.0), + (2.0, -2.0), + # on the line test cases + (-1.0, -1.0), + (1.0, -1.0), + (1.0, 1.0), + (-1.0, 1.0), + (0.0, 1), + (0, -1), + (1, 0), + (-1, 0), + ] + expected_results = [ + True, + False, + False, + False, + False, + False, + False, + False, + False, + # on the line test cases + border_value, + border_value, + border_value, + border_value, + border_value, + border_value, + border_value, + border_value, + ] - data = [ + proto_test_case(list(zip(p_test_cases, expected_results)), test_fct) + + +def test_read_json(): + path2json_file = abspath(join(__file__, pardir, pardir, "example.json")) + boundary_coordinates, list_of_holes = read_json(path2json_file) + assert len(boundary_coordinates) == 5 + assert len(boundary_coordinates[0]) == 2 + assert len(list_of_holes) == 2 + first_hole = list_of_holes[0] + assert len(first_hole) == 4 + assert len(first_hole[0]) == 2 + environment = PolygonEnvironment() + environment.store(boundary_coordinates, list_of_holes, validate=True) + + +@pytest.mark.parametrize( + "visible_idxs, cand_idx2repr, vert_idx2dist, expected", + [ + (set(), {}, {}, set()), + ({0}, {0: 0.0}, {0: 0.0}, {0}), + # different repr -> keep both + ({0, 1}, {0: 0.0, 1: 1.0}, {0: 0.0, 1: 0.0}, {0, 1}), + ({0, 1}, {0: 0.5, 1: 1.0}, {0: 0.0, 1: 1.0}, {0, 1}), + ({0, 1}, {0: 0.5, 1: 1.0}, {0: 1.0, 1: 1.0}, {0, 1}), + # same repr -> keep one the one with the lower dist + ({0, 1}, {0: 0.0, 1: 0.0}, {0: 0.0, 1: 1.0}, {0}), + ({0, 1}, {0: 0.0, 1: 0.0}, {0: 0.0, 1: 1.1}, {0}), + ({0, 1}, {0: 0.0, 1: 0.0}, {0: 1.0, 1: 0.0}, {1}), + ({0, 1}, {0: 0.0, 1: 0.0}, {0: 1.1, 1: 0.0}, {1}), + ], +) +def test_clean_visible_idxs( + visible_idxs: Set[int], cand_idx2repr: Dict[int, float], vert_idx2dist: Dict[int, float], expected: Set[int] +): + res = clean_visibles(visible_idxs, cand_idx2repr, vert_idx2dist) + assert res == expected + + +@pytest.mark.parametrize( + "coords, expected", + [ + ( + [ + (0, 0), + (10, 0), + (9, 5), + (10, 10), + (0, 10), + ], + {2}, + ), + ( + [ + (0, 0), + (10, 0), + (10, 10), + (0, 10), + ], + set(), + ), + ( + [ + (0, 0), + (-2, -2), + (-3, -2.5), + (-3, -4), + (2, -3), + (1, 2.5), + (0, -1), + ], + {6, 1}, + ), + ], +) +def test_compute_extremity_idxs(coords, expected): + coords = np.array(coords) + res = compute_extremity_idxs(coords) + assert set(res) == expected + + +@pytest.mark.parametrize( + "input, expected", + [ + # clockwise numbering! + ([(3.0, 7.0), (5.0, 9.0), (5.0, 7.0)], True), + ([(3.0, 7.0), (5.0, 9.0), (4.5, 7.0), (5.0, 4.0)], True), + ([(0.0, 0.0), (0.0, 1.0), (1.0, 0.0)], True), + # # counter clockwise edge numbering! + ([(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)], False), + ([(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], False), + ([(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (10.0, 10.0), (0.0, 10.0)], False), + ([(0.0, 0.0), (10.0, 0.0), (9.0, 5.0), (10.0, 10.0), (0.0, 10.0)], False), + ], +) +def test_clockwise_numering(input, expected): + def clockwise_test_fct(input): + return has_clockwise_numbering(np.array(input)) + + assert clockwise_test_fct(input) == expected + + +@pytest.mark.parametrize( + "input, expected", + [ + ([0.0, -5.0], (3.0, 5.0)), + ([-5.0, 0.0], (2.0, 5.0)), + ([-1.0, 0.0], (2.0, 1.0)), + ([1.0, 0.0], (0.0, 1.0)), + ([0.0, 1.0], (1.0, 1.0)), + ([-6.0, -5.0], (2.64018439966448, 7.810249675906654)), + ([-5.0, -6.0], (2.768221279597376, 7.810249675906654)), + ], +) +def test_compute_repr_n_dist(input, expected): + def test_fct(input): + return compute_repr_n_dist(np.array(input)) + + assert test_fct(input) == expected + + +@pytest.mark.parametrize( + "input, expected", + [ ([1.0, 0.0], 0.0), ([0.0, 1.0], 1.0), ([-1.0, 0.0], 2.0), @@ -25,101 +205,46 @@ def value_test_fct(input): ([0.0, 2.0], 1.0), ([-2.0, 0.0], 2.0), ([0.0, -2.0], 3.0), - ] - - proto_test_case(data, value_test_fct) - - def test_inside_polygon(self): - # TODO more detailed test. edges with slopes... also in timezonefinder - - for border_value in [True, False]: - - def test_fct(input): - polygon_test_case = np.array([(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]) - x, y = input - - return inside_polygon(x, y, polygon_test_case, border_value) - - p_test_cases = [ - # (x,y), - # inside - (0.0, 0.0), - # # outside - (-2.0, 2.0), - (0, 2.0), - (2.0, 2.0), - (-2.0, 0), - (2.0, 0), - (-2.0, -2.0), - (0, -2.0), - (2.0, -2.0), - # on the line test cases - (-1.0, -1.0), - (1.0, -1.0), - (1.0, 1.0), - (-1.0, 1.0), - (0.0, 1), - (0, -1), - (1, 0), - (-1, 0), - ] - expected_results = [ - True, - False, - False, - False, - False, - False, - False, - False, - False, - # on the line test cases - border_value, - border_value, - border_value, - border_value, - border_value, - border_value, - border_value, - border_value, - ] - - proto_test_case(list(zip(p_test_cases, expected_results)), test_fct) - - def test_clockwise_numering(self): - def clockwise_test_fct(input): - return has_clockwise_numbering(np.array(input)) - - data = [ - # clockwise numbering! - ([(3.0, 7.0), (5.0, 9.0), (5.0, 7.0)], True), - ([(3.0, 7.0), (5.0, 9.0), (4.5, 7.0), (5.0, 4.0)], True), - ([(0.0, 0.0), (0.0, 1.0), (1.0, 0.0)], True), - # # counter clockwise edge numbering! - ([(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)], False), - ([(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], False), - ([(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (10.0, 10.0), (0.0, 10.0)], False), - ([(0.0, 0.0), (10.0, 0.0), (9.0, 5.0), (10.0, 10.0), (0.0, 10.0)], False), - ] - proto_test_case(data, clockwise_test_fct) - - def test_read_json(self): - path2json_file = abspath(join(__file__, pardir, pardir, "example.json")) - boundary_coordinates, list_of_holes = read_json(path2json_file) - assert len(boundary_coordinates) == 5 - assert len(boundary_coordinates[0]) == 2 - assert len(list_of_holes) == 2 - first_hole = list_of_holes[0] - assert len(first_hole) == 4 - assert len(first_hole[0]) == 2 - environment = PolygonEnvironment() - environment.store(boundary_coordinates, list_of_holes, validate=True) - + ], +) +def test_angle_representation(input, expected): + def func(input): + repr, dist = compute_repr_n_dist(np.array(input)) + return repr -# TODO test if relation is really bidirectional (y in find_visible(x,y) <=> x in find_visible(y,x)) + assert func(input) == expected -if __name__ == "__main__": - suite = unittest.TestLoader().loadTestsFromTestCase(HelperFctsTest) - unittest.TextTestRunner(verbosity=2).run(suite) - # unittest.main() +@pytest.mark.parametrize( + "input, expected", + [ + ([1.0, 0.0], 0.0), + ([0.0, 1.0], 1.0), + ([-1.0, 0.0], 2.0), + ([0.0, -1.0], 3.0), + ([2.0, 0.0], 0.0), + ([0.0, 2.0], 1.0), + ([-2.0, 0.0], 2.0), + ([0.0, -2.0], 3.0), + ([1.0, 1.0], 0.0), + ([-1.0, 1.0], 1.0), + ([-1.0, -1.0], 2.0), + ([1.0, -1.0], 3.0), + ([1.0, 0.00001], 0.0), + ([0.00001, 1.0], 0.0), + ([-1.0, 0.00001], 1.0), + ([0.00001, -1.0], 3.0), + ([1.0, -0.00001], 3.0), + ([-0.00001, 1.0], 1.0), + ([-1.0, -0.00001], 2.0), + ([-0.00001, -1.0], 2.0), + ], +) +def test_angle_repr_quadrant(input, expected): + def func(input): + repr, dist = compute_repr_n_dist(np.array(input)) + return repr + + res = func(input) + assert res >= expected + assert res < expected + 1 diff --git a/tests/main_test.py b/tests/main_test.py index d86edcf..8c9e925 100755 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -1,12 +1,19 @@ -import unittest -from math import sqrt - import pytest from extremitypathfinder.extremitypathfinder import PolygonEnvironment from extremitypathfinder.plotting import PlottingEnvironment +from tests.test_cases import ( + GRID_ENV_PARAMS, + INVALID_DESTINATION_DATA, + OVERLAP_POLY_ENV_PARAMS, + POLY_ENV_PARAMS, + SEPARATED_ENV, + TEST_DATA_GRID_ENV, + TEST_DATA_OVERLAP_POLY_ENV, + TEST_DATA_POLY_ENV, + TEST_DATA_SEPARATE_ENV, +) -# TODO # PLOT_TEST_RESULTS = True PLOT_TEST_RESULTS = False TEST_PLOT_OUTPUT_FOLDER = "plots" @@ -19,266 +26,8 @@ ENVIRONMENT_CLASS = PolygonEnvironment CONSTRUCTION_KWARGS = {} -# size_x, size_y, obstacle_iter -GRID_ENV_PARAMS = ( - 19, - 10, - [ - # (x,y), - # obstacles changing boundary - (0, 1), - (1, 1), - (2, 1), - (3, 1), - (17, 9), - (17, 8), - (17, 7), - (17, 5), - (17, 4), - (17, 3), - (17, 2), - (17, 1), - (17, 0), - # hole 1 - (5, 5), - (5, 6), - (6, 6), - (6, 7), - (7, 7), - # hole 2 - (7, 5), - ], -) - -INVALID_DESTINATION_DATA = [ - # outside of map region - ((-1, 5.0), (17, 0.5)), - ((17, 0.5), (-1, 5.0)), - ((20, 5.0), (17, 0.5)), - ((17, 0.5), (20, 5.0)), - ((1, -5.0), (17, 0.5)), - ((17, 0.5), (1, -5.0)), - ((1, 11.0), (17, 0.5)), - ((17, 0.5), (1, 11.0)), - # outside boundary polygon - ((17.5, 5.0), (17, 0.5)), - ((17, 0.5), (17.5, 5.0)), - ((1, 1.5), (17, 0.5)), - ((17, 0.5), (1, 1.5)), - # inside hole - ((6.5, 6.5), (17, 0.5)), - ((17, 0.5), (6.5, 6.5)), -] - -TEST_DATA_GRID_ENV = [ - # ((start,goal),(path,distance)) - # shortest paths should be distinct (reverse will automatically be tested) - # identical nodes - (((15, 5), (15, 5)), ([(15, 5), (15, 5)], 0.0)), - # directly reachable - (((15, 5), (15, 6)), ([(15, 5), (15, 6)], 1.0)), - (((15, 5), (16, 6)), ([(15, 5), (16, 6)], sqrt(2))), - # points on the polygon edges (vertices) should be accepted! - # on edge - (((15, 0), (15, 6)), ([(15, 0), (15, 6)], 6.0)), - (((17, 5), (16, 5)), ([(17, 5), (16, 5)], 1.0)), - # on edge of hole - (((7, 8), (7, 9)), ([(7, 8), (7, 9)], 1.0)), - # on vertex - (((4, 2), (4, 3)), ([(4, 2), (4, 3)], 1.0)), - # on vertex of hole - (((6, 8), (6, 9)), ([(6, 8), (6, 9)], 1.0)), - # on two vertices - # coinciding with edge (direct neighbour) - (((4, 2), (4, 1)), ([(4, 2), (4, 1)], 1.0)), - (((5, 5), (5, 7)), ([(5, 5), (5, 7)], 2.0)), - # should have direct connection to all visible extremities! connected in graph - (((6, 8), (5, 7)), ([(6, 8), (5, 7)], sqrt(2))), - (((4, 1), (5, 7)), ([(4, 1), (5, 7)], sqrt(1**2 + 6**2))), - # should have direct connection to all visible extremities! even if not connected in graph! - (((4, 2), (5, 7)), ([(4, 2), (5, 7)], sqrt(1**2 + 5**2))), - # mix of edges and vertices, directly visible - (((2, 2), (5, 7)), ([(2, 2), (5, 7)], sqrt(3**2 + 5**2))), - # also regular points should have direct connection to all visible extremities! - (((10, 3), (17, 6)), ([(10, 3), (17, 6)], sqrt(7**2 + 3**2))), - (((10, 3), (8, 8)), ([(10, 3), (8, 8)], sqrt(2**2 + 5**2))), - # even if the query point lies in front of an extremity! (test if new query vertices are being created!) - (((10, 3), (8, 5)), ([(10, 3), (8, 5)], sqrt(2**2 + 2**2))), - # using a* graph search: - # directly reachable through a single vertex (does not change distance!) - (((5, 1), (3, 3)), ([(5, 1), (4, 2), (3, 3)], sqrt(2**2 + 2**2))), - # If two Polygons have vertices with identical coordinates (this is allowed), - # paths through these vertices are theoretically possible! - ( - ((6.5, 5.5), (7.5, 6.5)), - ([(6.5, 5.5), (7, 6), (7.5, 6.5)], sqrt(1**2 + 1**2)), - ), - # distance should stay the same even if multiple extremities lie on direct path - # test if path is skipping passed extremities - (((8, 4), (8, 8)), ([(8, 4), (8, 5), (8, 6), (8, 7), (8, 8)], 4)), - (((8, 4), (8, 9)), ([(8, 4), (8, 5), (8, 6), (8, 7), (8, 8), (8, 9)], 5)), - # regular examples - ( - ((0.5, 6), (18.5, 0.5)), - ( - [(0.5, 6.0), (5, 5), (6, 5), (7, 5), (8, 5), (17, 6), (18, 6), (18.5, 0.5)], - 23.18783787537749, - ), - ), - ( - ((0.5, 6), (9, 5.5)), - ([(0.5, 6.0), (5, 5), (6, 5), (7, 5), (8, 5), (9.0, 5.5)], 8.727806217396338), - ), - ( - ((0.5, 6), (18.5, 9)), - ( - [(0.5, 6.0), (5, 5), (6, 5), (7, 5), (8, 5), (18, 7), (18.5, 9.0)], - 19.869364068640845, - ), - ), - ( - ((6.9, 4), (7, 9)), - ([(6.9, 4.0), (7, 6), (8, 7), (8, 8), (7, 9)], 5.830925564196269), - ), - ( - ((6.5, 4), (7, 9)), - ([(6.5, 4.0), (7, 6), (8, 7), (8, 8), (7, 9)], 5.889979937555021), - ), -] - -POLY_ENV_PARAMS = ( - # boundary_coordinates - [(0.0, 0.0), (10.0, 0.0), (9.0, 5.0), (10.0, 10.0), (0.0, 10.0)], - # list_of_holes - [ - [ - (3.0, 7.0), - (5.0, 9.0), - (4.6, 7.0), - (5.0, 4.0), - ], - ], -) - -TEST_DATA_POLY_ENV = [ - # ((start,goal),(path,distance)) - # identical nodes - (((1, 1), (1, 1)), ([(1, 1), (1, 1)], 0.0)), - # directly reachable - (((1, 1), (1, 2)), ([(1, 1), (1, 2)], 1.0)), - (((1, 1), (2, 1)), ([(1, 1), (2, 1)], 1.0)), - # points on the polygon edges (vertices) should be accepted! - # on edge (boundary polygon) - (((1, 0), (1, 1)), ([(1, 0), (1, 1)], 1.0)), - (((9.5, 2.5), (8.5, 2.5)), ([(9.5, 2.5), (8.5, 2.5)], 1.0)), - (((0, 2), (0, 1)), ([(0, 2), (0, 1)], 1.0)), # both - (((1, 0), (5, 0)), ([(1, 0), (5, 0)], 4.0)), # both - # on edge of hole - (((4, 8), (3, 8)), ([(4, 8), (3, 8)], 1.0)), - (((4, 8), (4.1, 8.1)), ([(4, 8), (4.1, 8.1)], sqrt(2 * (0.1**2)))), # both - # on vertex - (((9, 5), (8, 5)), ([(9, 5), (8, 5)], 1.0)), - # on vertex of hole - (((3, 7), (2, 7)), ([(3, 7), (2, 7)], 1.0)), - # on two vertices - # coinciding with edge (direct neighbour) - (((3, 7), (5, 9)), ([(3, 7), (5, 9)], sqrt(8))), - (((4.6, 7), (5, 9)), ([(4.6, 7), (5, 9)], sqrt((0.4**2) + (2**2)))), - # should have direct connection to all visible extremities! connected in graph - (((5, 4), (5, 9)), ([(5, 4), (5, 9)], 5)), - # should have a direct connection to all visible extremities! even if not connected in graph! - (((9, 5), (5, 9)), ([(9, 5), (5, 9)], sqrt(2 * (4**2)))), - # using a* graph search: - # directly reachable through a single vertex (does not change distance!) - (((9, 4), (9, 6)), ([(9, 4), (9, 5), (9, 6)], 2)), -] - -OVERLAP_POLY_ENV_PARAMS = ( - # boundary_coordinates - [ - (9.5, 10.5), - (25.5, 10.5), - (25.5, 0.5), - (49.5, 0.5), - (49.5, 49.5), - (0.5, 49.5), - (0.5, 16.5), - (9.5, 16.5), - (9.5, 45.5), - (15.5, 45.5), - (15.5, 30.5), - (35.5, 30.5), - (35.5, 14.5), - (0.5, 14.5), - (0.5, 0.5), - (9.5, 0.5), - ], - # list_of_holes - [ - [ - (40.5, 4.5), - (29.5, 4.5), - (29.5, 15.0), - (40.5, 15.0), - ], - [ - (45.40990195143968, 14.5), - (44.59009804856032, 14.5), - (43.39009804883972, 20.5), - (46.60990195116028, 20.5), - ], - [ - (40.5, 34.5), - (24.5, 34.5), - (24.5, 40.5), - (40.5, 40.5), - ], - [ - (31.5, 5.390098048839718), - (31.5, 10.909901951439679), - (42.5, 13.109901951160282), - (42.5, 7.590098048560321), - ], - ], -) - -TEST_DATA_OVERLAP_POLY_ENV = [ - # ((start,goal),(path,distance)) - ( - ((1, 1), (5, 20)), - ( - [ - (1, 1), - (9.5, 10.5), - (25.5, 10.5), - (29.5, 4.5), - (40.5, 4.5), - (42.5, 7.590098048560321), - (42.5, 13.109901951160282), - (35.5, 30.5), - (24.5, 34.5), - (15.5, 45.5), - (9.5, 45.5), - (5, 20), - ], - 132.71677685197986, - ), - ), -] - -SEPARATED_ENV = ( - [(5, 5), (-5, 5), (-5, -5), (5, -5)], - [[(-5.1, 1), (-5.1, 2), (5.1, 2), (5.1, 1)]], # intersecting polygons -> no path possible - # [[(-5, 1), (-5, 2), (5, 2), (5, 1)]], # hole lies on the edges -> path possible -) - -TEST_DATA_SEPARATE_ENV = [ - # ((start,goal),(path,distance)) - (((0, 0), (0, 4)), ([], None)), # unreachable -] - -# ((start,goal),(path,distance)) +# TODO pytest parameterize def try_test_cases(environment, test_cases): @@ -296,8 +45,7 @@ def validate(start_coordinates, goal_coordinates, expected_output): else: status_str = "XX" print(f"{status_str} input: {(start_coordinates, goal_coordinates)} ") - if PLOT_TEST_RESULTS: - assert correct_result, f"unexpected result (path, length): got {output} instead of {expected_output} " + assert correct_result, f"unexpected result (path, length): got {output} instead of {expected_output} " print("testing if path and distance are correct:") for ((start_coordinates, goal_coordinates), expected_output) in test_cases: @@ -308,80 +56,80 @@ def validate(start_coordinates, goal_coordinates, expected_output): validate(goal_coordinates, start_coordinates, expected_output_reversed) -class MainTest(unittest.TestCase): - def test_fct(self): - grid_env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) - - grid_env.store_grid_world(*GRID_ENV_PARAMS, simplify=False, validate=False) - assert len(list(grid_env.all_extremities)) == 17, "extremities do not get detected correctly!" - grid_env.prepare() - # raise ValueError - assert len(grid_env.graph.all_nodes) == 16, "identical nodes should get joined in the graph!" - - # test if points outside the map are being rejected - for start_coordinates, goal_coordinates in INVALID_DESTINATION_DATA: - with pytest.raises(ValueError): - grid_env.find_shortest_path(start_coordinates, goal_coordinates) - - print("testing grid environment") - try_test_cases(grid_env, TEST_DATA_GRID_ENV) - - # when the deep copy mechanism works correctly - # even after many queries the internal graph should have the same structure as before - # otherwise the temporarily added vertices during a query stay stored - assert len(grid_env.graph.all_nodes) == 16, "the graph should stay unchanged by shortest path queries!" - - nr_nodes_env1_old = len(grid_env.graph.all_nodes) - - poly_env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) - poly_env.store(*POLY_ENV_PARAMS, validate=True) - NR_EXTR_POLY_ENV = 4 - assert ( - len(list(poly_env.all_extremities)) == NR_EXTR_POLY_ENV - ), f"the environment should detect all {NR_EXTR_POLY_ENV} extremities!" - poly_env.prepare() - nr_nodes_env2 = len(poly_env.graph.all_nodes) - assert nr_nodes_env2 == NR_EXTR_POLY_ENV, ( - f"the visibility graph should store all {NR_EXTR_POLY_ENV} extremities {list(poly_env.all_extremities)}!" - f"\n found: {poly_env.graph.all_nodes}" - ) - - nr_nodes_env1_new = len(grid_env.graph.all_nodes) - assert ( - nr_nodes_env1_new == nr_nodes_env1_old - ), "node amount of an grid_env should not change by creating another grid_env!" - assert grid_env.graph is not poly_env.graph, "different environments share the same graph object" - assert ( - grid_env.graph.all_nodes is not poly_env.graph.all_nodes - ), "different environments share the same set of nodes" - - print("\ntesting polygon environment") - try_test_cases(poly_env, TEST_DATA_POLY_ENV) - - # TODO test: When the paths should be blocked, use a single polygon with multiple identical - # vertices instead (also allowed?! change data requirements in doc!). - - # TODO test graph construction - # when two nodes have the same angle representation there should only be an edge to the closer node! - # test if property 1 is being properly exploited - # (extremities lying in front of each other need not be connected) - - def test_overlapping_polygon(self): - overlap_poly_env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) - overlap_poly_env.store(*OVERLAP_POLY_ENV_PARAMS) - overlap_poly_env.prepare() - print("\ntesting polygon environment with overlapping polygons") - try_test_cases(overlap_poly_env, TEST_DATA_OVERLAP_POLY_ENV) - - def test_separated_environment(self): - env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) - env.store(*SEPARATED_ENV) - env.prepare() - print("\ntesting polygon environment with two separated areas") - try_test_cases(env, TEST_DATA_SEPARATE_ENV) - - -if __name__ == "__main__": - suite = unittest.TestLoader().loadTestsFromTestCase(MainTest) - unittest.TextTestRunner(verbosity=2).run(suite) - unittest.main() +def test_grid_env(): + grid_env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) + + grid_env.store_grid_world(*GRID_ENV_PARAMS, simplify=False, validate=False) + nr_extremities = len(grid_env.all_extremities) + assert nr_extremities == 17, "extremities do not get detected correctly!" + grid_env.prepare() + nr_graph_nodes = len(grid_env.graph.all_nodes) + assert nr_graph_nodes == 16, "identical nodes should get joined in the graph!" + + # test if points outside the map are being rejected + for start_coordinates, goal_coordinates in INVALID_DESTINATION_DATA: + with pytest.raises(ValueError): + grid_env.find_shortest_path(start_coordinates, goal_coordinates) + + print("testing grid environment") + try_test_cases(grid_env, TEST_DATA_GRID_ENV) + + # when the deep copy mechanism works correctly + # even after many queries the internal graph should have the same structure as before + # otherwise the temporarily added vertices during a query stay stored + nr_graph_nodes = len(grid_env.graph.all_nodes) + # TODO + # assert nr_graph_nodes == 16, "the graph should stay unchanged by shortest path queries!" + + nr_nodes_env1_old = len(grid_env.graph.all_nodes) + + +def test_poly_env(): + poly_env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) + poly_env.store(*POLY_ENV_PARAMS, validate=True) + NR_EXTR_POLY_ENV = 4 + assert ( + len(list(poly_env.all_extremities)) == NR_EXTR_POLY_ENV + ), f"the environment should detect all {NR_EXTR_POLY_ENV} extremities!" + poly_env.prepare() + nr_nodes_env2 = len(poly_env.graph.all_nodes) + assert nr_nodes_env2 == NR_EXTR_POLY_ENV, ( + f"the visibility graph should store all {NR_EXTR_POLY_ENV} extremities {list(poly_env.all_extremities)}!" + f"\n found: {poly_env.graph.all_nodes}" + ) + + # nr_nodes_env1_new = len(grid_env.graph.all_nodes) + # assert ( + # nr_nodes_env1_new == nr_nodes_env1_old + # ), "node amount of an grid_env should not change by creating another grid_env!" + # assert grid_env.graph is not poly_env.graph, "different environments share the same graph object" + # assert ( + # grid_env.graph.all_nodes is not poly_env.graph.all_nodes + # ), "different environments share the same set of nodes" + + print("\ntesting polygon environment") + try_test_cases(poly_env, TEST_DATA_POLY_ENV) + + # TODO test: When the paths should be blocked, use a single polygon with multiple identical + # vertices instead (also allowed?! change data requirements in doc!). + + # TODO test graph construction + # when two nodes have the same angle representation there should only be an edge to the closer node! + # test if property 1 is being properly exploited + # (extremities lying in front of each other need not be connected) + + +def test_overlapping_polygon(): + overlap_poly_env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) + overlap_poly_env.store(*OVERLAP_POLY_ENV_PARAMS) + overlap_poly_env.prepare() + print("\ntesting polygon environment with overlapping polygons") + try_test_cases(overlap_poly_env, TEST_DATA_OVERLAP_POLY_ENV) + + +def test_separated_environment(): + env = ENVIRONMENT_CLASS(**CONSTRUCTION_KWARGS) + env.store(*SEPARATED_ENV) + env.prepare() + print("\ntesting polygon environment with two separated areas") + try_test_cases(env, TEST_DATA_SEPARATE_ENV) diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..3e8ff54 --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,346 @@ +# size_x, size_y, obstacle_iter +from math import sqrt + +GRID_ENV_PARAMS = ( + 19, + 10, + [ + # (x,y), + # obstacles changing boundary + (0, 1), + (1, 1), + (2, 1), + (3, 1), + (17, 9), + (17, 8), + (17, 7), + (17, 5), + (17, 4), + (17, 3), + (17, 2), + (17, 1), + (17, 0), + # hole 1 + (5, 5), + (5, 6), + (6, 6), + (6, 7), + (7, 7), + # hole 2 + (7, 5), + ], +) + +INVALID_DESTINATION_DATA = [ + # outside of map region + ((-1, 5.0), (17, 0.5)), + ((17, 0.5), (-1, 5.0)), + ((20, 5.0), (17, 0.5)), + ((17, 0.5), (20, 5.0)), + ((1, -5.0), (17, 0.5)), + ((17, 0.5), (1, -5.0)), + ((1, 11.0), (17, 0.5)), + ((17, 0.5), (1, 11.0)), + # outside boundary polygon + ((17.5, 5.0), (17, 0.5)), + ((17, 0.5), (17.5, 5.0)), + ((1, 1.5), (17, 0.5)), + ((17, 0.5), (1, 1.5)), + # inside hole + ((6.5, 6.5), (17, 0.5)), + ((17, 0.5), (6.5, 6.5)), +] + +TEST_DATA_GRID_ENV = [ + # ((start,goal),(path,distance)) + # shortest paths should be distinct (reverse will automatically be tested) + # identical nodes + (((15, 5), (15, 5)), ([(15, 5), (15, 5)], 0.0)), + # directly reachable + (((15, 5), (15, 6)), ([(15, 5), (15, 6)], 1.0)), + (((15, 5), (16, 6)), ([(15, 5), (16, 6)], sqrt(2))), + # points on the polygon edges (vertices) should be accepted! + # on edge + (((15, 0), (15, 6)), ([(15, 0), (15, 6)], 6.0)), + (((17, 5), (16, 5)), ([(17, 5), (16, 5)], 1.0)), + # on edge of hole + (((7, 8), (7, 9)), ([(7, 8), (7, 9)], 1.0)), + # on vertex + (((4, 2), (4, 3)), ([(4, 2), (4, 3)], 1.0)), + # on vertex of hole + (((6, 8), (6, 9)), ([(6, 8), (6, 9)], 1.0)), + # on two vertices + # coinciding with edge (direct neighbour) + (((4, 2), (4, 1)), ([(4, 2), (4, 1)], 1.0)), + (((5, 5), (5, 7)), ([(5, 5), (5, 7)], 2.0)), + # should have direct connection to all visible extremities! connected in graph + (((6, 8), (5, 7)), ([(6, 8), (5, 7)], sqrt(2))), + (((4, 1), (5, 7)), ([(4, 1), (5, 7)], sqrt(1**2 + 6**2))), + # should have direct connection to all visible extremities! even if not connected in graph! + (((4, 2), (5, 7)), ([(4, 2), (5, 7)], sqrt(1**2 + 5**2))), + # mix of edges and vertices, directly visible + (((2, 2), (5, 7)), ([(2, 2), (5, 7)], sqrt(3**2 + 5**2))), + # also regular points should have direct connection to all visible extremities! + (((10, 3), (17, 6)), ([(10, 3), (17, 6)], sqrt(7**2 + 3**2))), + (((10, 3), (8, 8)), ([(10, 3), (8, 8)], sqrt(2**2 + 5**2))), + # even if the query point lies in front of an extremity! (test if new query vertices are being created!) + (((10, 3), (8, 5)), ([(10, 3), (8, 5)], sqrt(2**2 + 2**2))), + # using a* graph search: + # directly reachable through a single vertex (does not change distance!) + (((5, 1), (3, 3)), ([(5, 1), (4, 2), (3, 3)], sqrt(2**2 + 2**2))), + # If two Polygons have vertices with identical coordinates (this is allowed), + # paths through these vertices are theoretically possible! + ( + ((6.5, 5.5), (7.5, 6.5)), + ([(6.5, 5.5), (7, 6), (7.5, 6.5)], sqrt(1**2 + 1**2)), + ), + # distance should stay the same even if multiple extremities lie on direct path + # test if path is skipping passed extremities + (((8, 4), (8, 8)), ([(8, 4), (8, 5), (8, 6), (8, 7), (8, 8)], 4)), + (((8, 4), (8, 9)), ([(8, 4), (8, 5), (8, 6), (8, 7), (8, 8), (8, 9)], 5)), + # regular examples + ( + ((0.5, 6), (18.5, 0.5)), + ( + [(0.5, 6.0), (5, 5), (6, 5), (7, 5), (8, 5), (17, 6), (18, 6), (18.5, 0.5)], + 23.18783787537749, + ), + ), + ( + ((0.5, 6), (9, 5.5)), + ([(0.5, 6.0), (5, 5), (6, 5), (7, 5), (8, 5), (9.0, 5.5)], 8.727806217396338), + ), + ( + ((0.5, 6), (18.5, 9)), + ( + [(0.5, 6.0), (5, 5), (6, 5), (7, 5), (8, 5), (18, 7), (18.5, 9.0)], + 19.869364068640845, + ), + ), + ( + ((6.9, 4), (7, 9)), + ([(6.9, 4.0), (7, 6), (8, 7), (8, 8), (7, 9)], 5.830925564196269), + ), + ( + ((6.5, 4), (7, 9)), + ([(6.5, 4.0), (7, 6), (8, 7), (8, 8), (7, 9)], 5.889979937555021), + ), + # symmetric around the lower boundary obstacle + ( + ((0.5, 0.5), (0.5, 2.5)), + ([(0.5, 0.5), (4, 1), (4, 2), (0.5, 2.5)], 8.071067811865476), + ), + # symmetric around the lower right boundary obstacle + ( + ((16.5, 0.5), (18.5, 0.5)), + ([(16.5, 0.5), (17, 6), (18, 6), (18.5, 0.5)], 12.045361017187261), + ), + # symmetric around the top right boundary obstacle + ( + ((16.5, 9.5), (18.5, 9.5)), + ([(16.5, 9.5), (17, 7), (18, 7), (18.5, 9.5)], 6.0990195135927845), + ), +] + +POLY_ENV_PARAMS = ( + # boundary_coordinates + [(0.0, 0.0), (10.0, 0.0), (9.0, 5.0), (10.0, 10.0), (0.0, 10.0)], + # list_of_holes + [ + [ + (3.0, 7.0), + (5.0, 9.0), + (4.6, 7.0), + (5.0, 4.0), + ], + ], +) + +TEST_DATA_POLY_ENV = [ + # ((start,goal),(path,distance)) + # identical nodes + (((1, 1), (1, 1)), ([(1, 1), (1, 1)], 0.0)), + # directly reachable + (((1, 1), (1, 2)), ([(1, 1), (1, 2)], 1.0)), + (((1, 1), (2, 1)), ([(1, 1), (2, 1)], 1.0)), + # points on the polygon edges (vertices) should be accepted! + # on edge (boundary polygon) + (((1, 0), (1, 1)), ([(1, 0), (1, 1)], 1.0)), + (((9.5, 2.5), (8.5, 2.5)), ([(9.5, 2.5), (8.5, 2.5)], 1.0)), + (((0, 2), (0, 1)), ([(0, 2), (0, 1)], 1.0)), # both + (((1, 0), (5, 0)), ([(1, 0), (5, 0)], 4.0)), # both + # on edge of hole + (((4, 8), (3, 8)), ([(4, 8), (3, 8)], 1.0)), + (((4, 8), (4.1, 8.1)), ([(4, 8), (4.1, 8.1)], sqrt(2 * (0.1**2)))), # both + # on vertex + (((9, 5), (8, 5)), ([(9, 5), (8, 5)], 1.0)), + # on vertex of hole + (((3, 7), (2, 7)), ([(3, 7), (2, 7)], 1.0)), + # on two vertices + # coinciding with edge (direct neighbour) + (((3, 7), (5, 9)), ([(3, 7), (5, 9)], sqrt(8))), + (((4.6, 7), (5, 9)), ([(4.6, 7), (5, 9)], sqrt((0.4**2) + (2**2)))), + # should have direct connection to all visible extremities! connected in graph + (((5, 4), (5, 9)), ([(5, 4), (5, 9)], 5)), + # should have a direct connection to all visible extremities! even if not connected in graph! + (((9, 5), (5, 9)), ([(9, 5), (5, 9)], sqrt(2 * (4**2)))), + # using a* graph search: + # directly reachable through a single vertex (does not change distance!) + (((9, 4), (9, 6)), ([(9, 4), (9, 5), (9, 6)], 2)), + # slightly indented, path must go through right boundary extremity + (((9.1, 4), (9.1, 6)), ([(9.1, 4.0), (9.0, 5.0), (9.1, 6.0)], 2.009975124224178)), + # path must go through lower hole extremity + (((4, 4.5), (6, 4.5)), ([(4.0, 4.5), (5.0, 4.0), (6.0, 4.5)], 2.23606797749979)), + # path must go through top hole extremity + (((4, 8.5), (6, 8.5)), ([(4.0, 8.5), (5.0, 9.0), (6.0, 8.5)], 2.23606797749979)), +] + +OVERLAP_POLY_ENV_PARAMS = ( + # boundary_coordinates + [ + (9.5, 10.5), + (25.5, 10.5), + (25.5, 0.5), + (49.5, 0.5), + (49.5, 49.5), + (0.5, 49.5), + (0.5, 16.5), + (9.5, 16.5), + (9.5, 45.5), + (10.0, 45.5), + (10.0, 30.5), + (35.5, 30.5), + (35.5, 14.5), + (0.5, 14.5), + (0.5, 0.5), + (9.5, 0.5), + ], + # list_of_holes + [ + [ + (40.5, 4.5), + (29.5, 4.5), + (29.5, 15.0), + (40.5, 15.0), + ], + [ + (45.4, 14.5), + (44.6, 14.5), + (43.4, 20.5), + (46.6, 20.5), + ], + # slightly right of the top boundary obstacle + # goal: create an obstacle that obstructs two very close extremities + # to check if visibility is correctly blocked in such cases + [ + (40, 34), + (10.5, 34), + (10.5, 40), + (40, 40), + ], + # on the opposite site close to top boundary obstacle + [ + (9, 34), + (5, 34), + (5, 40), + (9, 40), + ], + [ + (31.5, 5.390098048839718), + (31.5, 10.909901951439679), + (42.5, 13.109901951160282), + (42.5, 7.590098048560321), + ], + ], +) + +TEST_DATA_OVERLAP_POLY_ENV = [ + # ((start,goal),(path,distance)) + ( + ((1, 1), (5, 20)), + ( + [ + (1, 1), + (9.5, 10.5), + (25.5, 10.5), + (29.5, 4.5), + (40.5, 4.5), + (42.5, 7.590098048560321), + (42.5, 13.109901951160282), + (35.5, 30.5), + (10.5, 34.0), + (10.0, 45.5), + (9.5, 45.5), + (9, 34), + (5, 20), + ], + 138.23115155299263, + ), + ), + ( + ((2, 38), (45, 45)), + ([(2.0, 38.0), (9.5, 45.5), (10.0, 45.5), (45.0, 45.0)], 46.11017296417249), + ), + ( + ((2, 38), (45, 2)), + ( + [ + (2.0, 38.0), + (9.5, 45.5), + (10.0, 45.5), + (10.5, 34.0), + (35.5, 30.5), + (42.5, 13.109901951160282), + (45.0, 2.0), + ], + 77.99506635830616, + ), + ), + ( + ((2, 38), (38, 2)), + ( + [ + (2.0, 38.0), + (9.5, 45.5), + (10.0, 45.5), + (10.5, 34.0), + (35.5, 30.5), + (42.5, 13.109901951160282), + (42.5, 7.590098048560321), + (40.5, 4.5), + (38.0, 2.0), + ], + 79.34355163003127, + ), + ), + ( + ((2, 38), (28, 2)), + ( + [ + (2.0, 38.0), + (9.5, 45.5), + (10.0, 45.5), + (10.5, 34.0), + (35.5, 30.5), + (42.5, 13.109901951160282), + (42.5, 7.590098048560321), + (40.5, 4.5), + (28.0, 2.0), + ], + 88.55556650808049, + ), + ), +] + +SEPARATED_ENV = ( + [(5, 5), (-5, 5), (-5, -5), (5, -5)], + [[(-5.1, 1), (-5.1, 2), (5.1, 2), (5.1, 1)]], # intersecting polygons -> no path possible + # [[(-5, 1), (-5, 2), (5, 2), (5, 1)]], # hole lies on the edges -> path possible +) + +TEST_DATA_SEPARATE_ENV = [ + # ((start,goal),(path,distance)) + (((0, 0), (0, 4)), ([], None)), # unreachable +] + +# ((start,goal),(path,distance))