scgraph.core

   1from .utils import haversine, distance_converter, get_line_path, cheap_ruler
   2import json
   3from heapq import heappop, heappush
   4
   5
   6class Graph:
   7    @staticmethod
   8    def validate_graph(
   9        graph: list[dict[int, int | float]],
  10        check_symmetry: bool = True,
  11        check_connected: bool = True,
  12    ) -> None:
  13        """
  14        Function:
  15
  16        - Validate that a graph is properly formatted
  17
  18        Required Arguments:
  19
  20        - `graph`:
  21            - Type: list of dictionaries with integer keys and integer or float values
  22            - What: A list of dictionaries where the indicies are origin node ids and the values are dictionaries of destination node indices and graph weights
  23            - Note: All nodes must be included as origins in the graph regardless of if they have any connected destinations
  24            - EG:
  25            ```
  26                [
  27                    # From London (index 0)
  28                    {
  29                        # To Paris (index 1)
  30                        1: 311,
  31                    },
  32                    # From Paris (index 1)
  33                    {
  34                        # To London (index 0)
  35                        0: 311,
  36                        # To Berlin (index 2)
  37                        2: 878,
  38                        # To Rome (index 3)
  39                        3: 1439,
  40                        # To Madrid (index 4)
  41                        4: 1053
  42                    },
  43                    # From Berlin (index 2)
  44                    {
  45                        # To Paris (index 1)
  46                        1: 878,
  47                        # To Rome (index 3)
  48                        3: 1181,
  49                    },
  50                    # From Rome (index 3)
  51                    {
  52                        # To Paris (index 1)
  53                        1: 1439,
  54                        # To Berlin (index 2)
  55                        2: 1181,
  56                    },
  57                    # From Madrid (index 4)
  58                    {
  59                        # To Paris (index 1)
  60                        1: 1053,
  61                        # To Lisbon (index 5)
  62                        5: 623
  63                    },
  64                    # From Lisbon (index 5)
  65                    {
  66                        # To Madrid (index 4)
  67                        4: 623
  68                    }
  69                ]
  70            ```
  71
  72        Optional Arguments:
  73
  74        - `check_symmetry`
  75            - Type: bool
  76            - What: Whether to check that the graph is symmetric
  77            - Default: True
  78            - Note: This is forced to True if `check_connected` is True
  79        - `check_connected`
  80            - Type: bool
  81            - What: Whether to check that the graph is fully connected
  82            - Default: True
  83            - Note: For computational efficiency, only symmetric graphs are checked for connectivity
  84            - Note: If this is True, `check_symmetry` is forced to True and the graph will be checked for symmetry prior to checking for connectivity
  85        """
  86        check_symmetry = check_symmetry or check_connected
  87        assert isinstance(graph, list), "Your graph must be a list"
  88        len_graph = len(graph)
  89        for origin_id, origin_dict in enumerate(graph):
  90            assert isinstance(
  91                origin_dict, dict
  92            ), f"Your graph must be a list of dictionaries but the value for origin {origin_id} is not a dictionary"
  93            destinations = list(origin_dict.keys())
  94            lengths = list(origin_dict.values())
  95            assert all(
  96                [
  97                    (isinstance(i, int) and i >= 0 and i < len_graph)
  98                    for i in destinations
  99                ]
 100            ), f"Destination ids must be non-negative integers and equivalent to an existing index, but graph[{origin_id}] has an error in the destination ids"
 101            assert all(
 102                [(isinstance(i, (int, float)) and i >= 0) for i in lengths]
 103            ), f"Distances must be integers or floats, but graph[{origin_id}] contains a non-integer or non-float distance"
 104            if check_symmetry:
 105                for destination_id, distance in origin_dict.items():
 106                    assert (
 107                        graph[destination_id].get(origin_id) == distance
 108                    ), f"Your graph is not symmetric, the distance from node {origin_id} to node {destination_id} is {distance} but the distance from node {destination_id} to node {origin_id} is {graph.get(destination_id, {}).get(origin_id)}"
 109        if check_connected:
 110            assert Graph.validate_connected(
 111                graph
 112            ), "Your graph is not fully connected"
 113
 114    @staticmethod
 115    def validate_connected(
 116        graph: list[dict[int, int | float]], origin_id: int = 0
 117    ) -> bool:
 118        """
 119        Function:
 120
 121        - Validate that a graph is fully connected
 122            - This means that every node in the graph has a path to every other node in the graph
 123            - Note: This assumes that the graph is symmetric
 124        - Return True if the graph is fully connected and False if it is not
 125
 126        Required Arguments:
 127
 128        - `graph`:
 129            - Type: list of dictionaries
 130            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 131
 132        Optional Arguments:
 133
 134        - `origin_id`
 135            - Type: int
 136            - What: The id of the origin node from which to start the connectivity check
 137            - Default: 0
 138        """
 139        visited = [0] * len(graph)
 140        open_leaves = [origin_id]
 141
 142        while open_leaves:
 143            current_id = open_leaves.pop()
 144            visited[current_id] = 1
 145            for connected_id, connected_distance in graph[current_id].items():
 146                if visited[connected_id] == 0:
 147                    open_leaves.append(connected_id)
 148        return min(visited) == 1
 149
 150    @staticmethod
 151    def input_check(
 152        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
 153    ) -> None:
 154        """
 155        Function:
 156
 157        - Check that the inputs passed to the shortest path algorithm are valid
 158        - Raises an exception if the inputs passed are not valid
 159
 160        Required Arguments:
 161
 162        - `graph`:
 163            - Type: list of dictionaries
 164            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 165        - `origin_id`
 166            - Type: int
 167            - What: The id of the origin node from the graph dictionary to start the shortest path from
 168        - `destination_id`
 169            - Type: int
 170            - What: The id of the destination node from the graph dictionary to end the shortest path at
 171
 172        Optional Arguments:
 173
 174        - None
 175        """
 176        if (
 177            not isinstance(origin_id, int)
 178            and origin_id < len(graph)
 179            and origin_id >= 0
 180        ):
 181            raise Exception(f"Origin node ({origin_id}) is not in the graph")
 182        if (
 183            not isinstance(destination_id, int)
 184            and origin_id < len(graph)
 185            and origin_id >= 0
 186        ):
 187            raise Exception(
 188                f"Destination node ({destination_id}) is not in the graph"
 189            )
 190
 191    @staticmethod
 192    def dijkstra(
 193        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
 194    ) -> dict:
 195        """
 196        Function:
 197
 198        - Identify the shortest path between two nodes in a sparse network graph using a modified dijkstra algorithm
 199            - Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
 200            - This can dramatically reduce the memory and compute requirements of the algorithm
 201            - This algorithm runs in O(n^2) time where n is the number of nodes in the graph
 202        - Return a dictionary of various path information including:
 203            - `id_path`: A list of node ids in the order they are visited
 204            - `path`: A list of node dictionaries (lat + long) in the order they are visited
 205
 206        Required Arguments:
 207
 208        - `graph`:
 209            - Type: list of dictionaries
 210            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 211        - `origin_id`
 212            - Type: int
 213            - What: The id of the origin node from the graph dictionary to start the shortest path from
 214        - `destination_id`
 215            - Type: int
 216            - What: The id of the destination node from the graph dictionary to end the shortest path at
 217
 218        Optional Arguments:
 219
 220        - None
 221        """
 222        Graph.input_check(
 223            graph=graph, origin_id=origin_id, destination_id=destination_id
 224        )
 225        distance_matrix = [float("inf")] * len(graph)
 226        branch_tip_distances = [float("inf")] * len(graph)
 227        predecessor = [-1] * len(graph)
 228
 229        distance_matrix[origin_id] = 0
 230        branch_tip_distances[origin_id] = 0
 231
 232        while True:
 233            current_distance = min(branch_tip_distances)
 234            if current_distance == float("inf"):
 235                raise Exception(
 236                    "Something went wrong, the origin and destination nodes are not connected."
 237                )
 238            current_id = branch_tip_distances.index(current_distance)
 239            branch_tip_distances[current_id] = float("inf")
 240            if current_id == destination_id:
 241                break
 242            for connected_id, connected_distance in graph[current_id].items():
 243                possible_distance = current_distance + connected_distance
 244                if possible_distance < distance_matrix[connected_id]:
 245                    distance_matrix[connected_id] = possible_distance
 246                    predecessor[connected_id] = current_id
 247                    branch_tip_distances[connected_id] = possible_distance
 248
 249        output_path = [current_id]
 250        while predecessor[current_id] != -1:
 251            current_id = predecessor[current_id]
 252            output_path.append(current_id)
 253
 254        output_path.reverse()
 255
 256        return {
 257            "path": output_path,
 258            "length": distance_matrix[destination_id],
 259        }
 260
 261    @staticmethod
 262    def dijkstra_makowski(
 263        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
 264    ) -> dict:
 265        """
 266        Function:
 267
 268        - Identify the shortest path between two nodes in a sparse network graph using Makowski's modified Dijkstra algorithm
 269            - Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
 270            - Improvements include only computing future potential nodes based on the open leaves for each branch
 271                - Open leaves are nodes that have not been visited yet but are adjacent to other visited nodes
 272            - This can dramatically reduce the memory and compute requirements of the algorithm
 273            - For particularly sparse graphs, this algorithm runs close to O(n log n) time
 274                - Where n is the number of nodes in the graph
 275            - For dense graphs, this algorithm runs closer to O(n^2) time (similar to the standard Dijkstra algorithm)
 276                - Where n is the number of nodes in the graph
 277        - Return a dictionary of various path information including:
 278            - `id_path`: A list of node ids in the order they are visited
 279            - `path`: A list of node dictionaries (lat + long) in the order they are visited
 280
 281        Required Arguments:
 282
 283        - `graph`:
 284            - Type: list of dictionaries
 285            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 286        - `origin_id`
 287            - Type: int
 288            - What: The id of the origin node from the graph dictionary to start the shortest path from
 289        - `destination_id`
 290            - Type: int
 291            - What: The id of the destination node from the graph dictionary to end the shortest path at
 292
 293        Optional Arguments:
 294
 295        - None
 296        """
 297        # Input Validation
 298        Graph.input_check(
 299            graph=graph, origin_id=origin_id, destination_id=destination_id
 300        )
 301        # Variable Initialization
 302        distance_matrix = [float("inf")] * len(graph)
 303        distance_matrix[origin_id] = 0
 304        open_leaves = []
 305        heappush(open_leaves, (0, origin_id))
 306        predecessor = [-1] * len(graph)
 307
 308        while open_leaves:
 309            current_distance, current_id = heappop(open_leaves)
 310            if current_id == destination_id:
 311                break
 312            for connected_id, connected_distance in graph[current_id].items():
 313                possible_distance = current_distance + connected_distance
 314                if possible_distance < distance_matrix[connected_id]:
 315                    distance_matrix[connected_id] = possible_distance
 316                    predecessor[connected_id] = current_id
 317                    heappush(open_leaves, (possible_distance, connected_id))
 318        if current_id != destination_id:
 319            raise Exception(
 320                "Something went wrong, the origin and destination nodes are not connected."
 321            )
 322
 323        output_path = [current_id]
 324        while predecessor[current_id] != -1:
 325            current_id = predecessor[current_id]
 326            output_path.append(current_id)
 327
 328        output_path.reverse()
 329
 330        return {
 331            "path": output_path,
 332            "length": distance_matrix[destination_id],
 333        }
 334
 335    @staticmethod
 336    def a_star(
 337        graph: list[dict[int, int | float]],
 338        origin_id: int,
 339        destination_id: int,
 340        heuristic_fn=None,
 341    ) -> dict:
 342        """
 343        Function:
 344
 345        - Identify the shortest path between two nodes in a sparse network graph using an A* extension of Makowski's modified Dijkstra algorithm
 346        - Return a dictionary of various path information including:
 347            - `id_path`: A list of node ids in the order they are visited
 348            - `path`: A list of node dictionaries (lat + long) in the order they are visited
 349
 350        Required Arguments:
 351
 352        - `graph`:
 353            - Type: list of dictionaries
 354            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 355        - `origin_id`
 356            - Type: int
 357            - What: The id of the origin node from the graph dictionary to start the shortest path from
 358        - `destination_id`
 359            - Type: int
 360            - What: The id of the destination node from the graph dictionary to end the shortest path at
 361        - `heuristic_fn`
 362            - Type: function
 363            - What: A heuristic function that takes two node ids and returns an estimated distance between them
 364            - Note: If None, returns the shortest path using Makowski's modified Dijkstra algorithm
 365            - Default: None
 366
 367        Optional Arguments:
 368
 369        - None
 370        """
 371        if heuristic_fn is None:
 372            return Graph.dijkstra_makowski(
 373                graph=graph,
 374                origin_id=origin_id,
 375                destination_id=destination_id,
 376            )
 377        # Input Validation
 378        Graph.input_check(
 379            graph=graph, origin_id=origin_id, destination_id=destination_id
 380        )
 381        # Variable Initialization
 382        distance_matrix = [float("inf")] * len(graph)
 383        distance_matrix[origin_id] = 0
 384        open_leaves = []
 385        heappush(open_leaves, (0, origin_id))
 386        predecessor = [-1] * len(graph)
 387
 388        while open_leaves:
 389            current_id = heappop(open_leaves)[1]
 390            if current_id == destination_id:
 391                break
 392            current_distance = distance_matrix[current_id]
 393            for connected_id, connected_distance in graph[current_id].items():
 394                possible_distance = current_distance + connected_distance
 395                if possible_distance < distance_matrix[connected_id]:
 396                    distance_matrix[connected_id] = possible_distance
 397                    predecessor[connected_id] = current_id
 398                    heappush(
 399                        open_leaves,
 400                        (
 401                            possible_distance
 402                            + heuristic_fn(connected_id, destination_id),
 403                            connected_id,
 404                        ),
 405                    )
 406        if current_id != destination_id:
 407            raise Exception(
 408                "Something went wrong, the origin and destination nodes are not connected."
 409            )
 410
 411        output_path = [current_id]
 412        while predecessor[current_id] != -1:
 413            current_id = predecessor[current_id]
 414            output_path.append(current_id)
 415
 416        output_path.reverse()
 417
 418        return {
 419            "path": output_path,
 420            "length": distance_matrix[destination_id],
 421        }
 422
 423
 424class GeoGraph:
 425    def __init__(
 426        self,
 427        graph: list[dict[int, int | float]],
 428        nodes: list[list[float | int]],
 429    ) -> None:
 430        """
 431        Function:
 432
 433        - Create a GeoGraph object
 434
 435        Required Arguments:
 436
 437        - `graph`
 438            - Type: list of dictionaries
 439            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 440        - `nodes`
 441            - Type: list of lists of ints or floats
 442            - What: A list of lists where the values are coordinates (latitude then longitude)
 443            - Note: The length of the nodes list must be the same as that of the graph list
 444            - EG Continuing off the example from https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 445            ```
 446                [
 447                    # London (index 0)
 448                    [51.5074, 0.1278],
 449                    # Paris (index 1)
 450                    [48.8566, 2.3522],
 451                    # Berlin (index 2)
 452                    [52.5200, 13.4050],
 453                    # Rome (index 3)
 454                    [41.9028, 12.4964],
 455                    # Madrid (index 4)
 456                    [40.4168, 3.7038],
 457                    # Lisbon (index 5)
 458                    [38.7223, 9.1393]
 459                ]
 460            ```
 461        """
 462        self.graph = graph
 463        self.nodes = nodes
 464
 465    def validate_graph(
 466        self, check_symmetry: bool = True, check_connected: bool = True
 467    ) -> None:
 468        """
 469        Function:
 470
 471        - Validate that self.graph is properly formatted (see Graph.validate_graph)
 472        - Raises an exception if the graph is invalid
 473        - Returns None if the graph is valid
 474
 475        Required Arguments:
 476
 477        - None
 478
 479        Optional Arguments:
 480
 481        - `check_symmetry`
 482            - Type: bool
 483            - What: Whether to check that the graph is symmetric
 484            - Default: True
 485            - Note: This is forced to True if `check_connected` is True
 486        - `check_connected`
 487            - Type: bool
 488            - What: Whether to check that the graph is fully connected
 489            - Default: True
 490            - Note: For computational efficiency, graphs are validated for symmetry prior to checking for connectivity
 491        """
 492        Graph.validate_graph(
 493            self.graph,
 494            check_symmetry=check_symmetry,
 495            check_connected=check_connected,
 496        )
 497
 498    def validate_nodes(self) -> None:
 499        """
 500
 501        Function:
 502
 503        - Validate that self.nodes is properly formatted (see GeoGraph.__init__ docs for more details)
 504        - Raises an exception if the nodes are invalid
 505        - Returns None if the nodes are valid
 506
 507        Required Arguments:
 508
 509        - None
 510
 511        Optional Arguments:
 512
 513        - None
 514        """
 515        assert isinstance(self.nodes, list), "Your nodes must be a dictionary"
 516        assert all(
 517            [isinstance(i, list) for i in self.nodes]
 518        ), "Your nodes must be a list of lists"
 519        assert all(
 520            [len(i) == 2 for i in self.nodes]
 521        ), "Your nodes must be a list of lists where each sub list has a length of 2"
 522        assert all(
 523            [
 524                (
 525                    isinstance(i[0], (int, float))
 526                    and isinstance(i[1], (int, float))
 527                )
 528                for i in self.nodes
 529            ]
 530        ), "Your nodes must be a list of lists where each sub list has a numeric latitude and longitude value"
 531        assert all(
 532            [
 533                (i[0] >= -90 and i[0] <= 90 and i[1] >= -180 and i[1] <= 180)
 534                for i in self.nodes
 535            ]
 536        ), "Your nodes must be a list of lists where each sub list has a length of 2 with a latitude [-90,90] and longitude [-180,180] value"
 537
 538    def haversine(
 539        self,
 540        origin_id: int,
 541        destination_id: int,
 542    ):
 543        return haversine(
 544            origin=self.nodes[origin_id],
 545            destination=self.nodes[destination_id],
 546            units="km",
 547            circuity=1,
 548        )
 549
 550    def cheap_ruler(
 551        self,
 552        origin_id: int,
 553        destination_id: int,
 554    ):
 555        return cheap_ruler(
 556            origin=self.nodes[origin_id],
 557            destination=self.nodes[destination_id],
 558            units="km",
 559            # Use a circuity factor of 0.95 to account for the fact that cheap_ruler can overestimate distances
 560            circuity=0.9,
 561        )
 562
 563    def get_shortest_path(
 564        self,
 565        origin_node: dict[float | int],
 566        destination_node: dict[float | int],
 567        output_units: str = "km",
 568        algorithm_fn=Graph.dijkstra_makowski,
 569        algorithm_kwargs: dict = dict(),
 570        off_graph_circuity: [float | int] = 1,
 571        node_addition_type: str = "quadrant",
 572        node_addition_circuity: [float | int] = 4,
 573        geograph_units: str = "km",
 574        output_coordinate_path: str = "list_of_lists",
 575        output_path: bool = False,
 576        node_addition_lat_lon_bound: [float | int] = 5,
 577        node_addition_math: str = "euclidean",
 578        **kwargs,
 579    ) -> dict:
 580        """
 581        Function:
 582
 583        - Identify the shortest path between two nodes in a sparse network graph
 584
 585        - Return a dictionary of various path information including:
 586            - `id_path`: A list of node ids in the order they are visited
 587            - `path`: A list of nodes  (list of lat then long) in the order they are visited
 588            - `length`: The length of the path
 589
 590         Required Arguments:
 591
 592        - `origin_node`
 593            - Type: dict of int | float
 594            - What: A dictionary with the keys 'latitude' and 'longitude'
 595        - `destination_node`
 596            - Type: dict of int | float
 597            - What: A dictionary with the keys 'latitude' and 'longitude'
 598
 599        Optional Arguments:
 600
 601        - `output_units`
 602            - Type: str
 603            - What: The units in which to return the length of the path
 604            - Default: 'km'
 605            - Options:
 606                - 'km': Kilometers
 607                - 'm': Meters
 608                - 'mi': Miles
 609                - 'ft': Feet
 610        - `algorithm_fn`
 611            - Type: function | method
 612            - What: The algorithm function to identify the shortest path
 613            - Default: 'Graph.dijkstra_makowski'
 614            - Options:
 615                - 'Graph.dijkstra': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
 616                - 'Graph.dijkstra_makowski': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
 617                - Any user defined algorithm that takes the arguments:
 618                    - `graph`: A dictionary of dictionaries where the keys are origin node ids and the values are dictionaries of destination node ids and distances
 619                        - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 620                    - `origin`: The id of the origin node from the graph dictionary to start the shortest path from
 621                    - `destination`: The id of the destination node from the graph dictionary to end the shortest path at
 622        - `algorithm_kwargs`
 623            - Type: dict
 624            - What: Additional keyword arguments to pass to the algorithm function assuming it accepts them
 625        - `off_graph_circuity`
 626            - Type: int | float
 627            - What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
 628            - Default: 1
 629            - Notes:
 630                - For alogrithmic solving purposes, the node_addition_circuity is applied to the origin and destination nodes when they are added to the graph
 631                - This is only applied after an `optimal solution` using the `node_addition_circuity` has been found when it is then adjusted to equal the `off_graph_circuity`
 632        - `node_addition_type`
 633            - Type: str
 634            - What: The type of node addition to use when adding your origin node to the distance matrix
 635            - Default: 'quadrant'
 636            - Options:
 637                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 638                - 'closest': Add only the closest node to the distance matrix for this node
 639                - 'all': Add all nodes to the distance matrix for this node
 640            - Notes:
 641                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 642                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 643                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 644                - The destination node is always added as 'all' regardless of the `node_addition_type` setting
 645                    - This guarantees that any destination node will be connected to any origin node regardless of how or where the origin node is added to the graph
 646                - If the passed graph is not a connected graph (meaning it is comprised of multiple disconnected networks)
 647                    - The entrypoints generated using the `node_addition_type` will determine which disconnected networks will be used to calculate the `optimal route`
 648        - `node_addition_circuity`
 649            - Type: int | float
 650            - What: The circuity factor to apply when adding your origin and destination nodes to the distance matrix
 651            - Default: 4
 652            - Note:
 653                - This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 654                - A higher value will push the algorithm to join the network at a closer node to avoid the extra distance from the circuity factor
 655                - This is only relevant if `node_addition_type` is set to 'quadrant' or 'all' as it affects the choice on where to enter the graph network
 656                - This factor is used to calculate the node sequence for the `optimal route`, however the reported `length` of the path will be calculated using the `off_graph_circuity` factor
 657        - `geograph_units`
 658            - Type: str
 659            - What: The units of measurement in the geograph data
 660            - Default: 'km'
 661            - Options:
 662                - 'km': Kilometers
 663                - 'm': Meters
 664                - 'mi': Miles
 665                - 'ft': Feet
 666            - Note: In general, all scgraph provided geographs be in kilometers
 667        - `output_coordinate_path`
 668            - Type: str
 669            - What: The format of the output coordinate path
 670            - Default: 'list_of_lists'
 671            - Options:
 672                - 'list_of_dicts': A list of dictionaries with keys 'latitude' and 'longitude'
 673                - 'list_of_lists': A list of lists with the first value being latitude and the second being longitude
 674                - 'list_of_lists_long_first': A list of lists with the first value being longitude and the second being latitude
 675        - `output_path`
 676            - Type: bool
 677            - What: Whether to output the path as a list of geograph node ids (for debugging and other advanced uses)
 678            - Default: False
 679        - `node_addition_lat_lon_bound`
 680            - Type: int | float
 681            - What: Forms a bounding box around the origin and destination nodes as they are added to graph
 682                - Only points on the current graph inside of this bounding box are considered when updating the distance matrix for the origin or destination nodes
 683            - Default: 5
 684            - Note: If no nodes are found within the bounding box, the bounding box is expanded to 180 degrees in all directions (all nodes are considered)
 685            - Note: This is only used when adding a new node (the specified origin and destination) to the graph
 686        - `node_addition_math`
 687            - Type: str
 688            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 689            - Default: 'euclidean'
 690            - Options:
 691                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not as accurate (especially near the poles)
 692                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 693            - Notes:
 694                - Only used if `node_addition_type` is set to 'quadrant' or 'closest'
 695        - `**kwargs`
 696            - Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
 697        """
 698        original_graph_length = len(self.graph)
 699        # Add the origin and destination nodes to the graph
 700        origin_id = self.add_node(
 701            node=origin_node,
 702            node_addition_type=node_addition_type,
 703            circuity=node_addition_circuity,
 704            lat_lon_bound=node_addition_lat_lon_bound,
 705            node_addition_math=node_addition_math,
 706        )
 707        destination_id = self.add_node(
 708            node=destination_node,
 709            node_addition_type="all",
 710            circuity=node_addition_circuity,
 711            lat_lon_bound=node_addition_lat_lon_bound,
 712            node_addition_math=node_addition_math,
 713        )
 714        try:
 715            output = algorithm_fn(
 716                graph=self.graph,
 717                origin_id=origin_id,
 718                destination_id=destination_id,
 719                **algorithm_kwargs,
 720            )
 721            output["coordinate_path"] = self.get_coordinate_path(output["path"])
 722            output["length"] = self.adujust_circuity_length(
 723                output=output,
 724                node_addition_circuity=node_addition_circuity,
 725                off_graph_circuity=off_graph_circuity,
 726            )
 727            output["length"] = distance_converter(
 728                output["length"],
 729                input_units=geograph_units,
 730                output_units=output_units,
 731            )
 732            if output_coordinate_path == "list_of_dicts":
 733                output["coordinate_path"] = [
 734                    {"latitude": i[0], "longitude": i[1]}
 735                    for i in output["coordinate_path"]
 736                ]
 737            elif output_coordinate_path == "list_of_lists_long_first":
 738                output["coordinate_path"] = [
 739                    [i[1], i[0]] for i in output["coordinate_path"]
 740                ]
 741                output["long_first"] = True
 742            if not output_path:
 743                del output["path"]
 744            while len(self.graph) > original_graph_length:
 745                self.remove_appended_node()
 746            return output
 747        except Exception as e:
 748            while len(self.graph) > original_graph_length:
 749                self.remove_appended_node()
 750            raise e
 751
 752    def adujust_circuity_length(
 753        self,
 754        output: dict,
 755        node_addition_circuity: [float | int],
 756        off_graph_circuity: [float | int],
 757    ) -> [float | int]:
 758        """
 759        Function:
 760
 761        - Adjust the length of the path to account for the circuity factors applied to the origin and destination nodes
 762
 763        Required Arguments:
 764
 765        - `output`
 766            - Type: dict
 767            - What: The output from the algorithm function
 768        - `node_addition_circuity`
 769            - Type: int | float
 770            - What: The circuity factor that was applied when adding your origin and destination nodes to the distance matrix
 771        - `off_graph_circuity`
 772            - Type: int | float
 773            - What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
 774        """
 775        coordinate_path = output["coordinate_path"]
 776        # If the path does not enter the graph, undo the node_addition_circuity and apply the off_graph_circuity
 777        if len(output["coordinate_path"]) == 2:
 778            return (
 779                output["length"] / node_addition_circuity
 780            ) * off_graph_circuity
 781        else:
 782            direct_off_graph_length = haversine(
 783                coordinate_path[0], coordinate_path[1], circuity=1
 784            ) + haversine(coordinate_path[-2], coordinate_path[-1], circuity=1)
 785            return round(
 786                output["length"]
 787                + direct_off_graph_length
 788                * (off_graph_circuity - node_addition_circuity),
 789                4,
 790            )
 791
 792    def get_coordinate_path(self, path: list[int]) -> list[dict[float | int]]:
 793        """
 794        Function:
 795
 796        - Return a list of node dictionaries (lat + long) in the order they are visited
 797
 798        Required Arguments:
 799
 800        - `path`
 801            - Type: list
 802            - What: A list of node ids in the order they are visited
 803
 804        Optional Arguments:
 805
 806        - None
 807        """
 808        return [self.nodes[node_id] for node_id in path]
 809
 810    def remove_appended_node(self) -> None:
 811        """
 812        Function:
 813
 814        - Remove the last node that was appended to the graph
 815        - Assumes that this node has symmetric flows
 816            - EG: If node A has a distance of 10 to node B, then node B has a distance of 10 to node A
 817        - Return None
 818
 819        Required Arguments:
 820
 821        - None
 822
 823        Optional Arguments:
 824
 825        - None
 826        """
 827        node_id = len(self.graph) - 1
 828        for reverse_connection in [i for i in self.graph[node_id].keys()]:
 829            del self.graph[reverse_connection][node_id]
 830        self.graph = self.graph[:node_id]
 831        self.nodes = self.nodes[:node_id]
 832
 833    def get_node_distances(
 834        self,
 835        node: list,
 836        circuity: [float | int],
 837        node_addition_type: str,
 838        node_addition_math: str,
 839        lat_lon_bound: [float | int],
 840    ) -> dict[float | int]:
 841        """
 842        Function:
 843
 844        - Get the distances between a node and all other nodes in the graph
 845        - This is used to determine the closest node to add to the graph when adding a new node
 846
 847        Required Arguments:
 848
 849        - `node`
 850            - Type: list
 851            - What: A list of the latitude and longitude of the node
 852            - EG: [latitude, longitude] -> [31.23, 121.47]
 853        - `circuity`
 854            - Type: int | float
 855            - What: The circuity to apply to any distance calculations
 856            - Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 857        - `node_addition_type`
 858            - Type: str
 859            - What: The type of node addition to use
 860            - Options:
 861                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 862                - 'closest': Add only the closest node to the distance matrix for this node
 863                - 'all': Add all nodes to the distance matrix for this node
 864            - Notes:
 865                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 866                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 867                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 868        - `node_addition_math`
 869            - Type: str
 870            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 871            - Default: 'euclidean'
 872            - Options:
 873                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
 874                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 875            - Notes:
 876                - Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
 877        - `lat_lon_bound`
 878            - Type: int | float
 879            - What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
 880        """
 881        assert node_addition_type in [
 882            "quadrant",
 883            "all",
 884            "closest",
 885        ], f"Invalid node addition type provided ({node_addition_type}), valid options are: ['quadrant', 'all', 'closest']"
 886        assert node_addition_math in [
 887            "euclidean",
 888            "haversine",
 889        ], f"Invalid node addition math provided ({node_addition_math}), valid options are: ['euclidean', 'haversine']"
 890        # Get only bounded nodes
 891        nodes = {
 892            node_idx: node_i
 893            for node_idx, node_i in enumerate(self.nodes)
 894            if abs(node_i[0] - node[0]) < lat_lon_bound
 895            and abs(node_i[1] - node[1]) < lat_lon_bound
 896        }
 897        if len(nodes) == 0:
 898            # Default to all if the lat_lon_bound fails to find any nodes
 899            return self.get_node_distances(
 900                node=node,
 901                circuity=circuity,
 902                lat_lon_bound=180,
 903                node_addition_type=node_addition_type,
 904                node_addition_math=node_addition_math,
 905            )
 906        if node_addition_type == "all":
 907            return {
 908                node_idx: round(haversine(node, node_i, circuity=circuity), 4)
 909                for node_idx, node_i in nodes.items()
 910            }
 911        if node_addition_math == "haversine":
 912            dist_fn = lambda x: round(haversine(node, x, circuity=circuity), 4)
 913        else:
 914            dist_fn = lambda x: round(
 915                ((node[0] - x[0]) ** 2 + (node[1] - x[1]) ** 2) ** 0.5, 4
 916            )
 917        if node_addition_type == "closest":
 918            quadrant_fn = lambda x, y: "all"
 919        else:
 920            quadrant_fn = lambda x, y: ("n" if x[0] - y[0] > 0 else "s") + (
 921                "e" if x[1] - y[1] > 0 else "w"
 922            )
 923        min_diffs = {}
 924        min_diffs_idx = {}
 925        for node_idx, node_i in nodes.items():
 926            quadrant = quadrant_fn(node_i, node)
 927            dist = dist_fn(node_i)
 928            if dist < min_diffs.get(quadrant, 999999999):
 929                min_diffs[quadrant] = dist
 930                min_diffs_idx[quadrant] = node_idx
 931        return {
 932            node_idx: round(
 933                haversine(node, self.nodes[node_idx], circuity=circuity), 4
 934            )
 935            for node_idx in min_diffs_idx.values()
 936        }
 937
 938    def add_node(
 939        self,
 940        node: dict[float | int],
 941        circuity: [float | int],
 942        node_addition_type: str = "quadrant",
 943        node_addition_math: str = "euclidean",
 944        lat_lon_bound: [float | int] = 5,
 945    ) -> int:
 946        """
 947        Function:
 948
 949        - Add a node to the network
 950        - Returns the id of the new node
 951
 952        Required Arguments:
 953
 954        - `node`
 955            - Type: dict
 956            - What: A dictionary with the keys 'latitude' and 'longitude'
 957
 958        Optional Arguments:
 959
 960        - `circuity`
 961            - Type: int | float
 962            - What: The circuity to apply to any distance calculations
 963            - Default: 4
 964            - Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 965        - `node_addition_type`
 966            - Type: str
 967            - What: The type of node addition to use
 968            - Default: 'quadrant'
 969            - Options:
 970                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 971                - 'closest': Add only the closest node to the distance matrix for this node
 972                - 'all': Add all nodes to the distance matrix for this node
 973            - Notes:
 974                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 975                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 976                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 977        - `node_addition_math`
 978            - Type: str
 979            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 980            - Default: 'euclidean'
 981            - Options:
 982                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
 983                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 984            - Notes:
 985                - Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
 986        - `lat_lon_bound`
 987            - Type: int | float
 988            - What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
 989            - Default: 5
 990
 991        """
 992        # Validate the inputs
 993        assert isinstance(node, dict), "Node must be a dictionary"
 994        assert "latitude" in node.keys(), "Node must have a latitude"
 995        assert "longitude" in node.keys(), "Node must have a longitude"
 996        assert (
 997            node["latitude"] >= -90 and node["latitude"] <= 90
 998        ), "Latitude must be between -90 and 90"
 999        assert (
1000            node["longitude"] >= -180 and node["longitude"] <= 180
1001        ), "Longitude must be between -180 and 180"
1002        assert circuity > 0, "Circuity must be greater than 0"
1003        assert node_addition_type in [
1004            "quadrant",
1005            "all",
1006            "closest",
1007        ], f"Invalid node addition type provided ({node_addition_type}), valid options are: ['quadrant', 'all', 'closest']"
1008        assert node_addition_math in [
1009            "euclidean",
1010            "haversine",
1011        ], f"Invalid node addition math provided ({node_addition_math}), valid options are: ['euclidean', 'haversine']"
1012        assert isinstance(
1013            lat_lon_bound, (int, float)
1014        ), "Lat_lon_bound must be a number"
1015        assert lat_lon_bound > 0, "Lat_lon_bound must be greater than 0"
1016        node = [node["latitude"], node["longitude"]]
1017        # Get the distances to all other nodes
1018        distances = self.get_node_distances(
1019            node=node,
1020            circuity=circuity,
1021            node_addition_type=node_addition_type,
1022            node_addition_math=node_addition_math,
1023            lat_lon_bound=lat_lon_bound,
1024        )
1025
1026        # Create the node
1027        new_node_id = len(self.graph)
1028        self.nodes.append(node)
1029        self.graph.append(distances)
1030        for node_idx, node_distance in distances.items():
1031            self.graph[node_idx][new_node_id] = node_distance
1032        return new_node_id
1033
1034    def save_as_geojson(self, filename: str) -> None:
1035        """
1036        Function:
1037
1038        - Save the current geograph object as a geojson file specifed by `filename`
1039        - This is useful for understanding the underlying geograph and for debugging purposes
1040        - Returns None
1041
1042        Required Arguments:
1043
1044        - `filename`
1045            - Type: str
1046            - What: The filename to save the geojson file as
1047
1048        """
1049        features = []
1050        for origin_idx, destinations in enumerate(self.graph):
1051            for destination_idx, distance in destinations.items():
1052                # Create an undirected graph for geojson purposes
1053                if origin_idx > destination_idx:
1054                    continue
1055                origin = self.nodes[origin_idx]
1056                destination = self.nodes[destination_idx]
1057                features.append(
1058                    {
1059                        "type": "Feature",
1060                        "properties": {
1061                            "origin_idx": origin_idx,
1062                            "destination_idx": destination_idx,
1063                            "distance": distance,
1064                        },
1065                        "geometry": {
1066                            "type": "LineString",
1067                            "coordinates": [
1068                                [origin[1], origin[0]],
1069                                [
1070                                    destination[1],
1071                                    destination[0],
1072                                ],
1073                            ],
1074                        },
1075                    }
1076                )
1077
1078        out_dict = {"type": "FeatureCollection", "features": features}
1079        with open(filename, "w") as f:
1080            json.dump(out_dict, f)
1081
1082    def save_as_geograph(self, name: str) -> None:
1083        """
1084        Function:
1085
1086        - Save the current geograph as an importable python file
1087
1088        Required Arguments:
1089
1090        - `name`
1091            - Type: str
1092            - What: The name of the geograph and file
1093            - EG: 'custom'
1094                - Stored as: 'custom.py'
1095                    - In your current directory
1096                - Import as: 'from .custom import custom_geograph'
1097        """
1098        self.validate_nodes()
1099        self.validate_graph(check_symmetry=True, check_connected=False)
1100        out_string = f"""from scgraph.core import GeoGraph\ngraph={str(self.graph)}\nnodes={str(self.nodes)}\n{name}_geograph = GeoGraph(graph=graph, nodes=nodes)"""
1101        with open(name + ".py", "w") as f:
1102            f.write(out_string)
1103
1104    def mod_remove_arc(
1105        self, origin_idx: int, destination_idx: int, undirected: bool = True
1106    ) -> None:
1107        """
1108        Function:
1109
1110        - Remove an arc from the graph
1111
1112        Required Arguments:
1113
1114        - `origin_idx`
1115            - Type: int
1116            - What: The index of the origin node
1117        - `destination_idx`
1118            - Type: int
1119            - What: The index of the destination node
1120
1121        Optional Arguments:
1122
1123        - `undirected`
1124            - Type: bool
1125            - What: Whether to remove the arc in both directions
1126            - Default: True
1127        """
1128        assert origin_idx < len(self.graph), "Origin node does not exist"
1129        assert destination_idx < len(
1130            self.graph
1131        ), "Destination node does not exist"
1132        assert destination_idx in self.graph[origin_idx], "Arc does not exist"
1133        del self.graph[origin_idx][destination_idx]
1134        if undirected:
1135            if origin_idx in self.graph[destination_idx]:
1136                del self.graph[destination_idx][origin_idx]
1137
1138    def mod_add_node(
1139        self, latitude: [float | int], longitude: [float | int]
1140    ) -> int:
1141        """
1142        Function:
1143
1144        - Add a node to the graph
1145
1146        Required Arguments:
1147
1148        - `latitude`
1149            - Type: int | float
1150            - What: The latitude of the node
1151        - `longitude`
1152            - Type: int | float
1153            - What: The longitude of the node
1154
1155        Returns:
1156
1157        - The index of the new node
1158        """
1159        self.nodes.append([latitude, longitude])
1160        self.graph.append({})
1161        return len(self.graph) - 1
1162
1163    def mod_add_arc(
1164        self,
1165        origin_idx: int,
1166        destination_idx: int,
1167        distance: [float | int] = 0,
1168        use_haversine_distance=True,
1169        undirected: bool = True,
1170    ) -> None:
1171        """
1172        Function:
1173
1174        - Add an arc to the graph
1175
1176        Required Arguments:
1177
1178        - `origin_idx`
1179            - Type: int
1180            - What: The index of the origin node
1181        - `destination_idx`
1182            - Type: int
1183            - What: The index of the destination node
1184
1185        Optional Arguments:
1186
1187        - `distance`
1188            - Type: int | float
1189            - What: The distance between the origin and destination nodes in terms of the graph distance (normally km)
1190            - Default: 0
1191        - `use_haversine_distance`
1192            - Type: bool
1193            - What: Whether to calculate the haversine distance (km) between the nodes when calculating the distance
1194            - Default: True
1195            - Note: If true, overrides the `distance` argument
1196        - `undirected`
1197            - Type: bool
1198            - What: Whether to add the arc in both directions
1199            - Default: True
1200        """
1201        assert origin_idx < len(self.graph), "Origin node does not exist"
1202        assert destination_idx < len(
1203            self.graph
1204        ), "Destination node does not exist"
1205        if use_haversine_distance:
1206            distance = haversine(
1207                self.nodes[origin_idx], self.nodes[destination_idx]
1208            )
1209        self.graph[origin_idx][destination_idx] = distance
1210        if undirected:
1211            self.graph[destination_idx][origin_idx] = distance
1212
1213
1214def load_geojson_as_geograph(geojson_filename: str) -> GeoGraph:
1215    """
1216    Function:
1217
1218    - Create a CustomGeoGraph object loaded from a geojson file
1219
1220    Required Arguments:
1221
1222    - `geojson_filename`
1223        - Type: str
1224        - What: The filename of the geojson file to load
1225        - Note: All arcs read in will be undirected
1226        - Note: This geojson file must be formatted in a specific way
1227            - The geojson file must be a FeatureCollection
1228            - Each feature must be a LineString with two coordinate pairs
1229                - The first coordinate pair must be the origin node
1230                - The second coordinate pair must be the destination node
1231                - The properties of the feature must include the distance between the origin and destination nodes
1232                - The properties of the feature must include the origin and destination node idxs
1233                - Origin and destination node idxs must be integers between 0 and n-1 where n is the number of nodes in the graph
1234            - EG:
1235            ```
1236            {
1237                "type": "FeatureCollection",
1238                "features": [
1239                    {
1240                        "type": "Feature",
1241                        "properties": {
1242                            "origin_idx": 0,
1243                            "destination_idx": 1,
1244                            "distance": 10
1245                        },
1246                        "geometry": {
1247                            "type": "LineString",
1248                            "coordinates": [
1249                                [121.47, 31.23],
1250                                [121.48, 31.24]
1251                            ]
1252                        }
1253                    }
1254                ]
1255            }
1256            ```
1257    """
1258    with open(geojson_filename, "r") as f:
1259        geojson_features = json.load(f).get("features", [])
1260
1261    nodes_dict = {}
1262    graph_dict = {}
1263    for feature in geojson_features:
1264        properties = feature.get("properties", {})
1265        origin_idx = properties.get("origin_idx")
1266        destination_idx = properties.get("destination_idx")
1267        distance = properties.get("distance")
1268        geometry = feature.get("geometry", {})
1269        coordinates = geometry.get("coordinates", [])
1270
1271        # Validations
1272        assert (
1273            feature.get("type") == "Feature"
1274        ), "All features must be of type 'Feature'"
1275        assert (
1276            geometry.get("type") == "LineString"
1277        ), "All geometries must be of type 'LineString'"
1278        assert (
1279            len(coordinates) == 2
1280        ), "All LineStrings must have exactly 2 coordinates"
1281        assert isinstance(
1282            origin_idx, int
1283        ), "All features must have an 'origin_idx' property that is an integer"
1284        assert isinstance(
1285            destination_idx, int
1286        ), "All features must have a 'destination_idx' property that is an integer"
1287        assert isinstance(
1288            distance, (int, float)
1289        ), "All features must have a 'distance' property that is a number"
1290        assert (
1291            origin_idx >= 0
1292        ), "All origin_idxs must be greater than or equal to 0"
1293        assert (
1294            destination_idx >= 0
1295        ), "All destination_idxs must be greater than or equal to 0"
1296        assert distance >= 0, "All distances must be greater than or equal to 0"
1297        origin = coordinates[0]
1298        destination = coordinates[1]
1299        assert isinstance(origin, list), "All coordinates must be lists"
1300        assert isinstance(destination, list), "All coordinates must be lists"
1301        assert len(origin) == 2, "All coordinates must have a length of 2"
1302        assert len(destination) == 2, "All coordinates must have a length of 2"
1303        assert all(
1304            [isinstance(i, (int, float)) for i in origin]
1305        ), "All coordinates must be numeric"
1306        assert all(
1307            [isinstance(i, (int, float)) for i in destination]
1308        ), "All coordinates must be numeric"
1309        # assert all([origin[0] >= -90, origin[0] <= 90, origin[1] >= -180, origin[1] <= 180]), "All coordinates must be valid latitudes and longitudes"
1310        # assert all([destination[0] >= -90, destination[0] <= 90, destination[1] >= -180, destination[1] <= 180]), "All coordinates must be valid latitudes and longitudes"
1311
1312        # Update the data
1313        nodes_dict[origin_idx] = origin
1314        nodes_dict[destination_idx] = destination
1315        graph_dict[origin_idx] = {
1316            **graph_dict.get(origin_idx, {}),
1317            destination_idx: distance,
1318        }
1319        graph_dict[destination_idx] = {
1320            **graph_dict.get(destination_idx, {}),
1321            origin_idx: distance,
1322        }
1323    assert len(nodes_dict) == len(
1324        graph_dict
1325    ), "All nodes must be included as origins in the graph dictionary"
1326    nodes = [
1327        [i[1][1], i[1][0]]
1328        for i in sorted(nodes_dict.items(), key=lambda x: x[0])
1329    ]
1330    ordered_graph_tuple = sorted(graph_dict.items(), key=lambda x: x[0])
1331    graph_map = {i[0]: idx for idx, i in enumerate(ordered_graph_tuple)}
1332    graph = [
1333        {graph_map[k]: v for k, v in i[1].items()} for i in ordered_graph_tuple
1334    ]
1335    return GeoGraph(graph=graph, nodes=nodes)
1336
1337
1338def get_multi_path_geojson(
1339    routes: list[dict],
1340    filename: [str, None] = None,
1341    show_progress: bool = False,
1342) -> dict:
1343    """
1344    Creates a GeoJSON file with the shortest path between the origin and destination of each route.
1345
1346    Required Parameters:
1347
1348    - `routes`: list[dict]
1349        - List of dictionaries with the following keys:
1350            - geograph: GeoGraph
1351                - Geograph object to use for the shortest path calculation.
1352            - origin: dict[float|float]
1353                - Origin coordinates
1354                - EG: {"latitude":39.2904, "longitude":-76.6122}
1355            - destination: dict[float|int]
1356                - Destination coordinates
1357                - EG: {"latitude":39.2904, "longitude":-76.6122}
1358            - properties: dict
1359                - Dictionary with the properties of the route
1360                - Everything in this dictionary will be included in the output GeoJSON file as properties of the route.
1361                - EG: {"id":"route_1", "name":"Route 1", "color":"#ff0000"}
1362
1363    Optional Parameters:
1364
1365    - `filename`: str | None
1366        - Name of the output GeoJSON file.
1367        - If None, the function will not save the file
1368        - Default: None
1369    - `show_progress`: bool
1370        - Whether to show basic progress information
1371        - Default: False
1372
1373    Returns
1374
1375    - `output`: dict
1376        - Dictionary with the GeoJSON file content.
1377    """
1378    assert isinstance(routes, list), "Routes must be a list"
1379    assert all(
1380        [isinstance(i, dict) for i in routes]
1381    ), "Routes must be a list of dictionaries"
1382    assert all(
1383        [isinstance(i.get("geograph"), GeoGraph) for i in routes]
1384    ), "All routes must have a 'geograph' key with a GeoGraph object"
1385    assert all(
1386        [isinstance(i.get("origin"), dict) for i in routes]
1387    ), "All routes must have an 'origin' key with a dictionary"
1388    assert all(
1389        [isinstance(i.get("destination"), dict) for i in routes]
1390    ), "All routes must have a 'destination' key with a dictionary"
1391    assert all(
1392        [isinstance(i.get("properties"), dict) for i in routes]
1393    ), "All routes must have a 'properties' key with a dictionary"
1394    assert all(
1395        [isinstance(i["origin"].get("latitude"), (int, float)) for i in routes]
1396    ), "All origins must have a 'latitude' key with a number"
1397    assert all(
1398        [isinstance(i["origin"].get("longitude"), (int, float)) for i in routes]
1399    ), "All origins must have a 'longitude' key with a number"
1400    assert all(
1401        [
1402            isinstance(i["destination"].get("latitude"), (int, float))
1403            for i in routes
1404        ]
1405    ), "All destinations must have a 'latitude' key with a number"
1406    assert all(
1407        [
1408            isinstance(i["destination"].get("longitude"), (int, float))
1409            for i in routes
1410        ]
1411    ), "All destinations must have a 'longitude' key with a number"
1412    assert all(
1413        [
1414            (
1415                i["origin"].get("latitude") >= -90
1416                and i["origin"].get("latitude") <= 90
1417            )
1418            for i in routes
1419        ]
1420    ), "All origin latitudes must be between -90 and 90"
1421    assert all(
1422        [
1423            (
1424                i["origin"].get("longitude") >= -180
1425                and i["origin"].get("longitude") <= 180
1426            )
1427            for i in routes
1428        ]
1429    ), "All origin longitudes must be between -180 and 180"
1430    assert all(
1431        [
1432            (
1433                i["destination"].get("latitude") >= -90
1434                and i["destination"].get("latitude") <= 90
1435            )
1436            for i in routes
1437        ]
1438    ), "All destination latitudes must be between -90 and 90"
1439    assert all(
1440        [
1441            (
1442                i["destination"].get("longitude") >= -180
1443                and i["destination"].get("longitude") <= 180
1444            )
1445            for i in routes
1446        ]
1447    ), "All destination longitudes must be between -180 and 180"
1448
1449    output = {"type": "FeatureCollection", "features": []}
1450    len_routes = len(routes)
1451    for idx, route in enumerate(routes):
1452        shortest_path = route["geograph"].get_shortest_path(
1453            route["origin"], route["destination"]
1454        )
1455        shortest_line_path = get_line_path(shortest_path)
1456        output["features"].append(
1457            {
1458                "type": "Feature",
1459                "properties": route["properties"],
1460                "geometry": shortest_line_path,
1461            }
1462        )
1463        if show_progress:
1464            print(
1465                f"[{'='*(int((idx+1)/len_routes*20))}>{' '*(20-int((idx+1)/len_routes*20))}] {idx+1}/{len_routes}",
1466                end="\r",
1467            )
1468    if show_progress:
1469        print()
1470    if filename is not None:
1471        json.dump(output, open(filename, "w"))
1472    return output
class Graph:
  7class Graph:
  8    @staticmethod
  9    def validate_graph(
 10        graph: list[dict[int, int | float]],
 11        check_symmetry: bool = True,
 12        check_connected: bool = True,
 13    ) -> None:
 14        """
 15        Function:
 16
 17        - Validate that a graph is properly formatted
 18
 19        Required Arguments:
 20
 21        - `graph`:
 22            - Type: list of dictionaries with integer keys and integer or float values
 23            - What: A list of dictionaries where the indicies are origin node ids and the values are dictionaries of destination node indices and graph weights
 24            - Note: All nodes must be included as origins in the graph regardless of if they have any connected destinations
 25            - EG:
 26            ```
 27                [
 28                    # From London (index 0)
 29                    {
 30                        # To Paris (index 1)
 31                        1: 311,
 32                    },
 33                    # From Paris (index 1)
 34                    {
 35                        # To London (index 0)
 36                        0: 311,
 37                        # To Berlin (index 2)
 38                        2: 878,
 39                        # To Rome (index 3)
 40                        3: 1439,
 41                        # To Madrid (index 4)
 42                        4: 1053
 43                    },
 44                    # From Berlin (index 2)
 45                    {
 46                        # To Paris (index 1)
 47                        1: 878,
 48                        # To Rome (index 3)
 49                        3: 1181,
 50                    },
 51                    # From Rome (index 3)
 52                    {
 53                        # To Paris (index 1)
 54                        1: 1439,
 55                        # To Berlin (index 2)
 56                        2: 1181,
 57                    },
 58                    # From Madrid (index 4)
 59                    {
 60                        # To Paris (index 1)
 61                        1: 1053,
 62                        # To Lisbon (index 5)
 63                        5: 623
 64                    },
 65                    # From Lisbon (index 5)
 66                    {
 67                        # To Madrid (index 4)
 68                        4: 623
 69                    }
 70                ]
 71            ```
 72
 73        Optional Arguments:
 74
 75        - `check_symmetry`
 76            - Type: bool
 77            - What: Whether to check that the graph is symmetric
 78            - Default: True
 79            - Note: This is forced to True if `check_connected` is True
 80        - `check_connected`
 81            - Type: bool
 82            - What: Whether to check that the graph is fully connected
 83            - Default: True
 84            - Note: For computational efficiency, only symmetric graphs are checked for connectivity
 85            - Note: If this is True, `check_symmetry` is forced to True and the graph will be checked for symmetry prior to checking for connectivity
 86        """
 87        check_symmetry = check_symmetry or check_connected
 88        assert isinstance(graph, list), "Your graph must be a list"
 89        len_graph = len(graph)
 90        for origin_id, origin_dict in enumerate(graph):
 91            assert isinstance(
 92                origin_dict, dict
 93            ), f"Your graph must be a list of dictionaries but the value for origin {origin_id} is not a dictionary"
 94            destinations = list(origin_dict.keys())
 95            lengths = list(origin_dict.values())
 96            assert all(
 97                [
 98                    (isinstance(i, int) and i >= 0 and i < len_graph)
 99                    for i in destinations
100                ]
101            ), f"Destination ids must be non-negative integers and equivalent to an existing index, but graph[{origin_id}] has an error in the destination ids"
102            assert all(
103                [(isinstance(i, (int, float)) and i >= 0) for i in lengths]
104            ), f"Distances must be integers or floats, but graph[{origin_id}] contains a non-integer or non-float distance"
105            if check_symmetry:
106                for destination_id, distance in origin_dict.items():
107                    assert (
108                        graph[destination_id].get(origin_id) == distance
109                    ), f"Your graph is not symmetric, the distance from node {origin_id} to node {destination_id} is {distance} but the distance from node {destination_id} to node {origin_id} is {graph.get(destination_id, {}).get(origin_id)}"
110        if check_connected:
111            assert Graph.validate_connected(
112                graph
113            ), "Your graph is not fully connected"
114
115    @staticmethod
116    def validate_connected(
117        graph: list[dict[int, int | float]], origin_id: int = 0
118    ) -> bool:
119        """
120        Function:
121
122        - Validate that a graph is fully connected
123            - This means that every node in the graph has a path to every other node in the graph
124            - Note: This assumes that the graph is symmetric
125        - Return True if the graph is fully connected and False if it is not
126
127        Required Arguments:
128
129        - `graph`:
130            - Type: list of dictionaries
131            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
132
133        Optional Arguments:
134
135        - `origin_id`
136            - Type: int
137            - What: The id of the origin node from which to start the connectivity check
138            - Default: 0
139        """
140        visited = [0] * len(graph)
141        open_leaves = [origin_id]
142
143        while open_leaves:
144            current_id = open_leaves.pop()
145            visited[current_id] = 1
146            for connected_id, connected_distance in graph[current_id].items():
147                if visited[connected_id] == 0:
148                    open_leaves.append(connected_id)
149        return min(visited) == 1
150
151    @staticmethod
152    def input_check(
153        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
154    ) -> None:
155        """
156        Function:
157
158        - Check that the inputs passed to the shortest path algorithm are valid
159        - Raises an exception if the inputs passed are not valid
160
161        Required Arguments:
162
163        - `graph`:
164            - Type: list of dictionaries
165            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
166        - `origin_id`
167            - Type: int
168            - What: The id of the origin node from the graph dictionary to start the shortest path from
169        - `destination_id`
170            - Type: int
171            - What: The id of the destination node from the graph dictionary to end the shortest path at
172
173        Optional Arguments:
174
175        - None
176        """
177        if (
178            not isinstance(origin_id, int)
179            and origin_id < len(graph)
180            and origin_id >= 0
181        ):
182            raise Exception(f"Origin node ({origin_id}) is not in the graph")
183        if (
184            not isinstance(destination_id, int)
185            and origin_id < len(graph)
186            and origin_id >= 0
187        ):
188            raise Exception(
189                f"Destination node ({destination_id}) is not in the graph"
190            )
191
192    @staticmethod
193    def dijkstra(
194        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
195    ) -> dict:
196        """
197        Function:
198
199        - Identify the shortest path between two nodes in a sparse network graph using a modified dijkstra algorithm
200            - Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
201            - This can dramatically reduce the memory and compute requirements of the algorithm
202            - This algorithm runs in O(n^2) time where n is the number of nodes in the graph
203        - Return a dictionary of various path information including:
204            - `id_path`: A list of node ids in the order they are visited
205            - `path`: A list of node dictionaries (lat + long) in the order they are visited
206
207        Required Arguments:
208
209        - `graph`:
210            - Type: list of dictionaries
211            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
212        - `origin_id`
213            - Type: int
214            - What: The id of the origin node from the graph dictionary to start the shortest path from
215        - `destination_id`
216            - Type: int
217            - What: The id of the destination node from the graph dictionary to end the shortest path at
218
219        Optional Arguments:
220
221        - None
222        """
223        Graph.input_check(
224            graph=graph, origin_id=origin_id, destination_id=destination_id
225        )
226        distance_matrix = [float("inf")] * len(graph)
227        branch_tip_distances = [float("inf")] * len(graph)
228        predecessor = [-1] * len(graph)
229
230        distance_matrix[origin_id] = 0
231        branch_tip_distances[origin_id] = 0
232
233        while True:
234            current_distance = min(branch_tip_distances)
235            if current_distance == float("inf"):
236                raise Exception(
237                    "Something went wrong, the origin and destination nodes are not connected."
238                )
239            current_id = branch_tip_distances.index(current_distance)
240            branch_tip_distances[current_id] = float("inf")
241            if current_id == destination_id:
242                break
243            for connected_id, connected_distance in graph[current_id].items():
244                possible_distance = current_distance + connected_distance
245                if possible_distance < distance_matrix[connected_id]:
246                    distance_matrix[connected_id] = possible_distance
247                    predecessor[connected_id] = current_id
248                    branch_tip_distances[connected_id] = possible_distance
249
250        output_path = [current_id]
251        while predecessor[current_id] != -1:
252            current_id = predecessor[current_id]
253            output_path.append(current_id)
254
255        output_path.reverse()
256
257        return {
258            "path": output_path,
259            "length": distance_matrix[destination_id],
260        }
261
262    @staticmethod
263    def dijkstra_makowski(
264        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
265    ) -> dict:
266        """
267        Function:
268
269        - Identify the shortest path between two nodes in a sparse network graph using Makowski's modified Dijkstra algorithm
270            - Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
271            - Improvements include only computing future potential nodes based on the open leaves for each branch
272                - Open leaves are nodes that have not been visited yet but are adjacent to other visited nodes
273            - This can dramatically reduce the memory and compute requirements of the algorithm
274            - For particularly sparse graphs, this algorithm runs close to O(n log n) time
275                - Where n is the number of nodes in the graph
276            - For dense graphs, this algorithm runs closer to O(n^2) time (similar to the standard Dijkstra algorithm)
277                - Where n is the number of nodes in the graph
278        - Return a dictionary of various path information including:
279            - `id_path`: A list of node ids in the order they are visited
280            - `path`: A list of node dictionaries (lat + long) in the order they are visited
281
282        Required Arguments:
283
284        - `graph`:
285            - Type: list of dictionaries
286            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
287        - `origin_id`
288            - Type: int
289            - What: The id of the origin node from the graph dictionary to start the shortest path from
290        - `destination_id`
291            - Type: int
292            - What: The id of the destination node from the graph dictionary to end the shortest path at
293
294        Optional Arguments:
295
296        - None
297        """
298        # Input Validation
299        Graph.input_check(
300            graph=graph, origin_id=origin_id, destination_id=destination_id
301        )
302        # Variable Initialization
303        distance_matrix = [float("inf")] * len(graph)
304        distance_matrix[origin_id] = 0
305        open_leaves = []
306        heappush(open_leaves, (0, origin_id))
307        predecessor = [-1] * len(graph)
308
309        while open_leaves:
310            current_distance, current_id = heappop(open_leaves)
311            if current_id == destination_id:
312                break
313            for connected_id, connected_distance in graph[current_id].items():
314                possible_distance = current_distance + connected_distance
315                if possible_distance < distance_matrix[connected_id]:
316                    distance_matrix[connected_id] = possible_distance
317                    predecessor[connected_id] = current_id
318                    heappush(open_leaves, (possible_distance, connected_id))
319        if current_id != destination_id:
320            raise Exception(
321                "Something went wrong, the origin and destination nodes are not connected."
322            )
323
324        output_path = [current_id]
325        while predecessor[current_id] != -1:
326            current_id = predecessor[current_id]
327            output_path.append(current_id)
328
329        output_path.reverse()
330
331        return {
332            "path": output_path,
333            "length": distance_matrix[destination_id],
334        }
335
336    @staticmethod
337    def a_star(
338        graph: list[dict[int, int | float]],
339        origin_id: int,
340        destination_id: int,
341        heuristic_fn=None,
342    ) -> dict:
343        """
344        Function:
345
346        - Identify the shortest path between two nodes in a sparse network graph using an A* extension of Makowski's modified Dijkstra algorithm
347        - Return a dictionary of various path information including:
348            - `id_path`: A list of node ids in the order they are visited
349            - `path`: A list of node dictionaries (lat + long) in the order they are visited
350
351        Required Arguments:
352
353        - `graph`:
354            - Type: list of dictionaries
355            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
356        - `origin_id`
357            - Type: int
358            - What: The id of the origin node from the graph dictionary to start the shortest path from
359        - `destination_id`
360            - Type: int
361            - What: The id of the destination node from the graph dictionary to end the shortest path at
362        - `heuristic_fn`
363            - Type: function
364            - What: A heuristic function that takes two node ids and returns an estimated distance between them
365            - Note: If None, returns the shortest path using Makowski's modified Dijkstra algorithm
366            - Default: None
367
368        Optional Arguments:
369
370        - None
371        """
372        if heuristic_fn is None:
373            return Graph.dijkstra_makowski(
374                graph=graph,
375                origin_id=origin_id,
376                destination_id=destination_id,
377            )
378        # Input Validation
379        Graph.input_check(
380            graph=graph, origin_id=origin_id, destination_id=destination_id
381        )
382        # Variable Initialization
383        distance_matrix = [float("inf")] * len(graph)
384        distance_matrix[origin_id] = 0
385        open_leaves = []
386        heappush(open_leaves, (0, origin_id))
387        predecessor = [-1] * len(graph)
388
389        while open_leaves:
390            current_id = heappop(open_leaves)[1]
391            if current_id == destination_id:
392                break
393            current_distance = distance_matrix[current_id]
394            for connected_id, connected_distance in graph[current_id].items():
395                possible_distance = current_distance + connected_distance
396                if possible_distance < distance_matrix[connected_id]:
397                    distance_matrix[connected_id] = possible_distance
398                    predecessor[connected_id] = current_id
399                    heappush(
400                        open_leaves,
401                        (
402                            possible_distance
403                            + heuristic_fn(connected_id, destination_id),
404                            connected_id,
405                        ),
406                    )
407        if current_id != destination_id:
408            raise Exception(
409                "Something went wrong, the origin and destination nodes are not connected."
410            )
411
412        output_path = [current_id]
413        while predecessor[current_id] != -1:
414            current_id = predecessor[current_id]
415            output_path.append(current_id)
416
417        output_path.reverse()
418
419        return {
420            "path": output_path,
421            "length": distance_matrix[destination_id],
422        }
@staticmethod
def validate_graph( graph: list[dict[int, int | float]], check_symmetry: bool = True, check_connected: bool = True) -> None:
  8    @staticmethod
  9    def validate_graph(
 10        graph: list[dict[int, int | float]],
 11        check_symmetry: bool = True,
 12        check_connected: bool = True,
 13    ) -> None:
 14        """
 15        Function:
 16
 17        - Validate that a graph is properly formatted
 18
 19        Required Arguments:
 20
 21        - `graph`:
 22            - Type: list of dictionaries with integer keys and integer or float values
 23            - What: A list of dictionaries where the indicies are origin node ids and the values are dictionaries of destination node indices and graph weights
 24            - Note: All nodes must be included as origins in the graph regardless of if they have any connected destinations
 25            - EG:
 26            ```
 27                [
 28                    # From London (index 0)
 29                    {
 30                        # To Paris (index 1)
 31                        1: 311,
 32                    },
 33                    # From Paris (index 1)
 34                    {
 35                        # To London (index 0)
 36                        0: 311,
 37                        # To Berlin (index 2)
 38                        2: 878,
 39                        # To Rome (index 3)
 40                        3: 1439,
 41                        # To Madrid (index 4)
 42                        4: 1053
 43                    },
 44                    # From Berlin (index 2)
 45                    {
 46                        # To Paris (index 1)
 47                        1: 878,
 48                        # To Rome (index 3)
 49                        3: 1181,
 50                    },
 51                    # From Rome (index 3)
 52                    {
 53                        # To Paris (index 1)
 54                        1: 1439,
 55                        # To Berlin (index 2)
 56                        2: 1181,
 57                    },
 58                    # From Madrid (index 4)
 59                    {
 60                        # To Paris (index 1)
 61                        1: 1053,
 62                        # To Lisbon (index 5)
 63                        5: 623
 64                    },
 65                    # From Lisbon (index 5)
 66                    {
 67                        # To Madrid (index 4)
 68                        4: 623
 69                    }
 70                ]
 71            ```
 72
 73        Optional Arguments:
 74
 75        - `check_symmetry`
 76            - Type: bool
 77            - What: Whether to check that the graph is symmetric
 78            - Default: True
 79            - Note: This is forced to True if `check_connected` is True
 80        - `check_connected`
 81            - Type: bool
 82            - What: Whether to check that the graph is fully connected
 83            - Default: True
 84            - Note: For computational efficiency, only symmetric graphs are checked for connectivity
 85            - Note: If this is True, `check_symmetry` is forced to True and the graph will be checked for symmetry prior to checking for connectivity
 86        """
 87        check_symmetry = check_symmetry or check_connected
 88        assert isinstance(graph, list), "Your graph must be a list"
 89        len_graph = len(graph)
 90        for origin_id, origin_dict in enumerate(graph):
 91            assert isinstance(
 92                origin_dict, dict
 93            ), f"Your graph must be a list of dictionaries but the value for origin {origin_id} is not a dictionary"
 94            destinations = list(origin_dict.keys())
 95            lengths = list(origin_dict.values())
 96            assert all(
 97                [
 98                    (isinstance(i, int) and i >= 0 and i < len_graph)
 99                    for i in destinations
100                ]
101            ), f"Destination ids must be non-negative integers and equivalent to an existing index, but graph[{origin_id}] has an error in the destination ids"
102            assert all(
103                [(isinstance(i, (int, float)) and i >= 0) for i in lengths]
104            ), f"Distances must be integers or floats, but graph[{origin_id}] contains a non-integer or non-float distance"
105            if check_symmetry:
106                for destination_id, distance in origin_dict.items():
107                    assert (
108                        graph[destination_id].get(origin_id) == distance
109                    ), f"Your graph is not symmetric, the distance from node {origin_id} to node {destination_id} is {distance} but the distance from node {destination_id} to node {origin_id} is {graph.get(destination_id, {}).get(origin_id)}"
110        if check_connected:
111            assert Graph.validate_connected(
112                graph
113            ), "Your graph is not fully connected"

