From fba830cac02c1914670ca2def90c5c3447fd61e1 Mon Sep 17 00:00:00 2001 From: David Torres <23246013+torressa@users.noreply.github.com> Date: Fri, 31 Jul 2020 19:37:50 +0200 Subject: [PATCH] Added parameters and changed custom REF inputs (#51) * Resurrected backward REF, added REF join argument * Fixed #38, moved notes+examples to docs * Removed log * Merger 'master' into 'dev' (#44) * Dev (#39) * Resurrected backward REF, added REF join argument * Fixed #38, moved notes+examples to docs * Removed log * Updated changelog, bumped version docs conf file * squash! Dev (#39) * Removed unused import and useless-else-on-loop * Updated examples in the paper * Paper updated. Removed spacing in example bullets * Paper updated. Added spacing for example bullets * Add more clear explanation in docs #15 * Remove deque map and swo file * Update commit vrpy submodule * Update CHANGELOG for JOSS release * Patch41 (#45) * Dev (#39) * Resurrected backward REF, added REF join argument * Fixed #38, moved notes+examples to docs * Removed log * Updated changelog, bumped version docs conf file * squash! Dev (#39) * Removed unused import and useless-else-on-loop * Updated examples in the paper * Paper updated. Removed spacing in example bullets * Paper updated. Added spacing for example bullets * Add more clear explanation in docs #15 * Remove deque map and swo file * Add test for issue and fix for some cases of #41 * Uncomment mono directional tests * Patch43 (#48) * Dev (#39) * Resurrected backward REF, added REF join argument * Fixed #38, moved notes+examples to docs * Removed log * Updated changelog, bumped version docs conf file * squash! Dev (#39) * Removed unused import and useless-else-on-loop * Updated examples in the paper * Paper updated. Removed spacing in example bullets * Paper updated. Added spacing for example bullets * Add more clear explanation in docs #15 * Remove deque map and swo file * Update commit vrpy submodule * Update CHANGELOG for JOSS release * Initial redesign (failing unittests) * Remove duplicate imports * Refactored by Sourcery (#46) Co-authored-by: Sourcery AI * Move bidirectional files to algorithms, some fixes - Fix some tests: tests_issue20 (broken for parallel), tests_issue38 - Moved random state checks to checking.py * Fix unit tests! Clean up * Clean up BiDirectional, add kwargs for custom REFs Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Sourcery AI * Update README.md * Revert function in BiDirectional - Add time limit and threshold parameters - Add greedy_elimination unit tests (#9) * Revert to v0.1.1 based fixes - Clean up in tests - Update submodule commit * Remove unused import * 'Refactored by Sourcery' * Update path base break using threshold * Remove search object * Update tests and docs Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Sourcery AI Co-authored-by: Sourcery AI <> --- CHANGELOG.md | 11 +- README.md | 30 +- cspy/algorithms/bidirectional.py | 527 +++++++++++++------------- cspy/algorithms/grasp.py | 47 ++- cspy/algorithms/greedy_elimination.py | 46 ++- cspy/algorithms/label.py | 129 +++++-- cspy/algorithms/path_base.py | 75 ++-- cspy/algorithms/psolgent.py | 100 ++--- cspy/algorithms/tabu.py | 59 ++- cspy/checking.py | 49 ++- docs/how_to.rst | 87 +++-- examples/jpath/jpath_preprocessing.py | 14 +- examples/vrpy | 2 +- tests/tests_bidirectional.py | 198 ++++++---- tests/tests_grasp.py | 34 +- tests/tests_greedy_elimination.py | 125 ++++++ tests/tests_greedyelim.py | 1 - tests/tests_issue17.py | 172 +++------ tests/tests_issue20.py | 79 ++-- tests/tests_issue22.py | 154 +++----- tests/tests_issue25.py | 49 +-- tests/tests_issue32.py | 64 ++-- tests/tests_issue38.py | 27 +- tests/tests_issue41.py | 66 ++++ tests/tests_tabu.py | 147 ++++--- 25 files changed, 1300 insertions(+), 992 deletions(-) create mode 100644 tests/tests_greedy_elimination.py delete mode 100644 tests/tests_greedyelim.py create mode 100644 tests/tests_issue41.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 673d92e..717cd47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,24 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added + +- New paramenters: `time_limit` and `threshold`. +- Custom REF, backward incompatible change: additional argument for more flexibility. These are the current partial path and the accumulated cost. Note that these are optional and do not have to be used. However, a slight modificiation to the function has to be made, simply add `**kwargs` as well as the existing arguments. + ## [v0.1.1] - 21/05/2020 ### Changed - BiDirectional: - Reverted backward REF as it is required for some problems. - Added REF join parameter that is required when joining forward and backward labels using custom REFs. -- Moved notes and examples from docstrings to the docs folder. +- Moved notes and examples from docstrings to the docs folder. - Final JOSS paper changes ## [v0.1.0] - 14/04/2020 -### Added +### Added - BiDirectional: - Option to chose method for direction selection. - [vrpy](https://github.com/Kuifje02/vrpy) submodule. -### Changed +### Changed - BiDirectional: - Label storage, divided into unprocessed, generated and non-dominated labels diff --git a/README.md b/README.md index 0e3318a..2bfb92a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![codecov](https://codecov.io/gh/torressa/cspy/branch/master/graph/badge.svg?token=24tyrWinNT)](https://codecov.io/gh/torressa/cspy) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/c28f50e92dae4bcc921f1bd142370608)](https://www.codacy.com/app/torressa/cspy?utm_source=github.com&utm_medium=referral&utm_content=torressa/cspy&utm_campaign=Badge_Grade) [![JOSS badge](https://joss.theoj.org/papers/25eda55801a528b982d03a6a61f7730d/status.svg)](https://joss.theoj.org/papers/25eda55801a528b982d03a6a61f7730d) -![PyPI - Python Version](https://img.shields.io/pypi/pyversions/cspy) @@ -67,6 +66,10 @@ python3 -m pip install cspy ### Examples +- [`vrpy`](https://github.com/Kuifje02/vrpy) : External vehicle routing framework which uses `cspy` to solve different variants of the vehicle routing problem using column generation. +- [`cgar`](examples/cgar) : Complex example use of `cspy` in a column generation example applied to the aircraft recovery problem. +- [`jpath`](examples/jpath) : Simple example showing the necessary graph adptations and the use of custom resource extension functions. + The generic gist to run the algorithms on a specific graph is to load the algorithm of choice, say `alg`, call the `alg.run()` method, and query the relevant result attributes, @@ -75,13 +78,6 @@ relevant result attributes, - `alg.total_cost` for the accumulated cost of the path; - `alg.consumed_resources` for the accumulated resource usage of the path. -I have included a few examples: - -- [`jpath`](examples/jpath) : Simple example showing the necessary graph adptations and the use of custom resource extension functions. -- [`cgar`](examples/cgar) : Complex example use of `cspy` in a column generation example applied to the aircraft recovery problem. -- [`vrpy`](https://github.com/Kuifje02/vrpy) : (under development) external vehicle routing framework which uses `cspy` to solve different variants of the vehicle routing problem using column generation. - - ## Running the tests To run the tests first, clone the repository into a path in your machine `~/path/newfolder` by running @@ -124,17 +120,23 @@ After that feel free to send a pull request. If you have a question or need help, feel free to raise an issue explaining it. -Alternatively, email me at `d.torressanchez@lancs.ac.uk`. +Alternatively, email me at `david.sanchez@sintef.no`. ## Citing If you'd like to cite this package, please use the following bib format: ```none -@Misc{cspy, - author = {Torres Sanchez, David}, - title = {{cspy : A Python package with a collection of algorithms for the (Resource) Constrained Shortest Path problem}}, - year = {2019}, - url = {\url{https://github.com/torressa/cspy}} +@article{torressa2020, + doi = {10.21105/joss.01655}, + url = {https://doi.org/10.21105/joss.01655}, + year = {2020}, + publisher = {The Open Journal}, + volume = {5}, + number = {49}, + pages = {1655}, + author = {{Torres Sanchez}, David}, + title = {cspy: A Python package with a collection of algorithms for the (Resource) Constrained Shortest Path problem}, + journal = {Journal of Open Source Software} } ``` diff --git a/cspy/algorithms/bidirectional.py b/cspy/algorithms/bidirectional.py index 07d0854..3305093 100644 --- a/cspy/algorithms/bidirectional.py +++ b/cspy/algorithms/bidirectional.py @@ -1,161 +1,155 @@ -from __future__ import absolute_import - +from time import time from copy import deepcopy -from itertools import repeat from operator import add, sub from logging import getLogger from collections import OrderedDict, deque +from typing import List, Dict, Optional, Callable, Union, Tuple -from numpy import array +from networkx import DiGraph +from numpy import array, zeros from numpy.random import RandomState -# Local module imports -from cspy.checking import check from cspy.algorithms.label import Label from cspy.preprocessing import preprocess_graph +from cspy.checking import check, check_seed, check_time_limit_breached -log = getLogger(__name__) +LOG = getLogger(__name__) class BiDirectional: - """ - Implementation of the bidirectional labeling algorithm with dynamic + """Implementation of the bidirectional labeling algorithm with dynamic half-way point (`Tilk 2017`_). - This requires the joining procedure (Algorithm 3) from - `Righini and Salani (2006)`_, also implemented. - Depending on the range of values for U, L, we get - four different algorithms. See self.name_algorithm and Notes. + + Depending on the range of values for bounds for the first resource, we get + four different algorithms. See ``self.name_algorithm`` and Notes. Parameters ---------- G : object instance :class:`nx.Digraph()` must have ``n_res`` graph attribute and all edges must have ``res_cost`` attribute. - max_res : list of floats :math:`[H_F, M_1, M_2, ..., M_{n\_res}]` upper bounds for resource usage (including initial forward stopping point). We must have ``len(max_res)`` :math:`\geq 2`. - min_res : list of floats :math:`[H_B, L_1, L_2, ..., L_{n\_res}]` lower bounds for resource usage (including initial backward stopping point). We must have ``len(min_res)`` :math:`=` ``len(max_res)`` :math:`\geq 2` - preprocess : bool, optional enables preprocessing routine. Default : False. - direction : string, optional preferred search direction. Either "both", "forward", or, "backward". Default : "both". - method : string, optional preferred method for determining search direction. - Either "random", "generated" (direction with least number of generated labels), - "processed" (direction with least number of processed labels), or, - "unprocessed" (direction with least number of unprocessed labels). - Default: "random". - + Either "random", "generated" (direction with least number of generated + labels), "processed" (direction with least number of processed labels), + or, "unprocessed" (direction with least number of unprocessed labels). + Default: "random" + time_limit : int, optional + time limit in seconds. + Default: None + threshold : float, optional + specify a threshold for a an acceptable resource feasible path with + total cost <= threshold. + Note this typically causes the search to terminate early. + Default: None + elementary : bool, optional + whether the problem is elementary. i.e. no cycles are allowed in the + final path. Note this may increase run time. + Default: False + dominance_frequency : int, optional + multiple of iterations to run the dominance checks. + Default : 1 (every iteration) seed : None or int or numpy.random.RandomState instance, optional seed for PSOLGENT class. Default : None (which gives a single value numpy.random.RandomState). - REF_forward, REF_backward, REF_join : functions, optional Custom resource extension functions. See `REFs`_ for more details. - Default : additive. - + Default : additive .. _REFs : https://cspy.readthedocs.io/en/latest/how_to.html#refs .. _Tilk 2017: https://www.sciencedirect.com/science/article/pii/S0377221717302035 .. _Righini and Salani (2006): https://www.sciencedirect.com/science/article/pii/S1572528606000417 """ def __init__(self, - G, - max_res, - min_res, - preprocess=False, - direction="both", - method="random", - seed=None, - REF_forward=None, - REF_backward=None, - REF_join=None): - + G: DiGraph, + max_res: List[float], + min_res: List[float], + preprocess: Optional[bool] = False, + direction: Optional[str] = "both", + method: Optional[str] = "random", + time_limit: Optional[float] = None, + threshold: Optional[float] = None, + elementary: Optional[bool] = False, + dominance_frequency: Optional[int] = 1, + seed: Union[int, RandomState, None] = None, + REF_forward: Optional[Callable] = None, + REF_backward: Optional[Callable] = None, + REF_join: Optional[Callable] = None): # Check inputs check(G, max_res, min_res, + direction, REF_forward=REF_forward, REF_backward=REF_backward, REF_join=REF_join, - direction=direction, algorithm=__name__) # Preprocess graph self.G = preprocess_graph(G, max_res, min_res, preprocess, REF_forward) - self.REF_join = REF_join - self.direc_in = direction - self.max_res, self.min_res = max_res.copy(), min_res.copy() + + self.max_res = max_res.copy() + self.min_res = min_res.copy() self.max_res_in, self.min_res_in = array(max_res.copy()), array( min_res.copy()) + self.direction = direction self.method = method - # To expose results - self.best_label = None - - # Algorithm specific parameters # - # set bounds for bacward search - bwd_start = deepcopy(min_res) - bwd_start[0] = max_res[0] - # Current forward and backward labels - self.current_label = OrderedDict({ - "forward": Label(0, "Source", min_res, ["Source"]), - "backward": Label(0, "Sink", bwd_start, ["Sink"]) - }) - # Unprocessed labels dict (both directions) - self.unprocessed_labels = OrderedDict({ - "forward": deque(), - "backward": deque() - }) - # All generated label - self.generated_labels = OrderedDict({"forward": 0, "backward": 0}) - # Best labels - # (with initial labels for small cases, see: - # https://github.com/torressa/cspy/issues/38 ) - self.best_labels = OrderedDict({ - "forward": deque([self.current_label["forward"]]), - "backward": deque([self.current_label["backward"]]) - }) - # Final labels dicts for unidirectional search - self.final_label = None - - # If given, set REFs for dominance relations and feasibility checks - Label._REF_forward = REF_forward if REF_forward else add - Label._REF_backward = REF_backward if REF_backward else sub - # Init with seed if given - if seed is None: - self.random_state = RandomState() - elif isinstance(seed, int): - self.random_state = RandomState(seed) - elif isinstance(seed, RandomState): - self.random_state = seed - else: - raise Exception("{} cannot be used to seed".format(seed)) + self.time_limit = time_limit + self.elementary = elementary + self.threshold = threshold + self.dominance_frequency = dominance_frequency + self.random_state = check_seed(seed) + # Set label class attributes + Label.REF_forward = REF_forward if REF_forward else add + Label.REF_backward = REF_backward if REF_backward else sub + + self.REF_join = REF_join + + # Algorithm specific attributes + self.iteration = 0 + # Containers for labels + self.current_label: Dict[str, Label] = None + self.unprocessed_labels: Dict[str, List[Label]] = None + self.best_labels: Dict[str, List[Label]] = None + # Containers for counters + self.unprocessed_counts: Dict[str, int] = 0 + self.processed_counts: Dict[str, int] = 0 + self.generated_counts: Dict[str, int] = 0 + # For exposure + self.final_label: Label = None + self.best_label: Label = None + # Populate containers + self._init_containers() def run(self): - """ - Calculate shortest path with resource constraints. - """ + 'Run the algorithm' + self.start_time = time() while self.current_label["forward"] or self.current_label["backward"]: + if self._terminate(): + break direc = self._get_direction() if direc: self._algorithm(direc) else: break + return self._process_paths() @property def path(self): - """ - Get list with nodes in calculated path. + """Get list with nodes in calculated path. """ if not self.best_label: raise Exception("Please call the .run() method first") @@ -163,8 +157,7 @@ def path(self): @property def total_cost(self): - """ - Get accumulated cost along the path. + """Get accumulated cost along the path. """ if not self.best_label: raise Exception("Please call the .run() method first") @@ -172,37 +165,79 @@ def total_cost(self): @property def consumed_resources(self): - """ - Get accumulated resources consumed along the path. + """Get accumulated resources consumed along the path. """ if not self.best_label: raise Exception("Please call the .run() method first") return self.best_label.res - ########################### - # Classify Algorithm Type # - ########################### def name_algorithm(self): - """ - Determine which algorithm is running. - Logs algorithm classification at INFO level. + """Given the resource bounds, prints which algorithm will run. """ HF, HB = self.max_res[0], self.min_res[0] - if self.direc_in == "forward": - log.info("Monodirectional forward labeling algorithm") - elif self.direc_in == "backward": - log.info("Monodirectional backward labeling algorithm") + if self.direction == "forward": + print("Monodirectional forward labeling algorithm") + elif self.direction == "backward": + LOG.info("Monodirectional backward labeling algorithm") elif HF > HB: - log.info("Bidirectional labeling algorithm with" + - " dynamic halfway point") + print("Bidirectional labeling algorithm with" + + " dynamic halfway point") else: - log.info("The algorithm can't move in either direction!") + print("The algorithm can't move in either direction!") + + # Private methods # - ############# - # DIRECTION # - ############# - def _get_direction(self): - if self.direc_in == "both": + # Initialisations # + + def _init_containers(self): + 'Initialise containers (labels and counters)' + # set minimum bounds if not all 0 + if any(m != 0 for m in self.min_res): + self.min_res = zeros(len(self.min_res)) + bwd_start = self.min_res.copy() + bwd_start[0] = self.max_res[0] + + self.current_label = OrderedDict({ + "forward": Label(0, "Source", self.min_res, ["Source"]), + "backward": Label(0, "Sink", bwd_start, ["Sink"]) + }) + self.unprocessed_labels = OrderedDict({ + "forward": deque(), + "backward": deque() + }) + # Best labels + self.best_labels = OrderedDict({ + "forward": deque([self.current_label["forward"]]), + "backward": deque([self.current_label["backward"]]) + }) + # Counters + self.unprocessed_counts = OrderedDict({"forward": 0, "backward": 0}) + self.processed_counts = OrderedDict({"forward": 0, "backward": 0}) + self.generated_counts = OrderedDict({"forward": 0, "backward": 0}) + + # Algorithm # + + def _terminate(self) -> bool: + """Check whether time limit is violated or final path with weight + under the input threshold""" + if self.time_limit is not None and check_time_limit_breached( + self.start_time, self.time_limit): + return True + return bool(self._check_final_label()) + + def _check_final_label(self) -> bool: + """Check if the final label contains an s-t path with total weight that + is under the threshold.""" + return bool(self.final_label and + (self.threshold is not None and + self.final_label.check_threshold(self.threshold) and + self.final_label.check_st_path())) + + def _get_direction(self) -> Union[str, None]: + """Returns which direction should be searched next + None at termination. + """ + if self.direction == "both": if (self.current_label["forward"] and not self.current_label["backward"]): return "forward" @@ -213,208 +248,165 @@ def _get_direction(self): if self.method == "random": # return a random direction return self.random_state.choice(["forward", "backward"]) - elif self.method == "generated": - # return direction with least number of generated labels - return ("forward" if self.generated_labels["forward"] < - self.generated_labels["backward"] else "backward") - elif self.method == "processed": - # return direction with least number of "processed" labels - return ("forward" if len(self.best_labels["forward"]) < len( - self.best_labels["backward"]) else "backward") elif self.method == "unprocessed": # return direction with least number of unprocessed_labels labels - return ("forward" if len(self.unprocessed_labels["forward"]) - < len(self.unprocessed_labels["backward"]) else - "backward") + return ("forward" if self.unprocessed_counts["forward"] < + self.unprocessed_counts["backward"] else "backward") + elif self.method == "processed": + # return direction with least number of processed labels + return ("forward" if self.processed_counts["forward"] < + self.processed_counts["backward"] else "backward") + elif self.method == "generated": + # return direction with least number of generated labels + return ("forward" if self.generated_counts["forward"] < + self.generated_counts["backward"] else "backward") else: # if both are empty - return + return None else: - if not ( - self.current_label["forward"] or self.current_label["backward"] - ): - return - elif not self.current_label[self.direc_in]: - return - else: - return self.direc_in + if not (self.current_label["forward"] or + self.current_label["backward"]): + return None + elif not self.current_label[self.direction]: + return None + return self.direction - ############# - # ALGORITHM # - ############# def _algorithm(self, direc): + 'Algorithm step wrapper' if direc == "forward": # forward - idx = 0 # index for tail node # Update backwards half-way point self.min_res[0] = max( self.min_res[0], min(self.current_label[direc].res[0], self.max_res[0])) else: # backward - idx = 1 # index for head node # Update forwards half-way point self.max_res[0] = min( self.max_res[0], max(self.current_label[direc].res[0], self.min_res[0])) - # Select edges with the same tail/head node as the current label node. - edges = deque(e for e in self.G.edges(data=True) - if e[idx] == self.current_label[direc].node) - # Propagate current label along all suitable edges in current direction - for edge in edges: - self._propagate_label(edge, direc) + self._propagate_label(direc) # Extend label next_label = self._get_next_label(direc) self.current_label[direc] = next_label - self._check_dominance(next_label, direc) - - def _propagate_label(self, edge, direc): - new_label = self.current_label[direc].get_new_label(edge, direc) - # If the new label is resource feasible - if new_label and new_label.feasibility_check(self.max_res, - self.min_res): - # And is not already in the unprocessed labels list - if (new_label not in self.unprocessed_labels[direc]): - self.unprocessed_labels[direc].append(new_label) + if self.iteration % self.dominance_frequency == 0: + self._check_dominance(next_label, direc) + self.processed_counts[direc] += 1 + self.iteration += 1 + + def _propagate_label(self, direc): + 'Propagate current label along all suitable edges in current direction' + idx = 0 if direc == "forward" else 1 + for edge in (e for e in self.G.edges(data=True) + if e[idx] == self.current_label[direc].node): + new_label = self.current_label[direc].get_new_label(edge, direc) + # If the new label is resource feasible + if new_label and new_label.feasibility_check( + self.max_res, self.min_res): + # And is not already in the unprocessed labels list + if new_label not in self.unprocessed_labels[direc]: + self.unprocessed_labels[direc].append(new_label) + self.unprocessed_counts[direc] += 1 def _get_next_label(self, direc): current_label = self.current_label[direc] unproc_labels = self.unprocessed_labels[direc] - # Add 1 to count of generated_labels - self.generated_labels[direc] += 1 - self._remove_labels([current_label], direc, unproc=True) + self.generated_counts[direc] += 1 + self._remove_labels([current_label], direc) # Return label with minimum monotone resource for the forward search # and the maximum monotone resource for the backward search if unproc_labels: if direc == "forward": return min(unproc_labels, key=lambda x: x.res[0]) - else: - return max(unproc_labels, key=lambda x: x.res[0]) - else: - return None + return max(unproc_labels, key=lambda x: x.res[0]) + return None - ############# - # DOMINANCE # - ############# - def _check_dominance(self, label_to_check, direc, unproc=True, best=False): - """ - For all labels, checks if ``label_to_check`` is dominated, + def _check_dominance(self, label_to_check, direc, best=False): + """For all labels, checks if ``label_to_check`` is dominated, or itself dominates any other label in either the unprocessed_labels list or the non-dominated labels list. If this is found to be the case, the dominated label(s) is(are) removed from the appropriate list. """ # Select appropriate list to check - if unproc: + if not best: labels_to_check = self.unprocessed_labels[direc] - elif best: + else: labels_to_check = self.best_labels[direc] # If label is not None (at termination) if label_to_check: - labels_to_pop = deque() # Gather all comparable labels (same node) all_labels = deque( l for l in labels_to_check if l.node == label_to_check.node and l != label_to_check) # Add to list for removal if they are dominated - labels_to_pop.extend( - l for l in all_labels if label_to_check.dominates(l, direc)) + labels_to_pop = deque( + l for l in all_labels + if label_to_check.dominates(l, direc) and label_to_check. + feasibility_check(self.max_res_in, self.min_res_in)) # Add input label for removal if itself is dominated - if any(l.dominates(label_to_check, direc) for l in all_labels): + if any((l.dominates(label_to_check, direc) and + l.feasibility_check(self.max_res_in, self.min_res_in)) + for l in all_labels): labels_to_pop.append(label_to_check) - elif unproc: + elif not best: # check and save current label self._save_current_best_label(direc) # if unprocessed labels checked then remove labels_to_pop - if unproc: - self._remove_labels(labels_to_pop, direc, unproc, best) + if not best: + self._remove_labels(labels_to_pop, direc, best) # Otherwise, return labels_to_pop for later removal - elif best: + else: return labels_to_pop - def _remove_labels(self, labels_to_pop, direc, unproc=True, best=False): - """ - Remove all labels in ``labels_to_pop`` from either the array of + def _remove_labels(self, + labels_to_pop: List[Tuple[Label, bool]], + direc: str, + best: bool = False): + """Remove all labels in ``labels_to_pop`` from either the array of unprocessed labels or the array of non-dominated labels """ # Remove all processed labels from unprocessed dict for label_to_pop in deque(set(labels_to_pop)): - if unproc and label_to_pop in self.unprocessed_labels[direc]: + if not best and label_to_pop in self.unprocessed_labels[direc]: idx = self.unprocessed_labels[direc].index(label_to_pop) del self.unprocessed_labels[direc][idx] - elif best and label_to_pop in self.best_labels[direc]: + if best and label_to_pop in self.best_labels[direc]: idx = self.best_labels[direc].index(label_to_pop) del self.best_labels[direc][idx] def _save_current_best_label(self, direc): - """ - Label saving - """ current_label = self.current_label[direc] final_label = self.final_label - if self.direc_in == "both": - self.best_labels[direc].append(current_label) - flip_direc = "forward" if direc == "backward" else "backward" - # Check if other direction traversed - # (must contain more than just the initial label) - # If it has, then we are done as labels will have to be joined - if len(self.best_labels[flip_direc]) > 1: - return - # Otherwise save label as with the single direction case + self.best_labels[direc].append(current_label) - # Saving for single direction - # If first label if not final_label: self.final_label = current_label return - # Otherwise, check dominance and replace + + # If not resource feasible wrt input resource bounds + if not current_label or not current_label.feasibility_check( + self.max_res_in, self.min_res_in): + return + try: - if self._full_dominance_check(current_label, final_label, direc): - log.debug("Saving {} as best, with path {}".format( - current_label, current_label.path)) + if current_label.full_dominance(final_label, direc): self.final_label = current_label - except Exception: + except TypeError: # Labels are not comparable i.e. Belong to different nodes if (direc == "forward" and (current_label.path[-1] == "Sink" or final_label.node == "Source")): - log.debug("Saving {} as best, with path {}".format( - current_label, current_label.path)) self.final_label = current_label elif (direc == "backward" and (current_label.path[-1] == "Source" or final_label.node == "Sink")): - log.debug("Saving {} as best, with path {}".format( - current_label, current_label.path)) self.final_label = current_label - @staticmethod - def _full_dominance_check(label1, label2, direc): - """ - Checks whether label 1 dominates label 2 for the input direction. - In the case when neither dominates , i.e. they are non-dominated, - the direction is flipped labels are compared again. - """ - label1_dominates = label1.dominates(label2, direc) - label2_dominates = label2.dominates(label1, direc) - # label1 dominates label2 for the input direction - if label1_dominates: - return True - # Both non-dominated labels in this direction. - elif (not label1_dominates and not label2_dominates): - # flip directions - flip_direc = "forward" if direc == "backward" else "backward" - label1_dominates_flipped = label1.dominates(label2, flip_direc) - # label 1 dominates label2 in the flipped direction - if label1_dominates_flipped: - return True - elif label1.weight < label2.weight: - return True - - ################### - # PATH PROCESSING # - ################### + # Post processing # + def _process_paths(self): - # Processing of output path. + 'Processing of output path.' # If direction is both and both directions traversed - if (self.direc_in == "both" and len(self.best_labels["forward"]) > 1 and + if (self.direction == "both" and + len(self.best_labels["forward"]) > 1 and len(self.best_labels["backward"]) > 1): # Run path joining procedure. self._clean_up_best_labels() @@ -422,58 +414,59 @@ def _process_paths(self): # If one direction of not both directions traversed, else: # If forward direction specified or backward direction not traversed - if (self.direc_in == "forward" or - (self.direc_in == "both" and + if (self.direction == "forward" or + (self.direction == "both" and len(self.best_labels["backward"]) == 1)): # Forward self.best_label = self.final_label # If backward direction specified or forward direction not traversed else: # Backward - self.best_label = self._process_bwd_label( - self.final_label, self.min_res_in) - - def _process_bwd_label(self, label, cumulative_res, edge=None): - # Reverse backward path and inverts resource consumption + self.best_label = self._process_bwd_label(self.final_label, + self.min_res_in, + invert_min_res=True) + + def _process_bwd_label(self, + label, + cumulative_res=None, + invert_min_res=False): + 'Reverse backward path and inverts resource consumption' label.path.reverse() label.res[0] = self.max_res_in[0] - label.res[0] - label.res = label.res + cumulative_res + if cumulative_res is not None: + label.res = label.res + cumulative_res + if invert_min_res: + label.res[1:] = label.res[1:] - self.min_res_in[1:] return label def _clean_up_best_labels(self): - # Removed all dominated labels in best_labels + 'Remove all dominated labels in best_labels' for direc in ["forward", "backward"]: labels_to_pop = deque() - for l in self.best_labels[direc]: + for label in self.best_labels[direc]: labels_to_pop.extend( - self._check_dominance(l, direc, unproc=False, best=True)) - self._remove_labels(labels_to_pop, direc, unproc=False, best=True) + self._check_dominance(label, direc, best=True)) + self._remove_labels(labels_to_pop, direc, best=True) def _join_paths(self): - """ - The procedure "Join" or Algorithm 3 from `Righini and Salani (2006)`_. - + """The procedure "Join" or Algorithm 3 from + `Righini and Salani (2006)`_. Modified to get rid of nested for loops and reduced search. :return: list with the final path. - .. _Righini and Salani (2006): https://www.sciencedirect.com/science/article/pii/S1572528606000417 """ - log.debug("joining") + LOG.debug("Algorithm terminated. Joining labels...") for fwd_label in self.best_labels["forward"]: # Create generator for backward labels for current forward label. # Includes only those that: # 1. Paths can be joined (exists a connecting edge) # 2. Introduces no cycles # 3. When combined with the forward label, they satisfy the halfway check - bwd_labels = ( - l - for l in self.best_labels["backward"] - if (fwd_label.node, l.node) in self.G.edges() - and all(n not in fwd_label.path for n in l.path) - and self._half_way(fwd_label, l) - ) - + bwd_labels = (l for l in self.best_labels["backward"] + if (fwd_label.node, l.node) in self.G.edges() and all( + n not in fwd_label.path + for n in l.path) and self._half_way(fwd_label, l)) for bwd_label in bwd_labels: # Merge two labels merged_label = self._merge_labels(fwd_label, bwd_label) @@ -481,23 +474,16 @@ def _join_paths(self): if (merged_label and merged_label.feasibility_check( self.max_res_in, self.min_res_in)): # Save label - self._save(merged_label) + self._join_save(merged_label) def _half_way(self, fwd_label, bwd_label): - """ - Half-way check from `Righini and Salani (2006)`_. - Checks if a pair of labels is closes to the half-way point. - - :return: bool. True if the half-way check passes, false otherwise. - - .. _Righini and Salani (2006): https://www.sciencedirect.com/science/article/pii/S1572528606000417 + """Checks if a pair of labels is closes to the half-way point. """ phi = abs(fwd_label.res[0] - (self.max_res_in[0] - bwd_label.res[0])) return 0 <= phi <= 2 def _merge_labels(self, fwd_label, bwd_label): - """ - Merge labels produced by a backward and forward label. + """Merge labels produced by a backward and forward label. Paramaters ---------- @@ -509,7 +495,6 @@ def _merge_labels(self, fwd_label, bwd_label): merged_label : label.Label object If an s-t compatible path can be obtained the appropriately extended and merged label is returned - None Otherwise. """ @@ -536,10 +521,10 @@ def _merge_labels(self, fwd_label, bwd_label): final_path = fwd_label.path + _bwd_label.path return Label(weight, "Sink", final_res, final_path) - def _save(self, label): - # Saves a label for exposure - if not self.best_label or self._full_dominance_check( - label, self.best_label, "forward"): - log.debug("Saving label {} as best".format(label)) - log.debug("With path {}".format(label.path)) + def _join_save(self, label): + 'Saves a label for exposure' + if not self.best_label or label.full_dominance(self.best_label, + "forward"): + LOG.debug("Saving path %s as best, with weight %s", label.path, + label.weight) self.best_label = label diff --git a/cspy/algorithms/grasp.py b/cspy/algorithms/grasp.py index 927b225..06a8ee5 100644 --- a/cspy/algorithms/grasp.py +++ b/cspy/algorithms/grasp.py @@ -1,17 +1,17 @@ -# TODO: write checks for all inputs - -from __future__ import absolute_import -from __future__ import print_function - +from time import time from math import factorial from logging import getLogger from collections import deque -from numpy.random import choice -from random import sample, randint from itertools import permutations, repeat +from random import sample, randint +from typing import List, Optional, Callable + +from networkx import DiGraph +from numpy.random import choice # Local imports from cspy.algorithms.path_base import PathBase +from cspy.checking import check_time_limit_breached log = getLogger(__name__) @@ -45,6 +45,16 @@ class GRASP(PathBase): max_localiter : int, optional Maximum number of local search iterations. Default : 10. + time_limit : int, optional + time limit in seconds. + Default: None + + threshold : float, optional + specify a threshold for a an acceptable resource feasible path with + total cost <= threshold. + Note this typically causes the search to terminate early. + Default: None + alpha : float, optional Greediness factor 0 (random) --> 1 (greedy). Default : 0.2. @@ -63,19 +73,22 @@ class GRASP(PathBase): """ def __init__(self, - G, - max_res, - min_res, - preprocess=False, - max_iter=100, - max_localiter=10, - alpha=0.2, + G: DiGraph, + max_res: List[float], + min_res: List[float], + preprocess: Optional[bool] = False, + max_iter: Optional[int] = 100, + max_localiter: Optional[int] = 10, + time_limit: Optional[int] = None, + threshold: Optional[float] = None, + alpha: Optional[float] = 0.2, REF=None): # Pass arguments to parent class - super().__init__(G, max_res, min_res, preprocess, REF) + super().__init__(G, max_res, min_res, preprocess, threshold, REF) # Algorithm specific attributes self.max_iter = max_iter self.max_localiter = max_localiter + self.time_limit = time_limit self.alpha = alpha # Algorithm specific parameters self.it = 0 @@ -88,7 +101,9 @@ def run(self): """ Calculate shortest path with resource constraints. """ - while self.it < self.max_iter and not self.stop: + start = time() + while (self.it < self.max_iter and not self.stop and + not check_time_limit_breached(start, self.time_limit)): self._algorithm() self.it += 1 if not self.best_solution.path: diff --git a/cspy/algorithms/greedy_elimination.py b/cspy/algorithms/greedy_elimination.py index 277b4c9..cb90c41 100644 --- a/cspy/algorithms/greedy_elimination.py +++ b/cspy/algorithms/greedy_elimination.py @@ -1,14 +1,15 @@ -from __future__ import absolute_import -from __future__ import print_function +from time import time +from logging import getLogger +from typing import List, Optional, Callable -import logging from numpy import array -from networkx import NetworkXException +from networkx import NetworkXException, DiGraph # Local imports from cspy.algorithms.path_base import PathBase +from cspy.checking import check_time_limit_breached -log = logging.getLogger(__name__) +log = getLogger(__name__) class GreedyElim(PathBase): @@ -47,6 +48,16 @@ class GreedyElim(PathBase): If the total number of simple paths is less than max_depth, then the shortest path is used. + time_limit : int, optional + time limit in seconds. + Default: None + + threshold : float, optional + specify a threshold for a an acceptable resource feasible path with + total cost <= threshold. + Note this typically causes the search to terminate early. + Default: None + REF : function, optional Custom resource extension function. See `REFs`_ for more details. Default : additive. @@ -62,17 +73,21 @@ class GreedyElim(PathBase): """ def __init__(self, - G, - max_res, - min_res, - preprocess=False, - algorithm="simple", - max_depth=1000, - REF=None): + G: DiGraph, + max_res: List, + min_res: List, + preprocess: Optional[bool] = False, + algorithm: Optional[str] = "simple", + max_depth: Optional[int] = 1000, + time_limit: Optional[int] = None, + threshold: Optional[float] = None, + REF: Callable = None): # Pass arguments to parent class - super().__init__(G, max_res, min_res, preprocess, REF, algorithm) + super().__init__(G, max_res, min_res, preprocess, threshold, REF, + algorithm) # Algorithm specific parameters self.max_depth = max_depth + self.time_limit = time_limit self.stop = False self.predecessor_edges = [] self.last_edge_removed = None @@ -84,7 +99,9 @@ def run(self): """ Calculate shortest path with resource constraints. """ - while self.stop is False: + start = time() + while not self.stop and not check_time_limit_breached( + start, self.time_limit): self._algorithm() if not self.best_path: @@ -103,6 +120,7 @@ def _algorithm(self): self.stop = True else: self.remove_edge(edge_or_true) + self.last_edge_removed = edge_or_true else: # no path has been found for current graph # Add previously removed edge diff --git a/cspy/algorithms/label.py b/cspy/algorithms/label.py index f75d534..99fffbd 100644 --- a/cspy/algorithms/label.py +++ b/cspy/algorithms/label.py @@ -1,30 +1,30 @@ -import types +from typing import List, Callable +from types import BuiltinFunctionType +from numpy import greater, greater_equal, less_equal -class Label(object): + +class Label: """ Label object that allows comparison and the modelling of dominance relations. Parameters ----------- - weight : float cumulative edge weight - node : string name of last node visited - res : list cumulative edge resource consumption - path : list all nodes in the path """ - _REF_forward, _REF_backward = None, None + REF_forward: Callable = None + REF_backward: Callable = None - def __init__(self, weight, node, res, path): + def __init__(self, weight: float, node: str, res: List, path: List): self.weight = weight self.node = node self.res = res @@ -33,48 +33,39 @@ def __init__(self, weight, node, res, path): def __repr__(self): return str(self) - def __str__(self): # for printing purposes - return "Label({0},{1},{2})".format(self.weight, self.node, self.res) - - def dominates(self, other, direction): - # Determine whether self dominates other. Returns bool - if self.node != other.node: - raise Exception("Non-comparable labels given") - - # Assume self dominates other - if self.weight > other.weight: - return False - if direction == "backward": - # Check for the monotone resource (non-increasing) - if self.res[0] < other.res[0]: - return False - # Check for all other resources (non-decreasing) - if any(self.res[1:] > other.res[1:]): - return False - elif direction == "forward": - if any(self.res > other.res): - return False - return True + def __str__(self): + return "Label({0},{1})".format(self.weight, self.path) - def get_new_label(self, edge, direction): + def get_new_label(self, edge: tuple, direction: str): + """Create a label by extending the current label along the input `edge` + and `direction`. + :return: new Label object. + """ path = list(self.path) weight, res = edge[2]["weight"], edge[2]["res_cost"] node = edge[1] if direction == "forward" else edge[0] + # FIXME hardcoded elementary if node in path: # If node already visited. return None - else: - path.append(node) + path.append(node) if direction == "forward": - if isinstance(self._REF_forward, types.BuiltinFunctionType): + if isinstance(self.REF_forward, BuiltinFunctionType): res_new = self.res + res else: - res_new = self._REF_forward(self.res, edge) + res_new = self.REF_forward(self.res, + edge, + partial_path=self.path, + accumulated_cost=self.weight) + elif direction == "backward": - if isinstance(self._REF_backward, types.BuiltinFunctionType): + if isinstance(self.REF_backward, BuiltinFunctionType): res_new = self.res + res res_new[0] = self.res[0] - 1 else: - res_new = self._REF_backward(self.res, edge) + res_new = self.REF_backward(self.res, + edge, + partial_path=self.path, + accumulated_cost=self.weight) _new_label = Label(weight + self.weight, node, res_new, path) if _new_label == self: @@ -82,5 +73,65 @@ def get_new_label(self, edge, direction): return None return _new_label - def feasibility_check(self, max_res, min_res): - return all(max_res >= self.res) and all(min_res <= self.res) + def feasibility_check(self, max_res: List, min_res: List) -> bool: + """Check whether `self` satisfies resource constraints for input + `max_res` - Upper bound + `min_res` - Lower bound + :return: True if resource feasible label, False otherwise. + """ + return all(greater_equal(max_res, self.res)) and all( + less_equal(min_res, self.res)) + + def dominates(self, other, direction: str) -> bool: + """Determine whether `self` dominates `other`. + :return: bool + """ + # Assume self dominates other + if self.node != other.node: + raise TypeError("Non-comparable labels given") + + if self.weight > other.weight: + return False + if direction == "backward": + # Check for the monotone resource (non-increasing) + if self.res[0] < other.res[0]: + return False + # Check for all other resources (non-decreasing) + if any(greater(self.res[1:], other.res[1:])): + return False + elif direction == "forward": + if any(greater(self.res, other.res)): + return False + return True + + def full_dominance(self, other, direction: str) -> bool: + """Checks whether `self` dominates `other` for the input direction. + In the case when neither dominates , i.e. they are non-dominated, + the direction is flipped labels are compared again. + + :return: bool + """ + self_dominates = self.dominates(other, direction) + other_dominates = other.dominates(self, direction) + # self dominates other for the input direction + if self_dominates: + return True + # Both non-dominated labels in this direction. + elif (not self_dominates and not other_dominates): + # flip directions + flip_direc = "forward" if direction == "backward" else "backward" + self_dominates_flipped = self.dominates(other, flip_direc) + # label 1 dominates other in the flipped direction + if self_dominates_flipped: + return True + elif self.weight < other.weight: + return True + + def check_threshold(self, threshold) -> bool: + """Check if a s-t path has a total weight + is under the threshold.""" + return self.weight <= threshold + + def check_st_path(self) -> bool: + return ((self.path[0] == "Source" and self.path[-1] == "Sink") or + (self.path[-1] == "Source" and self.path[0] == "Sink")) diff --git a/cspy/algorithms/path_base.py b/cspy/algorithms/path_base.py index b4d0a48..2788556 100644 --- a/cspy/algorithms/path_base.py +++ b/cspy/algorithms/path_base.py @@ -1,15 +1,16 @@ from operator import add from collections import deque from logging import getLogger -from numpy import zeros, array from types import BuiltinFunctionType from itertools import filterfalse, tee, chain + +from numpy import zeros, array from networkx import (shortest_simple_paths, astar_path, negative_edge_cycle) from cspy.checking import check from cspy.preprocessing import preprocess_graph -log = getLogger(__name__) +LOG = getLogger(__name__) class PathBase(object): @@ -21,7 +22,14 @@ class PathBase(object): e.g. shortest path, feasibility checks, compatible joining """ - def __init__(self, G, max_res, min_res, preprocess, REF, algorithm=None): + def __init__(self, + G, + max_res, + min_res, + preprocess, + threshold, + REF, + algorithm=None): # Check inputs check(G, max_res, min_res, REF_forward=REF, algorithm=__name__) # Preprocess graph @@ -29,6 +37,7 @@ def __init__(self, G, max_res, min_res, preprocess, REF, algorithm=None): self.max_res = max_res self.min_res = min_res + self.threshold = threshold # Update resource extension function if given self.REF = REF if REF else add if negative_edge_cycle(G) or algorithm == "simple": @@ -78,41 +87,51 @@ def get_shortest_path(self, source, max_depth): # Select appropriate shortest path algorithm if self.algorithm == "simple": return self.get_simple_path(source, max_depth) - else: - return astar_path(self.G, source, "Sink") + return astar_path(self.G, source, "Sink") def get_simple_path(self, source, max_depth): + """Iterate through some paths (hopefully) and return the first one + with total cost <= {0 or threshold} if a threshold specified. + Otherwise, stops whenever max_depth is exceeded or all paths are + inspected, whichever occurs first. In this case, the path with lowest + cost is returned. + """ depth = 0 # Create two copies of the simple path generator paths, paths_backup = tee(shortest_simple_paths(self.G, source, 'Sink'), 2) + paths_reduced = True - # Select only paths with negative reduced cost if they exist + # Select paths with under threshold if they exist try: - cost_min = 0 + cost_min = self.threshold if self.threshold is not None else 0 paths_backup = filterfalse( lambda p: sum(self.G[i][j]["weight"] - for i, j in zip(p, p[1:])) >= 0, paths_backup) + for i, j in zip(p, p[1:])) >= cost_min, + paths_backup) first = paths_backup.__next__() # add first element back paths = chain([first], paths_backup) except StopIteration: - # if there exist paths with negative cost - cost_min = 1e10 + # if there are no paths under threshold or no paths with -ve cost + cost_min = self.threshold if self.threshold is not None else 1e10 + paths_reduced = False except KeyError: return for p in paths: c = sum(self.G[i][j]["weight"] for i, j in zip(p, p[1:])) - if c < cost_min: - path = p - cost_min = c + if c <= cost_min: + if not paths_reduced: + _path = p + cost_min = c + else: + return p if depth > max_depth: - return path + return _path depth += 1 - # All paths consumed - return path + return _path def check_feasibility(self, return_edge=True): """ @@ -131,23 +150,20 @@ def check_feasibility(self, return_edge=True): ]) # init total resources and cost total_res = zeros(self.G.graph['n_res']) - _cost = 0 + cost = 0 # Check path for resource feasibility by adding one edge at a time for edge in shortest_path_edges_w_data: - _cost += edge[2]['weight'] + cost += edge[2]['weight'] if isinstance(self.REF, BuiltinFunctionType): total_res += self._edge_extract(edge) else: total_res = self.REF(total_res, edge) - if not ( - (all(total_res <= self.max_res) and all(total_res >= self.min_res)) - ): + if not ((all(total_res <= self.max_res) and + all(total_res >= self.min_res))): break else: # Fesible path found. Save attributes. - self.best_path = self.st_path - self.best_path_total_res = total_res - self.best_path_cost = _cost + self.save(total_res, cost) return True # Return infeasible edge unless specified if return_edge: @@ -157,11 +173,11 @@ def check_feasibility(self, return_edge=True): def remove_edge(self, edge): if edge[:2] in self.G.edges(): - log.debug("Removed edge {}".format(edge[:2])) + LOG.debug("Removed edge {}".format(edge[:2])) self.G.remove_edge(*edge[:2]) def add_edge_back(self, edge): - log.debug("Added edge back {}".format(edge[:2])) + LOG.debug("Added edge back {}".format(edge[:2])) if "data" in edge[2]: self.G.add_edge(*edge[:2], res_cost=edge[2]['res_cost'], @@ -172,6 +188,13 @@ def add_edge_back(self, edge): res_cost=edge[2]['res_cost'], weight=edge[2]['weight']) + def save(self, total_res, cost): + if not self.threshold or (self.threshold is not None and + cost <= self.threshold): + self.best_path = self.st_path + self.best_path_total_res = total_res + self.best_path_cost = cost + @staticmethod def _edge_extract(edge): return array(edge[2]['res_cost']) diff --git a/cspy/algorithms/psolgent.py b/cspy/algorithms/psolgent.py index 3b63a79..31b2255 100644 --- a/cspy/algorithms/psolgent.py +++ b/cspy/algorithms/psolgent.py @@ -2,18 +2,20 @@ Adapted from https://github.com/100/Solid/blob/master/Solid/ParticleSwarm.py """ -from __future__ import absolute_import -from __future__ import print_function - +from time import time from math import sqrt from abc import ABCMeta from logging import getLogger +from typing import List, Optional, Callable + +from networkx import DiGraph from numpy.random import RandomState from numpy import (argmin, array, copy, diag_indices_from, exp, dot, zeros, ones, where) # Local imports from cspy.algorithms.path_base import PathBase +from cspy.checking import check_seed, check_time_limit_breached log = getLogger(__name__) @@ -53,6 +55,16 @@ class PSOLGENT(PathBase): max_iter : int, optional Maximum number of iterations for algorithm. Default : 100. + time_limit : int, optional + time limit in seconds. + Default: None + + threshold : float, optional + specify a threshold for a an acceptable resource feasible path with + total cost <= threshold. + Note this typically causes the search to terminate early. + Default: None + swarm_size : int, optional number of members in swarm. Default : 50. @@ -62,13 +74,6 @@ class PSOLGENT(PathBase): neighbourhood_size : int, optional size of neighbourhood. Default : 10. - lower_bound : list of floats, optional - list of lower bounds. Default : ``numpy.zeros(member_size)`` - (no nodes in path). - - upper_bound : list of floats, optional - list of upper bounds. Default : ``numpy.ones(member_size)`` - (all nodes in path). c1 : float, optional constant for 1st term in the velocity equation. @@ -102,24 +107,27 @@ class PSOLGENT(PathBase): __metaclass__ = ABCMeta def __init__(self, - G, - max_res, - min_res, - preprocess=False, - max_iter=100, - swarm_size=50, - member_size=None, - lower_bound=None, - upper_bound=None, - neighbourhood_size=10, - c1=1.35, - c2=1.35, - c3=1.4, - seed=None, - REF=None): + G: DiGraph, + max_res: List[float], + min_res: List[float], + preprocess: Optional[bool] = False, + max_iter: Optional[int] = 100, + time_limit: Optional[int] = None, + threshold: Optional[float] = None, + swarm_size: Optional[int] = 50, + member_size: Optional[int] = None, + neighbourhood_size: Optional[int] = 10, + c1: Optional[float] = 1.35, + c2: Optional[float] = 1.35, + c3: Optional[float] = 1.4, + seed: RandomState = None, + REF: Callable = None): # Pass arguments to parent class - super().__init__(G, max_res, min_res, preprocess, REF) + super().__init__(G, max_res, min_res, preprocess, threshold, REF) # Inputs + self.max_iter = max_iter + self.time_limit = time_limit + self.threshold = threshold self.swarm_size = swarm_size self.member_size = member_size if member_size else len(G.nodes()) self.hood_size = neighbourhood_size @@ -128,7 +136,7 @@ def __init__(self, self.c1 = float(c1) self.c2 = float(c2) self.c3 = float(c3) - self.max_iter = max_iter + self.random_state = check_seed(seed) # PSO Specific Parameters self.iter = 0 self.pos = None @@ -138,23 +146,14 @@ def __init__(self, self.best_fit = None self.local_best = None self.global_best = None - # Check seed and init `random_state`. Altered from: - # https://github.com/scikit-learn/scikit-learn/blob/92af3dabbb5f3381a656f7727171f332b8928e05/sklearn/utils/validation.py#L764-L782 - if seed is None: - self.random_state = RandomState() - elif isinstance(seed, int): - self.random_state = RandomState(seed) - elif isinstance(seed, RandomState): - self.random_state = seed - else: - raise Exception("{} cannot be used to seed".format(seed)) def run(self): + """Calculate shortest path with resource constraints. """ - Calculate shortest path with resource constraints. - """ + start = time() self._init_swarm() - while self.iter < self.max_iter: + while (self.iter < self.max_iter and + not check_time_limit_breached(start, self.time_limit)): pos_new = self.pos + self._get_vel() self._update_best(self.pos, pos_new) self.pos = pos_new @@ -164,6 +163,11 @@ def run(self): if self.iter % 100 == 0: log.info("Iteration: {0}. Current best fit: {1}".format( self.iter, self.best_fit)) + # Terminate if feasible path found with total cost <= threshold + if (self.best_path and self.best_path_cost and + self.threshold is not None and + self.best_path_cost <= self.threshold): + break self.iter += 1 if not self.best_path: raise Exception("No resource feasible path has been found") @@ -216,8 +220,10 @@ def _update_best(self, old, new): best = zeros(shape=old.shape) if any(of < nf for of, nf in zip(old_fitness, new_fitness)): # replace indices in best with old members if lower fitness - idx_old = [idx for idx, val in enumerate(zip(old_fitness, new_fitness)) - if val[0] < val[1]] + idx_old = [ + idx for idx, val in enumerate(zip(old_fitness, new_fitness)) + if val[0] < val[1] + ] best[idx_old] = old[idx_old] idx_new = where(best == 0) # replace indices in best with new members if lower fitness @@ -265,7 +271,9 @@ def _update_current_nodes(self, arr): 0 not present, 1 present. """ nodes = self._sort_nodes(list(self.G.nodes())) - self.current_nodes = [nodes[i] for i in range(len(nodes)) if arr[i] == 1] + self.current_nodes = [ + nodes[i] for i in range(len(nodes)) if arr[i] == 1 + ] def _get_fitness_member(self): # Returns the objective for a given path @@ -289,8 +297,10 @@ def _fitness(self, nodes): # Path-related methods # def _get_edges(self, nodes): # Creates a list of edges given the nodes selected - return [edge for edge in self.G.edges(self.G.nbunch_iter(nodes), data=True) - if edge[0:2] in zip(nodes, nodes[1:])] + return [ + edge for edge in self.G.edges(self.G.nbunch_iter(nodes), data=True) + if edge[0:2] in zip(nodes, nodes[1:]) + ] @staticmethod def _check_edges(edges): diff --git a/cspy/algorithms/tabu.py b/cspy/algorithms/tabu.py index 20bc01e..ac37881 100644 --- a/cspy/algorithms/tabu.py +++ b/cspy/algorithms/tabu.py @@ -1,12 +1,13 @@ -from __future__ import absolute_import -from __future__ import print_function - +from time import time from logging import getLogger +from typing import List, Optional, Callable from collections import deque -from networkx import NetworkXException + +from networkx import NetworkXException, DiGraph # Local imports from cspy.algorithms.path_base import PathBase +from cspy.checking import check_time_limit_breached log = getLogger(__name__) @@ -45,6 +46,16 @@ class Tabu(PathBase): If the total number of simple paths is less than max_depth, then the shortest path is used. + time_limit : int, optional + time limit in seconds. + Default: None + + threshold : float, optional + specify a threshold for a an acceptable resource feasible path with + total cost <= threshold. + Note this typically causes the search to terminate early. + Default: None + REF : function, optional Custom resource extension function. See `REFs`_ for more details. Default : additive. @@ -61,16 +72,20 @@ class Tabu(PathBase): """ def __init__(self, - G, - max_res, - min_res, - preprocess=False, - algorithm="simple", - max_depth=1000, - REF=None): - # Pass arguments to SimplePath object - super().__init__(G, max_res, min_res, preprocess, REF, algorithm) + G: DiGraph, + max_res: List[float], + min_res: List[float], + preprocess: Optional[bool] = False, + algorithm: Optional[str] = "simple", + max_depth: Optional[int] = 1000, + time_limit: Optional[int] = None, + threshold: Optional[float] = None, + REF: Callable = None): + # Pass arguments to PathBase object + super().__init__(G, max_res, min_res, preprocess, threshold, REF, + algorithm) # Algorithm specific parameters + self.time_limit = time_limit self.max_depth = max_depth self.iteration = 0 self.stop = False @@ -83,7 +98,9 @@ def run(self): """ Calculate shortest path with resource constraints. """ - while self.stop is False: + start = time() + while not self.stop and not check_time_limit_breached( + start, self.time_limit): self._algorithm() self.iteration += 1 @@ -116,9 +133,11 @@ def _update_path(self, neighbour, path): self.st_path = path elif neighbour in self.st_path: # Paths can be joined at neighbour - self.st_path = [node for node in self.st_path if - (node != neighbour and self.st_path.index(node) - < self.st_path.index(neighbour))] + path + self.st_path = [ + node for node in self.st_path + if (node != neighbour and + self.st_path.index(node) < self.st_path.index(neighbour)) + ] + path else: self._merge_paths(neighbour, path) @@ -126,8 +145,10 @@ def _merge_paths(self, neighbour, path): branch_path = [n for n in self.st_path if n not in path] for node in reversed(branch_path): if (node, neighbour) in self.G.edges(): - self.st_path = [n for n in branch_path if ( - branch_path.index(n) <= branch_path.index(node))] + path + self.st_path = [ + n for n in branch_path + if (branch_path.index(n) <= branch_path.index(node)) + ] + path break # Algorithm-specific methods # diff --git a/cspy/checking.py b/cspy/checking.py index dea9d8c..9857a62 100644 --- a/cspy/checking.py +++ b/cspy/checking.py @@ -1,5 +1,9 @@ +from time import time +from typing import Union + from networkx import DiGraph, NetworkXException, has_path from numpy import ndarray +from numpy.random import RandomState def check(G, @@ -46,7 +50,6 @@ def check(G, """ errors = [] if REF_forward or REF_backward or REF_join: - # Cannot apply pruning with custom REFs try: _check_REFs(REF_forward, REF_backward, REF_join) except Exception as e: @@ -74,18 +77,39 @@ def check(G, raise Exception('\n'.join('{}'.format(item) for item in errors)) +def check_seed(seed): + """Check whether given seed can be used to seed a numpy.random.RandomState + :return: numpy.random.RandomState (seeded if seed given) + """ + if seed is None: + return RandomState() + elif isinstance(seed, int): + return RandomState(seed) + elif isinstance(seed, RandomState): + return seed + else: + raise TypeError("{} cannot be used to seed".format(seed)) + + +def check_time_limit_breached(start_time: float, + time_limit: Union[int, None]) -> bool: + """Check time limit. + :return: True if difference between current time and start time + exceeds the time limit. False otherwise. + """ + if time_limit is not None: + return time_limit - (time() - start_time) <= 0.0 + return False + + def _check_res(G, max_res, min_res, direction, algorithm): if isinstance(max_res, list) and isinstance(min_res, list): if len(max_res) == len(min_res): if (algorithm and 'bidirectional' in algorithm and len(max_res) < 2): raise TypeError("Resources must be of length >= 2") - if not ( - ( - all(isinstance(i, (float, int)) for i in max_res) - and all(isinstance(i, (float, int)) for i in min_res) - ) - ): + if not ((all(isinstance(i, (float, int)) for i in max_res) and + all(isinstance(i, (float, int)) for i in min_res))): raise TypeError("Elements of input lists must be numbers") else: raise TypeError("Input lists have to be equal length") @@ -113,15 +137,12 @@ def _check_edge_attr(G, max_res, min_res, direction, algorithm): if any('res_cost' not in edge[2] for edge in G.edges(data=True)): raise TypeError("Input graph must have edges with 'res_cost' attribute") if any( - len(edge[2]['res_cost']) != G.graph['n_res'] - for edge in G.edges(data=True) - ): + len(edge[2]['res_cost']) != G.graph['n_res'] + for edge in G.edges(data=True)): raise TypeError( "Edges must have 'res_cost' attribute with length equal to 'n_res'") - if any( - not len(edge[2]['res_cost']) == len(max_res) == len(min_res) - for edge in G.edges(data=True) - ): + if any(not len(edge[2]['res_cost']) == len(max_res) == len(min_res) + for edge in G.edges(data=True)): raise TypeError( "Edges must have 'res_cost' attribute with length equal to" + " 'min_res' == 'max_res") diff --git a/docs/how_to.rst b/docs/how_to.rst index 1d152a3..c2306dc 100644 --- a/docs/how_to.rst +++ b/docs/how_to.rst @@ -8,7 +8,7 @@ Input Requirements In order to use `cspy` package and the algorithms within, first, one has to create a directed graph on which to apply the algorithms. -To do so, we make use of the well-known `networkx` package. +To do so, we make use of the well-known `networkx` package. To be able to apply resource constraints, we have the following input requirements, - Input graphs must be of type :class:`networkx.DiGraph`; @@ -30,7 +30,7 @@ For example the following simple network fulfills all the requirements listed ab The algorithms have some common inputs and requirements, - - Two lists ``max_res`` and ``min_res``, with lists of the maximum and minimum resource usage to be enforced for the resulting path; + - Two lists ``max_res`` and ``min_res``, with lists of the maximum and minimum resource usage to be enforced for the resulting path; For former, the user must ensure consistency between the index in ``res_cost`` and the index in ``max_res``\``min_res``, such that it corresponds to the same resource. @@ -40,13 +40,13 @@ The latter, is due to the fact that some algorithms depend standard shortest pat Algorithms ~~~~~~~~~~ -Have a look and choose which algorithm you'd like to use. +Have a look and choose which algorithm you'd like to use. In order to run the algorithms create a appropriate algorithm instance, say ``alg``, (with the appropriate inputs), call ``alg.run()``, and then access the different elements from the solution. -Attributes include ``alg.path`` for a list with the nodes in the path, +Attributes include ``alg.path`` for a list with the nodes in the path, ``alg.total_cost`` for the accumulated cost of the path, and ``alg.consumed_resources`` for the accumulated resource usage of the path. - + - :class:`BiDirectional`: `Bidirectional and monodirectional algorithms`_ - :class:`Tabu` `Heuristic Tabu Search`_ - :class:`GreedyElim` `Greedy Elimination Procedure`_ @@ -87,8 +87,8 @@ Pre-requirements For the :class:`BiDirectional` algorithm, there is a number of assumptions required by definition (`Tilk et al 2017`_). - 1. The first resource must be a monotone resource; - 2. The resource extension functions are invertible. +1. The first resource must be a monotone resource; +2. The resource extension functions are invertible. For assumption 1, the resource can be either artificial, such as the number of edges in the graph, or real, for example time. @@ -116,21 +116,21 @@ Additive REFs ************* Additive resource extension functions (REFs), are implemented by default in all the algorithms. -If left unchanged, this means that resources propagate in the following fashion. -Suppose we are considering extending partial path :math:`p_i` +If left unchanged, this means that resources propagate in the following fashion. +Suppose we are considering extending partial path :math:`p_i` (a path from the source to node :math:`i`), along edge :math:`(i, j)`. -Under the assumption that edge :math:`(i, j)` has a resource cost defined -(one for each of the resources); -the partial path :math:`p_j` (a path from the source to node :math:`j` passing +Under the assumption that edge :math:`(i, j)` has a resource cost defined +(one for each of the resources); +the partial path :math:`p_j` (a path from the source to node :math:`j` passing through node :math:`i`) will have a resource consumption equal to the total resource accumulated along :math:`p_i` plus the resource cost of edge :math:`(i, j)`. -If for instance, this resource consumption for a given resource exceeds the limit given in +If for instance, this resource consumption for a given resource exceeds the limit given in As discussed above, the resource costs are defined by the user in the input graph. Custom REFs *********** -Additionally, users can implement their own custom REFs. -This allows the modelling of more complex relationships and more realistic evolution +Additionally, users can implement their own custom REFs. +This allows the modelling of more complex relationships and more realistic evolution of resources. However, it is up the users to ensure that the custom REFs are well defined, it may be the case that the algorithm fails to find a feasible path, or gets stuck. @@ -143,28 +143,37 @@ Custom REF template Practically, if the users wished for more control on the propagation of resources, a custom REF can be defined as follows. -First, the function will need two inputs: ``res``, a cumulative resource array, and ``edge``, an edge to consider for the extension of the current partial path. This function will be called every time the algorithms wish to consider and edge as part of the shortest path. +First, the function will need two inputs: ``cumulative_res``, a cumulative resource array, +and ``edge``, an edge to consider for the extension of the current partial path. +Additionally, some optional arguments are given in ``kwargs`` (``partial_path`` and +``accumulated_cost``). +This function will be called every time the algorithms wish to consider and edge as part of the shortest path. -As an example, suppose the 2nd resource represents travel time (``res[1]``). Suppose the edge weight contains the travel time. Hence, every time an edge is traversed, the ``res[1]`` is updated by adding its previous cumulative value and the current edge weight. We can define our custom REF as follows, +As an example, suppose the 2nd resource represents travel time (``res[1]``). +Suppose the edge contains an attribute to hold the travel time. +Hence, every time an edge is traversed, the ``res[1]`` is updated by adding its previous cumulative value and the current edge weight. We can define our custom REF as follows, .. code-block:: python from numpy import array - def REF_custom(cumulative_res, edge): + def REF_custom(cumulative_res, edge, **kwargs): new_res = array(cumulative_res) # your filtering criteria that changes the elements of new_res # For example: head_node, tail_node, egde_data = edge[0:3] - new_res[1] += edge_data['weight'] + # Monotone resource + new_res[0] += 1 + # Travel time + new_res[1] += edge_data['travel_time'] return new_res -Your custom REF can then be passed with this format, into the algorithm of choice using the ``REF`` -argument (see individual algorithms for details). +Your custom REF can then be passed with this format, into the algorithm of choice using the ``REF`` +argument (see individual algorithms for details). As a word of warning, it is up to the user to ensure the custom REF behaves appropriately. Otherwise, you will most likely either stall the algorithms, get an exception saying that a resource -feasible path could not be found, or get a path that's not very meaningfull. +feasible path could not be found, or get a path that's not very meaningfull. For a simple example of custom REFs, please see the `unittest`_. @@ -172,30 +181,28 @@ For an intermediate example, see below. For more advanced examples, see the `examples`_ folder. - - Simple Example ~~~~~~~~~~~~~~ For illustration of most of the things discussed above, consider the following example. -Jane is part-time postwoman working in Delft, Netherlands. However, she is assigned a small area (the Indische Buurt neighbourhood) so when planning her daily route she wants to make it as long and exciting as possible. +Jane is part-time postwoman working in Delft, Netherlands. However, she is assigned a small area (the Indische Buurt neighbourhood) so when planning her daily route she wants to make it as long and exciting as possible. That is, when planning her routes she has to consider the total shift time, sights visited, travel time, and delivery time. Her shift has to be at most 5 hours. -This problem can easily be modelled as a CSP problem. +This problem can easily be modelled as a CSP problem. With the description above, the set of resources can be defined as, .. code-block:: python - R = ['sights', 'shift', 'travel-time', 'delivery-time'] + R = ['sights', 'shift', 'travel-time', 'delivery-time'] # len(R) = 4 -Let ``G`` denote a directed graph with edges to/from all streets of the Indische Buurt -neighbourhood. +Let ``G`` denote a directed graph with edges to/from all streets of the Indische Buurt +neighbourhood. Each edge has an attribute ``weight`` and an attribute ``res_cost`` which is an array (specifically, a ``numpy.array``) -with length ``len(R)``. +with length ``len(R)``. The entries of ``res_cost`` have the same order as the entries in ``R``. The first entry of this array, corresponds to the ``'sights'`` resource, i.e. how many sights there are along a specific edge. The last entry of this array, corresponds to the ``'delivery-time'`` resource, i.e. time taken to deliver post along a specific edge. The remaining entries can be initialised to be 0. Also, when defining ``G``, one has to specify the number of resources ``n_res``, which also has to be equal to ``len(R)``. @@ -218,10 +225,10 @@ Now, using the open source package OSMnx, we can easily generate a network for J simplify=False) -We have to transform the network for one compatible with ``cspy``, -as per the `Input Requirements`_. -The following code will convert a city map into a directed graph, -rename the start/end nodes of Janes walk to be ``Source`` and ``Sink`` (names which ``cspy`` uses), +We have to transform the network for one compatible with ``cspy``, +as per the `Input Requirements`_. +The following code will convert a city map into a directed graph, +rename the start/end nodes of Janes walk to be ``Source`` and ``Sink`` (names which ``cspy`` uses), and calculate the specifics of Jane's walk (figuring out travel time, adding buildings/sights, etc). .. code-block:: python @@ -229,7 +236,7 @@ and calculate the specifics of Jane's walk (figuring out travel time, adding bui from networkx import DiGraph from jpath_preprocessing import relabel_source_sink, add_cspy_edge_attributes - # Transform M from networkx.MultiGraph to networkx.DiGraph + # Transform M from networkx.MultiGraph to networkx.DiGraph # This is requirement by the algorithms G = DiGraph(M, directed=True, n_res=4) @@ -243,7 +250,7 @@ and calculate the specifics of Jane's walk (figuring out travel time, adding bui To define the custom REFs, ``jane_REF``, that controls how resources evolve throughout the path, -we require two inputs: an array of current cumulative resource values ``res``, +we require two inputs: an array of current cumulative resource values ``res``, and the edge that is being considered for an extension of a path ``edge`` (which consists of two nodes and the edge data). @@ -251,7 +258,7 @@ and the edge that is being considered for an extension of a path ``edge`` .. code-block:: python from numpy import array - def jane_REF(res, edge): + def jane_REF(res, edge, **kwargs): arr = array(res) # local array i, j, edge_data = edge[:] # unpack edge # i, j : string, edge_data : dict @@ -271,7 +278,7 @@ Hence, each resource is restricted and updated as follows: - ``'sights'`` : the cumulative number of sights visited has a dummy upper bound equal to the number of edges in the graph as there is no restriction to as how many sights Jane visits. Additionally, the value of this resource in the final path, will provide us with the accumulated number of sights in the path; - ``'shift'`` : the cumulative shift time is updated as the travel time along the edge plus the delivery time, the upper bound of ``SHIFT_DURATION`` ensures that Jane doesn't exceed her part-time hours; -- ``'travel-time'`` : the cumulative travel time is updated using the positive distance travelled (``-edge_data['weight']``) over an average walking speed. Given the relationship between this resource and +- ``'travel-time'`` : the cumulative travel time is updated using the positive distance travelled (``-edge_data['weight']``) over an average walking speed. Given the relationship between this resource and - ``'shift'`` : a maximum of the shift duration provides no restriction. - ``'delivery-time'`` : the cumulative delivery time is simply updated using edge data. Similarly as for the previous resource, a maximum of the shift duration provides no restriction. @@ -282,7 +289,7 @@ Using ``cspy``, Jane can obtain a route ``path`` and subject to her constraints from cspy import Tabu SHIFT_DURATION = 5 - n_edges = len(G.edges()) # number of edges in network + n_edges = len(G.edges()) # number of edges in network # Maximum resources max_res = [n_edges, SHIFT_DURATION, SHIFT_DURATION, SHIFT_DURATION] # Minimum resources @@ -306,4 +313,4 @@ Additionally, we can query other useful attributes as .. _Tilk et al 2017: https://www.sciencedirect.com/science/article/pii/S0377221717302035 .. _Inrich 2005: https://www.researchgate.net/publication/227142556_Shortest_Path_Problems_with_Resource_Constraints .. _unittest: https://github.com/torressa/cspy/tree/master/tests/tests_issue32.py -.. _Input Requirements: https://cspy.readthedocs.io/en/latest/how_to.html#input-requirements +.. _Input Requirements: https://cspy.readthedocs.io/en/latest/how_to.html#input-requirements diff --git a/examples/jpath/jpath_preprocessing.py b/examples/jpath/jpath_preprocessing.py index 2d55186..e21914c 100644 --- a/examples/jpath/jpath_preprocessing.py +++ b/examples/jpath/jpath_preprocessing.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, print_function - from numpy import array from numpy.random import RandomState from networkx import relabel_nodes, set_edge_attributes @@ -26,11 +24,15 @@ def relabel_source_sink(G, """ # Identify Source and Sink according to specifications # Source is the post office in Ternatestraat - source = [e for e in G.edges(data=True) - if 'name' in e[2] and nodes_to_relabel["Source"] in e[2]['name']][-2][0] + source = [ + e for e in G.edges(data=True) + if 'name' in e[2] and nodes_to_relabel["Source"] in e[2]['name'] + ][-2][0] # Sink is Jane's home in Ceramstraat - sink = [e for e in G.edges(data=True) - if 'name' in e[2] and nodes_to_relabel["Sink"] in e[2]['name']][0][1] + sink = [ + e for e in G.edges(data=True) + if 'name' in e[2] and nodes_to_relabel["Sink"] in e[2]['name'] + ][0][1] # Relabel nodes G = relabel_nodes(G, {source: 'Source', sink: 'Sink'}) return G diff --git a/examples/vrpy b/examples/vrpy index 54edcff..ed94b88 160000 --- a/examples/vrpy +++ b/examples/vrpy @@ -1 +1 @@ -Subproject commit 54edcffe2b344fb8df5509c4f46d4f7d308e59c4 +Subproject commit ed94b88bd626dce3479dbbe23e10717498f446c3 diff --git a/tests/tests_bidirectional.py b/tests/tests_bidirectional.py index 04344c3..75a7d10 100644 --- a/tests/tests_bidirectional.py +++ b/tests/tests_bidirectional.py @@ -1,11 +1,11 @@ -import sys import unittest +from time import time from networkx import DiGraph from numpy import array +from parameterized import parameterized -sys.path.append("../") -from cspy.algorithms.bidirectional import BiDirectional +from cspy import BiDirectional from cspy.algorithms.label import Label @@ -27,76 +27,126 @@ def setUp(self): self.G.add_edge('B', 'Sink', res_cost=array([1, 2]), weight=10) self.G.add_edge('C', 'Sink', res_cost=array([1, 10]), weight=-1) - def testBiDirectionalBothDynamic(self): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm with dynamic halfway point. - """ - bidirec = BiDirectional(self.G, self.max_res, self.min_res) - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'dynamic' - self.assertRegex(cm.output[0], 'dynamic') - # Check exception for not running first - with self.assertRaises(Exception) as context: - bidirec.path - self.assertTrue("run()" in str(context.exception)) + self.result_path = ['Source', 'A', 'B', 'C', 'Sink'] + self.total_cost = -13 + self.consumed_resources = [4, 15.3] + + @parameterized.expand(zip(range(100), range(100))) + def test_random(self, _, seed): + 'Test method = "random" for a range of seeds' + alg = BiDirectional(self.G, self.max_res, self.min_res, seed=seed) # Run and test results - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - self.assertEqual(path, ['Source', 'A', 'B', 'C', 'Sink']) - self.assertEqual(cost, -13) - self.assertTrue(all(total_res == [4, 15.3])) - - def testBiDirectionalForward(self): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm with only forward direction. - """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - direction='forward') - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'forward' - self.assertRegex(cm.output[0], 'forward') - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - self.assertEqual(path, ['Source', 'A', 'B', 'C', 'Sink']) - self.assertEqual(cost, -13) - self.assertTrue(all(total_res == [4, 15.3])) - - def testBiDirectionalBackward(self): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm with only backward direction. - """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - direction='backward') - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'backward' - self.assertRegex(cm.output[0], 'backward') + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_generated(self): + 'Test method = "generated"' + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="generated") + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_processed(self): + 'Test method = "processed"' + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="processed") + alg.run() + path = alg.path + cost = alg.total_cost + total_res = alg.consumed_resources + self.assertEqual(path, self.result_path) + self.assertEqual(cost, self.total_cost) + self.assertTrue(all(total_res == self.consumed_resources)) + + def test_unprocessed(self): + 'Test method = "unprocessed"' + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="unprocessed") + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_unprocessed_time_limit(self): + 'Test time_limit parameter' + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="unprocessed", + time_limit=0.001) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001 + 1e-3) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_unprocessed_threshold(self): + 'Test threshold parameter' + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="unprocessed", + threshold=0) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_unprocessed_time_limit_threshold(self): + 'Test time_limit and threshold parameters' + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="unprocessed", + time_limit=0.001, + threshold=0) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001 + 1e-3) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_time_limit_raises(self): + 'Time limit of 0 raises an exception' + alg = BiDirectional(self.G, self.max_res, self.min_res, time_limit=0) + alg.run() + with self.assertRaises(Exception) as context: + alg.path + + def test_forward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='forward') + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_backward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='backward') # Check path - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - self.assertEqual(path, ['Source', 'A', 'B', 'C', 'Sink']) - self.assertEqual(cost, -13) - self.assertTrue(all(total_res == [4, 15.3])) - - def testDominance(self): + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_dominance(self): # Check forward and backward label dominance L1 = Label(10, 'B', array([6, 5]), []) L2 = Label(1, 'B', array([6, -3]), []) @@ -105,11 +155,7 @@ def testDominance(self): self.assertTrue(L2.dominates(L1, "forward")) self.assertTrue(L3.dominates(L4, "forward")) - def testInputExceptions(self): + def test_input_exceptions(self): # Check whether wrong input raises exceptions self.assertRaises(Exception, BiDirectional, self.G, 'x', [1, 'foo'], 'up') - - -if __name__ == '__main__': - unittest.main(TestsBiDirectional()) diff --git a/tests/tests_grasp.py b/tests/tests_grasp.py index b6a77c9..ddf7db8 100644 --- a/tests/tests_grasp.py +++ b/tests/tests_grasp.py @@ -43,26 +43,22 @@ def setUp(self): self.G.add_edge('F', 'Sink', res_cost=array([10, 1]), weight=1) self.G.add_edge('E', 'Sink', res_cost=array([1, 1]), weight=1) - def testGRASP(self): - grasp = GRASP(self.G, - self.max_res, - self.min_res, - max_iter=50, - max_localiter=10) - # Check exception for not running first - with self.assertRaises(Exception) as context: - grasp.path - self.assertTrue("run()" in str(context.exception)) - # Run and test results - grasp.run() - path = grasp.path - cost = grasp.total_cost - total_res = grasp.consumed_resources - self.assertEqual(path, ['Source', 'A', 'C', 'D', 'E', 'Sink']) - self.assertEqual(cost, 3) - self.assertTrue(all(total_res == [5, 5])) + self.result_path = ['Source', 'A', 'C', 'D', 'E', 'Sink'] + self.total_cost = 3 + self.consumed_resources = [5, 5] - def testInputExceptions(self): + def test(self): + alg = GRASP(self.G, + self.max_res, + self.min_res, + max_iter=50, + max_localiter=10) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_input_exceptions(self): # Check whether wrong input raises exceptions self.assertRaises(Exception, GRASP, self.G, 'x', [1, 'foo'], 'maxnumber') diff --git a/tests/tests_greedy_elimination.py b/tests/tests_greedy_elimination.py new file mode 100644 index 0000000..039699e --- /dev/null +++ b/tests/tests_greedy_elimination.py @@ -0,0 +1,125 @@ +import unittest +from time import time + +from networkx import DiGraph +from numpy import array +from parameterized import parameterized + +from cspy import GreedyElim + + +class TestsGreedyElimination(unittest.TestCase): + """ + Tests for finding the resource constrained shortest + path of simple DiGraph using the BiDirectional algorithm. + Includes algorithm classification, and some exception handling. + """ + + def setUp(self): + # Maximum and minimum resource arrays + self.max_res, self.min_res = [4, 20], [0, 0] + # Create simple digraph with appropriate attributes + self.G = DiGraph(directed=True, n_res=2) + self.G.add_edge('Source', 'A', res_cost=array([1, 2]), weight=-1) + self.G.add_edge('A', 'B', res_cost=array([1, 0.3]), weight=-1) + self.G.add_edge('B', 'C', res_cost=array([1, 3]), weight=-10) + self.G.add_edge('B', 'Sink', res_cost=array([1, 2]), weight=10) + self.G.add_edge('C', 'Sink', res_cost=array([1, 10]), weight=-1) + + self.result_path = ['Source', 'A', 'B', 'C', 'Sink'] + self.total_cost = -13 + self.consumed_resources = [4, 15.3] + + def test_simple(self): + 'algorithm = "simple" (default)' + alg = GreedyElim(self.G, self.max_res, self.min_res) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar(self): + 'algorithm = "astar"' + alg = GreedyElim(self.G, self.max_res, self.min_res, algorithm="astar") + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_time_limit(self): + 'time limit parameter' + alg = GreedyElim(self.G, self.max_res, self.min_res, time_limit=0.001) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_threshold(self): + 'test threshold parameter' + alg = GreedyElim(self.G, self.max_res, self.min_res, threshold=100) + alg.run() + self.assertEqual(alg.path, ["Source", "A", "B", "Sink"]) + self.assertEqual(alg.total_cost, 8) + self.assertTrue(all(alg.consumed_resources == [3, 4.3])) + + def test_time_limit_threshold(self): + 'time limit and threshold parameters' + alg = GreedyElim(self.G, + self.max_res, + self.min_res, + time_limit=0.001, + threshold=100) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, ["Source", "A", "B", "Sink"]) + self.assertEqual(alg.total_cost, 8) + self.assertTrue(all(alg.consumed_resources == [3, 4.3])) + + def test_astar_time_limit(self): + 'time limit parameter' + alg = GreedyElim(self.G, + self.max_res, + self.min_res, + algorithm="astar", + time_limit=0.001) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar_threshold(self): + 'test threshold parameter' + alg = GreedyElim(self.G, + self.max_res, + self.min_res, + algorithm="astar", + threshold=100) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar_time_limit_threshold(self): + 'time limit and threshold parameters' + alg = GreedyElim(self.G, + self.max_res, + self.min_res, + algorithm="astar", + time_limit=0.001, + threshold=100) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_time_limit_raises(self): + 'Time limit of 0 raises an exception' + alg = GreedyElim(self.G, self.max_res, self.min_res, time_limit=0) + self.assertRaises(Exception, alg.run) diff --git a/tests/tests_greedyelim.py b/tests/tests_greedyelim.py deleted file mode 100644 index 069fa01..0000000 --- a/tests/tests_greedyelim.py +++ /dev/null @@ -1 +0,0 @@ -# TODO Write tests \ No newline at end of file diff --git a/tests/tests_issue17.py b/tests/tests_issue17.py index d6f8458..6119f00 100644 --- a/tests/tests_issue17.py +++ b/tests/tests_issue17.py @@ -1,4 +1,3 @@ -import sys import unittest from networkx import DiGraph @@ -6,9 +5,7 @@ from parameterized import parameterized -sys.path.append("../") from cspy.algorithms.tabu import Tabu -from cspy.algorithms.label import Label from cspy.algorithms.bidirectional import BiDirectional @@ -33,122 +30,57 @@ def setUp(self): self.G.add_edge(4, 2, weight=3, res_cost=array([1, 1])) self.G.add_edge(4, "Sink", weight=3, res_cost=array([1, 1])) # Maximum and minimum resource arrays - self.max_res, self.min_res = [len(self.G.edges()), 6], [0, 0] + self.max_res, self.min_res = [len(self.G.nodes()), 6], [0, 0] + # Expected results + self.result_path = ['Source', 2, 5, 'Sink'] + self.total_cost = 1 + self.consumed_resources = [3, 3] @parameterized.expand(zip(range(100), range(100))) - def testBiDirectionalBothDynamic(self, _, seed): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm for a range of seeds. - Note the first argument is required to work using parameterized and unittest. - """ - bidirec = BiDirectional(self.G, self.max_res, self.min_res, seed=seed) - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'dynamic' - self.assertRegex(cm.output[0], 'dynamic') - - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path and other attributes - self.assertEqual(path, ['Source', 2, 5, 'Sink']) - self.assertEqual(cost, 1) - self.assertTrue(all(total_res == [3, 3])) - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) - self.assertEqual(self.max_res, [len(self.G.edges()), 6]) - self.assertEqual(self.min_res, [0, 0]) - - def testBiDirectionalForward(self): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm with only forward direction. - """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - direction='forward') - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'forward' - self.assertRegex(cm.output[0], 'forward') - - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path and other attributes - self.assertEqual(path, ['Source', 2, 5, 'Sink']) - self.assertEqual(cost, 1) - self.assertTrue(all(total_res == [3, 3])) - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) - self.assertEqual(self.max_res, [len(self.G.edges()), 6]) - self.assertEqual(self.min_res, [0, 0]) - - def testBiDirectionalBackward(self): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm with only backward direction. - """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - direction='backward') - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'backward' - self.assertRegex(cm.output[0], 'backward') - - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path and other attributes - self.assertEqual(path, ['Source', 2, 5, 'Sink']) - self.assertEqual(cost, 1) - self.assertTrue(all(total_res == [3, 3])) - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) - self.assertEqual(self.max_res, [len(self.G.edges()), 6]) - self.assertEqual(self.min_res, [0, 0]) - - def testDominance(self): - # Check forward and backward label dominance - L1 = Label(0, 1, array([1, 1]), []) - L2 = Label(-1, 1, array([1, 1]), []) - L3 = Label(-10, 2, array([1, 1]), []) - L4 = Label(-10, 2, array([0, 1]), []) - L5 = Label(0, 2, array([1, 1]), []) - self.assertTrue(L2.dominates(L1, "forward")) - self.assertRaises(Exception, L1.dominates, L3) - self.assertFalse(L3.dominates(L4, "forward")) - self.assertTrue(L4.dominates(L3, "forward")) - self.assertTrue(L3.dominates(L5, "forward")) - - def testTabu(self): - tabu = Tabu(self.G, self.max_res, self.min_res) - tabu.run() - path = tabu.path - cost_tabu = tabu.total_cost - total_res = tabu.consumed_resources - cost = sum(edge[2]['weight'] for edge in self.G.edges(data=True) - if edge[0:2] in zip(path, path[1:])) - # Check new cost attribute - self.assertEqual(cost, cost_tabu) - self.assertTrue(all(total_res == [3, 3])) - self.assertEqual(path, ['Source', 2, 5, 'Sink']) - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) - self.assertEqual(self.max_res, [len(self.G.edges()), 6]) - self.assertEqual(self.min_res, [0, 0]) - - def testInputExceptions(self): - # Check whether wrong input raises exceptions - self.assertRaises(Exception, BiDirectional, self.G, 'x', [1, 'foo'], - 'up') - - -if __name__ == '__main__': - unittest.main(TestsIssue17()) + def test_bidirectional_random(self, _, seed): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + seed=seed, + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) + + def test_bidirectional_forward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='forward', + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) + + def test_bidirectional_backward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='backward', + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) + + def test_tabu(self): + alg = Tabu(self.G, self.max_res, self.min_res) + alg.run() + self.assertEqual(alg.total_cost, 1) + self.assertEqual(alg.path, ['Source', 2, 5, 4, 'Sink']) + self.assertTrue(all(alg.consumed_resources == [4, 4])) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) diff --git a/tests/tests_issue20.py b/tests/tests_issue20.py index fb0a700..e35312a 100644 --- a/tests/tests_issue20.py +++ b/tests/tests_issue20.py @@ -1,18 +1,15 @@ -import sys import unittest from numpy import array from networkx import DiGraph from parameterized import parameterized -sys.path.append("../") from cspy.algorithms.tabu import Tabu from cspy.algorithms.bidirectional import BiDirectional class TestsIssue20(unittest.TestCase): - """ - Tests for issue #20 + """Tests for issue #20 https://github.com/torressa/cspy/issues/20 """ @@ -27,42 +24,56 @@ def setUp(self): self.G.add_edge(3, "Sink", weight=-10, res_cost=array([1, 0])) self.G.add_edge(3, 2, weight=-5, res_cost=array([1, 1])) self.G.add_edge(2, 1, weight=-10, res_cost=array([1, 1])) - + # Maximum and minimum resource arrays self.max_res, self.min_res = [len(self.G.edges()), 2], [0, 0] + # Expected results + self.result_path = ['Source', 2, 1, 'Sink'] + self.total_cost = -10 + self.consumed_resources = [3, 2] @parameterized.expand(zip(range(100), range(100))) - def testBiDirectional(self, _, seed): + def test_bidirectional_random(self, _, seed): """ - Find shortest path of simple test digraph using BiDirectional + Test BiDirectional with randomly chosen sequence of directions + for a range of seeds. """ - bidirec = BiDirectional(self.G, self.max_res, self.min_res, seed=seed) - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path - self.assertEqual(path, ['Source', 2, 1, 'Sink']) - # Check attributes - self.assertEqual(cost, -10) - self.assertTrue(all(total_res == [3, 2])) - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + seed=seed, + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) - def testTabu(self): + def test_bidirectional_generated(self): """ - Find shortest path of simple test digraph using Tabu + Test BiDirectional with the search direction chosen by the number of + direction with lowest number of generated labels. """ - tabu = Tabu(self.G, self.max_res, self.min_res) - tabu.run() - path = tabu.path - cost = tabu.total_cost - total_res = tabu.consumed_resources - # Check attributes - self.assertEqual(cost, -5) - self.assertTrue(all(total_res == [3, 2])) - self.assertEqual(path, ['Source', 3, 2, 'Sink']) - # Check path - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) - + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="generated", + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) -if __name__ == '__main__': - unittest.main(TestsIssue20()) + def test_tabu(self): + """ + Find shortest path of using Tabu + """ + alg = Tabu(self.G, self.max_res, self.min_res) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) diff --git a/tests/tests_issue22.py b/tests/tests_issue22.py index fef3e6a..d858d61 100644 --- a/tests/tests_issue22.py +++ b/tests/tests_issue22.py @@ -1,4 +1,3 @@ -import sys import unittest from numpy import array @@ -6,7 +5,6 @@ from networkx import DiGraph, astar_path from parameterized import parameterized -sys.path.append("../") from cspy.algorithms.tabu import Tabu from cspy.algorithms.label import Label from cspy.algorithms.bidirectional import BiDirectional @@ -29,110 +27,82 @@ def setUp(self): self.G.add_edge(3, "Sink", weight=-10, res_cost=array([1, 0])) self.G.add_edge(3, 2, weight=-5, res_cost=array([1, 1])) self.G.add_edge(2, 1, weight=-10, res_cost=array([1, 1])) - + # Maximum and minimum resource arrays self.max_res, self.min_res = [len(self.G.edges()), 2], [0, 0] + # Expected results + self.result_path = ['Source', 2, 1, 'Sink'] + self.total_cost = -10 + self.consumed_resources = [3, 2] - def testDominance(self): - # Check forward and backward label dominance - L1 = Label(-10, "Sink", array([3, 0]), []) - L2 = Label(0, "Sink", array([1, 0]), []) - - self.assertFalse(L1.dominates(L2, "forward")) - self.assertFalse(L2.dominates(L1, "forward")) - self.assertTrue(L1.dominates(L2, "backward")) - self.assertFalse(L2.dominates(L1, "backward")) - - if not (L1.dominates(L2, "forward") or L2.dominates(L1, "forward")): - self.assertTrue(L1.dominates(L2, "backward")) - - def testTabu(self): - """ - Find shortest path of simple test digraph using Tabu. + def test_tabu(self): + """Find shortest path of simple test digraph using Tabu. """ - tabu = Tabu(self.G, self.max_res, self.min_res) - tabu.run() - path = tabu.path - cost = tabu.total_cost - total_res = tabu.consumed_resources - # Check attributes - self.assertEqual(cost, -5) - self.assertTrue(all(total_res == [3, 2])) - # Check path - self.assertEqual(path, ['Source', 3, 2, 'Sink']) - self.assertTrue(all(e in self.G.edges() for e in zip(path, path[1:]))) - # Check if networkx's astar_path gives the same path + alg = Tabu(self.G, self.max_res, self.min_res) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + self.assertTrue( + all(e in self.G.edges() for e in zip(alg.path, alg.path[1:]))) + # Check networkx's astar_path path_star = astar_path(self.G, "Source", "Sink") self.assertEqual(path_star, ['Source', 1, 'Sink']) @parameterized.expand(zip(range(100), range(100))) - def testBiDirectionalBothDynamic(self, _, seed): + def test_bidirectional_random(self, _, seed): """ - Find shortest path of simple test digraph using BiDirectional. + Test BiDirectional with randomly chosen sequence of directions + for a range of seeds. """ - bidirec = BiDirectional(self.G, self.max_res, self.min_res, seed=seed) - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'dynamic' - self.assertRegex(cm.output[0], 'dynamic') - - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path and other attributes - self.assertEqual(path, ['Source', 2, 1, 'Sink']) - self.assertEqual(cost, -10) - self.assertTrue(all(total_res == [3, 2])) - - def testBiDirectionalForward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + seed=seed, + elementary=False) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_bidirectional_forward(self): """ - Find shortest path of simple test digraph using BiDirectional - algorithm with only forward direction. + Find shortest path using BiDirectional algorithm with only forward + direction. """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - direction='forward') - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'forward' - self.assertRegex(cm.output[0], 'forward') - - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path and other attributes - self.assertEqual(path, ['Source', 2, 1, 'Sink']) - self.assertEqual(cost, -10) - self.assertTrue(all(total_res == [3, 2])) - - def testBiDirectionalBackward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='forward', + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_bidirectional_backward(self): """ Find shortest path of simple test digraph using BiDirectional algorithm with only backward direction. """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - direction='backward') - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'forward' - self.assertRegex(cm.output[0], 'backward') - - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - # Check path and other attributes - self.assertEqual(path, ['Source', 2, 1, 'Sink']) - self.assertEqual(cost, -10) - self.assertTrue(all(total_res == [3, 2])) + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='backward', + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_dominance(self): + # Check forward and backward label dominance + L1 = Label(-10, "Sink", array([3, 0]), []) + L2 = Label(0, "Sink", array([1, 0]), []) + self.assertFalse(L1.dominates(L2, "forward")) + self.assertFalse(L2.dominates(L1, "forward")) + self.assertTrue(L1.dominates(L2, "backward")) + self.assertFalse(L2.dominates(L1, "backward")) -if __name__ == '__main__': - unittest.main(TestsIssue22()) + if not (L1.dominates(L2, "forward") or L2.dominates(L1, "forward")): + self.assertTrue(L1.dominates(L2, "backward")) diff --git a/tests/tests_issue25.py b/tests/tests_issue25.py index 6414d7a..184e493 100644 --- a/tests/tests_issue25.py +++ b/tests/tests_issue25.py @@ -1,14 +1,9 @@ -import sys import unittest from numpy import array -from random import randint -from networkx import DiGraph, astar_path +from networkx import DiGraph from parameterized import parameterized -sys.path.append("../") -from cspy.algorithms.tabu import Tabu -from cspy.algorithms.label import Label from cspy.algorithms.bidirectional import BiDirectional @@ -28,33 +23,19 @@ def setUp(self): self.G.add_edge('B', 'C', res_cost=array([1, 3]), weight=-10) self.G.add_edge('B', 'Sink', res_cost=array([1, 2]), weight=10) self.G.add_edge('C', 'Sink', res_cost=array([1, 10]), weight=-1) + # Expected results + self.result_path = ['Source', 'A', 'B', 'C', 'Sink'] + self.total_cost = -13 + self.consumed_resources = [4, 15.3] @parameterized.expand(zip(range(100), range(100))) - def testBiDirectionalBothDynamic(self, _, seed): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm for a range of seeds. - Note the first argument is required to work using parameterized and unittest. - """ - bidirec = BiDirectional(self.G, self.max_res, self.min_res, seed=seed) - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'dynamic' - self.assertRegex(cm.output[0], 'dynamic') - # Check exception for not running first - with self.assertRaises(Exception) as context: - bidirec.path - self.assertTrue("run()" in str(context.exception)) - # Run and test results - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - self.assertEqual(path, ['Source', 'A', 'B', 'C', 'Sink']) - self.assertEqual(cost, -13) - self.assertTrue(all(total_res == [4, 15.3])) - - -if __name__ == '__main__': - unittest.main(TestsIssue25()) + def test_bidirectional_random(self, _, seed): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + seed=seed, + elementary=True) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) diff --git a/tests/tests_issue32.py b/tests/tests_issue32.py index 41c0297..3d73025 100644 --- a/tests/tests_issue32.py +++ b/tests/tests_issue32.py @@ -1,13 +1,8 @@ -import sys import unittest from numpy import array -from random import randint -from networkx import DiGraph, astar_path +from networkx import DiGraph -sys.path.append("../") -from cspy.algorithms.tabu import Tabu -from cspy.algorithms.label import Label from cspy.algorithms.bidirectional import BiDirectional from parameterized import parameterized @@ -30,8 +25,12 @@ def setUp(self): self.G.add_edge(2, 4, res_cost=array([0, 1, 0]), weight=-10) self.G.add_edge(3, 4, res_cost=array([0, 1, 0]), weight=-10) self.G.add_edge(4, 'Sink', res_cost=array([0, 0, 0]), weight=-1) + # Expected results + self.result_path = ['Source', 1, 2, 3, 4, 'Sink'] + self.total_cost = -23 + self.consumed_resources = [5, 30, 1] - def custom_REF_forward(self, cumulative_res, edge): + def custom_REF_forward(self, cumulative_res, edge, **kwargs): res_new = array(cumulative_res) # Unpack edge u, v, edge_data = edge[0:3] @@ -46,7 +45,7 @@ def custom_REF_forward(self, cumulative_res, edge): res_new[2] += edge_data["res_cost"][1] return res_new - def custom_REF_backward(self, cumulative_res, edge): + def custom_REF_backward(self, cumulative_res, edge, **kwargs): res_new = array(cumulative_res) # Unpack edge u, v, edge_data = edge[0:3] @@ -56,42 +55,23 @@ def custom_REF_backward(self, cumulative_res, edge): if v == "Sink": res_new[1] = res_new[1] else: - res_new[1] -= int(v)**2 + res_new[1] += int(v)**2 # Resource reset - res_new[2] -= edge_data["res_cost"][1] + res_new[2] += edge_data["res_cost"][1] return res_new @parameterized.expand(zip(range(100), range(100))) - def testBiDirectionalBothDynamic(self, _, seed): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm for a range of seeds. - Note the first argument is required to work using parameterized and unittest. + def test_bidirectional_random(self, _, seed): + """Test BiDirectional with randomly chosen sequence of directions + for a range of seeds. """ - bidirec = BiDirectional(self.G, - self.max_res, - self.min_res, - REF_forward=self.custom_REF_forward, - REF_backward=self.custom_REF_backward, - seed=seed) - # Check classification - with self.assertLogs('cspy.algorithms.bidirectional') as cm: - bidirec.name_algorithm() - # Log should contain the word 'dynamic' - self.assertRegex(cm.output[0], 'dynamic') - # Check exception for not running first - with self.assertRaises(Exception) as context: - bidirec.path - self.assertTrue("run()" in str(context.exception)) - # Run and test results - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - self.assertEqual(path, ['Source', 1, 2, 3, 4, 'Sink']) - self.assertEqual(cost, -23) - self.assertTrue(all(total_res == [5, 30, 1])) - - -if __name__ == '__main__': - unittest.main(TestsIssue32()) + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + REF_forward=self.custom_REF_forward, + REF_backward=self.custom_REF_backward, + seed=seed) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) diff --git a/tests/tests_issue38.py b/tests/tests_issue38.py index 27a7a96..0f53db6 100644 --- a/tests/tests_issue38.py +++ b/tests/tests_issue38.py @@ -1,10 +1,8 @@ -import sys import unittest from numpy import array from networkx import DiGraph -sys.path.append("../") from cspy.algorithms.bidirectional import BiDirectional @@ -23,22 +21,9 @@ def setUp(self): self.G.add_edge("Source", "A", res_cost=array([1, 2]), weight=0) self.G.add_edge("A", "Sink", res_cost=array([1, 10]), weight=0) - def testBiDirectionalBothDynamic(self): - """ - Find shortest path of simple test digraph using the BiDirectional - algorithm for a range of seeds. - Note the first argument is required to work using parameterized and unittest. - """ - bidirec = BiDirectional(self.G, self.max_res, self.min_res) - # Run and test results - bidirec.run() - path = bidirec.path - cost = bidirec.total_cost - total_res = bidirec.consumed_resources - self.assertEqual(path, ['Source', "A", 'Sink']) - self.assertEqual(cost, 0) - self.assertTrue(all(total_res == [2, 12])) - - -if __name__ == '__main__': - unittest.main(TestsIssue38()) + def test_bidirectional_random(self): + alg = BiDirectional(self.G, self.max_res, self.min_res) + alg.run() + self.assertEqual(alg.path, ['Source', "A", 'Sink']) + self.assertEqual(alg.total_cost, 0) + self.assertTrue(all(alg.consumed_resources == [2, 12])) diff --git a/tests/tests_issue41.py b/tests/tests_issue41.py new file mode 100644 index 0000000..fc579ef --- /dev/null +++ b/tests/tests_issue41.py @@ -0,0 +1,66 @@ +import unittest + +from numpy import array +from networkx import DiGraph +from parameterized import parameterized + +from cspy import BiDirectional + + +class TestsIssue41(unittest.TestCase): + """ + Tests for issue #41 + https://github.com/torressa/cspy/issues/41 + """ + + def setUp(self): + # Maximum and minimum resource arrays + self.max_res, self.min_res = [3, 3], [0, 3] + # Create simple digraph with appropriate attributes + self.G = DiGraph(directed=True, n_res=2) + self.G.add_edge("Source", "A", res_cost=array([1, 1]), weight=10) + self.G.add_edge("A", "B", res_cost=array([1, 0]), weight=3) + self.G.add_edge("A", "C", res_cost=array([1, 1]), weight=10) + self.G.add_edge("B", "C", res_cost=array([1, 0]), weight=3) + self.G.add_edge("B", "Sink", res_cost=array([1, 1]), weight=5) + self.G.add_edge("C", "Sink", res_cost=array([1, 1]), weight=0) + # Expected results + self.result_path = ['Source', 'A', 'C', 'Sink'] + self.total_cost = 20 + self.consumed_resources = [3, 3] + + @parameterized.expand(zip(range(100), range(100))) + def test_bidirectional_random(self, _, seed): + """ + Test BiDirectional with randomly chosen sequence of directions + for a range of seeds. + """ + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + method="random", + seed=seed) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_bidirectional_forward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='forward') + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_bidirectional_backward(self): + alg = BiDirectional(self.G, + self.max_res, + self.min_res, + direction='backward') + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) diff --git a/tests/tests_tabu.py b/tests/tests_tabu.py index 5dade44..5b6ce89 100644 --- a/tests/tests_tabu.py +++ b/tests/tests_tabu.py @@ -1,10 +1,9 @@ -import sys import unittest +from time import time from networkx import DiGraph from numpy import array -sys.path.append("../") from cspy.algorithms.tabu import Tabu @@ -13,52 +12,110 @@ class TestsTabu(unittest.TestCase): Tests for finding the resource constrained shortest path of simple DiGraph using the Tabu algorithm. """ + def setUp(self): - self.max_res, self.min_res = [5, 5], [0, 0] - # Create digraph with a resource infeasible minimum cost path + # Maximum and minimum resource arrays + self.max_res, self.min_res = [4, 20], [0, 0] + # Create simple digraph with appropriate attributes self.G = DiGraph(directed=True, n_res=2) - self.G.add_edge('Source', 'A', res_cost=array([1, 1]), weight=1) - self.G.add_edge('Source', 'B', res_cost=array([1, 1]), weight=1) - # Resource infeasible edge - self.G.add_edge('Source', 'C', res_cost=array([10, 1]), weight=10) - self.G.add_edge('A', 'C', res_cost=array([1, 1]), weight=1) - # Resource infeasible edge - self.G.add_edge('A', 'E', res_cost=array([10, 1]), weight=10) - # Resource infeasible edge - self.G.add_edge('A', 'F', res_cost=array([10, 1]), weight=10) - self.G.add_edge('B', 'C', res_cost=array([2, 1]), weight=-1) - # Resource infeasible edge - self.G.add_edge('B', 'F', res_cost=array([10, 1]), weight=10) - # Resource infeasible edge - self.G.add_edge('B', 'E', res_cost=array([10, 1]), weight=10) - self.G.add_edge('C', 'D', res_cost=array([1, 1]), weight=-1) - self.G.add_edge('D', 'E', res_cost=array([1, 1]), weight=1) - self.G.add_edge('D', 'F', res_cost=array([1, 1]), weight=1) - # Resource infeasible edge - self.G.add_edge('D', 'Sink', res_cost=array([10, 10]), weight=10) - # Resource infeasible edge - self.G.add_edge('F', 'Sink', res_cost=array([10, 1]), weight=1) - self.G.add_edge('E', 'Sink', res_cost=array([1, 1]), weight=1) - - def testTabu(self): - tabu = Tabu(self.G, self.max_res, self.min_res) - # Check exception for not running first - with self.assertRaises(Exception) as context: - tabu.path - self.assertTrue("run()" in str(context.exception)) - # Run and test results - tabu.run() - path = tabu.path - cost = tabu.total_cost - total_res = tabu.consumed_resources - self.assertEqual(path, ['Source', 'A', 'C', 'D', 'E', 'Sink']) - self.assertEqual(cost, 3) - self.assertTrue(all(total_res == [5, 5])) + self.G.add_edge('Source', 'A', res_cost=array([1, 2]), weight=-1) + self.G.add_edge('A', 'B', res_cost=array([1, 0.3]), weight=-1) + self.G.add_edge('B', 'C', res_cost=array([1, 3]), weight=-10) + self.G.add_edge('B', 'Sink', res_cost=array([1, 2]), weight=10) + self.G.add_edge('C', 'Sink', res_cost=array([1, 10]), weight=-1) + + self.result_path = ['Source', 'A', 'B', 'C', 'Sink'] + self.total_cost = -13 + self.consumed_resources = [4, 15.3] + + def test_simple(self): + alg = Tabu(self.G, self.max_res, self.min_res) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar(self): + alg = Tabu(self.G, self.max_res, self.min_res, algorithm="astar") + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_time_limit(self): + alg = Tabu(self.G, self.max_res, self.min_res, time_limit=0.001) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_threshold(self): + alg = Tabu(self.G, self.max_res, self.min_res, threshold=100) + alg.run() + self.assertEqual(alg.path, ["Source", "A", "B", "Sink"]) + self.assertEqual(alg.total_cost, 8) + self.assertTrue(all(alg.consumed_resources == [3, 4.3])) + + def test_time_limit_threshold(self): + alg = Tabu(self.G, + self.max_res, + self.min_res, + time_limit=0.001, + threshold=0) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar_time_limit(self): + 'time limit parameter' + alg = Tabu(self.G, + self.max_res, + self.min_res, + algorithm="astar", + time_limit=0.001) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar_threshold(self): + 'test threshold parameter' + alg = Tabu(self.G, + self.max_res, + self.min_res, + algorithm="astar", + threshold=100) + alg.run() + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_astar_time_limit_threshold(self): + 'time limit and threshold parameters' + alg = Tabu(self.G, + self.max_res, + self.min_res, + algorithm="astar", + time_limit=0.001, + threshold=100) + start = time() + alg.run() + self.assertTrue(time() - start <= 0.001) + self.assertEqual(alg.path, self.result_path) + self.assertEqual(alg.total_cost, self.total_cost) + self.assertTrue(all(alg.consumed_resources == self.consumed_resources)) + + def test_time_limit_raises(self): + alg = Tabu(self.G, self.max_res, self.min_res, time_limit=0) + self.assertRaises(Exception, alg.run) def testInputExceptions(self): # Check whether wrong input raises exceptions self.assertRaises(Exception, Tabu, self.G, 'x', [1, 'foo'], 'up') - - -if __name__ == '__main__': - unittest.main()