diff --git a/.gitignore b/.gitignore index b6e4761..c6dc0ce 100644 --- a/.gitignore +++ b/.gitignore @@ -83,7 +83,8 @@ ipython_config.py # pyenv .python-version - +.idea +*.png # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies diff --git a/README.md b/README.md index a53d95c..5302808 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,38 @@ # Random Walk Simulation -This is a **group exercise**, so you should be working in pairs of two students. It's **30% of your final grade**. +## Implementation +The application is written in Python3 using Shapely to generate a Playground for multiple Random-Walkers. +Obstacles and borders are implemented as shapely polygons limiting the walking ranges of the walkers. -The Goal is to **practise writing readable, maintainable and reliable code collaboratively.** +Organized in 2 Modules: -## Group Exercise +- random_walker: Contains the default Random-Walker and different extensions, organized in subclasses. Each subclass follows specific rules when walking randomly over the Playground. Most subclasses are chess-inspired and follow the basic movement rules for five of the six stones on the board (sadly there is no Knight...). +- playground: Defining the area, in which the Walker is allowed to "play". A playground is seed-generated s.t. different obstacels can be implemented, while the default map simply spans an quadratic area that can be scaled via initial argument. -1. One student of your group forks the code from [https://github.com/advanced-geoscripting-2021/random_walker.git](https://github.com/advanced-geoscripting-2021/random_walker.git) +## Application -2. This student invites the other student as a collaborator to the forked repository. Now you can both work on the code. +When running our Random-Walker-Application, a number of randomly chosen walkers will walk a set number of steps following their spefific rules. The resulting paths are drawn and returned as a plot. +For the execution of main.py you can set the following flags (if no flags are set during execution, default parameters are set for a number of 2 walkers on the default playground with scaling factor 4 -> playground size: 1000 x 1000): +- -h : show help message and how to use it +- -w : number of walkers {1...Inf} +- -n : space separated list of walker names to choose from {Rook,King,Bishop,Queen,Pawn} +- -ls: set playground scale factor {1...Inf} +- -ps : set playground/map generation seed (-> currently only one other playground available (seed=1) containing a hole, representing a lake) {0,1} +- -s : number of steps per walkers {1...Inf} +- --save : save the simulation to a file -3. Adapt the code to fulfil the requirements (see below). -4. Code review: Each group reviews the code of another group. +## Examples Configurations +![readmefigures/king.png](readmefigures/king.png) -5. Improve your code based on the review you got. +Simulate three Kings: +- python main.py -w 3 -n King +Simulate four walkers, choose from Queen and Pawn: +- python main.py -w 4 -n Queen Pawn -## Write an extended random walk program - -In this repo you find a basic implementation of a [random walk simulation](https://en.wikipedia.org/wiki/Random_walk) in 2-dimensional space taken from [this blogpost](https://www.geeksforgeeks.org/random-walk-implementation-python/). Running the code yields an image which shows the path of the random walk. - -![random_walk](rand_walk_100000.png) - -The program works but it is not very readable. In addition, you should **extend the program based on the requirements listed below. - -**Remember to apply the best practices in scientific computing** to make the code more readable, maintainable, reusable and efficient. - -### Minimum requirements: - -Extend the program so the following requirements are met: - -1. The program should be able to simulate multiple random walkers. -2. The program should be executable from the command line. -3. The user should be able to specify the number of random walkers through a command line parameter. -4. Document the dependencies and instructions of how to run the program in your README.md. - -### Additional requirements: - -1. Create three different types of walkers, e.g. a "fast walker" which has a bigger step size. -2. Add a "landscape" in which the random walkers are walking in which contains obstacles which the walkers cannot cross (e.g. a lake) -3. Invent and implement another functionality of your own. - -Be creative here! :) - -## Code Review - -Review the code of another group: (tuesday afternoon or wednesday morning) - -1. Does it work properly? Try to make it fail! -2. Are the best-practices implemented in the code? -3. Is the documentation clear? -4. Can you adapt the code easily? E.g. try to create a new type of random walker which moves two cells per iteration. +Simulate two random walkers for 10 steps: +- python main.py -w 2 -s 10 +Simulate two random walkers on a map with obstacles: +- python main.py -w 2 -ps 1 diff --git a/main.py b/main.py index 9aac7f0..09943b9 100644 --- a/main.py +++ b/main.py @@ -1,40 +1,61 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """A Random Walk Simulation """ - -# Python code for 2D random walk. -# Source: https://www.geeksforgeeks.org/random-walk-implementation-python/ -import numpy +import argparse +from typing import List import matplotlib.pyplot as plt -import random +import random_walker +from playground import Playground -# defining the number of steps -n = 100000 -# creating two array for containing x and y coordinate -# of size equals to the number of size and filled up with 0's -x = numpy.zeros(n) -y = numpy.zeros(n) +def main(steps: int, walkers: int, save: bool, map_seed: int, land_scale: int, + walker_types: List[str]): + """ + Execute the random walker simulation for a given set of parameters -# filling the coordinates with random variables -for i in range(1, n): - val = random.randint(1, 4) - if val == 1: - x[i] = x[i - 1] + 1 - y[i] = y[i - 1] - elif val == 2: - x[i] = x[i - 1] - 1 - y[i] = y[i - 1] - elif val == 3: - x[i] = x[i - 1] - y[i] = y[i - 1] + 1 - else: - x[i] = x[i - 1] - y[i] = y[i - 1] - 1 + :param steps: number of steps per walker + :param walkers: number of walkers + :param save: save the created figure + :param map_seed: map seed for the playground + :param land_scale: scale for the playground + :param walker_types: specified walker types + :return: + """ + # Create Playground and walkers + playground = Playground(seed=map_seed, scaling=land_scale) + walker_list = random_walker.create_different_walkers(walkers, steps, walker_types) + # Add Playground to plt + plt.title("Random Walk ($n = " + str(steps) + "$ steps)") + for x_positions, y_positions in playground.get_line_segments(): + plt.plot(x_positions, y_positions, color='red') + # for each walker calculate the random walk and add to plt + for walker_index in range(walkers): + walker = walker_list[walker_index] + walker.execute_random_walk(playground) + # plotting the walk + plt.plot(walker.x_positions, + walker.y_positions, + label=str(type(walker).__name__) + ' index: ' + str(walker_index)) + # show legend and plot + plt.legend() + # optional save the plot + if save: + plt.savefig("./rand_walk_{}.png".format(steps)) + plt.show() -# plotting the walk -plt.title("Random Walk ($n = " + str(n) + "$ steps)") -plt.plot(x, y) -plt.savefig("./rand_walk_{}.png".format(n)) -plt.show() \ No newline at end of file +if __name__ == '__main__': + # Parse Arguments + parser = argparse.ArgumentParser(description='Executes and prints some random walkers') + parser.add_argument('-w', '--walkers', type=int, default=3, help='number of walkers') + parser.add_argument('-n', '--names', default=random_walker.get_walker_names(), nargs='+', + choices=random_walker.get_walker_names(), + help='space separated list of names of walker types to choose randomly') + parser.add_argument('-ls', '--landscale', type=int, default=4, help='playground scale') + parser.add_argument('-ps', '--playgroundseed', type=int, default=0, + choices=[0, 1], help='map generation seed') + parser.add_argument('-s', '--steps', type=int, default=100, help='number of steps per walker') + parser.add_argument('--save', action="store_true", help='save figure') + args = parser.parse_args() + # Execute Main + main(args.steps, args.walkers, args.save, args.playgroundseed, args.landscale, args.names) diff --git a/playground.py b/playground.py new file mode 100644 index 0000000..3831bb2 --- /dev/null +++ b/playground.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" Playground class for the random walk simulation """ + +from shapely.geometry import Polygon, Point + + +class Playground: + """Defines a playground with obstacles for RandomWalkers""" + def __init__(self, scaling: int = 1, x_max: int = 250, y_max: int = 250, seed: int = 0): + """ + Create a new Playground + + :param scaling: scale factor for x and y + :param x_max: x range + :param y_max: y range + :param seed: map seed + """ + self.x_max = scaling * x_max + self.y_max = scaling * y_max + x_max = self.x_max + y_max = self.y_max + border_polygon = [ + (-x_max, -y_max), + (-x_max, y_max), + (x_max, y_max), + (x_max, -y_max), + ] + holes = [] + if seed == 1: + moon_lake = [ + (x_max/2.5, y_max/2.5), + (x_max/2.5, -y_max/2.5), + (0, -y_max/2), + (0, -y_max/1.5), + (x_max*2/3, -y_max/1.5), + (x_max*3/4, 0), + (x_max * 1 / 2, y_max * 5 / 6), + (x_max/4, y_max*3/4), + (x_max / 2.5, y_max / 2.5) + ] + holes.append(moon_lake) + ''' In Development: + elif seed == 2: + outer_lake_border = [ + (0, y_max/10), + (x_max/15, y_max/15), + (x_max / 10, 0), + (x_max / 15, -y_max / 15), + (0, -y_max / 10), + (-x_max / 15, -y_max / 15), + (-x_max / 10, 0), + (-x_max / 15, y_max / 15), + (0, y_max/10) + ] + inner_lake_border = [ + (0, y_max/30), + (x_max/25, y_max/25), + (x_max/30, 0), + (x_max/25, -y_max/25), + (0, -y_max/30), + (-x_max/25, -y_max/25), + (-x_max/30, 0), + (-x_max/25, y_max/25), + (0, y_max/30) + ] + holes.append(outer_lake_border) + holes.append(inner_lake_border) + ''' + self.holes = holes + self.shape = Polygon(border_polygon, holes) + + def is_position_in_playground(self, x_position: float, y_position: float) -> bool: + """ + Check, whether the given walker position is valid on the playground + + :param x_position: walker position + :param y_position: walker position + :return: True for a valid position, else False + """ + position = Point((x_position, y_position)) + return self.shape.contains(position) + + def get_line_segments(self) -> list: + """ + Get all parts from the playground in order to print them with py plot + + :return: list of polygon border + """ + result = [] + for hole in self.holes: + poly = Polygon(hole) + result.append(poly.exterior.xy) + result.append(self.shape.exterior.xy) + return result diff --git a/rand_walk_100000.png b/rand_walk_100000.png index 5582dd8..f2a9d88 100644 Binary files a/rand_walk_100000.png and b/rand_walk_100000.png differ diff --git a/random_walker.py b/random_walker.py new file mode 100644 index 0000000..6fba6f4 --- /dev/null +++ b/random_walker.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +"""Base class unit for random walker""" +from enum import Enum +from math import sqrt +from typing import List, Union +import random +import numpy as np +from playground import Playground + +# constant for square root of 2 +SQRT2 = sqrt(2) + + +class WalkDirection(Enum): + """Enum Class with all possible walk directions""" + RIGHT = 1 + LEFT = 2 + UP = 3 + DOWN = 4 + UPRIGHT = 5 + UPLEFT = 6 + DOWNRIGHT = 7 + DOWNLEFT = 8 + + def get_x_factor(self) -> float: + """ + Get the x amount of a walk direction + + :return: x amount + """ + if self.name == 'RIGHT': + return 1 + if self.name == 'LEFT': + return -1 + if self.name == 'UP' or self.name == 'DOWN': + return 0 + if self.name == 'UPRIGHT' or self.name == 'DOWNRIGHT': + return SQRT2 + return -SQRT2 + + def get_y_factor(self) -> float: + """ + Get the y amount of a walk direction + + :return: y amount + """ + if self.name == 'RIGHT' or self.name == 'LEFT': + return 0 + if self.name == 'UP': + return 1 + if self.name == 'DOWN': + return -1 + if self.name == 'UPRIGHT' or self.name == 'UPLEFT': + return SQRT2 + return -SQRT2 + + +class RandomWalker: + """ + Base class for a random walker + The possible walk directions and the step length + can be changed by subclasses + """ + def __init__(self, steps: int = 10000): + """ + Create a new random walker + + :param steps: number of steps + """ + # number of steps + self.steps = steps + # possible walk directions + self.walk_directions: List[WalkDirection] = \ + [WalkDirection.RIGHT, WalkDirection.LEFT, WalkDirection.UP, WalkDirection.DOWN] + # positions at each step + self.x_positions = np.zeros(self.steps, dtype=float) + self.y_positions = np.zeros(self.steps, dtype=float) + # default step length of the walker + self.step_length = 1 + + def get_random_walk_direction(self) -> WalkDirection: + """ + Chooses a random WalkDirection from the walkDirections array + + :return: WalkDirection + """ + return self.walk_directions[random.randint(0, len(self.walk_directions) - 1)] + + def get_walk_step_length(self, walk_direction: WalkDirection) -> Union[int, float]: + """ + Calculates the step length for a given direction. + Override in subclass in order to implement an other behaviour + + :param walk_direction: direction for this step + :return: step length + """ + if walk_direction in self.walk_directions: + return self.step_length + return 0 + + def reset_position(self, index: int): + """ + Reset the current position from index to the previous position + + :param index: current step + :return: None + """ + self.x_positions[index] = self.x_positions[index - 1] + self.y_positions[index] = self.y_positions[index - 1] + + def calculate_position(self, index: int, playground: Playground): + """ + Calculate the next position by randomly select a new walk direction + and moving self.get_walk_step_length() in this direction. + The position will be reset to the previous one if the position is outside the playground + + :param index: step index + :param playground: Playground for the walker + :return: None + """ + walk_direction = self.get_random_walk_direction() + step_length = self.get_walk_step_length(walk_direction) + self.x_positions[index] = \ + self.x_positions[index - 1] + \ + walk_direction.get_x_factor() * step_length + self.y_positions[index] = \ + self.y_positions[index - 1] + \ + walk_direction.get_y_factor() * step_length + if not playground.is_position_in_playground(self.x_positions[index], + self.y_positions[index]): + self.reset_position(index) + + def execute_random_walk(self, playground: Playground, start_x: int = 0, start_y: int = 0): + """ + Execute the random walk calculation for all steps, starting at start_x, + start_y on a playground + + :param playground: Playground for the walker + :param start_x: start position x + :param start_y: start position y + :return: None + """ + self.x_positions[0] = start_x + self.y_positions[0] = start_y + for index in range(1, self.steps): + self.calculate_position(index, playground) + + +def create_different_walkers(count: int, steps: int, walker_types: List[str]) -> List[RandomWalker]: + """ + Walker Factory creating random walkers which were inherited from the class RandomWalker + + :param count: number of walkers + :param steps: steps for the walkers + :param walker_types: specify the possible walker types to choose + :return: Array of Walkers + """ + result = [] + # get subclasses from RandomWalker + types = RandomWalker.__subclasses__() + for _ in range(count): + # pick random subclass + random_type = types[random.randint(0, len(types) - 1)] + while random_type.__name__ not in walker_types: + random_type = types[random.randint(0, len(types) - 1)] + # generate object + random_object = random_type(steps) + # add object to result array + result.append(random_object) + return result + + +def get_walker_names() -> List[str]: + """ + Get all possible walker names from RandomWalker subclasses + + :return: List of Class Names + """ + result = [] + # get subclasses from RandomWalker + types = RandomWalker.__subclasses__() + for class_type in types: + result.append(class_type.__name__) + return result + + +class Rook(RandomWalker): + """Rook Walker, can walk Up, Down, Left, Right, randomly 0 to 20 fields per step""" + def __init__(self, *args): + super().__init__(*args) + + def get_walk_step_length(self, walk_direction: WalkDirection) -> Union[int, float]: + """Override base method to implement a random step length""" + return random.randint(0, 20) + + +class King(RandomWalker): + """King Walker, can walk in each directions, but only one field""" + def __init__(self, *args): + super().__init__(*args) + self.walk_directions = [WalkDirection.DOWNLEFT, WalkDirection.UPLEFT, + WalkDirection.DOWNRIGHT, WalkDirection.UPRIGHT, + WalkDirection.UP, WalkDirection.DOWN, + WalkDirection.RIGHT, WalkDirection.LEFT] + self.step_length = 1 + + +class Bishop(RandomWalker): + """Bishop Walker, can walk diagonal, using random step length between 0 and 20""" + def __init__(self, *args): + super().__init__(*args) + self.walk_directions = [WalkDirection.DOWNLEFT, WalkDirection.UPLEFT, + WalkDirection.DOWNRIGHT, WalkDirection.UPRIGHT] + + def get_walk_step_length(self, walk_direction: WalkDirection) -> Union[int, float]: + """Override base method to implement a random step length""" + return random.randint(0, 20) + + +class Queen(RandomWalker): + """Queen Walker, can walk in each direction, using fixed step length of 20""" + def __init__(self, *args): + super().__init__(*args) + self.walk_directions = [WalkDirection.DOWNLEFT, WalkDirection.UPLEFT, + WalkDirection.DOWNRIGHT, WalkDirection.UPRIGHT, + WalkDirection.UP, WalkDirection.DOWN, + WalkDirection.RIGHT, WalkDirection.LEFT] + self.step_length = 20 + + +class Pawn(RandomWalker): + """Pawn Walker, can walk up or down including diagonals""" + def __init__(self, *args): + super().__init__(*args) + if random.randint(0, 1) == 0: + self.walk_directions = [WalkDirection.UP, WalkDirection.UPLEFT, + WalkDirection.UPRIGHT] + else: + self.walk_directions = [WalkDirection.DOWN, WalkDirection.DOWNLEFT, + WalkDirection.DOWNRIGHT] + self.step_length = 1 diff --git a/readmefigures/king.png b/readmefigures/king.png new file mode 100644 index 0000000..d5edfd6 Binary files /dev/null and b/readmefigures/king.png differ diff --git a/readmefigures/queenpawn.png b/readmefigures/queenpawn.png new file mode 100644 index 0000000..f09f783 Binary files /dev/null and b/readmefigures/queenpawn.png differ diff --git a/readmefigures/rand_walk_10_random.png b/readmefigures/rand_walk_10_random.png new file mode 100644 index 0000000..fde0081 Binary files /dev/null and b/readmefigures/rand_walk_10_random.png differ diff --git a/readmefigures/withobstacles.png b/readmefigures/withobstacles.png new file mode 100644 index 0000000..4c1ddf4 Binary files /dev/null and b/readmefigures/withobstacles.png differ diff --git a/task_specification.md b/task_specification.md new file mode 100644 index 0000000..83908f8 --- /dev/null +++ b/task_specification.md @@ -0,0 +1,53 @@ +# Exercise description +## Group Exercise + +This is a **group exercise**, so you should be working in pairs of two students. It's **30% of your final grade**. + +The Goal is to **practise writing readable, maintainable and reliable code collaboratively.** + +1. One student of your group forks the code from [https://github.com/advanced-geoscripting-2021/random_walker.git](https://github.com/advanced-geoscripting-2021/random_walker.git) + +2. This student invites the other student as a collaborator to the forked repository. Now you can both work on the code. + +3. Adapt the code to fulfil the requirements (see below). + +4. Code review: Each group reviews the code of another group. + +5. Improve your code based on the review you got. + + +## Write an extended random walk program + +In this repo you find a basic implementation of a [random walk simulation](https://en.wikipedia.org/wiki/Random_walk) in 2-dimensional space taken from [this blogpost](https://www.geeksforgeeks.org/random-walk-implementation-python/). Running the code yields an image which shows the path of the random walk. + +![random_walk](rand_walk_100000.png) + +The program works but it is not very readable. In addition, you should **extend the program based on the requirements listed below. + +**Remember to apply the best practices in scientific computing** to make the code more readable, maintainable, reusable and efficient. + +### Minimum requirements: + +Extend the program so the following requirements are met: + +1. The program should be able to simulate multiple random walkers. +2. The program should be executable from the command line. +3. The user should be able to specify the number of random walkers through a command line parameter. +4. Document the dependencies and instructions of how to run the program in your README.md. + +### Additional requirements: + +1. Create three different types of walkers, e.g. a "fast walker" which has a bigger step size. +2. Add a "landscape" in which the random walkers are walking in which contains obstacles which the walkers cannot cross (e.g. a lake) +3. Invent and implement another functionality of your own. + +Be creative here! :) + +## Code Review + +Review the code of another group: (tuesday afternoon or wednesday morning) + +1. Does it work properly? Try to make it fail! +2. Are the best-practices implemented in the code? +3. Is the documentation clear? +4. Can you adapt the code easily? E.g. try to create a new type of random walker which moves two cells per iteration. diff --git a/test_playground.py b/test_playground.py new file mode 100644 index 0000000..cb35a3e --- /dev/null +++ b/test_playground.py @@ -0,0 +1,23 @@ +"""Pytest Unit""" +from playground import Playground +from shapely.geometry import Polygon + +def test_standard_constructor(): + """Calling the standard constructor with default inputs to test the class setter-functions""" + playground_obj = Playground(scaling=1, x_max=250, y_max=250, seed=0) + assert playground_obj.x_max == 250 + assert playground_obj.y_max == 250 + assert len(playground_obj.holes) == 0 + assert playground_obj.shape == Polygon([(-250, -250), (-250, 250), (250, 250), (250, -250)]) + +def test_func_position_is_in_playground(): + """Test method is_position_in_playground for Point in Playground""" + test_position = (0.5, 0.5) + playground = Playground(x_max=1, y_max=1) + assert playground.is_position_in_playground(test_position[0], test_position[1]) is True + +def test_func_position_is_not_in_playground(): + """Test method is_position_in_playground for Point outside Playground""" + test_position = (1.5, 1.5) + playground = Playground(x_max=1, y_max=1) + assert playground.is_position_in_playground(test_position[0], test_position[1]) is False \ No newline at end of file diff --git a/test_random_walker.py b/test_random_walker.py new file mode 100644 index 0000000..73e3d90 --- /dev/null +++ b/test_random_walker.py @@ -0,0 +1,50 @@ +"""Pytest Unit""" +import random_walker + + +def test_walk_direction_up(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.UP.get_x_factor() == 0 + assert random_walker.WalkDirection.UP.get_y_factor() == 1 + + +def test_walk_direction_down(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.DOWN.get_x_factor() == 0 + assert random_walker.WalkDirection.DOWN.get_y_factor() == -1 + + +def test_walk_direction_left(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.LEFT.get_x_factor() == -1 + assert random_walker.WalkDirection.LEFT.get_y_factor() == 0 + + +def test_walk_direction_right(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.RIGHT.get_x_factor() == 1 + assert random_walker.WalkDirection.RIGHT.get_y_factor() == 0 + + +def test_walk_direction_up_left(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.UPLEFT.get_x_factor() == -random_walker.SQRT2 + assert random_walker.WalkDirection.UPLEFT.get_y_factor() == random_walker.SQRT2 + + +def test_walk_direction_up_right(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.UPRIGHT.get_x_factor() == random_walker.SQRT2 + assert random_walker.WalkDirection.UPRIGHT.get_y_factor() == random_walker.SQRT2 + + +def test_walk_direction_down_right(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.DOWNRIGHT.get_x_factor() == random_walker.SQRT2 + assert random_walker.WalkDirection.DOWNRIGHT.get_y_factor() == -random_walker.SQRT2 + + +def test_walk_direction_down_left(): + """Test Walk Direction handling""" + assert random_walker.WalkDirection.DOWNLEFT.get_x_factor() == -random_walker.SQRT2 + assert random_walker.WalkDirection.DOWNLEFT.get_y_factor() == -random_walker.SQRT2