Function:

  • Validate that a graph is properly formatted

Required Arguments:

  • graph:

    • Type: list of dictionaries with integer keys and integer or float values
    • What: A list of dictionaries where the indicies are origin node ids and the values are dictionaries of destination node indices and graph weights
    • Note: All nodes must be included as origins in the graph regardless of if they have any connected destinations
    • EG:
        [
            # From London (index 0)
            {
                # To Paris (index 1)
                1: 311,
            },
            # From Paris (index 1)
            {
                # To London (index 0)
                0: 311,
                # To Berlin (index 2)
                2: 878,
                # To Rome (index 3)
                3: 1439,
                # To Madrid (index 4)
                4: 1053
            },
            # From Berlin (index 2)
            {
                # To Paris (index 1)
                1: 878,
                # To Rome (index 3)
                3: 1181,
            },
            # From Rome (index 3)
            {
                # To Paris (index 1)
                1: 1439,
                # To Berlin (index 2)
                2: 1181,
            },
            # From Madrid (index 4)
            {
                # To Paris (index 1)
                1: 1053,
                # To Lisbon (index 5)
                5: 623
            },
            # From Lisbon (index 5)
            {
                # To Madrid (index 4)
                4: 623
            }
        ]
    

Optional Arguments:

  • check_symmetry
    • Type: bool
    • What: Whether to check that the graph is symmetric
    • Default: True
    • Note: This is forced to True if check_connected is True
  • check_connected
    • Type: bool
    • What: Whether to check that the graph is fully connected
    • Default: True
    • Note: For computational efficiency, only symmetric graphs are checked for connectivity
    • Note: If this is True, check_symmetry is forced to True and the graph will be checked for symmetry prior to checking for connectivity
@staticmethod
def validate_connected(graph: list[dict[int, int | float]], origin_id: int = 0) -> bool:
115    @staticmethod
116    def validate_connected(
117        graph: list[dict[int, int | float]], origin_id: int = 0
118    ) -> bool:
119        """
120        Function:
121
122        - Validate that a graph is fully connected
123            - This means that every node in the graph has a path to every other node in the graph
124            - Note: This assumes that the graph is symmetric
125        - Return True if the graph is fully connected and False if it is not
126
127        Required Arguments:
128
129        - `graph`:
130            - Type: list of dictionaries
131            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
132
133        Optional Arguments:
134
135        - `origin_id`
136            - Type: int
137            - What: The id of the origin node from which to start the connectivity check
138            - Default: 0
139        """
140        visited = [0] * len(graph)
141        open_leaves = [origin_id]
142
143        while open_leaves:
144            current_id = open_leaves.pop()
145            visited[current_id] = 1
146            for connected_id, connected_distance in graph[current_id].items():
147                if visited[connected_id] == 0:
148                    open_leaves.append(connected_id)
149        return min(visited) == 1

Function:

  • Validate that a graph is fully connected
    • This means that every node in the graph has a path to every other node in the graph
    • Note: This assumes that the graph is symmetric
  • Return True if the graph is fully connected and False if it is not

Required Arguments:

Optional Arguments:

  • origin_id
    • Type: int
    • What: The id of the origin node from which to start the connectivity check
    • Default: 0
@staticmethod
def input_check( graph: list[dict[int, int | float]], origin_id: int, destination_id: int) -> None:
151    @staticmethod
152    def input_check(
153        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
154    ) -> None:
155        """
156        Function:
157
158        - Check that the inputs passed to the shortest path algorithm are valid
159        - Raises an exception if the inputs passed are not valid
160
161        Required Arguments:
162
163        - `graph`:
164            - Type: list of dictionaries
165            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
166        - `origin_id`
167            - Type: int
168            - What: The id of the origin node from the graph dictionary to start the shortest path from
169        - `destination_id`
170            - Type: int
171            - What: The id of the destination node from the graph dictionary to end the shortest path at
172
173        Optional Arguments:
174
175        - None
176        """
177        if (
178            not isinstance(origin_id, int)
179            and origin_id < len(graph)
180            and origin_id >= 0
181        ):
182            raise Exception(f"Origin node ({origin_id}) is not in the graph")
183        if (
184            not isinstance(destination_id, int)
185            and origin_id < len(graph)
186            and origin_id >= 0
187        ):
188            raise Exception(
189                f"Destination node ({destination_id}) is not in the graph"
190            )

Function:

  • Check that the inputs passed to the shortest path algorithm are valid
  • Raises an exception if the inputs passed are not valid

Required Arguments:

Optional Arguments:

  • None
@staticmethod
def dijkstra( graph: list[dict[int, int | float]], origin_id: int, destination_id: int) -> dict:
192    @staticmethod
193    def dijkstra(
194        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
195    ) -> dict:
196        """
197        Function:
198
199        - Identify the shortest path between two nodes in a sparse network graph using a modified dijkstra algorithm
200            - Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
201            - This can dramatically reduce the memory and compute requirements of the algorithm
202            - This algorithm runs in O(n^2) time where n is the number of nodes in the graph
203        - Return a dictionary of various path information including:
204            - `id_path`: A list of node ids in the order they are visited
205            - `path`: A list of node dictionaries (lat + long) in the order they are visited
206
207        Required Arguments:
208
209        - `graph`:
210            - Type: list of dictionaries
211            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
212        - `origin_id`
213            - Type: int
214            - What: The id of the origin node from the graph dictionary to start the shortest path from
215        - `destination_id`
216            - Type: int
217            - What: The id of the destination node from the graph dictionary to end the shortest path at
218
219        Optional Arguments:
220
221        - None
222        """
223        Graph.input_check(
224            graph=graph, origin_id=origin_id, destination_id=destination_id
225        )
226        distance_matrix = [float("inf")] * len(graph)
227        branch_tip_distances = [float("inf")] * len(graph)
228        predecessor = [-1] * len(graph)
229
230        distance_matrix[origin_id] = 0
231        branch_tip_distances[origin_id] = 0
232
233        while True:
234            current_distance = min(branch_tip_distances)
235            if current_distance == float("inf"):
236                raise Exception(
237                    "Something went wrong, the origin and destination nodes are not connected."
238                )
239            current_id = branch_tip_distances.index(current_distance)
240            branch_tip_distances[current_id] = float("inf")
241            if current_id == destination_id:
242                break
243            for connected_id, connected_distance in graph[current_id].items():
244                possible_distance = current_distance + connected_distance
245                if possible_distance < distance_matrix[connected_id]:
246                    distance_matrix[connected_id] = possible_distance
247                    predecessor[connected_id] = current_id
248                    branch_tip_distances[connected_id] = possible_distance
249
250        output_path = [current_id]
251        while predecessor[current_id] != -1:
252            current_id = predecessor[current_id]
253            output_path.append(current_id)
254
255        output_path.reverse()
256
257        return {
258            "path": output_path,
259            "length": distance_matrix[destination_id],
260        }

Function:

  • Identify the shortest path between two nodes in a sparse network graph using a modified dijkstra algorithm
    • Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
    • This can dramatically reduce the memory and compute requirements of the algorithm
    • This algorithm runs in O(n^2) time where n is the number of nodes in the graph
  • Return a dictionary of various path information including:
    • id_path: A list of node ids in the order they are visited
    • path: A list of node dictionaries (lat + long) in the order they are visited

Required Arguments:

Optional Arguments:

  • None
@staticmethod
def dijkstra_makowski( graph: list[dict[int, int | float]], origin_id: int, destination_id: int) -> dict:
262    @staticmethod
263    def dijkstra_makowski(
264        graph: list[dict[int, int | float]], origin_id: int, destination_id: int
265    ) -> dict:
266        """
267        Function:
268
269        - Identify the shortest path between two nodes in a sparse network graph using Makowski's modified Dijkstra algorithm
270            - Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
271            - Improvements include only computing future potential nodes based on the open leaves for each branch
272                - Open leaves are nodes that have not been visited yet but are adjacent to other visited nodes
273            - This can dramatically reduce the memory and compute requirements of the algorithm
274            - For particularly sparse graphs, this algorithm runs close to O(n log n) time
275                - Where n is the number of nodes in the graph
276            - For dense graphs, this algorithm runs closer to O(n^2) time (similar to the standard Dijkstra algorithm)
277                - Where n is the number of nodes in the graph
278        - Return a dictionary of various path information including:
279            - `id_path`: A list of node ids in the order they are visited
280            - `path`: A list of node dictionaries (lat + long) in the order they are visited
281
282        Required Arguments:
283
284        - `graph`:
285            - Type: list of dictionaries
286            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
287        - `origin_id`
288            - Type: int
289            - What: The id of the origin node from the graph dictionary to start the shortest path from
290        - `destination_id`
291            - Type: int
292            - What: The id of the destination node from the graph dictionary to end the shortest path at
293
294        Optional Arguments:
295
296        - None
297        """
298        # Input Validation
299        Graph.input_check(
300            graph=graph, origin_id=origin_id, destination_id=destination_id
301        )
302        # Variable Initialization
303        distance_matrix = [float("inf")] * len(graph)
304        distance_matrix[origin_id] = 0
305        open_leaves = []
306        heappush(open_leaves, (0, origin_id))
307        predecessor = [-1] * len(graph)
308
309        while open_leaves:
310            current_distance, current_id = heappop(open_leaves)
311            if current_id == destination_id:
312                break
313            for connected_id, connected_distance in graph[current_id].items():
314                possible_distance = current_distance + connected_distance
315                if possible_distance < distance_matrix[connected_id]:
316                    distance_matrix[connected_id] = possible_distance
317                    predecessor[connected_id] = current_id
318                    heappush(open_leaves, (possible_distance, connected_id))
319        if current_id != destination_id:
320            raise Exception(
321                "Something went wrong, the origin and destination nodes are not connected."
322            )
323
324        output_path = [current_id]
325        while predecessor[current_id] != -1:
326            current_id = predecessor[current_id]
327            output_path.append(current_id)
328
329        output_path.reverse()
330
331        return {
332            "path": output_path,
333            "length": distance_matrix[destination_id],
334        }

Function:

  • Identify the shortest path between two nodes in a sparse network graph using Makowski's modified Dijkstra algorithm
    • Modifications allow for a sparse distance matrix to be used instead of a dense distance matrix
    • Improvements include only computing future potential nodes based on the open leaves for each branch
      • Open leaves are nodes that have not been visited yet but are adjacent to other visited nodes
    • This can dramatically reduce the memory and compute requirements of the algorithm
    • For particularly sparse graphs, this algorithm runs close to O(n log n) time
      • Where n is the number of nodes in the graph
    • For dense graphs, this algorithm runs closer to O(n^2) time (similar to the standard Dijkstra algorithm)
      • Where n is the number of nodes in the graph
  • Return a dictionary of various path information including:
    • id_path: A list of node ids in the order they are visited
    • path: A list of node dictionaries (lat + long) in the order they are visited

Required Arguments:

Optional Arguments:

  • None
@staticmethod
def a_star( graph: list[dict[int, int | float]], origin_id: int, destination_id: int, heuristic_fn=None) -> dict:
336    @staticmethod
337    def a_star(
338        graph: list[dict[int, int | float]],
339        origin_id: int,
340        destination_id: int,
341        heuristic_fn=None,
342    ) -> dict:
343        """
344        Function:
345
346        - Identify the shortest path between two nodes in a sparse network graph using an A* extension of Makowski's modified Dijkstra algorithm
347        - Return a dictionary of various path information including:
348            - `id_path`: A list of node ids in the order they are visited
349            - `path`: A list of node dictionaries (lat + long) in the order they are visited
350
351        Required Arguments:
352
353        - `graph`:
354            - Type: list of dictionaries
355            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
356        - `origin_id`
357            - Type: int
358            - What: The id of the origin node from the graph dictionary to start the shortest path from
359        - `destination_id`
360            - Type: int
361            - What: The id of the destination node from the graph dictionary to end the shortest path at
362        - `heuristic_fn`
363            - Type: function
364            - What: A heuristic function that takes two node ids and returns an estimated distance between them
365            - Note: If None, returns the shortest path using Makowski's modified Dijkstra algorithm
366            - Default: None
367
368        Optional Arguments:
369
370        - None
371        """
372        if heuristic_fn is None:
373            return Graph.dijkstra_makowski(
374                graph=graph,
375                origin_id=origin_id,
376                destination_id=destination_id,
377            )
378        # Input Validation
379        Graph.input_check(
380            graph=graph, origin_id=origin_id, destination_id=destination_id
381        )
382        # Variable Initialization
383        distance_matrix = [float("inf")] * len(graph)
384        distance_matrix[origin_id] = 0
385        open_leaves = []
386        heappush(open_leaves, (0, origin_id))
387        predecessor = [-1] * len(graph)
388
389        while open_leaves:
390            current_id = heappop(open_leaves)[1]
391            if current_id == destination_id:
392                break
393            current_distance = distance_matrix[current_id]
394            for connected_id, connected_distance in graph[current_id].items():
395                possible_distance = current_distance + connected_distance
396                if possible_distance < distance_matrix[connected_id]:
397                    distance_matrix[connected_id] = possible_distance
398                    predecessor[connected_id] = current_id
399                    heappush(
400                        open_leaves,
401                        (
402                            possible_distance
403                            + heuristic_fn(connected_id, destination_id),
404                            connected_id,
405                        ),
406                    )
407        if current_id != destination_id:
408            raise Exception(
409                "Something went wrong, the origin and destination nodes are not connected."
410            )
411
412        output_path = [current_id]
413        while predecessor[current_id] != -1:
414            current_id = predecessor[current_id]
415            output_path.append(current_id)
416
417        output_path.reverse()
418
419        return {
420            "path": output_path,
421            "length": distance_matrix[destination_id],
422        }

Function:

  • Identify the shortest path between two nodes in a sparse network graph using an A* extension of Makowski's modified Dijkstra algorithm
  • Return a dictionary of various path information including:
    • id_path: A list of node ids in the order they are visited
    • path: A list of node dictionaries (lat + long) in the order they are visited

Required Arguments:

  • graph:
  • origin_id
    • Type: int
    • What: The id of the origin node from the graph dictionary to start the shortest path from
  • destination_id
    • Type: int
    • What: The id of the destination node from the graph dictionary to end the shortest path at
  • heuristic_fn
    • Type: function
    • What: A heuristic function that takes two node ids and returns an estimated distance between them
    • Note: If None, returns the shortest path using Makowski's modified Dijkstra algorithm
    • Default: None

Optional Arguments:

  • None
class GeoGraph:
 425class GeoGraph:
 426    def __init__(
 427        self,
 428        graph: list[dict[int, int | float]],
 429        nodes: list[list[float | int]],
 430    ) -> None:
 431        """
 432        Function:
 433
 434        - Create a GeoGraph object
 435
 436        Required Arguments:
 437
 438        - `graph`
 439            - Type: list of dictionaries
 440            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 441        - `nodes`
 442            - Type: list of lists of ints or floats
 443            - What: A list of lists where the values are coordinates (latitude then longitude)
 444            - Note: The length of the nodes list must be the same as that of the graph list
 445            - EG Continuing off the example from https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 446            ```
 447                [
 448                    # London (index 0)
 449                    [51.5074, 0.1278],
 450                    # Paris (index 1)
 451                    [48.8566, 2.3522],
 452                    # Berlin (index 2)
 453                    [52.5200, 13.4050],
 454                    # Rome (index 3)
 455                    [41.9028, 12.4964],
 456                    # Madrid (index 4)
 457                    [40.4168, 3.7038],
 458                    # Lisbon (index 5)
 459                    [38.7223, 9.1393]
 460                ]
 461            ```
 462        """
 463        self.graph = graph
 464        self.nodes = nodes
 465
 466    def validate_graph(
 467        self, check_symmetry: bool = True, check_connected: bool = True
 468    ) -> None:
 469        """
 470        Function:
 471
 472        - Validate that self.graph is properly formatted (see Graph.validate_graph)
 473        - Raises an exception if the graph is invalid
 474        - Returns None if the graph is valid
 475
 476        Required Arguments:
 477
 478        - None
 479
 480        Optional Arguments:
 481
 482        - `check_symmetry`
 483            - Type: bool
 484            - What: Whether to check that the graph is symmetric
 485            - Default: True
 486            - Note: This is forced to True if `check_connected` is True
 487        - `check_connected`
 488            - Type: bool
 489            - What: Whether to check that the graph is fully connected
 490            - Default: True
 491            - Note: For computational efficiency, graphs are validated for symmetry prior to checking for connectivity
 492        """
 493        Graph.validate_graph(
 494            self.graph,
 495            check_symmetry=check_symmetry,
 496            check_connected=check_connected,
 497        )
 498
 499    def validate_nodes(self) -> None:
 500        """
 501
 502        Function:
 503
 504        - Validate that self.nodes is properly formatted (see GeoGraph.__init__ docs for more details)
 505        - Raises an exception if the nodes are invalid
 506        - Returns None if the nodes are valid
 507
 508        Required Arguments:
 509
 510        - None
 511
 512        Optional Arguments:
 513
 514        - None
 515        """
 516        assert isinstance(self.nodes, list), "Your nodes must be a dictionary"
 517        assert all(
 518            [isinstance(i, list) for i in self.nodes]
 519        ), "Your nodes must be a list of lists"
 520        assert all(
 521            [len(i) == 2 for i in self.nodes]
 522        ), "Your nodes must be a list of lists where each sub list has a length of 2"
 523        assert all(
 524            [
 525                (
 526                    isinstance(i[0], (int, float))
 527                    and isinstance(i[1], (int, float))
 528                )
 529                for i in self.nodes
 530            ]
 531        ), "Your nodes must be a list of lists where each sub list has a numeric latitude and longitude value"
 532        assert all(
 533            [
 534                (i[0] >= -90 and i[0] <= 90 and i[1] >= -180 and i[1] <= 180)
 535                for i in self.nodes
 536            ]
 537        ), "Your nodes must be a list of lists where each sub list has a length of 2 with a latitude [-90,90] and longitude [-180,180] value"
 538
 539    def haversine(
 540        self,
 541        origin_id: int,
 542        destination_id: int,
 543    ):
 544        return haversine(
 545            origin=self.nodes[origin_id],
 546            destination=self.nodes[destination_id],
 547            units="km",
 548            circuity=1,
 549        )
 550
 551    def cheap_ruler(
 552        self,
 553        origin_id: int,
 554        destination_id: int,
 555    ):
 556        return cheap_ruler(
 557            origin=self.nodes[origin_id],
 558            destination=self.nodes[destination_id],
 559            units="km",
 560            # Use a circuity factor of 0.95 to account for the fact that cheap_ruler can overestimate distances
 561            circuity=0.9,
 562        )
 563
 564    def get_shortest_path(
 565        self,
 566        origin_node: dict[float | int],
 567        destination_node: dict[float | int],
 568        output_units: str = "km",
 569        algorithm_fn=Graph.dijkstra_makowski,
 570        algorithm_kwargs: dict = dict(),
 571        off_graph_circuity: [float | int] = 1,
 572        node_addition_type: str = "quadrant",
 573        node_addition_circuity: [float | int] = 4,
 574        geograph_units: str = "km",
 575        output_coordinate_path: str = "list_of_lists",
 576        output_path: bool = False,
 577        node_addition_lat_lon_bound: [float | int] = 5,
 578        node_addition_math: str = "euclidean",
 579        **kwargs,
 580    ) -> dict:
 581        """
 582        Function:
 583
 584        - Identify the shortest path between two nodes in a sparse network graph
 585
 586        - Return a dictionary of various path information including:
 587            - `id_path`: A list of node ids in the order they are visited
 588            - `path`: A list of nodes  (list of lat then long) in the order they are visited
 589            - `length`: The length of the path
 590
 591         Required Arguments:
 592
 593        - `origin_node`
 594            - Type: dict of int | float
 595            - What: A dictionary with the keys 'latitude' and 'longitude'
 596        - `destination_node`
 597            - Type: dict of int | float
 598            - What: A dictionary with the keys 'latitude' and 'longitude'
 599
 600        Optional Arguments:
 601
 602        - `output_units`
 603            - Type: str
 604            - What: The units in which to return the length of the path
 605            - Default: 'km'
 606            - Options:
 607                - 'km': Kilometers
 608                - 'm': Meters
 609                - 'mi': Miles
 610                - 'ft': Feet
 611        - `algorithm_fn`
 612            - Type: function | method
 613            - What: The algorithm function to identify the shortest path
 614            - Default: 'Graph.dijkstra_makowski'
 615            - Options:
 616                - 'Graph.dijkstra': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
 617                - 'Graph.dijkstra_makowski': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
 618                - Any user defined algorithm that takes the arguments:
 619                    - `graph`: A dictionary of dictionaries where the keys are origin node ids and the values are dictionaries of destination node ids and distances
 620                        - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
 621                    - `origin`: The id of the origin node from the graph dictionary to start the shortest path from
 622                    - `destination`: The id of the destination node from the graph dictionary to end the shortest path at
 623        - `algorithm_kwargs`
 624            - Type: dict
 625            - What: Additional keyword arguments to pass to the algorithm function assuming it accepts them
 626        - `off_graph_circuity`
 627            - Type: int | float
 628            - What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
 629            - Default: 1
 630            - Notes:
 631                - For alogrithmic solving purposes, the node_addition_circuity is applied to the origin and destination nodes when they are added to the graph
 632                - This is only applied after an `optimal solution` using the `node_addition_circuity` has been found when it is then adjusted to equal the `off_graph_circuity`
 633        - `node_addition_type`
 634            - Type: str
 635            - What: The type of node addition to use when adding your origin node to the distance matrix
 636            - Default: 'quadrant'
 637            - Options:
 638                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 639                - 'closest': Add only the closest node to the distance matrix for this node
 640                - 'all': Add all nodes to the distance matrix for this node
 641            - Notes:
 642                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 643                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 644                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 645                - The destination node is always added as 'all' regardless of the `node_addition_type` setting
 646                    - This guarantees that any destination node will be connected to any origin node regardless of how or where the origin node is added to the graph
 647                - If the passed graph is not a connected graph (meaning it is comprised of multiple disconnected networks)
 648                    - The entrypoints generated using the `node_addition_type` will determine which disconnected networks will be used to calculate the `optimal route`
 649        - `node_addition_circuity`
 650            - Type: int | float
 651            - What: The circuity factor to apply when adding your origin and destination nodes to the distance matrix
 652            - Default: 4
 653            - Note:
 654                - This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 655                - A higher value will push the algorithm to join the network at a closer node to avoid the extra distance from the circuity factor
 656                - This is only relevant if `node_addition_type` is set to 'quadrant' or 'all' as it affects the choice on where to enter the graph network
 657                - This factor is used to calculate the node sequence for the `optimal route`, however the reported `length` of the path will be calculated using the `off_graph_circuity` factor
 658        - `geograph_units`
 659            - Type: str
 660            - What: The units of measurement in the geograph data
 661            - Default: 'km'
 662            - Options:
 663                - 'km': Kilometers
 664                - 'm': Meters
 665                - 'mi': Miles
 666                - 'ft': Feet
 667            - Note: In general, all scgraph provided geographs be in kilometers
 668        - `output_coordinate_path`
 669            - Type: str
 670            - What: The format of the output coordinate path
 671            - Default: 'list_of_lists'
 672            - Options:
 673                - 'list_of_dicts': A list of dictionaries with keys 'latitude' and 'longitude'
 674                - 'list_of_lists': A list of lists with the first value being latitude and the second being longitude
 675                - 'list_of_lists_long_first': A list of lists with the first value being longitude and the second being latitude
 676        - `output_path`
 677            - Type: bool
 678            - What: Whether to output the path as a list of geograph node ids (for debugging and other advanced uses)
 679            - Default: False
 680        - `node_addition_lat_lon_bound`
 681            - Type: int | float
 682            - What: Forms a bounding box around the origin and destination nodes as they are added to graph
 683                - Only points on the current graph inside of this bounding box are considered when updating the distance matrix for the origin or destination nodes
 684            - Default: 5
 685            - Note: If no nodes are found within the bounding box, the bounding box is expanded to 180 degrees in all directions (all nodes are considered)
 686            - Note: This is only used when adding a new node (the specified origin and destination) to the graph
 687        - `node_addition_math`
 688            - Type: str
 689            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 690            - Default: 'euclidean'
 691            - Options:
 692                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not as accurate (especially near the poles)
 693                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 694            - Notes:
 695                - Only used if `node_addition_type` is set to 'quadrant' or 'closest'
 696        - `**kwargs`
 697            - Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
 698        """
 699        original_graph_length = len(self.graph)
 700        # Add the origin and destination nodes to the graph
 701        origin_id = self.add_node(
 702            node=origin_node,
 703            node_addition_type=node_addition_type,
 704            circuity=node_addition_circuity,
 705            lat_lon_bound=node_addition_lat_lon_bound,
 706            node_addition_math=node_addition_math,
 707        )
 708        destination_id = self.add_node(
 709            node=destination_node,
 710            node_addition_type="all",
 711            circuity=node_addition_circuity,
 712            lat_lon_bound=node_addition_lat_lon_bound,
 713            node_addition_math=node_addition_math,
 714        )
 715        try:
 716            output = algorithm_fn(
 717                graph=self.graph,
 718                origin_id=origin_id,
 719                destination_id=destination_id,
 720                **algorithm_kwargs,
 721            )
 722            output["coordinate_path"] = self.get_coordinate_path(output["path"])
 723            output["length"] = self.adujust_circuity_length(
 724                output=output,
 725                node_addition_circuity=node_addition_circuity,
 726                off_graph_circuity=off_graph_circuity,
 727            )
 728            output["length"] = distance_converter(
 729                output["length"],
 730                input_units=geograph_units,
 731                output_units=output_units,
 732            )
 733            if output_coordinate_path == "list_of_dicts":
 734                output["coordinate_path"] = [
 735                    {"latitude": i[0], "longitude": i[1]}
 736                    for i in output["coordinate_path"]
 737                ]
 738            elif output_coordinate_path == "list_of_lists_long_first":
 739                output["coordinate_path"] = [
 740                    [i[1], i[0]] for i in output["coordinate_path"]
 741                ]
 742                output["long_first"] = True
 743            if not output_path:
 744                del output["path"]
 745            while len(self.graph) > original_graph_length:
 746                self.remove_appended_node()
 747            return output
 748        except Exception as e:
 749            while len(self.graph) > original_graph_length:
 750                self.remove_appended_node()
 751            raise e
 752
 753    def adujust_circuity_length(
 754        self,
 755        output: dict,
 756        node_addition_circuity: [float | int],
 757        off_graph_circuity: [float | int],
 758    ) -> [float | int]:
 759        """
 760        Function:
 761
 762        - Adjust the length of the path to account for the circuity factors applied to the origin and destination nodes
 763
 764        Required Arguments:
 765
 766        - `output`
 767            - Type: dict
 768            - What: The output from the algorithm function
 769        - `node_addition_circuity`
 770            - Type: int | float
 771            - What: The circuity factor that was applied when adding your origin and destination nodes to the distance matrix
 772        - `off_graph_circuity`
 773            - Type: int | float
 774            - What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
 775        """
 776        coordinate_path = output["coordinate_path"]
 777        # If the path does not enter the graph, undo the node_addition_circuity and apply the off_graph_circuity
 778        if len(output["coordinate_path"]) == 2:
 779            return (
 780                output["length"] / node_addition_circuity
 781            ) * off_graph_circuity
 782        else:
 783            direct_off_graph_length = haversine(
 784                coordinate_path[0], coordinate_path[1], circuity=1
 785            ) + haversine(coordinate_path[-2], coordinate_path[-1], circuity=1)
 786            return round(
 787                output["length"]
 788                + direct_off_graph_length
 789                * (off_graph_circuity - node_addition_circuity),
 790                4,
 791            )
 792
 793    def get_coordinate_path(self, path: list[int]) -> list[dict[float | int]]:
 794        """
 795        Function:
 796
 797        - Return a list of node dictionaries (lat + long) in the order they are visited
 798
 799        Required Arguments:
 800
 801        - `path`
 802            - Type: list
 803            - What: A list of node ids in the order they are visited
 804
 805        Optional Arguments:
 806
 807        - None
 808        """
 809        return [self.nodes[node_id] for node_id in path]
 810
 811    def remove_appended_node(self) -> None:
 812        """
 813        Function:
 814
 815        - Remove the last node that was appended to the graph
 816        - Assumes that this node has symmetric flows
 817            - EG: If node A has a distance of 10 to node B, then node B has a distance of 10 to node A
 818        - Return None
 819
 820        Required Arguments:
 821
 822        - None
 823
 824        Optional Arguments:
 825
 826        - None
 827        """
 828        node_id = len(self.graph) - 1
 829        for reverse_connection in [i for i in self.graph[node_id].keys()]:
 830            del self.graph[reverse_connection][node_id]
 831        self.graph = self.graph[:node_id]
 832        self.nodes = self.nodes[:node_id]
 833
 834    def get_node_distances(
 835        self,
 836        node: list,
 837        circuity: [float | int],
 838        node_addition_type: str,
 839        node_addition_math: str,
 840        lat_lon_bound: [float | int],
 841    ) -> dict[float | int]:
 842        """
 843        Function:
 844
 845        - Get the distances between a node and all other nodes in the graph
 846        - This is used to determine the closest node to add to the graph when adding a new node
 847
 848        Required Arguments:
 849
 850        - `node`
 851            - Type: list
 852            - What: A list of the latitude and longitude of the node
 853            - EG: [latitude, longitude] -> [31.23, 121.47]
 854        - `circuity`
 855            - Type: int | float
 856            - What: The circuity to apply to any distance calculations
 857            - Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 858        - `node_addition_type`
 859            - Type: str
 860            - What: The type of node addition to use
 861            - Options:
 862                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 863                - 'closest': Add only the closest node to the distance matrix for this node
 864                - 'all': Add all nodes to the distance matrix for this node
 865            - Notes:
 866                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 867                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 868                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 869        - `node_addition_math`
 870            - Type: str
 871            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 872            - Default: 'euclidean'
 873            - Options:
 874                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
 875                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 876            - Notes:
 877                - Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
 878        - `lat_lon_bound`
 879            - Type: int | float
 880            - What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
 881        """
 882        assert node_addition_type in [
 883            "quadrant",
 884            "all",
 885            "closest",
 886        ], f"Invalid node addition type provided ({node_addition_type}), valid options are: ['quadrant', 'all', 'closest']"
 887        assert node_addition_math in [
 888            "euclidean",
 889            "haversine",
 890        ], f"Invalid node addition math provided ({node_addition_math}), valid options are: ['euclidean', 'haversine']"
 891        # Get only bounded nodes
 892        nodes = {
 893            node_idx: node_i
 894            for node_idx, node_i in enumerate(self.nodes)
 895            if abs(node_i[0] - node[0]) < lat_lon_bound
 896            and abs(node_i[1] - node[1]) < lat_lon_bound
 897        }
 898        if len(nodes) == 0:
 899            # Default to all if the lat_lon_bound fails to find any nodes
 900            return self.get_node_distances(
 901                node=node,
 902                circuity=circuity,
 903                lat_lon_bound=180,
 904                node_addition_type=node_addition_type,
 905                node_addition_math=node_addition_math,
 906            )
 907        if node_addition_type == "all":
 908            return {
 909                node_idx: round(haversine(node, node_i, circuity=circuity), 4)
 910                for node_idx, node_i in nodes.items()
 911            }
 912        if node_addition_math == "haversine":
 913            dist_fn = lambda x: round(haversine(node, x, circuity=circuity), 4)
 914        else:
 915            dist_fn = lambda x: round(
 916                ((node[0] - x[0]) ** 2 + (node[1] - x[1]) ** 2) ** 0.5, 4
 917            )
 918        if node_addition_type == "closest":
 919            quadrant_fn = lambda x, y: "all"
 920        else:
 921            quadrant_fn = lambda x, y: ("n" if x[0] - y[0] > 0 else "s") + (
 922                "e" if x[1] - y[1] > 0 else "w"
 923            )
 924        min_diffs = {}
 925        min_diffs_idx = {}
 926        for node_idx, node_i in nodes.items():
 927            quadrant = quadrant_fn(node_i, node)
 928            dist = dist_fn(node_i)
 929            if dist < min_diffs.get(quadrant, 999999999):
 930                min_diffs[quadrant] = dist
 931                min_diffs_idx[quadrant] = node_idx
 932        return {
 933            node_idx: round(
 934                haversine(node, self.nodes[node_idx], circuity=circuity), 4
 935            )
 936            for node_idx in min_diffs_idx.values()
 937        }
 938
 939    def add_node(
 940        self,
 941        node: dict[float | int],
 942        circuity: [float | int],
 943        node_addition_type: str = "quadrant",
 944        node_addition_math: str = "euclidean",
 945        lat_lon_bound: [float | int] = 5,
 946    ) -> int:
 947        """
 948        Function:
 949
 950        - Add a node to the network
 951        - Returns the id of the new node
 952
 953        Required Arguments:
 954
 955        - `node`
 956            - Type: dict
 957            - What: A dictionary with the keys 'latitude' and 'longitude'
 958
 959        Optional Arguments:
 960
 961        - `circuity`
 962            - Type: int | float
 963            - What: The circuity to apply to any distance calculations
 964            - Default: 4
 965            - Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 966        - `node_addition_type`
 967            - Type: str
 968            - What: The type of node addition to use
 969            - Default: 'quadrant'
 970            - Options:
 971                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 972                - 'closest': Add only the closest node to the distance matrix for this node
 973                - 'all': Add all nodes to the distance matrix for this node
 974            - Notes:
 975                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 976                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 977                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 978        - `node_addition_math`
 979            - Type: str
 980            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 981            - Default: 'euclidean'
 982            - Options:
 983                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
 984                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 985            - Notes:
 986                - Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
 987        - `lat_lon_bound`
 988            - Type: int | float
 989            - What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
 990            - Default: 5
 991
 992        """
 993        # Validate the inputs
 994        assert isinstance(node, dict), "Node must be a dictionary"
 995        assert "latitude" in node.keys(), "Node must have a latitude"
 996        assert "longitude" in node.keys(), "Node must have a longitude"
 997        assert (
 998            node["latitude"] >= -90 and node["latitude"] <= 90
 999        ), "Latitude must be between -90 and 90"
1000        assert (
1001            node["longitude"] >= -180 and node["longitude"] <= 180
1002        ), "Longitude must be between -180 and 180"
1003        assert circuity > 0, "Circuity must be greater than 0"
1004        assert node_addition_type in [
1005            "quadrant",
1006            "all",
1007            "closest",
1008        ], f"Invalid node addition type provided ({node_addition_type}), valid options are: ['quadrant', 'all', 'closest']"
1009        assert node_addition_math in [
1010            "euclidean",
1011            "haversine",
1012        ], f"Invalid node addition math provided ({node_addition_math}), valid options are: ['euclidean', 'haversine']"
1013        assert isinstance(
1014            lat_lon_bound, (int, float)
1015        ), "Lat_lon_bound must be a number"
1016        assert lat_lon_bound > 0, "Lat_lon_bound must be greater than 0"
1017        node = [node["latitude"], node["longitude"]]
1018        # Get the distances to all other nodes
1019        distances = self.get_node_distances(
1020            node=node,
1021            circuity=circuity,
1022            node_addition_type=node_addition_type,
1023            node_addition_math=node_addition_math,
1024            lat_lon_bound=lat_lon_bound,
1025        )
1026
1027        # Create the node
1028        new_node_id = len(self.graph)
1029        self.nodes.append(node)
1030        self.graph.append(distances)
1031        for node_idx, node_distance in distances.items():
1032            self.graph[node_idx][new_node_id] = node_distance
1033        return new_node_id
1034
1035    def save_as_geojson(self, filename: str) -> None:
1036        """
1037        Function:
1038
1039        - Save the current geograph object as a geojson file specifed by `filename`
1040        - This is useful for understanding the underlying geograph and for debugging purposes
1041        - Returns None
1042
1043        Required Arguments:
1044
1045        - `filename`
1046            - Type: str
1047            - What: The filename to save the geojson file as
1048
1049        """
1050        features = []
1051        for origin_idx, destinations in enumerate(self.graph):
1052            for destination_idx, distance in destinations.items():
1053                # Create an undirected graph for geojson purposes
1054                if origin_idx > destination_idx:
1055                    continue
1056                origin = self.nodes[origin_idx]
1057                destination = self.nodes[destination_idx]
1058                features.append(
1059                    {
1060                        "type": "Feature",
1061                        "properties": {
1062                            "origin_idx": origin_idx,
1063                            "destination_idx": destination_idx,
1064                            "distance": distance,
1065                        },
1066                        "geometry": {
1067                            "type": "LineString",
1068                            "coordinates": [
1069                                [origin[1], origin[0]],
1070                                [
1071                                    destination[1],
1072                                    destination[0],
1073                                ],
1074                            ],
1075                        },
1076                    }
1077                )
1078
1079        out_dict = {"type": "FeatureCollection", "features": features}
1080        with open(filename, "w") as f:
1081            json.dump(out_dict, f)
1082
1083    def save_as_geograph(self, name: str) -> None:
1084        """
1085        Function:
1086
1087        - Save the current geograph as an importable python file
1088
1089        Required Arguments:
1090
1091        - `name`
1092            - Type: str
1093            - What: The name of the geograph and file
1094            - EG: 'custom'
1095                - Stored as: 'custom.py'
1096                    - In your current directory
1097                - Import as: 'from .custom import custom_geograph'
1098        """
1099        self.validate_nodes()
1100        self.validate_graph(check_symmetry=True, check_connected=False)
1101        out_string = f"""from scgraph.core import GeoGraph\ngraph={str(self.graph)}\nnodes={str(self.nodes)}\n{name}_geograph = GeoGraph(graph=graph, nodes=nodes)"""
1102        with open(name + ".py", "w") as f:
1103            f.write(out_string)
1104
1105    def mod_remove_arc(
1106        self, origin_idx: int, destination_idx: int, undirected: bool = True
1107    ) -> None:
1108        """
1109        Function:
1110
1111        - Remove an arc from the graph
1112
1113        Required Arguments:
1114
1115        - `origin_idx`
1116            - Type: int
1117            - What: The index of the origin node
1118        - `destination_idx`
1119            - Type: int
1120            - What: The index of the destination node
1121
1122        Optional Arguments:
1123
1124        - `undirected`
1125            - Type: bool
1126            - What: Whether to remove the arc in both directions
1127            - Default: True
1128        """
1129        assert origin_idx < len(self.graph), "Origin node does not exist"
1130        assert destination_idx < len(
1131            self.graph
1132        ), "Destination node does not exist"
1133        assert destination_idx in self.graph[origin_idx], "Arc does not exist"
1134        del self.graph[origin_idx][destination_idx]
1135        if undirected:
1136            if origin_idx in self.graph[destination_idx]:
1137                del self.graph[destination_idx][origin_idx]
1138
1139    def mod_add_node(
1140        self, latitude: [float | int], longitude: [float | int]
1141    ) -> int:
1142        """
1143        Function:
1144
1145        - Add a node to the graph
1146
1147        Required Arguments:
1148
1149        - `latitude`
1150            - Type: int | float
1151            - What: The latitude of the node
1152        - `longitude`
1153            - Type: int | float
1154            - What: The longitude of the node
1155
1156        Returns:
1157
1158        - The index of the new node
1159        """
1160        self.nodes.append([latitude, longitude])
1161        self.graph.append({})
1162        return len(self.graph) - 1
1163
1164    def mod_add_arc(
1165        self,
1166        origin_idx: int,
1167        destination_idx: int,
1168        distance: [float | int] = 0,
1169        use_haversine_distance=True,
1170        undirected: bool = True,
1171    ) -> None:
1172        """
1173        Function:
1174
1175        - Add an arc to the graph
1176
1177        Required Arguments:
1178
1179        - `origin_idx`
1180            - Type: int
1181            - What: The index of the origin node
1182        - `destination_idx`
1183            - Type: int
1184            - What: The index of the destination node
1185
1186        Optional Arguments:
1187
1188        - `distance`
1189            - Type: int | float
1190            - What: The distance between the origin and destination nodes in terms of the graph distance (normally km)
1191            - Default: 0
1192        - `use_haversine_distance`
1193            - Type: bool
1194            - What: Whether to calculate the haversine distance (km) between the nodes when calculating the distance
1195            - Default: True
1196            - Note: If true, overrides the `distance` argument
1197        - `undirected`
1198            - Type: bool
1199            - What: Whether to add the arc in both directions
1200            - Default: True
1201        """
1202        assert origin_idx < len(self.graph), "Origin node does not exist"
1203        assert destination_idx < len(
1204            self.graph
1205        ), "Destination node does not exist"
1206        if use_haversine_distance:
1207            distance = haversine(
1208                self.nodes[origin_idx], self.nodes[destination_idx]
1209            )
1210        self.graph[origin_idx][destination_idx] = distance
1211        if undirected:
1212            self.graph[destination_idx][origin_idx] = distance
GeoGraph(graph: list[dict[int, int | float]], nodes: list[list[float | int]])
426    def __init__(
427        self,
428        graph: list[dict[int, int | float]],
429        nodes: list[list[float | int]],
430    ) -> None:
431        """
432        Function:
433
434        - Create a GeoGraph object
435
436        Required Arguments:
437
438        - `graph`
439            - Type: list of dictionaries
440            - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
441        - `nodes`
442            - Type: list of lists of ints or floats
443            - What: A list of lists where the values are coordinates (latitude then longitude)
444            - Note: The length of the nodes list must be the same as that of the graph list
445            - EG Continuing off the example from https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
446            ```
447                [
448                    # London (index 0)
449                    [51.5074, 0.1278],
450                    # Paris (index 1)
451                    [48.8566, 2.3522],
452                    # Berlin (index 2)
453                    [52.5200, 13.4050],
454                    # Rome (index 3)
455                    [41.9028, 12.4964],
456                    # Madrid (index 4)
457                    [40.4168, 3.7038],
458                    # Lisbon (index 5)
459                    [38.7223, 9.1393]
460                ]
461            ```
462        """
463        self.graph = graph
464        self.nodes = nodes

Function:

  • Create a GeoGraph object

Required Arguments:

graph
nodes
def validate_graph(self, check_symmetry: bool = True, check_connected: bool = True) -> None:
466    def validate_graph(
467        self, check_symmetry: bool = True, check_connected: bool = True
468    ) -> None:
469        """
470        Function:
471
472        - Validate that self.graph is properly formatted (see Graph.validate_graph)
473        - Raises an exception if the graph is invalid
474        - Returns None if the graph is valid
475
476        Required Arguments:
477
478        - None
479
480        Optional Arguments:
481
482        - `check_symmetry`
483            - Type: bool
484            - What: Whether to check that the graph is symmetric
485            - Default: True
486            - Note: This is forced to True if `check_connected` is True
487        - `check_connected`
488            - Type: bool
489            - What: Whether to check that the graph is fully connected
490            - Default: True
491            - Note: For computational efficiency, graphs are validated for symmetry prior to checking for connectivity
492        """
493        Graph.validate_graph(
494            self.graph,
495            check_symmetry=check_symmetry,
496            check_connected=check_connected,
497        )

Function:

  • Validate that self.graph is properly formatted (see Graph.validate_graph)
  • Raises an exception if the graph is invalid
  • Returns None if the graph is valid

Required Arguments:

  • None

Optional Arguments:

  • check_symmetry
    • Type: bool
    • What: Whether to check that the graph is symmetric
    • Default: True
    • Note: This is forced to True if check_connected is True
  • check_connected
    • Type: bool
    • What: Whether to check that the graph is fully connected
    • Default: True
    • Note: For computational efficiency, graphs are validated for symmetry prior to checking for connectivity
def validate_nodes(self) -> None:
499    def validate_nodes(self) -> None:
500        """
501
502        Function:
503
504        - Validate that self.nodes is properly formatted (see GeoGraph.__init__ docs for more details)
505        - Raises an exception if the nodes are invalid
506        - Returns None if the nodes are valid
507
508        Required Arguments:
509
510        - None
511
512        Optional Arguments:
513
514        - None
515        """
516        assert isinstance(self.nodes, list), "Your nodes must be a dictionary"
517        assert all(
518            [isinstance(i, list) for i in self.nodes]
519        ), "Your nodes must be a list of lists"
520        assert all(
521            [len(i) == 2 for i in self.nodes]
522        ), "Your nodes must be a list of lists where each sub list has a length of 2"
523        assert all(
524            [
525                (
526                    isinstance(i[0], (int, float))
527                    and isinstance(i[1], (int, float))
528                )
529                for i in self.nodes
530            ]
531        ), "Your nodes must be a list of lists where each sub list has a numeric latitude and longitude value"
532        assert all(
533            [
534                (i[0] >= -90 and i[0] <= 90 and i[1] >= -180 and i[1] <= 180)
535                for i in self.nodes
536            ]
537        ), "Your nodes must be a list of lists where each sub list has a length of 2 with a latitude [-90,90] and longitude [-180,180] value"

Function:

  • Validate that self.nodes is properly formatted (see GeoGraph.__init__ docs for more details)
  • Raises an exception if the nodes are invalid
  • Returns None if the nodes are valid

Required Arguments:

  • None

Optional Arguments:

  • None
def haversine(self, origin_id: int, destination_id: int):
539    def haversine(
540        self,
541        origin_id: int,
542        destination_id: int,
543    ):
544        return haversine(
545            origin=self.nodes[origin_id],
546            destination=self.nodes[destination_id],
547            units="km",
548            circuity=1,
549        )
def cheap_ruler(self, origin_id: int, destination_id: int):
551    def cheap_ruler(
552        self,
553        origin_id: int,
554        destination_id: int,
555    ):
556        return cheap_ruler(
557            origin=self.nodes[origin_id],
558            destination=self.nodes[destination_id],
559            units="km",
560            # Use a circuity factor of 0.95 to account for the fact that cheap_ruler can overestimate distances
561            circuity=0.9,
562        )
def get_shortest_path( self, origin_node: dict[float | int], destination_node: dict[float | int], output_units: str = 'km', algorithm_fn=<function Graph.dijkstra_makowski>, algorithm_kwargs: dict = {}, off_graph_circuity: [float | int] = 1, node_addition_type: str = 'quadrant', node_addition_circuity: [float | int] = 4, geograph_units: str = 'km', output_coordinate_path: str = 'list_of_lists', output_path: bool = False, node_addition_lat_lon_bound: [float | int] = 5, node_addition_math: str = 'euclidean', **kwargs) -> dict:
564    def get_shortest_path(
565        self,
566        origin_node: dict[float | int],
567        destination_node: dict[float | int],
568        output_units: str = "km",
569        algorithm_fn=Graph.dijkstra_makowski,
570        algorithm_kwargs: dict = dict(),
571        off_graph_circuity: [float | int] = 1,
572        node_addition_type: str = "quadrant",
573        node_addition_circuity: [float | int] = 4,
574        geograph_units: str = "km",
575        output_coordinate_path: str = "list_of_lists",
576        output_path: bool = False,
577        node_addition_lat_lon_bound: [float | int] = 5,
578        node_addition_math: str = "euclidean",
579        **kwargs,
580    ) -> dict:
581        """
582        Function:
583
584        - Identify the shortest path between two nodes in a sparse network graph
585
586        - Return a dictionary of various path information including:
587            - `id_path`: A list of node ids in the order they are visited
588            - `path`: A list of nodes  (list of lat then long) in the order they are visited
589            - `length`: The length of the path
590
591         Required Arguments:
592
593        - `origin_node`
594            - Type: dict of int | float
595            - What: A dictionary with the keys 'latitude' and 'longitude'
596        - `destination_node`
597            - Type: dict of int | float
598            - What: A dictionary with the keys 'latitude' and 'longitude'
599
600        Optional Arguments:
601
602        - `output_units`
603            - Type: str
604            - What: The units in which to return the length of the path
605            - Default: 'km'
606            - Options:
607                - 'km': Kilometers
608                - 'm': Meters
609                - 'mi': Miles
610                - 'ft': Feet
611        - `algorithm_fn`
612            - Type: function | method
613            - What: The algorithm function to identify the shortest path
614            - Default: 'Graph.dijkstra_makowski'
615            - Options:
616                - 'Graph.dijkstra': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
617                - 'Graph.dijkstra_makowski': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
618                - Any user defined algorithm that takes the arguments:
619                    - `graph`: A dictionary of dictionaries where the keys are origin node ids and the values are dictionaries of destination node ids and distances
620                        - See: https://connor-makowski.github.io/scgraph/scgraph/core.html#Graph.validate_graph
621                    - `origin`: The id of the origin node from the graph dictionary to start the shortest path from
622                    - `destination`: The id of the destination node from the graph dictionary to end the shortest path at
623        - `algorithm_kwargs`
624            - Type: dict
625            - What: Additional keyword arguments to pass to the algorithm function assuming it accepts them
626        - `off_graph_circuity`
627            - Type: int | float
628            - What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
629            - Default: 1
630            - Notes:
631                - For alogrithmic solving purposes, the node_addition_circuity is applied to the origin and destination nodes when they are added to the graph
632                - This is only applied after an `optimal solution` using the `node_addition_circuity` has been found when it is then adjusted to equal the `off_graph_circuity`
633        - `node_addition_type`
634            - Type: str
635            - What: The type of node addition to use when adding your origin node to the distance matrix
636            - Default: 'quadrant'
637            - Options:
638                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
639                - 'closest': Add only the closest node to the distance matrix for this node
640                - 'all': Add all nodes to the distance matrix for this node
641            - Notes:
642                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
643                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
644                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
645                - The destination node is always added as 'all' regardless of the `node_addition_type` setting
646                    - This guarantees that any destination node will be connected to any origin node regardless of how or where the origin node is added to the graph
647                - If the passed graph is not a connected graph (meaning it is comprised of multiple disconnected networks)
648                    - The entrypoints generated using the `node_addition_type` will determine which disconnected networks will be used to calculate the `optimal route`
649        - `node_addition_circuity`
650            - Type: int | float
651            - What: The circuity factor to apply when adding your origin and destination nodes to the distance matrix
652            - Default: 4
653            - Note:
654                - This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
655                - A higher value will push the algorithm to join the network at a closer node to avoid the extra distance from the circuity factor
656                - This is only relevant if `node_addition_type` is set to 'quadrant' or 'all' as it affects the choice on where to enter the graph network
657                - This factor is used to calculate the node sequence for the `optimal route`, however the reported `length` of the path will be calculated using the `off_graph_circuity` factor
658        - `geograph_units`
659            - Type: str
660            - What: The units of measurement in the geograph data
661            - Default: 'km'
662            - Options:
663                - 'km': Kilometers
664                - 'm': Meters
665                - 'mi': Miles
666                - 'ft': Feet
667            - Note: In general, all scgraph provided geographs be in kilometers
668        - `output_coordinate_path`
669            - Type: str
670            - What: The format of the output coordinate path
671            - Default: 'list_of_lists'
672            - Options:
673                - 'list_of_dicts': A list of dictionaries with keys 'latitude' and 'longitude'
674                - 'list_of_lists': A list of lists with the first value being latitude and the second being longitude
675                - 'list_of_lists_long_first': A list of lists with the first value being longitude and the second being latitude
676        - `output_path`
677            - Type: bool
678            - What: Whether to output the path as a list of geograph node ids (for debugging and other advanced uses)
679            - Default: False
680        - `node_addition_lat_lon_bound`
681            - Type: int | float
682            - What: Forms a bounding box around the origin and destination nodes as they are added to graph
683                - Only points on the current graph inside of this bounding box are considered when updating the distance matrix for the origin or destination nodes
684            - Default: 5
685            - Note: If no nodes are found within the bounding box, the bounding box is expanded to 180 degrees in all directions (all nodes are considered)
686            - Note: This is only used when adding a new node (the specified origin and destination) to the graph
687        - `node_addition_math`
688            - Type: str
689            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
690            - Default: 'euclidean'
691            - Options:
692                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not as accurate (especially near the poles)
693                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
694            - Notes:
695                - Only used if `node_addition_type` is set to 'quadrant' or 'closest'
696        - `**kwargs`
697            - Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
698        """
699        original_graph_length = len(self.graph)
700        # Add the origin and destination nodes to the graph
701        origin_id = self.add_node(
702            node=origin_node,
703            node_addition_type=node_addition_type,
704            circuity=node_addition_circuity,
705            lat_lon_bound=node_addition_lat_lon_bound,
706            node_addition_math=node_addition_math,
707        )
708        destination_id = self.add_node(
709            node=destination_node,
710            node_addition_type="all",
711            circuity=node_addition_circuity,
712            lat_lon_bound=node_addition_lat_lon_bound,
713            node_addition_math=node_addition_math,
714        )
715        try:
716            output = algorithm_fn(
717                graph=self.graph,
718                origin_id=origin_id,
719                destination_id=destination_id,
720                **algorithm_kwargs,
721            )
722            output["coordinate_path"] = self.get_coordinate_path(output["path"])
723            output["length"] = self.adujust_circuity_length(
724                output=output,
725                node_addition_circuity=node_addition_circuity,
726                off_graph_circuity=off_graph_circuity,
727            )
728            output["length"] = distance_converter(
729                output["length"],
730                input_units=geograph_units,
731                output_units=output_units,
732            )
733            if output_coordinate_path == "list_of_dicts":
734                output["coordinate_path"] = [
735                    {"latitude": i[0], "longitude": i[1]}
736                    for i in output["coordinate_path"]
737                ]
738            elif output_coordinate_path == "list_of_lists_long_first":
739                output["coordinate_path"] = [
740                    [i[1], i[0]] for i in output["coordinate_path"]
741                ]
742                output["long_first"] = True
743            if not output_path:
744                del output["path"]
745            while len(self.graph) > original_graph_length:
746                self.remove_appended_node()
747            return output
748        except Exception as e:
749            while len(self.graph) > original_graph_length:
750                self.remove_appended_node()
751            raise e

Function:

  • Identify the shortest path between two nodes in a sparse network graph

  • Return a dictionary of various path information including:

    • id_path: A list of node ids in the order they are visited
    • path: A list of nodes (list of lat then long) in the order they are visited
    • length: The length of the path

    Required Arguments:

  • origin_node

    • Type: dict of int | float
    • What: A dictionary with the keys 'latitude' and 'longitude'
  • destination_node
    • Type: dict of int | float
    • What: A dictionary with the keys 'latitude' and 'longitude'

Optional Arguments:

  • output_units
    • Type: str
    • What: The units in which to return the length of the path
    • Default: 'km'
    • Options:
      • 'km': Kilometers
      • 'm': Meters
      • 'mi': Miles
      • 'ft': Feet
  • algorithm_fn
    • Type: function | method
    • What: The algorithm function to identify the shortest path
    • Default: 'Graph.dijkstra_makowski'
    • Options:
      • 'Graph.dijkstra': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
      • 'Graph.dijkstra_makowski': A modified dijkstra algorithm that uses a sparse distance matrix to identify the shortest path
      • Any user defined algorithm that takes the arguments:
  • algorithm_kwargs
    • Type: dict
    • What: Additional keyword arguments to pass to the algorithm function assuming it accepts them
  • off_graph_circuity
    • Type: int | float
    • What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
    • Default: 1
    • Notes:
      • For alogrithmic solving purposes, the node_addition_circuity is applied to the origin and destination nodes when they are added to the graph
      • This is only applied after an optimal solution using the node_addition_circuity has been found when it is then adjusted to equal the off_graph_circuity
  • node_addition_type
    • Type: str
    • What: The type of node addition to use when adding your origin node to the distance matrix
    • Default: 'quadrant'
    • Options:
      • 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
      • 'closest': Add only the closest node to the distance matrix for this node
      • 'all': Add all nodes to the distance matrix for this node
    • Notes:
      • dijkstra_makowski will operate substantially faster if the node_addition_type is set to 'quadrant' or 'closest'
      • dijkstra will operate at the similar speeds regardless of the node_addition_type
      • When using all, you should consider using dijkstra instead of dijkstra_makowski as it will be faster
      • The destination node is always added as 'all' regardless of the node_addition_type setting
        • This guarantees that any destination node will be connected to any origin node regardless of how or where the origin node is added to the graph
      • If the passed graph is not a connected graph (meaning it is comprised of multiple disconnected networks)
        • The entrypoints generated using the node_addition_type will determine which disconnected networks will be used to calculate the optimal route
  • node_addition_circuity
    • Type: int | float
    • What: The circuity factor to apply when adding your origin and destination nodes to the distance matrix
    • Default: 4
    • Note:
      • This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
      • A higher value will push the algorithm to join the network at a closer node to avoid the extra distance from the circuity factor
      • This is only relevant if node_addition_type is set to 'quadrant' or 'all' as it affects the choice on where to enter the graph network
      • This factor is used to calculate the node sequence for the optimal route, however the reported length of the path will be calculated using the off_graph_circuity factor
  • geograph_units
    • Type: str
    • What: The units of measurement in the geograph data
    • Default: 'km'
    • Options:
      • 'km': Kilometers
      • 'm': Meters
      • 'mi': Miles
      • 'ft': Feet
    • Note: In general, all scgraph provided geographs be in kilometers
  • output_coordinate_path
    • Type: str
    • What: The format of the output coordinate path
    • Default: 'list_of_lists'
    • Options:
      • 'list_of_dicts': A list of dictionaries with keys 'latitude' and 'longitude'
      • 'list_of_lists': A list of lists with the first value being latitude and the second being longitude
      • 'list_of_lists_long_first': A list of lists with the first value being longitude and the second being latitude
  • output_path
    • Type: bool
    • What: Whether to output the path as a list of geograph node ids (for debugging and other advanced uses)
    • Default: False
  • node_addition_lat_lon_bound
    • Type: int | float
    • What: Forms a bounding box around the origin and destination nodes as they are added to graph
      • Only points on the current graph inside of this bounding box are considered when updating the distance matrix for the origin or destination nodes
    • Default: 5
    • Note: If no nodes are found within the bounding box, the bounding box is expanded to 180 degrees in all directions (all nodes are considered)
    • Note: This is only used when adding a new node (the specified origin and destination) to the graph
  • node_addition_math
    • Type: str
    • What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
    • Default: 'euclidean'
    • Options:
      • 'euclidean': Use the euclidean distance between nodes. This is much faster but is not as accurate (especially near the poles)
      • 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
    • Notes:
      • Only used if node_addition_type is set to 'quadrant' or 'closest'
  • **kwargs
    • Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
def adujust_circuity_length( self, output: dict, node_addition_circuity: [float | int], off_graph_circuity: [float | int]) -> [float | int]:
753    def adujust_circuity_length(
754        self,
755        output: dict,
756        node_addition_circuity: [float | int],
757        off_graph_circuity: [float | int],
758    ) -> [float | int]:
759        """
760        Function:
761
762        - Adjust the length of the path to account for the circuity factors applied to the origin and destination nodes
763
764        Required Arguments:
765
766        - `output`
767            - Type: dict
768            - What: The output from the algorithm function
769        - `node_addition_circuity`
770            - Type: int | float
771            - What: The circuity factor that was applied when adding your origin and destination nodes to the distance matrix
772        - `off_graph_circuity`
773            - Type: int | float
774            - What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
775        """
776        coordinate_path = output["coordinate_path"]
777        # If the path does not enter the graph, undo the node_addition_circuity and apply the off_graph_circuity
778        if len(output["coordinate_path"]) == 2:
779            return (
780                output["length"] / node_addition_circuity
781            ) * off_graph_circuity
782        else:
783            direct_off_graph_length = haversine(
784                coordinate_path[0], coordinate_path[1], circuity=1
785            ) + haversine(coordinate_path[-2], coordinate_path[-1], circuity=1)
786            return round(
787                output["length"]
788                + direct_off_graph_length
789                * (off_graph_circuity - node_addition_circuity),
790                4,
791            )

Function:

  • Adjust the length of the path to account for the circuity factors applied to the origin and destination nodes

Required Arguments:

  • output
    • Type: dict
    • What: The output from the algorithm function
  • node_addition_circuity
    • Type: int | float
    • What: The circuity factor that was applied when adding your origin and destination nodes to the distance matrix
  • off_graph_circuity
    • Type: int | float
    • What: The circuity factor to apply to any distance calculations between your origin and destination nodes and their connecting nodes in the graph
def get_coordinate_path(self, path: list[int]) -> list[dict[float | int]]:
793    def get_coordinate_path(self, path: list[int]) -> list[dict[float | int]]:
794        """
795        Function:
796
797        - Return a list of node dictionaries (lat + long) in the order they are visited
798
799        Required Arguments:
800
801        - `path`
802            - Type: list
803            - What: A list of node ids in the order they are visited
804
805        Optional Arguments:
806
807        - None
808        """
809        return [self.nodes[node_id] for node_id in path]

Function:

  • Return a list of node dictionaries (lat + long) in the order they are visited

Required Arguments:

  • path
    • Type: list
    • What: A list of node ids in the order they are visited

Optional Arguments:

  • None
def remove_appended_node(self) -> None:
811    def remove_appended_node(self) -> None:
812        """
813        Function:
814
815        - Remove the last node that was appended to the graph
816        - Assumes that this node has symmetric flows
817            - EG: If node A has a distance of 10 to node B, then node B has a distance of 10 to node A
818        - Return None
819
820        Required Arguments:
821
822        - None
823
824        Optional Arguments:
825
826        - None
827        """
828        node_id = len(self.graph) - 1
829        for reverse_connection in [i for i in self.graph[node_id].keys()]:
830            del self.graph[reverse_connection][node_id]
831        self.graph = self.graph[:node_id]
832        self.nodes = self.nodes[:node_id]

Function:

  • Remove the last node that was appended to the graph
  • Assumes that this node has symmetric flows
    • EG: If node A has a distance of 10 to node B, then node B has a distance of 10 to node A
  • Return None

Required Arguments:

  • None

Optional Arguments:

  • None
def get_node_distances( self, node: list, circuity: [float | int], node_addition_type: str, node_addition_math: str, lat_lon_bound: [float | int]) -> dict[float | int]:
834    def get_node_distances(
835        self,
836        node: list,
837        circuity: [float | int],
838        node_addition_type: str,
839        node_addition_math: str,
840        lat_lon_bound: [float | int],
841    ) -> dict[float | int]:
842        """
843        Function:
844
845        - Get the distances between a node and all other nodes in the graph
846        - This is used to determine the closest node to add to the graph when adding a new node
847
848        Required Arguments:
849
850        - `node`
851            - Type: list
852            - What: A list of the latitude and longitude of the node
853            - EG: [latitude, longitude] -> [31.23, 121.47]
854        - `circuity`
855            - Type: int | float
856            - What: The circuity to apply to any distance calculations
857            - Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
858        - `node_addition_type`
859            - Type: str
860            - What: The type of node addition to use
861            - Options:
862                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
863                - 'closest': Add only the closest node to the distance matrix for this node
864                - 'all': Add all nodes to the distance matrix for this node
865            - Notes:
866                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
867                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
868                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
869        - `node_addition_math`
870            - Type: str
871            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
872            - Default: 'euclidean'
873            - Options:
874                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
875                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
876            - Notes:
877                - Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
878        - `lat_lon_bound`
879            - Type: int | float
880            - What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
881        """
882        assert node_addition_type in [
883            "quadrant",
884            "all",
885            "closest",
886        ], f"Invalid node addition type provided ({node_addition_type}), valid options are: ['quadrant', 'all', 'closest']"
887        assert node_addition_math in [
888            "euclidean",
889            "haversine",
890        ], f"Invalid node addition math provided ({node_addition_math}), valid options are: ['euclidean', 'haversine']"
891        # Get only bounded nodes
892        nodes = {
893            node_idx: node_i
894            for node_idx, node_i in enumerate(self.nodes)
895            if abs(node_i[0] - node[0]) < lat_lon_bound
896            and abs(node_i[1] - node[1]) < lat_lon_bound
897        }
898        if len(nodes) == 0:
899            # Default to all if the lat_lon_bound fails to find any nodes
900            return self.get_node_distances(
901                node=node,
902                circuity=circuity,
903                lat_lon_bound=180,
904                node_addition_type=node_addition_type,
905                node_addition_math=node_addition_math,
906            )
907        if node_addition_type == "all":
908            return {
909                node_idx: round(haversine(node, node_i, circuity=circuity), 4)
910                for node_idx, node_i in nodes.items()
911            }
912        if node_addition_math == "haversine":
913            dist_fn = lambda x: round(haversine(node, x, circuity=circuity), 4)
914        else:
915            dist_fn = lambda x: round(
916                ((node[0] - x[0]) ** 2 + (node[1] - x[1]) ** 2) ** 0.5, 4
917            )
918        if node_addition_type == "closest":
919            quadrant_fn = lambda x, y: "all"
920        else:
921            quadrant_fn = lambda x, y: ("n" if x[0] - y[0] > 0 else "s") + (
922                "e" if x[1] - y[1] > 0 else "w"
923            )
924        min_diffs = {}
925        min_diffs_idx = {}
926        for node_idx, node_i in nodes.items():
927            quadrant = quadrant_fn(node_i, node)
928            dist = dist_fn(node_i)
929            if dist < min_diffs.get(quadrant, 999999999):
930                min_diffs[quadrant] = dist
931                min_diffs_idx[quadrant] = node_idx
932        return {
933            node_idx: round(
934                haversine(node, self.nodes[node_idx], circuity=circuity), 4
935            )
936            for node_idx in min_diffs_idx.values()
937        }

Function:

  • Get the distances between a node and all other nodes in the graph
  • This is used to determine the closest node to add to the graph when adding a new node

Required Arguments:

  • node
    • Type: list
    • What: A list of the latitude and longitude of the node
    • EG: [latitude, longitude] -> [31.23, 121.47]
  • circuity
    • Type: int | float
    • What: The circuity to apply to any distance calculations
    • Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
  • node_addition_type
    • Type: str
    • What: The type of node addition to use
    • Options:
      • 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
      • 'closest': Add only the closest node to the distance matrix for this node
      • 'all': Add all nodes to the distance matrix for this node
    • Notes:
      • dijkstra_makowski will operate substantially faster if the node_addition_type is set to 'quadrant' or 'closest'
      • dijkstra will operate at the similar speeds regardless of the node_addition_type
      • When using all, you should consider using dijkstra instead of dijkstra_makowski as it will be faster
  • node_addition_math
    • Type: str
    • What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
    • Default: 'euclidean'
    • Options:
      • 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
      • 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
    • Notes:
      • Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
  • lat_lon_bound
    • Type: int | float
    • What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
def add_node( self, node: dict[float | int], circuity: [float | int], node_addition_type: str = 'quadrant', node_addition_math: str = 'euclidean', lat_lon_bound: [float | int] = 5) -> int:
 939    def add_node(
 940        self,
 941        node: dict[float | int],
 942        circuity: [float | int],
 943        node_addition_type: str = "quadrant",
 944        node_addition_math: str = "euclidean",
 945        lat_lon_bound: [float | int] = 5,
 946    ) -> int:
 947        """
 948        Function:
 949
 950        - Add a node to the network
 951        - Returns the id of the new node
 952
 953        Required Arguments:
 954
 955        - `node`
 956            - Type: dict
 957            - What: A dictionary with the keys 'latitude' and 'longitude'
 958
 959        Optional Arguments:
 960
 961        - `circuity`
 962            - Type: int | float
 963            - What: The circuity to apply to any distance calculations
 964            - Default: 4
 965            - Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
 966        - `node_addition_type`
 967            - Type: str
 968            - What: The type of node addition to use
 969            - Default: 'quadrant'
 970            - Options:
 971                - 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
 972                - 'closest': Add only the closest node to the distance matrix for this node
 973                - 'all': Add all nodes to the distance matrix for this node
 974            - Notes:
 975                - `dijkstra_makowski` will operate substantially faster if the `node_addition_type` is set to 'quadrant' or 'closest'
 976                - `dijkstra` will operate at the similar speeds regardless of the `node_addition_type`
 977                - When using `all`, you should consider using `dijkstra` instead of `dijkstra_makowski` as it will be faster
 978        - `node_addition_math`
 979            - Type: str
 980            - What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
 981            - Default: 'euclidean'
 982            - Options:
 983                - 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
 984                - 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
 985            - Notes:
 986                - Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
 987        - `lat_lon_bound`
 988            - Type: int | float
 989            - What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
 990            - Default: 5
 991
 992        """
 993        # Validate the inputs
 994        assert isinstance(node, dict), "Node must be a dictionary"
 995        assert "latitude" in node.keys(), "Node must have a latitude"
 996        assert "longitude" in node.keys(), "Node must have a longitude"
 997        assert (
 998            node["latitude"] >= -90 and node["latitude"] <= 90
 999        ), "Latitude must be between -90 and 90"
1000        assert (
1001            node["longitude"] >= -180 and node["longitude"] <= 180
1002        ), "Longitude must be between -180 and 180"
1003        assert circuity > 0, "Circuity must be greater than 0"
1004        assert node_addition_type in [
1005            "quadrant",
1006            "all",
1007            "closest",
1008        ], f"Invalid node addition type provided ({node_addition_type}), valid options are: ['quadrant', 'all', 'closest']"
1009        assert node_addition_math in [
1010            "euclidean",
1011            "haversine",
1012        ], f"Invalid node addition math provided ({node_addition_math}), valid options are: ['euclidean', 'haversine']"
1013        assert isinstance(
1014            lat_lon_bound, (int, float)
1015        ), "Lat_lon_bound must be a number"
1016        assert lat_lon_bound > 0, "Lat_lon_bound must be greater than 0"
1017        node = [node["latitude"], node["longitude"]]
1018        # Get the distances to all other nodes
1019        distances = self.get_node_distances(
1020            node=node,
1021            circuity=circuity,
1022            node_addition_type=node_addition_type,
1023            node_addition_math=node_addition_math,
1024            lat_lon_bound=lat_lon_bound,
1025        )
1026
1027        # Create the node
1028        new_node_id = len(self.graph)
1029        self.nodes.append(node)
1030        self.graph.append(distances)
1031        for node_idx, node_distance in distances.items():
1032            self.graph[node_idx][new_node_id] = node_distance
1033        return new_node_id

Function:

  • Add a node to the network
  • Returns the id of the new node

Required Arguments:

  • node
    • Type: dict
    • What: A dictionary with the keys 'latitude' and 'longitude'

Optional Arguments:

  • circuity
    • Type: int | float
    • What: The circuity to apply to any distance calculations
    • Default: 4
    • Note: This defaults to 4 to prevent the algorithm from taking a direct route in direction of the destination over some impassible terrain (EG: a maritime network that goes through land)
  • node_addition_type
    • Type: str
    • What: The type of node addition to use
    • Default: 'quadrant'
    • Options:
      • 'quadrant': Add the closest node in each quadrant (ne, nw, se, sw) to the distance matrix for this node
      • 'closest': Add only the closest node to the distance matrix for this node
      • 'all': Add all nodes to the distance matrix for this node
    • Notes:
      • dijkstra_makowski will operate substantially faster if the node_addition_type is set to 'quadrant' or 'closest'
      • dijkstra will operate at the similar speeds regardless of the node_addition_type
      • When using all, you should consider using dijkstra instead of dijkstra_makowski as it will be faster
  • node_addition_math
    • Type: str
    • What: The math to use when calculating the distance between nodes when determining the closest node (or closest quadrant node) to add to the graph
    • Default: 'euclidean'
    • Options:
      • 'euclidean': Use the euclidean distance between nodes. This is much faster but is not accurate (especially near the poles)
      • 'haversine': Use the haversine distance between nodes. This is slower but is an accurate representation of the surface distance between two points on the earth
    • Notes:
      • Once the closest node (or closest quadrant node) is determined, the haversine distance (with circuity) is used to calculate the distance between the nodes when adding it to the graph.
  • lat_lon_bound
    • Type: int | float
    • What: Forms a bounding box around the node that is to be added to graph. Only selects graph nodes to consider joining that are within this bounding box.
    • Default: 5
def save_as_geojson(self, filename: str) -> None:
1035    def save_as_geojson(self, filename: str) -> None:
1036        """
1037        Function:
1038
1039        - Save the current geograph object as a geojson file specifed by `filename`
1040        - This is useful for understanding the underlying geograph and for debugging purposes
1041        - Returns None
1042
1043        Required Arguments:
1044
1045        - `filename`
1046            - Type: str
1047            - What: The filename to save the geojson file as
1048
1049        """
1050        features = []
1051        for origin_idx, destinations in enumerate(self.graph):
1052            for destination_idx, distance in destinations.items():
1053                # Create an undirected graph for geojson purposes
1054                if origin_idx > destination_idx:
1055                    continue
1056                origin = self.nodes[origin_idx]
1057                destination = self.nodes[destination_idx]
1058                features.append(
1059                    {
1060                        "type": "Feature",
1061                        "properties": {
1062                            "origin_idx": origin_idx,
1063                            "destination_idx": destination_idx,
1064                            "distance": distance,
1065                        },
1066                        "geometry": {
1067                            "type": "LineString",
1068                            "coordinates": [
1069                                [origin[1], origin[0]],
1070                                [
1071                                    destination[1],
1072                                    destination[0],
1073                                ],
1074                            ],
1075                        },
1076                    }
1077                )
1078
1079        out_dict = {"type": "FeatureCollection", "features": features}
1080        with open(filename, "w") as f:
1081            json.dump(out_dict, f)

Function:

  • Save the current geograph object as a geojson file specifed by filename
  • This is useful for understanding the underlying geograph and for debugging purposes
  • Returns None

Required Arguments:

  • filename
    • Type: str
    • What: The filename to save the geojson file as
def save_as_geograph(self, name: str) -> None:
1083    def save_as_geograph(self, name: str) -> None:
1084        """
1085        Function:
1086
1087        - Save the current geograph as an importable python file
1088
1089        Required Arguments:
1090
1091        - `name`
1092            - Type: str
1093            - What: The name of the geograph and file
1094            - EG: 'custom'
1095                - Stored as: 'custom.py'
1096                    - In your current directory
1097                - Import as: 'from .custom import custom_geograph'
1098        """
1099        self.validate_nodes()
1100        self.validate_graph(check_symmetry=True, check_connected=False)
1101        out_string = f"""from scgraph.core import GeoGraph\ngraph={str(self.graph)}\nnodes={str(self.nodes)}\n{name}_geograph = GeoGraph(graph=graph, nodes=nodes)"""
1102        with open(name + ".py", "w") as f:
1103            f.write(out_string)

Function:

  • Save the current geograph as an importable python file

Required Arguments:

  • name
    • Type: str
    • What: The name of the geograph and file
    • EG: 'custom'
      • Stored as: 'custom.py'
        • In your current directory
      • Import as: 'from .custom import custom_geograph'
def mod_remove_arc( self, origin_idx: int, destination_idx: int, undirected: bool = True) -> None:
1105    def mod_remove_arc(
1106        self, origin_idx: int, destination_idx: int, undirected: bool = True
1107    ) -> None:
1108        """
1109        Function:
1110
1111        - Remove an arc from the graph
1112
1113        Required Arguments:
1114
1115        - `origin_idx`
1116            - Type: int
1117            - What: The index of the origin node
1118        - `destination_idx`
1119            - Type: int
1120            - What: The index of the destination node
1121
1122        Optional Arguments:
1123
1124        - `undirected`
1125            - Type: bool
1126            - What: Whether to remove the arc in both directions
1127            - Default: True
1128        """
1129        assert origin_idx < len(self.graph), "Origin node does not exist"
1130        assert destination_idx < len(
1131            self.graph
1132        ), "Destination node does not exist"
1133        assert destination_idx in self.graph[origin_idx], "Arc does not exist"
1134        del self.graph[origin_idx][destination_idx]
1135        if undirected:
1136            if origin_idx in self.graph[destination_idx]:
1137                del self.graph[destination_idx][origin_idx]

Function:

  • Remove an arc from the graph

Required Arguments:

  • origin_idx
    • Type: int
    • What: The index of the origin node
  • destination_idx
    • Type: int
    • What: The index of the destination node

Optional Arguments:

  • undirected
    • Type: bool
    • What: Whether to remove the arc in both directions
    • Default: True
def mod_add_node(self, latitude: [float | int], longitude: [float | int]) -> int:
1139    def mod_add_node(
1140        self, latitude: [float | int], longitude: [float | int]
1141    ) -> int:
1142        """
1143        Function:
1144
1145        - Add a node to the graph
1146
1147        Required Arguments:
1148
1149        - `latitude`
1150            - Type: int | float
1151            - What: The latitude of the node
1152        - `longitude`
1153            - Type: int | float
1154            - What: The longitude of the node
1155
1156        Returns:
1157
1158        - The index of the new node
1159        """
1160        self.nodes.append([latitude, longitude])
1161        self.graph.append({})
1162        return len(self.graph) - 1

Function:

  • Add a node to the graph

Required Arguments:

  • latitude
    • Type: int | float
    • What: The latitude of the node
  • longitude
    • Type: int | float
    • What: The longitude of the node

Returns:

  • The index of the new node
def mod_add_arc( self, origin_idx: int, destination_idx: int, distance: [float | int] = 0, use_haversine_distance=True, undirected: bool = True) -> None:
1164    def mod_add_arc(
1165        self,
1166        origin_idx: int,
1167        destination_idx: int,
1168        distance: [float | int] = 0,
1169        use_haversine_distance=True,
1170        undirected: bool = True,
1171    ) -> None:
1172        """
1173        Function:
1174
1175        - Add an arc to the graph
1176
1177        Required Arguments:
1178
1179        - `origin_idx`
1180            - Type: int
1181            - What: The index of the origin node
1182        - `destination_idx`
1183            - Type: int
1184            - What: The index of the destination node
1185
1186        Optional Arguments:
1187
1188        - `distance`
1189            - Type: int | float
1190            - What: The distance between the origin and destination nodes in terms of the graph distance (normally km)
1191            - Default: 0
1192        - `use_haversine_distance`
1193            - Type: bool
1194            - What: Whether to calculate the haversine distance (km) between the nodes when calculating the distance
1195            - Default: True
1196            - Note: If true, overrides the `distance` argument
1197        - `undirected`
1198            - Type: bool
1199            - What: Whether to add the arc in both directions
1200            - Default: True
1201        """
1202        assert origin_idx < len(self.graph), "Origin node does not exist"
1203        assert destination_idx < len(
1204            self.graph
1205        ), "Destination node does not exist"
1206        if use_haversine_distance:
1207            distance = haversine(
1208                self.nodes[origin_idx], self.nodes[destination_idx]
1209            )
1210        self.graph[origin_idx][destination_idx] = distance
1211        if undirected:
1212            self.graph[destination_idx][origin_idx] = distance

Function:

  • Add an arc to the graph

Required Arguments:

  • origin_idx
    • Type: int
    • What: The index of the origin node
  • destination_idx
    • Type: int
    • What: The index of the destination node

Optional Arguments:

  • distance
    • Type: int | float
    • What: The distance between the origin and destination nodes in terms of the graph distance (normally km)
    • Default: 0
  • use_haversine_distance
    • Type: bool
    • What: Whether to calculate the haversine distance (km) between the nodes when calculating the distance
    • Default: True
    • Note: If true, overrides the distance argument
  • undirected
    • Type: bool
    • What: Whether to add the arc in both directions
    • Default: True
def load_geojson_as_geograph(geojson_filename: str) -> GeoGraph:
1215def load_geojson_as_geograph(geojson_filename: str) -> GeoGraph:
1216    """
1217    Function:
1218
1219    - Create a CustomGeoGraph object loaded from a geojson file
1220
1221    Required Arguments:
1222
1223    - `geojson_filename`
1224        - Type: str
1225        - What: The filename of the geojson file to load
1226        - Note: All arcs read in will be undirected
1227        - Note: This geojson file must be formatted in a specific way
1228            - The geojson file must be a FeatureCollection
1229            - Each feature must be a LineString with two coordinate pairs
1230                - The first coordinate pair must be the origin node
1231                - The second coordinate pair must be the destination node
1232                - The properties of the feature must include the distance between the origin and destination nodes
1233                - The properties of the feature must include the origin and destination node idxs
1234                - Origin and destination node idxs must be integers between 0 and n-1 where n is the number of nodes in the graph
1235            - EG:
1236            ```
1237            {
1238                "type": "FeatureCollection",
1239                "features": [
1240                    {
1241                        "type": "Feature",
1242                        "properties": {
1243                            "origin_idx": 0,
1244                            "destination_idx": 1,
1245                            "distance": 10
1246                        },
1247                        "geometry": {
1248                            "type": "LineString",
1249                            "coordinates": [
1250                                [121.47, 31.23],
1251                                [121.48, 31.24]
1252                            ]
1253                        }
1254                    }
1255                ]
1256            }
1257            ```
1258    """
1259    with open(geojson_filename, "r") as f:
1260        geojson_features = json.load(f).get("features", [])
1261
1262    nodes_dict = {}
1263    graph_dict = {}
1264    for feature in geojson_features:
1265        properties = feature.get("properties", {})
1266        origin_idx = properties.get("origin_idx")
1267        destination_idx = properties.get("destination_idx")
1268        distance = properties.get("distance")
1269        geometry = feature.get("geometry", {})
1270        coordinates = geometry.get("coordinates", [])
1271
1272        # Validations
1273        assert (
1274            feature.get("type") == "Feature"
1275        ), "All features must be of type 'Feature'"
1276        assert (
1277            geometry.get("type") == "LineString"
1278        ), "All geometries must be of type 'LineString'"
1279        assert (
1280            len(coordinates) == 2
1281        ), "All LineStrings must have exactly 2 coordinates"
1282        assert isinstance(
1283            origin_idx, int
1284        ), "All features must have an 'origin_idx' property that is an integer"
1285        assert isinstance(
1286            destination_idx, int
1287        ), "All features must have a 'destination_idx' property that is an integer"
1288        assert isinstance(
1289            distance, (int, float)
1290        ), "All features must have a 'distance' property that is a number"
1291        assert (
1292            origin_idx >= 0
1293        ), "All origin_idxs must be greater than or equal to 0"
1294        assert (
1295            destination_idx >= 0
1296        ), "All destination_idxs must be greater than or equal to 0"
1297        assert distance >= 0, "All distances must be greater than or equal to 0"
1298        origin = coordinates[0]
1299        destination = coordinates[1]
1300        assert isinstance(origin, list), "All coordinates must be lists"
1301        assert isinstance(destination, list), "All coordinates must be lists"
1302        assert len(origin) == 2, "All coordinates must have a length of 2"
1303        assert len(destination) == 2, "All coordinates must have a length of 2"
1304        assert all(
1305            [isinstance(i, (int, float)) for i in origin]
1306        ), "All coordinates must be numeric"
1307        assert all(
1308            [isinstance(i, (int, float)) for i in destination]
1309        ), "All coordinates must be numeric"
1310        # assert all([origin[0] >= -90, origin[0] <= 90, origin[1] >= -180, origin[1] <= 180]), "All coordinates must be valid latitudes and longitudes"
1311        # assert all([destination[0] >= -90, destination[0] <= 90, destination[1] >= -180, destination[1] <= 180]), "All coordinates must be valid latitudes and longitudes"
1312
1313        # Update the data
1314        nodes_dict[origin_idx] = origin
1315        nodes_dict[destination_idx] = destination
1316        graph_dict[origin_idx] = {
1317            **graph_dict.get(origin_idx, {}),
1318            destination_idx: distance,
1319        }
1320        graph_dict[destination_idx] = {
1321            **graph_dict.get(destination_idx, {}),
1322            origin_idx: distance,
1323        }
1324    assert len(nodes_dict) == len(
1325        graph_dict
1326    ), "All nodes must be included as origins in the graph dictionary"
1327    nodes = [
1328        [i[1][1], i[1][0]]
1329        for i in sorted(nodes_dict.items(), key=lambda x: x[0])
1330    ]
1331    ordered_graph_tuple = sorted(graph_dict.items(), key=lambda x: x[0])
1332    graph_map = {i[0]: idx for idx, i in enumerate(ordered_graph_tuple)}
1333    graph = [
1334        {graph_map[k]: v for k, v in i[1].items()} for i in ordered_graph_tuple
1335    ]
1336    return GeoGraph(graph=graph, nodes=nodes)

Function:

  • Create a CustomGeoGraph object loaded from a geojson file

Required Arguments:

  • geojson_filename

    • Type: str
    • What: The filename of the geojson file to load
    • Note: All arcs read in will be undirected
    • Note: This geojson file must be formatted in a specific way

      • The geojson file must be a FeatureCollection
      • Each feature must be a LineString with two coordinate pairs
        • The first coordinate pair must be the origin node
        • The second coordinate pair must be the destination node
        • The properties of the feature must include the distance between the origin and destination nodes
        • The properties of the feature must include the origin and destination node idxs
        • Origin and destination node idxs must be integers between 0 and n-1 where n is the number of nodes in the graph
      • EG:
      {
          "type": "FeatureCollection",
          "features": [
              {
                  "type": "Feature",
                  "properties": {
                      "origin_idx": 0,
                      "destination_idx": 1,
                      "distance": 10
                  },
                  "geometry": {
                      "type": "LineString",
                      "coordinates": [
                          [121.47, 31.23],
                          [121.48, 31.24]
                      ]
                  }
              }
          ]
      }
      
def get_multi_path_geojson( routes: list[dict], filename: [<class 'str'>, None] = None, show_progress: bool = False) -> dict:
1339def get_multi_path_geojson(
1340    routes: list[dict],
1341    filename: [str, None] = None,
1342    show_progress: bool = False,
1343) -> dict:
1344    """
1345    Creates a GeoJSON file with the shortest path between the origin and destination of each route.
1346
1347    Required Parameters:
1348
1349    - `routes`: list[dict]
1350        - List of dictionaries with the following keys:
1351            - geograph: GeoGraph
1352                - Geograph object to use for the shortest path calculation.
1353            - origin: dict[float|float]
1354                - Origin coordinates
1355                - EG: {"latitude":39.2904, "longitude":-76.6122}
1356            - destination: dict[float|int]
1357                - Destination coordinates
1358                - EG: {"latitude":39.2904, "longitude":-76.6122}
1359            - properties: dict
1360                - Dictionary with the properties of the route
1361                - Everything in this dictionary will be included in the output GeoJSON file as properties of the route.
1362                - EG: {"id":"route_1", "name":"Route 1", "color":"#ff0000"}
1363
1364    Optional Parameters:
1365
1366    - `filename`: str | None
1367        - Name of the output GeoJSON file.
1368        - If None, the function will not save the file
1369        - Default: None
1370    - `show_progress`: bool
1371        - Whether to show basic progress information
1372        - Default: False
1373
1374    Returns
1375
1376    - `output`: dict
1377        - Dictionary with the GeoJSON file content.
1378    """
1379    assert isinstance(routes, list), "Routes must be a list"
1380    assert all(
1381        [isinstance(i, dict) for i in routes]
1382    ), "Routes must be a list of dictionaries"
1383    assert all(
1384        [isinstance(i.get("geograph"), GeoGraph) for i in routes]
1385    ), "All routes must have a 'geograph' key with a GeoGraph object"
1386    assert all(
1387        [isinstance(i.get("origin"), dict) for i in routes]
1388    ), "All routes must have an 'origin' key with a dictionary"
1389    assert all(
1390        [isinstance(i.get("destination"), dict) for i in routes]
1391    ), "All routes must have a 'destination' key with a dictionary"
1392    assert all(
1393        [isinstance(i.get("properties"), dict) for i in routes]
1394    ), "All routes must have a 'properties' key with a dictionary"
1395    assert all(
1396        [isinstance(i["origin"].get("latitude"), (int, float)) for i in routes]
1397    ), "All origins must have a 'latitude' key with a number"
1398    assert all(
1399        [isinstance(i["origin"].get("longitude"), (int, float)) for i in routes]
1400    ), "All origins must have a 'longitude' key with a number"
1401    assert all(
1402        [
1403            isinstance(i["destination"].get("latitude"), (int, float))
1404            for i in routes
1405        ]
1406    ), "All destinations must have a 'latitude' key with a number"
1407    assert all(
1408        [
1409            isinstance(i["destination"].get("longitude"), (int, float))
1410            for i in routes
1411        ]
1412    ), "All destinations must have a 'longitude' key with a number"
1413    assert all(
1414        [
1415            (
1416                i["origin"].get("latitude") >= -90
1417                and i["origin"].get("latitude") <= 90
1418            )
1419            for i in routes
1420        ]
1421    ), "All origin latitudes must be between -90 and 90"
1422    assert all(
1423        [
1424            (
1425                i["origin"].get("longitude") >= -180
1426                and i["origin"].get("longitude") <= 180
1427            )
1428            for i in routes
1429        ]
1430    ), "All origin longitudes must be between -180 and 180"
1431    assert all(
1432        [
1433            (
1434                i["destination"].get("latitude") >= -90
1435                and i["destination"].get("latitude") <= 90
1436            )
1437            for i in routes
1438        ]
1439    ), "All destination latitudes must be between -90 and 90"
1440    assert all(
1441        [
1442            (
1443                i["destination"].get("longitude") >= -180
1444                and i["destination"].get("longitude") <= 180
1445            )
1446            for i in routes
1447        ]
1448    ), "All destination longitudes must be between -180 and 180"
1449
1450    output = {"type": "FeatureCollection", "features": []}
1451    len_routes = len(routes)
1452    for idx, route in enumerate(routes):
1453        shortest_path = route["geograph"].get_shortest_path(
1454            route["origin"], route["destination"]
1455        )
1456        shortest_line_path = get_line_path(shortest_path)
1457        output["features"].append(
1458            {
1459                "type": "Feature",
1460                "properties": route["properties"],
1461                "geometry": shortest_line_path,
1462            }
1463        )
1464        if show_progress:
1465            print(
1466                f"[{'='*(int((idx+1)/len_routes*20))}>{' '*(20-int((idx+1)/len_routes*20))}] {idx+1}/{len_routes}",
1467                end="\r",
1468            )
1469    if show_progress:
1470        print()
1471    if filename is not None:
1472        json.dump(output, open(filename, "w"))
1473    return output

Creates a GeoJSON file with the shortest path between the origin and destination of each route.

Required Parameters:

  • routes: list[dict]
    • List of dictionaries with the following keys:
      • geograph: GeoGraph
        • Geograph object to use for the shortest path calculation.
      • origin: dict[float|float]
        • Origin coordinates
        • EG: {"latitude":39.2904, "longitude":-76.6122}
      • destination: dict[float|int]
        • Destination coordinates
        • EG: {"latitude":39.2904, "longitude":-76.6122}
      • properties: dict
        • Dictionary with the properties of the route
        • Everything in this dictionary will be included in the output GeoJSON file as properties of the route.
        • EG: {"id":"route_1", "name":"Route 1", "color":"#ff0000"}

Optional Parameters:

  • filename: str | None
    • Name of the output GeoJSON file.
    • If None, the function will not save the file
    • Default: None
  • show_progress: bool
    • Whether to show basic progress information
    • Default: False

Returns

  • output: dict
    • Dictionary with the GeoJSON file content.