diff --git a/.docs/module_class_overview.png b/.docs/module_class_overview.png new file mode 100644 index 0000000..921628f Binary files /dev/null and b/.docs/module_class_overview.png differ diff --git a/.docs/module_class_overview.vsdx b/.docs/module_class_overview.vsdx new file mode 100644 index 0000000..03ab17c Binary files /dev/null and b/.docs/module_class_overview.vsdx differ diff --git a/.docs/~$$module_class_overview.~vsdx b/.docs/~$$module_class_overview.~vsdx new file mode 100644 index 0000000..f515c9e Binary files /dev/null and b/.docs/~$$module_class_overview.~vsdx differ diff --git a/.gitignore b/.gitignore index 07f43b8..d53d2f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,165 @@ -data/* \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# 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 +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +*_log.json +*.csv \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e9e6a80 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index 85a6dcd..b034000 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,604 @@ -# cellular-automatic +# Pedestrian simulation written in python -## Testing -to run unittests execute: python -m unittest discover -s tests -p "*" \ No newline at end of file +## Table of Contents + +[1. Overview](#section/overview) \ +[1.1 Getting Started](#overview/getting-started) + +[2. Configuration](#section/configuration) \ +[2.1 Configuration Types](#configuration/types) \ +[2.1.1 Simulation Type](#configuration/types) \ +[2.1.2 Grid Type](#configuration/types/grid-type) \ +[2.1.3 Target Type](#configuration/types/target-type) \ +[2.1.4 Spawner Type](#configuration/types/spawner-type) \ +[2.1.5 Obstacle Type](#configuration/types/obstacle-type) \ +[2.1.6 Social Distancing Type](#configuration/types/social-distancing-type) \ +[2.1.7 Distancing Type](#configuration/types/distancing-type) \ +[2.1.8 Cell Type](#configuration/types/cell-type) \ +[2.2 Algorithm Implementations](#configuration/implementations) \ +[2.2.1 Heatmap Generator Type](#configuration/implementations/heatmap-generator-type) \ +[2.2.2 Neighbourhood Type](#configuration/implementations/neighbourhood-type) \ +[2.2.3 Distancing Algorithm Type](#configuration/implementations/distancing-algorithm-type) \ +[2.3 Configuration Enums](#configuration/enums) \ +[2.3.1 Cell State](#configuration/enums/cell-state) \ +[2.3.2 TargetingStrategy Type](#configuration/enums/targeting-type) + +[3. Visualisation](#section/visualisation) \ +[3.1 Visualisation Features](#visualisation/features) \ +[3.2 Keyboard Shortcuts](#visualisation/keyboard-shortcuts) \ +[3.3 Mouse Interaction](#visualisation/mouse-interaction) + +[4. Architecture](#architecture) \ +[4.1 Modules](#architecture/modules) \ +[4.1.1 Overview](#architecture/overview) \ +[4.1.2 Exceptions Module](#architecture/exceptions) \ +[4.1.3 Serialization Module](#architecture/serialization) \ +[4.1.3.1 Heatmap Serialization](#architecture/serialization/heatmap) \ +[4.1.4 Simulation Core Module](#architecture/simulation/core) \ +[4.1.4.1 Pedestrian](#architecture/simulation/core/pedestrian) \ +[4.1.4.2 Spawner](#architecture/simulation/core/spawner) \ +[4.1.4.3 Target](#architecture/simulation/core/target) \ +[4.1.4.4 Position](#architecture/simulation/core/position) \ +[4.1.4.5 Waypoint](#architecture/simulation/core/waypoint) + +[5. Testing](#section/testing) \ +[5.1 Testing Coverage](#testing/coverage) + + +

Overview

+ +The main file of this project is run.py the first passed argument is the path to a json [ +`simulation configuration`](#section/configuration) file. + +The visualisation will start automatically with the simulation. See the [`manual`](#section/visualisation) for a basic +overview of the visualisation. + +

Getting Started

+ +Make sure that you are on python 3.13 and have installed the required dependencies found in `requirements.txt`. + + +On Linux: +```shell +python -m venv ./.venv +source ./.venv/bin/activate +pip install -r requirements.txt +``` + +On Windows using powershell +```powershell +python --version # make sure python 3.13 is returned +python -m venv ./.venv +& ./.venv/Scripts/activate.ps1 +pip install -r requirements.txt +``` + +On Windows using batch +```bat +python --version # make sure python 3.13 is returned +python -m venv ./.venv +.\.venv\Scripts\activate.bat +pip install -r requirements.txt +``` + +To run the simulation call `run.py` and pass the path to a simulation configuration file as the first argument. +For example to run the `Chicken Test` : + +```shell +python run.py ./simulation_config/chicken_test.json +``` + +

Configuration

+ +The configuration is split into the following sections + +| Field | Type | Required | Default | Description | +|---------------------|--------------------------------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `grid` | [`Grid`](#configuration/types/grid-type) | Yes | - | Configuration of the simulation grid | +| `neighbourhood` | [`NeighbourhoodType`](#configuration/implementations/neighbourhood-type) | Yes | - | Which algorithm to use to get neighbouring cells | +| `obstacles` | [`Obstacle[]`](#configuration/types/obstacle-type) | No | null | An array of obstacles | +| `spawners` | [`Spawner[]`](#configuration/types/spawner-type) | Yes | - | An array of spawners | +| `targets` | [`Target[]`](#configuration/types/target-type) | Yes | - | An array of target cells | +| `social_distancing` | [`SocialDistancing`](#configuration/types/social-distancing-type) | Yes | - | Configuration of social distancing rules | +| `distancing` | [`Distancing`](#configuration/types/distancing-type) | Yes | - | Configuration of social distancing rules | +| `simulation` | [`Simulation`](#configuration/types/simulation-type) | Yes | - | Configuration of the simulation | +| `log_file` | `string\|null` | No | null | The path to a file to log the simulation data step-wise. `{0}` will be formated with the current date. If set to `null` logging is disabled | + +Example Configuration: + +```json +{ + "grid": { + "width": 30, + "height": 10, + "neighbourhood": "MooreNeighbourhood" + }, + "neighbourhood": "MooreNeighbourhood", + "obstacles": [ + { + "name": "obstacle1", + "rect": [0, 0, 2, 2] + } + ], + "spawners": [ + { + "name": "S_left", + "rect": [0, 0, 0, 9], + "targets": ["T_right"], + "total_spawns": 10, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0, + "targeting": "RANDOM" + } + ], + "targets": [ + { + "name": "T_right", + "rect": [29, 0, 29, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": 1.0, + "waypoint_threshold": 1.0, + "waypoint_distance": 5, + "waypoint_heatmap_generator": "DijkstraHeatmapGenerator" + } +} +``` + +

Configuration Types

+ +

Simulation Type

+ +| Field | Type | Required | Default | Description | +|------------------------------|---------------------------------------------------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `time_resolution` | `float` | Yes | - | The minimum amount of time between simulation steps in seconds | +| `occupation_bias_modifier` | `float\|null` | No | 1.0 | A factor which is multiplied with the occupation bias of a cell higher values mean the cell is less favourable as a next target. If set to `null` pedestrians cannot target cells which are `OCCUPIED` | +| `retargeting_threshold` | `float\|null` | No | -1.0 | The threshold in virtual moved meters at which a pedestrian will retarget to another cell which is not occupied | +| `waypoint_threshold` | `float\|null` | No | - | The threshold in virtual moved meters at which a pedestrian will retarget to a waypoint. Waypoints are a cheaper alternative to recalculating the target heatmap each simulation tick. Set to `null` to disable Waypoints | +| `waypoint_distance` | `int` | No* | - | The depth for pathfinding to select a waypoint | +| `waypoint_heatmap_generator` | [`HeatmapGeneratorType`](#configuration/implementations/heatmap-generator-type) | No* | - | The type of heatmap generator to use for pathfinding to a waypoint, cellstate will be set to `[OBSTACLE]` | + +*Only if `waypoint_threshold` is set + +Example Simulation Configuration: + +```json +{ + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": 1.0, + "waypoint_threshold": 1.0, + "waypoint_distance": 5, + "waypoint_heatmap_generator": "DijkstraHeatmapGenerator" +} +``` + +

Grid Type

+ +| Field | Type | Required | Default | Description | +|-----------------|--------------------------------------------------------------------------|----------|---------|--------------------------------------------------| +| `width` | `int` | Yes | - | The width of the simulation grid | +| `height` | `int` | Yes | - | The height of the simulation grid | +| `neighbourhood` | [`NeighbourhoodType`](#configuration/implementations/neighbourhood-type) | Yes | - | Which algorithm to use to get neighbouring cells | + +

Target Type

+ +| Field | Type | Required | Default | Description | +|---------------------|---------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `name` | `string` | Yes | - | A unique name of the target | +| `rect` | `int[]` | No* | - | An array with exactly 4 entries `[x1, y1, x2, y2]` which spans an rectangle betwen `(x1, y1)` and `(x2, y2)` | +| `cells` | [`Cell[]`](#configuration/types/cell-type) | No* | - | An array of cells that are part of the obstacle. Each cell is represented by a single integer. | +| `cellstate` | [`CellState[]`](#configuration/enums/cell-state) | No | `[OBSTACLE]` | A list of [`CellState`](#configuration/enums/cell-state) which are considered blocked by the pathfinding algorithm. Pathfinding only gets updated each simulation step if any of the [`CellState`](#configuration/enums/cell-state) is not static | +| `heatmap_generator` | [`HeatmapGeneratorType`](#configuration/implementations/heatmap-generator-type) | Yes | - | The type of heatmap generator to use for pathfinding for this target | + +*either `rect` or `cells` must be present + +Example Target Configuration with `rect`: + +```json +{ + "name": "T_right", + "rect": [29, 0, 29, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] +} +``` + +

Spawner Type

+ +| Field | Type | Required | Default | Description | +|-----------------|--------------------------------------------------------|----------|----------|--------------------------------------------------------------------------------------------------------------| +| `name` | `string` | Yes | - | A unique name of the spawner | +| `rect` | `int[]` | No* | - | An array with exactly 4 entries `[x1, y1, x2, y2]` which spans an rectangle betwen `(x1, y1)` and `(x2, y2)` | +| `cells` | [`Cell[]`](#configuration/types/cell-type) | No* | - | An array of cells that are part of the obstacle. Each cell is represented by a single integer. | +| `targets` | `str[]` | Yes | - | An array of target names that the pedestrians spawned by this spawner can walk to | +| `total_spawns` | `int\|null` | Yes | - | The total number of pedestrians to spawn. Use `null` for unlimited spawning | +| `batch_size` | `int` | Yes | - | The maximum amount of pedestrians to spawn in each spawn attempt | +| `spawn_delay` | `float` | Yes | - | The delay in seconds between each spawn attempt | +| `initial_delay` | `float` | Yes | - | The delay in seconds before the first spawn attempt after the simulation started | +| `targeting` | [`TargetingType`](#configuration/enums/targeting-type) | No | `RANDOM` | How to pick a target for a newly spawned pedestrian | + +*either `rect` or `cells` must be present + +Example Spawner Configuration with `rect`: + +```json +{ + "name": "S_left", + "rect": [0, 0, 0, 9], + "targets": ["T_right"], + "total_spawns": 10, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0, + "targeting": "RANDOM" +} +``` + +

Obstacle Type

+ +| Field | Type | Required | Default | Description | +|---------|--------------------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------| +| `name` | `string` | Yes | - | A unique name of the obstacle | +| `rect` | `int[]` | No* | - | An array with exactly 4 entries `[x1, y1, x2, y2]` which spans an rectangle betwen `(x1, y1)` and `(x2, y2)` | +| `cells` | [`Cell[]`](#configuration/types/cell-type) | No* | - | An array of cells that are part of the obstacle. Each cell is represented by a single integer. | + +*either `rect` or `cells` must be present + +Example Obstacle Configuration with `rect`: + +```json +{ + "name": "obstacle1", + "rect": [0, 0, 2, 2] +} +``` + +Example Obstacle Configuration with `cells`: + +```json +{ + "name": "obstacle1", + "cells": [[0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [2, 2]] +} +``` + +

Social Distancing Type

+ +| Field | Type | Required | Default | Description | +|----------|-------|----------|---------|------------------------------------------| +| `width` | `int` | Yes | - | The width for social distancing formula | +| `height` | `int` | Yes | - | The height for social distancing formula | + +Example Social Distancing Configuration: + +```json +{ + "width": 3, + "height": 3 +} +``` + +The social distancing formula is defined as follows: +$$ +\begin{cases} +\text{repulsion} = height * \exp{\frac{1}{(\text{distance}/{width})^2 - 1}} & \text{if |distance|} < width \\ +\text{repulsion} = 0 & \text{otherwise} +\end{cases} +$$ + +

Distancing Type

+ +| Field | Type | Required | Default | Description | +|---------|------------------------------------------------------------------------------|----------|---------|----------------------------------------------| +| `type` | [`DistancingType`](#configuration/implementations/distancing-algorithm-type) | Yes | - | The type of social distancing to apply | +| `scale` | `float` | Yes | - | The distance in meters of two adjacent cells | + +Example Distancing Configuration: + +```json +{ + "type": "EuclideanDistance", + "scale": 1.0 +} +``` + +

Cell Type

+A cell is an `int[]` with exactly 2 entries `[x, y]` which represent the zero based x and y coordinates of the cell on +the grid. `[0, 0]` is the top left corner of the grid. + +| Index | Type | Required | Default | Description | +|-------|-------|----------|---------|--------------------------------| +| `[0]` | `int` | Yes | - | The `x` coordinate of the cell | +| `[1]` | `int` | Yes | - | The `y` coordinate of the cell | + +

Algorithm Implementations

+ +Different implementations of algorithms needed in the simulation can be selected by the following names: + +

Heatmap Generator Type

+ +| Name | Description | +|--------------------------------|----------------------------------------------------------| +| `DijkstraHeatmapGenerator` | Use Dijkstra's algorithm to generate the heatmap. | +| `FastMarchingHeatmapGenerator` | Use the Fast Marching algorithm to generate the heatmap. | + +

Neighbourhood Type

+ +| Name | Description | +|------------------------|-------------------------------------------------------| +| `NeumannNeighbourhood` | The four cells directly adjacent to the current cell | +| `MooreNeighbourhood` | The eight cells directly adjacent to the current cell | + +

Distancing Algorithm Type

+ +| Name | Description | +|---------------------|------------------------------------------| +| `EuclideanDistance` | The Euclidean distance between two cells | +| `TaxiDistance` | The Manhattan distance between two cells | + +### Configuration Enums + +

Configuration Enums

+ +Different enums are used throughout the configuration to select from a predefined set of values. + +

Cell State

+ +| Name | Is Static | Description | +|------------|-----------|-------------------------------------------------| +| `FREE` | Yes | The cell is free to walk on. | +| `OBSTACLE` | Yes | The cell is blocked by an obstacle. | +| `OCCUPIED` | No | The cell is currently occupied by a pedestrian. | + +

TargetingStrategy Type

+ +| Name | Description | +|------------|------------------------------------------------------------------------------------------------------------------------| +| `RANDOM` | Pick a random target from the `targets` array | +| `CLOSEST` | Pick the target that is closest to the spawner. If multiple targets are equally close, pick the first one in the list | +| `FURTHEST` | Pick the target that is furthest from the spawner. If multiple targets are equally far, pick the first one in the list | + +

Visualisation

+ +The visualisation runs in realtime and consists of different toggable layers called [ +`features`](#visualisation/features). Additionally to the buttons on the top of the window, the following keyboard +shortcuts are available as well: + +

Visualisation Features

+ +| Name | Z-Index* | Description | +|-----------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `TargetHeatmap` | 0 | Renders the heatmap of the selected target and social distancing heatmap | +| `Spawner` | 1 | Renders the spawners and their spawn cells | +| `Target` | 2 | Renders the target cells and their target cells | +| `Grid` | 3 | Renders the base grid, obstacles and grid lines | +| `Path` | 4 | Renders the path of the selected pedestrian | +| `Waypoint` | 5 | Renders all waypoints and a line connecting them to their associated pedestrian | +| `Pedestrian` | 6 | Renders all pedestrians, their headed direction, their target cell and information. Inner color is the same as its spawners color and outline has the same color as it's target | +| `Info` | 7 | Renders the current simulation time, step count, current fps and the amount of pedestrians in the grid | + +*Order of the rendering, lower layers are rendered first meaning they can get covered by higher layers + +

Keyboard Shortcuts

+ +| Combination | Description | +|---------------|------------------------------------------------------------------------------------------------------| +| `h` | Toggle the heatmap visualisation feature | +| `s` | Toggle the spawner visualisation feature | +| `t` | Toggle the target visualisation feature | +| `g` | Toggle the grid visualisation feature | +| `p` | Toggle the pedestrian visualisation feature | +| `i` | Toggle the info visualisation feature | +| `d` | Toggle social distancing rendering in heatmap visualisation feature | +| `r` | Toggle the path visualisation feature | +| `l` | Toggle rendering of the grid lines in the grid visualisation feature | +| `w` | Toggle rendering of the waypoints visualisation feature | +| `n` | Toggle rendering of object names in all visualisation feature | +| `p` + `SHIFT` | Toggle rendering of pedestrian detail text in pedestrian visualisation feature | +| `p` + `CTRL` | Toggle rendering of line pointing to the pedestrians target cell in pedestrian visualisation feature | +| `w` + `SHIFT` | Toggle rendering of line between pedestrian and it's waypoint in pedestrian visualisation feature | +| `Space` | Toggle running of the simulation | +| `Arrow Up` | Select previous pedestrian path in pedestrian visualisation feature | +| `Arrow Down` | Select next pedestrian path in pedestrian visualisation feature | +| `Arrow Left` | Select previous target in heatmap visualisation feature | +| `Arrow Right` | Select next target in heatmap visualisation feature | + +

Mouse Interaction

+ +| Action | Target | Description | +|--------------|------------|----------------------------------------------------------------------------------| +| `Left Click` | Pedestrian | Select the pedestrian and show it's path in the pedestrian visualisation feature | +| `Left Click` | Target | Select the target and show it's heatmap in the heatmap visualisation feature | + +## Architecture + +The architecture is divided into different [`modules`](#architecture/modules), each module is responsible for a specific +aspect of the +simulation. + +

Modules

+ +| Path | Description | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| [`exceptions`](#architecture/exceptions) | Contains custom exceptions for the simulation | +| [`serialization`](#architecture/serialization) | Contains the interfaces and logic for serializing and deserializing the states of simulation objects for logging | +| [ `simulation/core`](#architecture/simulation/core) | Contains the core simulation logic, such as the simulation loop, pedestrian movement and pathfinding as well as the core components | +| `simulation/heatmaps` | Contains the heatmap generation logic, such as Dijkstra's algorithm and the Fast Marching algorithm | +| `simulation/heatmaps/distancing` | Contains different algorithms for distance calculation between two cells | +| `simulation/neighbourhood` | Contains different algorithms for getting neighbouring cells | +| `simulation_config` | Contains the logic for parsing the simulation configuration from a json file | +| `visualisation` | Contains the logic for the visualisation of the simulation | +| `visualisation/features` | Contains the different visualisation features and their logic | +| `utils` | Contains utility functions used throughout the simulation | + +

Overview over all classes and their inheritance

+ +Class Diagram + +

Exceptions Module

+ +This module contains `SimulationError` and an enum of applicable error codes. + +| File | Description | +|----------------------------|--------------------------------------------------------------| +| `simulation_error.py` | Contains the `SimulationError` class | +| `simulation_error_code.py` | Contains the `SimulationErrorCode` enum with all error codes | + +The `SimulationError` class is a custom exception class that takes an error code and a dictionary as context. The error +message is derived from the error code and the context is used to enrich the error message with additional information. + +

Serialization Module

+ +| File | Description | +|-------------------|---------------------------------------------------------------------------------------------------| +| `serializer.py` | Contains the logic for serializing and deserializing the states of simulation objects for logging | +| `serializable.py` | Contains the abstract base class for objects that can be serialized and deserialized | + +Serialization is done by inheriting the `Serializable` base class and implementing the `get_serialization_data` and +`get_identifier` methods. The `Serializer` class can then be used to serialize and deserialize the objects. +`get_serialization_data` should return a dictionary with string key and value which is either a `primitive`, an `array` +or a `Serializable` with the data that should be serialized and `get_identifier` should return a unique identifier for +the object. +This dictionary is then traversed recursively and all `Serializable` objects are replaced with the result of their +`get_serialization_data` method. + +
Heatmap Serialization
+ +Heatmaps are serialized as binary data and afterward encoded in base64 to save space in the log file. +The 2D heatmap is indexed as a 1D array, the index of the 1D array is calculated as `y * width + x`. + +| Field | Offset | Size | Type | Endianess | Description | +|--------|--------|---------------------------|-----------|-----------|---------------------------| +| width | 0x00 | 0x04 | uint32 | little | The width of the heatmap | +| height | 0x04 | 0x04 | uint32 | little | The height of the heatmap | +| data | 0x08 | 0x08 * `width` * `height` | float64[] | little | The heatmap data | + +

Simulation Core Module

+ +This module contains the core simulation logic, such as the simulation loop and core classes + +| File | Description | +|-------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `cell.py` | Contains the `Cell` class which represents a cell on the grid | +| `cell_state.py` | Contains the `CellState` enum which represents the state of a cell on the grid | +| `grid_base.py` | Contains the `GridBase[T]` class which is an abstract base class for all 2D grids. It contains logic to index the internal 1D array and to check if something is in bounds | +| [`pedestrian.py`](#architecture/simulation/core/pedestrian) | Contains the `Pedestrian` class which contains fields and logic to handle pedestrians | +| `position.py` | Contains the abstract `Position` class which is inherited by anything which has a position on the simulation grid. Also offers methods to compare different implementations of `Position` only by `x` and `y` coordinate | +| `simulation.py` | Contains the `Simulation` class which represents the complete simulation and core loop of the simulation | +| `spawner.py` | Contains the `Spawner` class which handles the logic for [`Pedestrian`](#architecture/simulation/core/pedestrian) creation | +| `target.py` | Contains the `Target` class which represents a target a [`Pedestrian`](#architecture/simulation/core/pedestrian) can walk to and exit the simulation. This class contains all cells which belong to the target and handles the generation of it's navigation heatmap | +| `targeting_strategy` | Contains the [`TargetingStrategy`](#configuration/enums/targeting-type) enum | +| `waypoint.py` | Contains the `Waypoint` class which represents a waypoint a [`Pedestrian`](#architecture/simulation/core/pedestrian) can walk to and exit the simulation. This class contains the cell where the waypoint is located and handles the generation of it's navigation heatmap | + +
Pedestrian
+ +The [`Pedestrian`](#architecture/simulation/core/pedestrian) class handles the tracking of a single pedestrians. +Pedestrians have an `update` method which should be called every simulation tick. The `can_move` method checks if the +pedestrian can move to it's current target cell including bookkeeping of the moved distance to handle different walking +speeds. Once a pedestrian `can_move` returns `True` the `move` method to update the pedestrians state, the cell has to +be updated by the simulation itself since the pedestrian only modifies it's own state. Bookkeeping of movement speed +works by calculating the distance to it's target cell and storing it in `_current_distance`. Every update the +`time_delta` is multiplied by the pedestrians `_current_speed` and subtracted from `_current_distance`. Once +`_current_distance` reaches 0 the pedestrian can move upon it's target cell if the cell is free. `_current_distance` can +become negative to signal for how long a pedestrian has been stuck. Pedestrians are updated in order of their +`_current_distance` where the ones with the lowest value get updated first + +
Spawner
+ +The [`Spawner`](#architecture/simulation/core/spawner) class handles the spawning of pedestrians. The `update` method +should be called every simulation tick. It yields a list of newly created pedestrians which have to be added to the +simulation. It also chooses a target for the pedestrian and it's optimal walking speed by sampling from +a [normal distribution](#architecture/utils/clipped_normal_distribution) with mean 1.34 m/s and standard deviation 0.26 +m/s. It's value is taken absolutely (to prevent negative speeds) and clipped to a range between 0.69 and 2.45 as +suggested +by [Similar Normal Distribution of Pedestrian Speeds at Signalized Intersection Crosswalks](https://ieeexplore.ieee.org/document/6977742). +Spawning happens in batches where the size is the minimum of `batch_size` and the count of free spawn cells. + +
Target
+ +The [`Target`](#architecture/simulation/core/target) class handles the generation of the navigation heatmap for the +target. The heatmap is generated by the selected [ +`HeatmapGenerator`](#configuration/implementations/heatmap-generator-type) and is +updated either at the beginning of the simulation or every simulation tick if it also considers [ +`OCCUPIED`](#configuration/enums/cell-state) as blocked. The heatmap is used by the pedestrians to find the shortest +path to +the target. The target also contains the cells and a method `is_inside_target` to check if a [ +`Position`](#architecture/simulation/core/position) is inside of one of the target's cells. + +
Position
+ +The [`Position`](#architecture/simulation/core/position) class is an abstract base class which is inherited by anything +which has a position on the simulation grid. It contains the `x` and `y` coordinates and a `pos_equals` method to +compare different implementations of `Position` only by `x` and `y` coordinate. + +
Waypoint
+ +The [`Waypoint`](#architecture/simulation/core/waypoint) class handles the generation of the navigation heatmap for the +waypoint. The heatmap is generated by the selected [ +`HeatmapGenerator`](#configuration/implementations/heatmap-generator-type) and is +updated either at the beginning of the simulation or every simulation tick if it also considers [ +`OCCUPIED`](#configuration/enums/cell-state) as blocked. The waypoint is used by the pedestrians to find the shortest +path to +the waypoint. The waypoint also contains the cell and a method `is_inside_waypoint` to check if a [ +`Position`](#architecture/simulation/core/position) is inside of the waypoint's cell. A waypoint is only created when +the simulations `waypoint_threshold` is set and the pedestrian couldn't move for longer than the threshold. A [ +`Waypoint`](#architecture/simulation/core/waypoint) is a cheaper alternative to updating the navigation heatmaps of each +target each ticks in respect of all pedestrians. It works by generating a Heatmap with djisktras algorithm with respect +to all pedestrians. It then pathfinds for a given depth (`waypoint_distance`) starting at the pedestrians current +location towards it's target. Once a pedestrian has a set [`Waypoint`](#architecture/simulation/core/waypoint) it will +first walk to it's waypoint before continuing to it's original target. Waypoints mainly should prevent pedestrians +getting stuck in narrow corridors. + +

Testing

+ +The project is tested with `pytest`. The tests are located in the `tests` directory and can be run with the following +Currently the tests only cover the core components of the simulations and parts of it's logic due to time constraints. + +```shell +pytest tests +``` + +

Testing Coverage

+ +All no mentioned modules aren't covered at all. + +| Module | File | Implemented | +|-------------------------------------|------------------------------------------|-------------| +| `simulation/core` | `cell.py` | Yes | +| `simulation/core` | `cell_state.py` | Yes | +| `simulation/core` | `grid_base.py` | Yes | +| `simulation/core` | `pedestrian.py` | Yes | +| `simulation/core` | `position.py` | Yes | +| `simulation/core` | `simulation.py` | No | +| `simulation/core` | `simulation_grid.py` | Yes | +| `simulation/core` | `spawner.py` | Yes | +| `simulation/core` | `target.py` | Yes | +| `simulation/core` | `waypoint.py` | No | +| `simulation/core` | `targeting_strategy.py` | No | +| `simulation/heatmaps` | `dijkstra_heatmap_generator.py` | Yes | +| `simulation/heatmaps` | `fast_marching_heatmap_generator.py` | Yes | +| `simulation/heatmaps` | `social_distancing_heatmap_generator.py` | Yes | +| `simulation/heatmaps` | `pathfinding_queue.py` | No | +| `simulation/heatmaps` | `heatmap.py` | No | +| `simulation/heatmaps` | `heatmap_generator_base.py` | No | +| `simulation/heatmaps/neighbourhood` | `neumann_neighbourhood.py` | Yes | +| `simulation/heatmaps/neighbourhood` | `moore_neighbourhood.py` | Yes | +| `simulation/heatmaps/neighbourhood` | `base_neighbourhood.py` | No | +| `simulation/heatmaps/distancing` | `euclidean_distance.py` | Yes | +| `simulation/heatmaps/distancing` | `taxi_distance.py` | Yes | +| `simulation/heatmaps/distancing` | `base_distance` | No | diff --git a/src/__init__.py b/exceptions/__init__.py similarity index 100% rename from src/__init__.py rename to exceptions/__init__.py diff --git a/exceptions/simulation_error.py b/exceptions/simulation_error.py new file mode 100644 index 0000000..b6dbdb3 --- /dev/null +++ b/exceptions/simulation_error.py @@ -0,0 +1,26 @@ +import json +from typing import Dict, Any + +from exceptions.simulation_error_codes import SimulationErrorCode + +class SimulationError(Exception): + def __init__(self, error_code: SimulationErrorCode, context: Dict[str, Any] = None) -> None: + self._error_code: SimulationErrorCode = error_code + self._context: Dict[str, Any]|None = context + + def get_message(self) -> str: + return self._error_code.value[1] + + def get_code(self) -> str: + return self._error_code.value[0] + + def __str__(self): + return repr(self) + + def __repr__(self): + msg = f"[{self.get_code()}]: {self.get_message()}" + if self._context is not None: + context_msg = ", ".join([f"{key}={repr(value)}" for key, value in self._context.items()]) + msg += f"\nContext {{{context_msg}}}" + + return msg \ No newline at end of file diff --git a/exceptions/simulation_error_codes.py b/exceptions/simulation_error_codes.py new file mode 100644 index 0000000..a7b6b22 --- /dev/null +++ b/exceptions/simulation_error_codes.py @@ -0,0 +1,20 @@ +from enum import Enum + +class ErrorCodeDataMixin: + error_code: int + message: str + +class SimulationErrorCode(ErrorCodeDataMixin, Enum): + NOT_IMPLEMENTED_IN_SIMULATION = 1, "A Function in the simulation is not implemented" + LENGTH_OF_GRID_INVALID = 2, "The length of the grid has to be more than 0 and of type int" + INVALID_COORDINATES = 3, "The accessed coordinates are outside of the grid" + PEDESTRIAN_HEAT_MAP_CALCULATION_FAILED = 4, "The calculation of the pedestrian heat map failed" + PEDESTRIAN_HEAT_MAP_NOT_FOUND = 5, "The Pedestrian Heat Map could not be found." + CELL_OCCUPIED = 6, "The cell is already occupied with another pedestrian" + CELL_BLOCKED = 7, "This cell is blocked by an obstacle and cannot be entered" + CELL_NOT_OCCUPIED = 8, "Tried to unoccupy a cell that is not occupied" + VALUE_NOT_INITIALIZED = 9, "The value has not been initialized yet, this mostly occurs when the first simulation step has not been executed" + ALREADY_IN_CELL = 10, "The pedestrian is already in the cell and can't target it" + CANNOT_MOVE = 11, "Move was called but the pedestrian can't move or has already reached it's target" + NO_FIXED_NEIGHBOURS = 12, "No fixed neighbours found for fast marching algorithm" + TELEPORTER_FULL = 13, "Trying to spawn a pedestrian on an full teleporter." diff --git a/plotFlowChart.py b/plotFlowChart.py new file mode 100644 index 0000000..8049048 --- /dev/null +++ b/plotFlowChart.py @@ -0,0 +1,30 @@ +import pandas as pd +import matplotlib.pyplot as plt + +def main(): + user_input = input("Enter the names of the CSV files separated by commas: ").split(',') + file_list = [file.strip() for file in user_input] + data = read_and_combine_csv(file_list) + plotData(data) + +def read_and_combine_csv(file_list): + dataframes = [] + for file in file_list: + try: + df = pd.read_csv(file) + dataframes.append(df) + except FileNotFoundError: + print(f"Error: File '{file}' not found.") + return pd.concat(dataframes, ignore_index=True) + +def plotData(data): + plt.figure(figsize=(10, 6)) + plt.plot(data['pedestrianDensity'], data['flowRate'], color='blue') + plt.title('Flow Rate') + plt.xlabel('Pedestrian Density') + plt.ylabel('Flow Rate') + plt.legend() + plt.show() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 485eb9e..e11777a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ +numpy +scipy pygame -numpy \ No newline at end of file +pandas +matplotlib \ No newline at end of file diff --git a/run.py b/run.py index e20d950..3093b44 100644 --- a/run.py +++ b/run.py @@ -1,7 +1,109 @@ -from src.userSimulation import exampleSimulation +import sys +from typing import Generator + +from simulation.core.cell import Cell +from simulation.core.teleporter import Teleporter +from simulation.core.simulation import Simulation +from simulation.core.simulation_grid import SimulationGrid +from simulation.core.spawner import Spawner +from simulation.core.target import Target +from simulation.core.targeting_stratey import TargetingStrategy +from simulation.heatmaps.social_distancing_heatmap_generator import SocialDistancingHeatmapGenerator +from visualisation.flow_meter import FlowMeter +from visualisation.visualisation import Visualisation +from simulation_config.config_loader import SimulationConfigLoader + + + +def get_cells(config, grid) -> Generator[Cell]: + if "cells" in config: + yield from [grid.get_cell(x, y) for (x, y) in config["cells"]] + elif "rect" in config: + x1, y1, x2, y2 = config["rect"] + for x in range(x1, x2 + 1): + for y in range(y1, y2 + 1): + yield grid.get_cell(x, y) + +def main(config_path): + simulation_config = SimulationConfigLoader.load_config(config_path) + + neighbourhood_class = SimulationConfigLoader.get_neighbourhood_class(simulation_config["grid"]["neighbourhood"]) + grid = SimulationGrid(simulation_config["grid"]["width"], simulation_config["grid"]["height"], neighbourhood_class) + + if "obstacles" in simulation_config: + for obstacle in simulation_config["obstacles"]: + for cell in get_cells(obstacle, grid): + cell.set_osbtacle() + + distance_class = SimulationConfigLoader.get_distance_class(simulation_config["distancing"]["type"]) + distancing = distance_class(simulation_config["distancing"]["scale"]) + + target_mapping = {} + for target_config in simulation_config["targets"]: + target_cells = list(get_cells(target_config, grid)) + blocked_states = SimulationConfigLoader.get_cell_states(target_config.get("cellstate", None)) + heatmap_generator = SimulationConfigLoader.create_heatmap_generator(target_config["heatmap_generator"], distancing, blocked_states) + target_obj = Target(target_config["name"], target_cells, grid, heatmap_generator) + target_mapping[target_config["name"]] = target_obj + + + spawners = [] + for spawner_config in simulation_config["spawners"]: + spawner_cells = list(get_cells(spawner_config, grid)) + spawner_targets = [target_mapping[name] for name in spawner_config["targets"]] + targeting = TargetingStrategy[spawner_config.get("targeting", "RANDOM")] + spawners.append(Spawner( + spawner_config["name"], distancing, spawner_cells, spawner_targets, + spawner_config["total_spawns"], spawner_config["batch_size"], + spawner_config["spawn_delay"], spawner_config["initial_delay"], + targeting + )) + + social_distancing = SocialDistancingHeatmapGenerator( + distancing, + simulation_config["social_distancing"]["width"], + simulation_config["social_distancing"]["height"] + ) + + teleporter = None + if "teleports" in simulation_config: + teleporter_cells = list(get_cells(simulation_config["teleports"], grid)) + teleporter = Teleporter(teleporter_cells) + + flow_meters = None + + if "flow_meters" in simulation_config: + flow_meters = [] + for flow_meter_config in simulation_config["flow_meters"]: + name = flow_meter_config["name"] + time_span = flow_meter_config.get("time_span") + logfile = flow_meter_config.get("logfile") + log_interval = flow_meter_config.get("log_interval") + cells = list(get_cells(flow_meter_config, grid)) + flow_meters.append(FlowMeter(name, time_span, cells, logfile, log_interval)) + + sim = Simulation( + simulation_config["simulation"]["time_resolution"], + grid, + distancing, + social_distancing, + list(target_mapping.values()), + spawners, + simulation_config["simulation"].get("occupation_bias_modifier", 1.0), + simulation_config["simulation"].get("retargeting_threshold", -1.0), + simulation_config["simulation"].get("waypoint_threshold", None), + simulation_config["simulation"].get("waypoint_distance", None), + SimulationConfigLoader.create_heatmap_generator(simulation_config["simulation"].get("waypoint_heatmap_generator", None), distancing, None), + teleporter=teleporter, + flow_meter=flow_meters + ) + + + + log_file = simulation_config.get("log_file", None) + vis = Visualisation(sim, None, 30.0, log_file, flow_meters) + vis.run() -def main(): - exampleSimulation.run() if __name__ == "__main__": - main() \ No newline at end of file + main(sys.argv[1]) \ No newline at end of file diff --git a/src/exceptions/__init__.py b/serialization/__init__.py similarity index 100% rename from src/exceptions/__init__.py rename to serialization/__init__.py diff --git a/serialization/serializable.py b/serialization/serializable.py new file mode 100644 index 0000000..9962d40 --- /dev/null +++ b/serialization/serializable.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +class Serializable(ABC): + @abstractmethod + def get_serialization_data(self) -> dict[str, any]: + pass + + @abstractmethod + def get_identifier(self) -> str: + pass \ No newline at end of file diff --git a/serialization/serializer.py b/serialization/serializer.py new file mode 100644 index 0000000..e1b8b8a --- /dev/null +++ b/serialization/serializer.py @@ -0,0 +1,91 @@ +import json +from asyncio import gather + +from serialization.serializable import Serializable +from simulation.core.simulation import Simulation +from utils import utils + + +class Serializer: + def __init__(self, simulation: Simulation, file: str): + self._simulation = simulation + self._file = open(file, "w") + self._initialize() + self._is_first = True + pass + + def _initialize(self): + grid = self.handle_serializable(self._simulation.get_grid()) + grid["neighbourhood"] = self._simulation.get_grid()._neighbourhood.__class__.__name__ + + targets = [{ + "id": target.get_identifier(), + "cells": [cell.get_identifier() for cell in target.get_cells()], + "heatmap": utils.heatmap_to_base64(target.get_heatmap()), + } for target in self._simulation.get_targets()] + + spawners = [{ + "id": spawner.get_identifier(), + "cells": [cell.get_identifier() for cell in spawner.get_cells()], + "spawn_delay": spawner._spawn_delay, + "initial_delay": spawner._current_delay, + "total_spawns": spawner._total_spawns, + "batch_size": spawner._batch_size, + "targeting_strategy": int(spawner._targeting_strategy), + "targets": [target.get_identifier() for target in spawner._targets], + } for spawner in self._simulation.get_spawners()] + + distancing = { + "scale": self._simulation._distancing.get_scale(), + "type": self._simulation._distancing.__class__.__name__, + } + + simulation = { + "time_resolution": self._simulation.get_time_resolution(), + "grid": grid, + "distancing": distancing, + "targets": targets, + "spawners": spawners, + "social_distancing": { + "width": self._simulation._social_distancing_generator._width, + "height": self._simulation._social_distancing_generator._height, + } + } + + + self._file.write(f'{{"setup": {json.dumps(simulation, indent=4)},\n"steps": [') + + def write_current_state(self): + if self._file is None: + return + + if self._is_first: + self._is_first = False + else: + self._file.write(",\n") + + self._file.write(json.dumps(self.handle_serializable(self._simulation))) + if self._simulation.is_done(): + self.close() + + def close(self): + if self._file is not None: + self._file.write("]}") + self._file.close() + self._file = None + + def handle_dict(self, data: dict[str, any]) -> dict[str, any]: + for key, value in data.items(): + if isinstance(value, Serializable): + data[key] = self.handle_serializable(value) + elif isinstance(value, list): + data[key] = [self.handle_serializable(item) if isinstance(item, Serializable) else item for item in value] + + return data + + def handle_serializable(self, serializable: Serializable) -> dict[str, any]: + data = serializable.get_serialization_data() + return self.handle_dict(data) + + def serialize (self, data: Serializable) -> str: + return json.dumps(self.handle_serializable(data), indent=4) \ No newline at end of file diff --git a/tests/config/__init__.py b/simulation/__init__.py similarity index 100% rename from tests/config/__init__.py rename to simulation/__init__.py diff --git a/tests/exceptions/__init__.py b/simulation/core/__init__.py similarity index 100% rename from tests/exceptions/__init__.py rename to simulation/core/__init__.py diff --git a/simulation/core/cell.py b/simulation/core/cell.py new file mode 100644 index 0000000..fcc55a7 --- /dev/null +++ b/simulation/core/cell.py @@ -0,0 +1,69 @@ +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from serialization.serializable import Serializable +from simulation.core.cell_state import CellState +from simulation.core.position import Position + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from simulation.core.pedestrian import Pedestrian + +class Cell(Position, Serializable): + def __init__(self, x: int, y: int, initial_state: CellState = CellState.FREE): + super().__init__(x, y) + self._state: CellState = initial_state + self._pedestrian: 'Pedestrian' | None = None + + def __eq__(self, other): + return self._x == other._x and self._y == other._y + + def __hash__(self): + return hash((self._x, self._y)) + + def get_state(self) -> CellState: + return self._state + + def set_osbtacle(self) -> None: + self._state = CellState.OBSTACLE + + def set_pedestrian(self, pedestrian: 'Pedestrian') -> None: + if self._state == CellState.OBSTACLE: + raise SimulationError(SimulationErrorCode.CELL_BLOCKED) + + if self._state == CellState.OCCUPIED: + raise SimulationError(SimulationErrorCode.CELL_OCCUPIED, {"pedestrian": pedestrian}) + + self._pedestrian = pedestrian + self._state = CellState.OCCUPIED + + def remove_pedestrian(self) -> None: + if self._state != CellState.OCCUPIED: + raise SimulationError(SimulationErrorCode.CELL_NOT_OCCUPIED, {"x": self._x, "y": self._y}) + + self._pedestrian = None + self._state = CellState.FREE + + def is_free(self): + return self._state == CellState.FREE + + def get_pedestrian(self) -> 'Pedestrian': + return self._pedestrian + + def is_occupied(self): + return self._state == CellState.OCCUPIED + + def get_identifier(self) -> str: + return f"{self._x}_{self._y}" + + def get_serialization_data(self) -> dict[str, any]: + data = { + "id": self.get_identifier(), + "state": int(self._state), + "x": self._x, + "y": self._y + } + + if self._pedestrian is not None: + data["pedestrian"] = self._pedestrian.get_identifier() + + return data \ No newline at end of file diff --git a/simulation/core/cell_state.py b/simulation/core/cell_state.py new file mode 100644 index 0000000..aad70bf --- /dev/null +++ b/simulation/core/cell_state.py @@ -0,0 +1,7 @@ +from enum import Enum, IntEnum + + +class CellState(IntEnum): + FREE = 0, + OCCUPIED = 1, + OBSTACLE = 2 \ No newline at end of file diff --git a/simulation/core/grid_base.py b/simulation/core/grid_base.py new file mode 100644 index 0000000..aa4e894 --- /dev/null +++ b/simulation/core/grid_base.py @@ -0,0 +1,65 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Sequence, Generator + +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.position import Position +from utils.immutable_list import ImmutableList + + +class GridBase[T](ABC): + def __init__(self, width: int, height: int): + self._width: int = width + self._height: int = height + self.cells: list[T] = [self._init_cell(*self._get_cell_coordinate(i)) for i in range(width * height)] + + @abstractmethod + def _init_cell(self, x: int, y: int) -> T: + pass + + def get_width(self) -> int: + return self._width + + def get_height(self) -> int: + return self._height + + def get_cells(self) -> ImmutableList[T]: + return ImmutableList(self.cells) + + def _get_cell_index(self, x: int, y: int) -> int: + return y * self._width + x + + def _get_cell_coordinate(self, index: int) -> tuple[int, int]: + return index % self._width, index // self._width + + + def is_in_bounds(self, x: int, y: int) -> bool: + return 0 <= x < self._width and 0 <= y < self._height + + + def check_bounds(self, x: int, y: int) -> None: + """ + Raises a SimulationError if the given coordinates are out of bounds. + """ + if not self.is_in_bounds(x, y): + raise SimulationError(SimulationErrorCode.INVALID_COORDINATES, {"x": x, "y": y}) + + def get_cell(self, x: int, y: int) -> T: + self.check_bounds(x, y) + return self.cells[self._get_cell_index(x, y)] + + def get_cell_at_pos(self, pos: Position): + return self.get_cell(pos.get_x(), pos.get_y()) + + def __contains__(self, item): + if isinstance(item, Cell): + return item in self.cells + elif isinstance(item, Position): + return self.is_in_bounds(item.get_x(), item.get_y()) + elif isinstance(item, tuple) and len(item) == 2: + return self.is_in_bounds(*item) + else: + return False \ No newline at end of file diff --git a/simulation/core/pedestrian.py b/simulation/core/pedestrian.py new file mode 100644 index 0000000..458581e --- /dev/null +++ b/simulation/core/pedestrian.py @@ -0,0 +1,164 @@ +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from serialization.serializable import Serializable +from simulation.core.cell_state import CellState +from simulation.core.position import Position +from simulation.heatmaps.distancing.base_distance import DistanceBase +from typing import TYPE_CHECKING + +from simulation.core.waypoint import Waypoint + +if TYPE_CHECKING: + from simulation.core.cell import Cell + from simulation.core.target import Target + from simulation.core.spawner import Spawner + from simulation.heatmaps.heatmap import Heatmap + from simulation.heatmaps.distancing.base_distance import DistanceBase + +class Pedestrian(Position, Serializable): + NEGATIVE_INFINITY = float('-inf') + INFINITY = float('inf') + ID_COUNTER = 0 + + @staticmethod + def get_next_id() -> int: + Pedestrian.ID_COUNTER += 1 + return Pedestrian.ID_COUNTER + + def __init__(self, x: int, y: int, speed: float, spawner: 'Spawner', target: 'Target', distancing: 'DistanceBase', time_alive: float = 0): + super().__init__(x, y) + self._id: int = Pedestrian.get_next_id() + self._optimal_speed: float = speed + self._current_speed: float = speed + self._target: 'Target' = target + self._spawner: 'Spawner' = spawner + self._current_distance: float = Pedestrian.INFINITY + self._distance_to_target: float = Pedestrian.INFINITY + self._distancing: 'DistanceBase' = distancing + self._target_cell: 'Cell' | None = None + self._time_alive: float = time_alive + self._total_distance_moved: float = 0 + self._refund_distance_flag = False + self._reached_target = False + self._waypoint: Waypoint|None = None + + def set_waypoint(self, cell: Waypoint) -> None: + self._waypoint = cell + + def get_waypoint(self) -> Waypoint: + return self._waypoint + + def clear_waypoint(self) -> None: + self._waypoint = None + + def has_reached_waypoint(self) -> bool: + return self._waypoint is not None and self._waypoint.get_cell().pos_equals(self) + + def has_waypoint(self) -> bool: + return self._waypoint is not None + + def set_reached_target(self) -> None: + self._reached_target = True + + def has_reached_target(self) -> bool: + return self._reached_target + + def set_target_cell(self, target_cell: 'Cell') -> None: + if target_cell is None: + self._target_cell = None + self._current_distance = Pedestrian.NEGATIVE_INFINITY + else: + if target_cell.pos_equals(self): + raise SimulationError(SimulationErrorCode.ALREADY_IN_CELL, {"cell": target_cell}) + + if self._refund_distance_flag and self._current_distance is not Pedestrian.NEGATIVE_INFINITY and self._current_distance is not Pedestrian.INFINITY: + self._current_distance += self._distancing.calculate_distance(self, target_cell) + else: + self._current_distance = self._distancing.calculate_distance(self, target_cell) + + self._distance_to_target = self._current_distance + self._target_cell = target_cell + self._refund_distance_flag = True + + def get_spawner(self) -> 'Spawner': + return self._spawner + + def get_id(self) -> int: + return self._id + + def is_inside_target(self) -> bool: + return self._target.is_inside_target(self) + + def get_targeted_cell(self) -> 'Cell': + return self._target_cell + + def can_move(self) -> bool: + return self._current_distance < 0 and self.has_targeted_cell() and self._target_cell.is_free() and not self.has_reached_target() + + def move(self) -> None: + if not self.can_move() or self.has_reached_target(): + raise SimulationError(SimulationErrorCode.CANNOT_MOVE) + + # pedestrians shouldn't manipulate the themselves + # self._target_cell.set_pedestrian(self) + self._x = self._target_cell.get_x() + self._y = self._target_cell.get_y() + self._total_distance_moved += self._distance_to_target + # self.set_target_cell(None) + self._distance_to_target = Pedestrian.INFINITY + + def get_occupation_bias(self) -> float: + if self._target_cell is None: + return 1 + else: + return 1.0 - (max(0.0, self._current_distance)/self._distance_to_target) + + def get_average_speed(self) -> float: + return self._total_distance_moved / self._time_alive + + def update(self, delta: float): + self._time_alive += delta + if self._current_distance < 0: + self._refund_distance_flag = False + + self._current_distance -= self._current_speed * delta + + def get_current_distance(self) -> float: + return self._current_distance + + def get_optimal_speed(self) -> float: + return self._optimal_speed + + def get_target(self) -> 'Target': + return self._target + + def get_heatmap(self) -> 'Heatmap': + return self._target.get_heatmap() + + def has_targeted_cell(self): + return self._target_cell is not None + + def get_serialization_data(self) -> dict[str, any]: + return { + "id": self._id, + "average_speed": self.get_average_speed(), + "current_distance": self._current_distance, + "time_alive": self._time_alive, + "total_distance_moved": self._total_distance_moved, + "optimal_speed": self._optimal_speed, + "target": self._target.get_identifier(), + "target_cell": self._target_cell.get_identifier() if self._target_cell is not None else None, + "spawner": self._spawner.get_identifier(), + "cell_id": f"{self._x}_{self._y}", + "x": self._x, + "y": self._y, + } + + def get_identifier(self) -> str: + return str(self._id) + + def get_distancing(self) -> DistanceBase: + return self._distancing + + def get_time_alive(self) -> float: + return self._time_alive \ No newline at end of file diff --git a/simulation/core/position.py b/simulation/core/position.py new file mode 100644 index 0000000..501d590 --- /dev/null +++ b/simulation/core/position.py @@ -0,0 +1,33 @@ +from abc import ABC + + +class Position(ABC): + def __init__(self, x: int, y: int): + self._x: int = x + self._y: int = y + + def get_x(self) -> int: + return self._x + + def get_y(self) -> int: + return self._y + + def as_tuple(self) -> tuple[int, int]: + return self._x, self._y + + def pos_equals(self, other: object) -> bool: + return isinstance(other, Position) and self.get_x() == other.get_x() and self.get_y() == other.get_y() + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Position): + return False + return self._x == other.get_x() and self._y == other.get_y() + + def __hash__(self) -> int: + return hash((self._x, self._y)) + + def __str__(self) -> str: + return f"({self._x}, {self._y})" \ No newline at end of file diff --git a/simulation/core/simulation.py b/simulation/core/simulation.py new file mode 100644 index 0000000..1a62f58 --- /dev/null +++ b/simulation/core/simulation.py @@ -0,0 +1,299 @@ +from typing import Iterable + +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from simulation.core.cell_state import CellState +from simulation.heatmaps.djisktra_heatmap_generator import DijkstraHeatmapGenerator +from utils.utils import none_check + +from typing import Iterable + +import utils.utils +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from serialization.serializable import Serializable +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.position import Position +from simulation.core.spawner import Spawner +from simulation.core.teleporter import Teleporter +from simulation.core.target import Target +from simulation.core.waypoint import Waypoint +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.heatmaps.djisktra_heatmap_generator import DijkstraHeatmapGenerator +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase +from simulation.heatmaps.pathfinding_queue import PathfindingQueue +from simulation.heatmaps.social_distancing_heatmap_generator import SocialDistancingHeatmapGenerator +from simulation.neighbourhood.base_neighbourhood import NeighbourhoodBase +from simulation.core.pedestrian import Pedestrian +from simulation.core.simulation_grid import SimulationGrid +from utils.immutable_list import ImmutableList +from utils.utils import none_check +from visualisation.flow_meter import FlowMeter + +class Simulation(Serializable): + def __init__(self, time_resolution: float, grid: SimulationGrid, distancing: DistanceBase, + social_distancing: SocialDistancingHeatmapGenerator, targets: list[Target], spawners: list[Spawner], + occupation_bias_modifier: float | None = 1.0, retargeting_threshold: float | None = -1.0, waypoint_threshold: float | None = None, waypoint_distance: int | None = None, waypoint_heatmap_generator: HeatmapGeneratorBase | None = None, teleporter: Teleporter | None = None, flow_meter: list[FlowMeter] | None = None): + self._pedestrians: list[Pedestrian] = list() + self._grid: SimulationGrid = grid + self._targets: list[Target] = targets + self._spawners: list[Spawner] = spawners + self._social_distancing_generator: SocialDistancingHeatmapGenerator = social_distancing + self._distancing_heatmap: Heatmap = None + self._distancing = distancing + self._steps: int = 0 + self._run_time: float = 0 + self._time_resolution: float = time_resolution + self._occupation_bias_modifier: float | None = occupation_bias_modifier + self._retargeting_threshold: float | None = retargeting_threshold + self._waypoint_threshold: float | None = waypoint_threshold + self._waypoint_distance: int | None = waypoint_distance + self._waypoint_heatmap_generator: HeatmapGeneratorBase | None = waypoint_heatmap_generator + self._waypoints: list[Waypoint] = [] + self._waypoint_pathfinding_heatmap_generator: DijkstraHeatmapGenerator = DijkstraHeatmapGenerator(distancing, {CellState.OBSTACLE, CellState.OCCUPIED}) + self._waypoint_heatmap_cache: dict[Target, Heatmap] = {} + self._teleporter = teleporter + self._number_of_movable_cells = self.get_number_of_movable_cells() + self._flow_meters: list[FlowMeter] = flow_meter + self._init_flow_meter_logs() + + waypoint_none, none_fields = none_check(waypoint_threshold=waypoint_threshold, waypoint_distance=waypoint_distance, waypoint_heatmap_generator=waypoint_heatmap_generator) + if waypoint_none is False: + raise SimulationError(SimulationErrorCode.VALUE_NOT_INITIALIZED, {"parameters": none_fields}) + + def get_number_of_movable_cells(self) -> int: + number_of_movable_cells = 0 + for cell in self._grid.cells: + if not cell.get_state() == CellState.OBSTACLE: + number_of_movable_cells += 1 + return number_of_movable_cells + + def get_waypoints(self) -> ImmutableList[Waypoint]: + return ImmutableList(self._waypoints) + + def get_time_resolution(self) -> float: + return self._time_resolution + + def get_spawners(self) -> ImmutableList[Spawner]: + return ImmutableList(self._spawners) + + def get_targets(self) -> ImmutableList[Target]: + return ImmutableList(self._targets) + + def get_grid(self) -> SimulationGrid: + return self._grid + + def get_max_grid_distance(self): + max_point = Position(self.get_grid().get_width(), self.get_grid().get_height()) + min_point = Position(0, 0) + return self._distancing.calculate_distance(min_point, max_point) + + def get_distancing_heatmap(self) -> Heatmap: + return self._distancing_heatmap + + def get_target(self, x: int, y: int) -> Target | None: + for target in self._targets: + if target.is_coordinate_inside_target(x, y): + return target + return None + + def get_pedestrians(self) -> ImmutableList[Pedestrian]: + return ImmutableList(self._pedestrians) + + def get_steps(self) -> int: + return self._steps + + def get_run_time(self) -> float: + return self._run_time + + def is_done(self) -> bool: + return all(spawner.is_done() for spawner in self._spawners) and len(self._pedestrians) == 0 + + def update(self, delta: float = None): + delta = delta or self._time_resolution + self._update_spawners(delta) + self._update_distancing_heatmap() + self._update_targets() + self._update_waypoints() + self._update_pedestrians(delta) + self._update_flow_meters(delta) + self._steps += 1 + self._run_time += delta + + def _init_flow_meter_logs(self): + if self._flow_meters == None: + return + for flow_meter in self._flow_meters: + flow_meter.create_log() + + def _remove_waypoint(self, waypoint: Waypoint): + waypoint.get_pedestrian().clear_waypoint() + self._waypoints.remove(waypoint) + + def _create_waypoint(self, cell: Cell, pedestrian: Pedestrian): + waypoint = Waypoint(self._waypoint_heatmap_generator, self._grid, cell, pedestrian) + self._waypoints.append(waypoint) + pedestrian.set_waypoint(waypoint) + + def _remove_pedestrian(self, pedestrian: Pedestrian): + if not self._teleporter: + self._pedestrians.remove(pedestrian) + cell = self._grid.get_cell_at_pos(pedestrian) + cell.remove_pedestrian() + pedestrian.set_reached_target() + if self._teleporter and self._teleporter.has_free_cell(): + self._respawn_pedestrian_in_cell(pedestrian) + self._pedestrians.remove(pedestrian) + cell = self._grid.get_cell_at_pos(pedestrian) + cell.remove_pedestrian() + pedestrian.set_reached_target() + + def _respawn_pedestrian_in_cell(self, pedestrian: Pedestrian) -> None: + speed = pedestrian.get_optimal_speed() + spawner = pedestrian.get_spawner() + target = pedestrian.get_target() + distancing = pedestrian.get_distancing() + time_alive = pedestrian.get_time_alive() + self._add_pedestrian(self._teleporter.spawn(speed, spawner, target, distancing, time_alive)) + + def _add_pedestrian(self, pedestrian: Pedestrian): + cell = self._grid.get_cell_at_pos(pedestrian) + cell.set_pedestrian(pedestrian) + self._pedestrians.append(pedestrian) + + def _update_spawners(self, delta: float): + for spawner in self._spawners: + for pedestrian in spawner.update(delta): + self._add_pedestrian(pedestrian) + + def _update_distancing_heatmap(self): + pedestrian_cells = [self._grid.get_cell_at_pos(pedestrian) for pedestrian in self._pedestrians] + self._distancing_heatmap = self._social_distancing_generator.generate_heatmap(pedestrian_cells, + self._grid) + + def _update_targets(self): + for target in self._targets: + target.update_heatmap() + + def _get_cell_value(self, pos: Position, last_pos: Position, cell: Cell, heatmap: Heatmap) -> float: + value = heatmap.get_cell_at_pos(cell) + value += min(0, self._distancing_heatmap.get_cell_at_pos(cell) - self._social_distancing_generator.get_bias(last_pos, cell)) + if self._occupation_bias_modifier is not None: + value += (self._occupation_bias_modifier * cell.get_pedestrian().get_occupation_bias()) if cell.is_occupied() else 0 + + value += self._distancing.calculate_distance(pos, cell) + return value + + def _get_next_target_cell(self, heatmap: Heatmap, pos: Position, last_pos: Position) -> Cell | None: + neighbours = self._grid.get_neighbours_at(pos) + neigbour_values = [(cell, self._get_cell_value(pos, last_pos, cell, heatmap)) for cell in neighbours] + sorted_neighbours = sorted(neigbour_values, key=lambda n: n[1]); + for cell, value in sorted_neighbours: + if (self._occupation_bias_modifier is not None or cell.is_free()) and value != Heatmap.INFINITY: + return cell + + return None + + def _get_next_pedestrian_target(self, pedestrian: Pedestrian, last_pos: Position) -> Cell | None: + if pedestrian.is_inside_target(): + self._remove_pedestrian(pedestrian) + return None + elif pedestrian.has_waypoint(): + return pedestrian.get_waypoint().next_cell(pedestrian) + else: + return self._get_next_target_cell(pedestrian.get_target().get_heatmap(), pedestrian, last_pos) + + + def _get_waypoint_heatmap(self, target: Target) -> Heatmap: + if target not in self._waypoint_heatmap_cache: + self._waypoint_heatmap_cache[target] = self._waypoint_heatmap_generator.generate_heatmap(target.get_cells(), self._grid) + + return self._waypoint_heatmap_cache[target] + + def _find_waypoint(self, cell: Cell, target: Target, depth: int = 10) -> Cell | None: + heatmap = self._get_waypoint_heatmap(target) + queue = PathfindingQueue() + queue.push(cell, heatmap.get_cell_at_pos(cell)) + current: Cell = cell + while depth > 0 and not queue.is_empty(): + current = queue.pop() + for neighbour in self._grid.get_neighbours_at(current): + if neighbour in queue: + continue + + if neighbour.is_free(): + queue.push(neighbour, heatmap.get_cell_at_pos(neighbour)) + else: + queue.mark_visited(neighbour) + + depth -= 1 + + return None if cell.pos_equals(current) else (current if queue.is_empty() else queue.pop()) + + def _update_pedestrians(self, delta: float): + for pedestrian in sorted(self._pedestrians, key=lambda p: p.get_current_distance()): + pedestrian.update(delta) + + if pedestrian.has_reached_waypoint(): + self._remove_waypoint(pedestrian.get_waypoint()) + + if pedestrian.can_move(): + cell = self._grid.get_cell_at_pos(pedestrian) + cell.remove_pedestrian() + pedestrian.move() + pedestrian.get_targeted_cell().set_pedestrian(pedestrian) + new_target_cell = self._get_next_pedestrian_target(pedestrian, cell) + pedestrian.set_target_cell(new_target_cell) + + elif pedestrian.has_targeted_cell() is False: + new_target_cell = self._get_next_pedestrian_target(pedestrian, pedestrian) + pedestrian.set_target_cell(new_target_cell) + elif (pedestrian.get_targeted_cell().is_occupied() and + self._retargeting_threshold is not None and + self._retargeting_threshold > pedestrian.get_current_distance() and + (new_target_cell := self._get_next_pedestrian_target(pedestrian, pedestrian)) and + new_target_cell is not None and + new_target_cell.is_free()): + + pedestrian.set_target_cell(new_target_cell) + elif (pedestrian.has_waypoint() is False and + self._waypoint_threshold is not None and + self._waypoint_threshold > pedestrian.get_current_distance() and + (waypoint_target := self._find_waypoint(self._grid.get_cell_at_pos(pedestrian), pedestrian.get_target(), self._waypoint_distance)) is not None): + + self._create_waypoint(waypoint_target, pedestrian) + else: + pass + # pedestrian is stuck, do nothing + + + def _update_flow_meters(self, delta: float) -> None: + if self._flow_meters == None: + return + pedestrian_densety = self._get_pedestrian_density() + for flow_meter in self._flow_meters: + flow_meter.log(delta, self._run_time, pedestrian_densety) + + def _get_pedestrian_density(self) -> float: + return len(self._pedestrians) / self._number_of_movable_cells + + def _update_waypoints(self): + self._waypoint_heatmap_cache.clear() + for waypoint in self._waypoints: + waypoint.update() + + def get_serialization_data(self) -> dict[str, any]: + return { + "run_time": self._run_time, + "step": self._steps, + "pedestrians": self._pedestrians, + "targets": self._targets, + "spawners": self._spawners, + "waypoints": self._waypoints, + "social_distancing_heatmap": utils.utils.heatmap_to_base64(self._distancing_heatmap), + } + + def get_identifier(self) -> str: + return "simulation" \ No newline at end of file diff --git a/simulation/core/simulation_grid.py b/simulation/core/simulation_grid.py new file mode 100644 index 0000000..7fe07b8 --- /dev/null +++ b/simulation/core/simulation_grid.py @@ -0,0 +1,37 @@ +from typing import Type, Generator + +from scipy.stats import wasserstein_distance + +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from serialization.serializable import Serializable +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.grid_base import GridBase +from simulation.core.position import Position +from simulation.neighbourhood.base_neighbourhood import NeighbourhoodBase + + +class SimulationGrid(GridBase[Cell], Serializable): + def __init__(self, width: int, height: int, neighbourhood: Type[NeighbourhoodBase]): + super().__init__(width, height) + self._neighbourhood: NeighbourhoodBase = neighbourhood(width, height) + + def _init_cell(self, x: int, y: int) -> Cell: + return Cell(x, y) + + def get_neighbours(self, x: int, y: int, width: int = 1, height: int = 1) -> Generator[Cell]: + for x, y in self._neighbourhood.get_neighbours(x, y, width, height): + yield self.get_cell(x, y) + + def get_neighbours_at(self, pos: Position, width: int = 1, height: int = 1) -> Generator[Cell]: + return self.get_neighbours(pos.get_x(), pos.get_y(), width, height) + + def get_serialization_data(self) -> dict[str, any]: + return { + "cells": self.cells + } + + def get_identifier(self) -> str: + return "grid" + diff --git a/simulation/core/spawner.py b/simulation/core/spawner.py new file mode 100644 index 0000000..abe4094 --- /dev/null +++ b/simulation/core/spawner.py @@ -0,0 +1,104 @@ +import random +from typing import Generator + +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from serialization.serializable import Serializable +from simulation.core.pedestrian import Pedestrian +from simulation.core.targeting_stratey import TargetingStrategy + +from utils.clipped_normal_distribution import ClippedNormalDistribution + +from typing import TYPE_CHECKING + +from utils.immutable_list import ImmutableList + +if TYPE_CHECKING: + from simulation.core.cell import Cell + from simulation.core.target import Target + from simulation.heatmaps.distancing.base_distance import DistanceBase + + +class Spawner(Serializable): + # Chosen by values from this paper: https://ieeexplore.ieee.org/document/6977742 + SPEED_DISTRIBUTION = ClippedNormalDistribution(1.34, 0.26, 0.69, 2.45) + + def __init__(self, name: str, distancing: 'DistanceBase', cells: list['Cell'], targets: list['Target'], total_spawns: int | None, batch_size: int | None, spawn_delay: float, initial_delay: float, targeting_strategy: TargetingStrategy = TargetingStrategy.RANDOM): + self._name: str = name + self._cells: list['Cell'] = cells + self._targets: list['Target'] = targets + self._total_spawns: int | None = total_spawns + self._batch_size: int | None = None if batch_size is None else min(batch_size, len(cells)) # can't spawn more pedestrians than there are cells in one batch + self._spawn_delay: float = spawn_delay + self._current_delay: float = initial_delay + self._distancing: 'DistanceBase' = distancing + self._targeting_strategy: TargetingStrategy = targeting_strategy + self._spawn_count: int = 0 + + def get_name(self) -> str: + return self._name + + def get_spawn_count(self) -> int: + return self._spawn_count + + def get_cells(self) -> ImmutableList['Cell']: + return ImmutableList(self._cells) + + def can_spawn(self) -> bool: + return (self._total_spawns is None or self._total_spawns > 0) and self._current_delay <= 0 + + def is_done(self) -> bool: + return self._total_spawns == 0 + + def update(self, delta: float) -> Generator[Pedestrian]: + if not self.is_done(): + self._current_delay -= delta + if self.can_spawn(): + self._current_delay = self._spawn_delay + yield from self.spawn() + + def decrement_total_spawns(self) -> bool: + if self._total_spawns is not None: + if self._total_spawns > 0: + self._total_spawns -= 1 + self._spawn_count += 1 + return True + else: + return False + + self._spawn_count += 1 + return True + + def _get_target(self, cell) -> 'Target': + if self._targeting_strategy == TargetingStrategy.RANDOM: + return random.choice(self._targets) + elif self._targeting_strategy == TargetingStrategy.CLOSEST: + return min(self._targets, key=lambda target: min(self._distancing.calculate_distance(cell, x) for x in target.get_cells())) + elif self._targeting_strategy == TargetingStrategy.FARTHEST: + return max(self._targets, key=lambda target: max(self._distancing.calculate_distance(cell, x) for x in target.get_cells())) + else: + raise SimulationError(SimulationErrorCode.NOT_IMPLEMENTED_IN_SIMULATION, {"targeting-strategy": self._targeting_strategy}) + + def spawn(self) -> Generator[Pedestrian]: + free_cells = list([cell for cell in self._cells if cell.is_free()]) + random.shuffle(free_cells) + for spawn_index in range(min(len(free_cells), self._batch_size)): + if self.decrement_total_spawns() is False: + break + + cell = free_cells[spawn_index] + target = self._get_target(cell) + speed = self.SPEED_DISTRIBUTION.sample() + pedestrian = Pedestrian(cell.get_x(), cell.get_y(), speed, self, target, self._distancing) + yield pedestrian + + def get_serialization_data(self) -> dict[str, any]: + return { + "id": self.get_identifier(), + "total_spawns": self._total_spawns, + "current_delay": self._current_delay, + "spawn_count": self._spawn_count + } + + def get_identifier(self) -> str: + return self._name \ No newline at end of file diff --git a/simulation/core/target.py b/simulation/core/target.py new file mode 100644 index 0000000..8d311c8 --- /dev/null +++ b/simulation/core/target.py @@ -0,0 +1,74 @@ +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from simulation.core.cell_state import CellState +from serialization.serializable import Serializable +from simulation.core.position import Position +from utils import utils +from typing import TYPE_CHECKING + +from utils.immutable_list import ImmutableList + +if TYPE_CHECKING: + from simulation.core.simulation_grid import SimulationGrid + from simulation.core.cell import Cell + from simulation.heatmaps.heatmap import Heatmap + from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase + +class Target(Serializable): + def __init__(self, name: str, cells: 'list[Cell]', grid: 'SimulationGrid', heatmap_generator: 'HeatmapGeneratorBase'): + self._name: str = name + self._cells: 'list[Cell]' = cells + self._heatmap_generator: 'HeatmapGeneratorBase' = heatmap_generator + self._grid: 'SimulationGrid' = grid + self._heatmap: 'Heatmap'|None = None + self._exit_count: int = 0 + self._is_static_heatmap: bool = CellState.OCCUPIED not in heatmap_generator.get_blocked() + + def get_name(self) -> str: + return self._name + + def get_cells(self) -> ImmutableList['Cell']: + return ImmutableList(self._cells) + + def get_heatmap(self) -> 'Heatmap': + if self._heatmap is None: + raise SimulationError(SimulationErrorCode.VALUE_NOT_INITIALIZED, {"value": "heatmap"}) + + return self._heatmap + + def update_heatmap(self) -> None: + if self._is_static_heatmap is False or self._heatmap is None: + self._heatmap = self._heatmap_generator.generate_heatmap(self._cells, self._grid) + + def increment_exit_count(self) -> None: + self._exit_count += 1 + + def get_exit_count(self) -> int: + return self._exit_count + + def is_coordinate_inside_target(self, x: int, y: int) -> bool: + for cell in self._cells: + if cell.get_x() == x and cell.get_y() == y: + return True + + def is_inside_target(self, pos: Position) -> bool: + for cell in self._cells: + if pos.pos_equals(cell): + return True + + return False + + def get_serialization_data(self) -> dict[str, any]: + data = { + "id": self.get_identifier(), + "exit_count": self._exit_count, + #"heatmap": utils.heatmap_to_base64(self._heatmap) + } + + if not self._is_static_heatmap: + data["heatmap"] = utils.heatmap_to_base64(self._heatmap) + + return data + + def get_identifier(self) -> str: + return self._name diff --git a/simulation/core/targeting_stratey.py b/simulation/core/targeting_stratey.py new file mode 100644 index 0000000..6de742b --- /dev/null +++ b/simulation/core/targeting_stratey.py @@ -0,0 +1,7 @@ +from enum import Enum, IntEnum + + +class TargetingStrategy(IntEnum): + RANDOM = 1, + CLOSEST = 2, + FARTHEST = 3 \ No newline at end of file diff --git a/simulation/core/teleporter.py b/simulation/core/teleporter.py new file mode 100644 index 0000000..7408a5e --- /dev/null +++ b/simulation/core/teleporter.py @@ -0,0 +1,33 @@ +import random +from typing import Generator +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from simulation.core.pedestrian import Pedestrian + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.cell import Cell + from simulation.core.target import Target + from simulation.core.spawner import Spawner + from simulation.heatmaps.distancing.base_distance import DistanceBase + +class Teleporter(): + + def __init__(self, cells: list['Cell']) -> None: + self._cells: list['Cell'] = cells + + def has_free_cell(self) -> bool: + for cell in self._cells: + if (cell.is_free()): + return True + return False + + def spawn(self, speed, spawner: 'Spawner', target: 'Target', distancing: 'DistanceBase', time_alive: float) -> Pedestrian: + free_cells = list([cell for cell in self._cells if cell.is_free()]) + random.shuffle(free_cells) + if len(free_cells) < 1: + raise SimulationError(SimulationErrorCode.TELEPORTER_FULL) + cell = free_cells[0] + pedestrian = Pedestrian(cell.get_x(), cell.get_y(), speed, spawner, target, distancing, time_alive) + return pedestrian \ No newline at end of file diff --git a/simulation/core/waypoint.py b/simulation/core/waypoint.py new file mode 100644 index 0000000..baafd89 --- /dev/null +++ b/simulation/core/waypoint.py @@ -0,0 +1,64 @@ +import utils.utils +from serialization.serializable import Serializable +from simulation.core.cell_state import CellState +from simulation.core.position import Position +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from simulation.core.pedestrian import Pedestrian + from simulation.core.cell import Cell + +class Waypoint(Serializable): + WAYPOINT_ID_COUNTER = 0 + + @staticmethod + def get_next_id() -> int: + Waypoint.WAYPOINT_ID_COUNTER += 1 + return Waypoint.WAYPOINT_ID_COUNTER + + def __init__(self, heatmap_generator: HeatmapGeneratorBase, grid: SimulationGrid, cell: 'Cell', pedestrian: 'Pedestrian'): + self._id = Waypoint.get_next_id() + self._heatmap_generator = heatmap_generator + self._heatmap: Heatmap = None + self._is_static_heatmap = CellState.OCCUPIED in heatmap_generator.get_blocked() + self._grid: SimulationGrid = grid + self._cell = cell + self._pedestrian: 'Pedestrian' = pedestrian + self.update() + + def get_pedestrian(self) -> 'Pedestrian': + return self._pedestrian + + def get_heatmap(self) -> Heatmap: + return self._heatmap + + def update(self): + if not self._is_static_heatmap and self._heatmap is None: + self._heatmap = self._heatmap_generator.generate_heatmap([self._cell], self._grid) + + def get_cell(self) -> 'Cell': + return self._cell + + def next_cell(self, current: Position) -> 'Cell|None': + for cell in sorted(self._grid.get_neighbours_at(current), key=lambda x: self._heatmap.get_cell_at_pos(x)): + if cell.is_free(): + return cell + + return None + + def is_inside_waypoint(self, pos: Position): + return self._cell.pos_equals(pos) + + def get_serialization_data(self) -> dict[str, any]: + return { + "id": self.get_identifier(), + "cell": self._cell.get_identifier(), + "pedestrian": self._pedestrian.get_identifier(), + "heatmap": utils.utils.heatmap_to_base64(self._heatmap) + } + + def get_identifier(self) -> str: + return str(f"W_{self._id}") \ No newline at end of file diff --git a/tests/simulation/__init__.py b/simulation/heatmaps/__init__.py similarity index 100% rename from tests/simulation/__init__.py rename to simulation/heatmaps/__init__.py diff --git a/simulation/heatmaps/distance_heatmap_generator.py b/simulation/heatmaps/distance_heatmap_generator.py new file mode 100644 index 0000000..d0896a9 --- /dev/null +++ b/simulation/heatmaps/distance_heatmap_generator.py @@ -0,0 +1,31 @@ +from typing import Iterable + +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase +from simulation.heatmaps.distancing.base_distance import DistanceBase + + +class DistanceHeatmapGenerator(HeatmapGeneratorBase): + def __init__(self, distancing: DistanceBase, blocked=None): + """ + :param distancing: algorithm for calculating distance between cells + :param delta_x: distance between cells + :param blocked: set of CellStates that are considered blocked, default is {CellState.OBSTACLE} + """ + super().__init__(blocked if blocked is not None else {CellState.OBSTACLE}) + self._distancing = distancing + self._delta_x = distancing.get_scale() + + def generate_heatmap(self, target: Iterable[Cell], grid: SimulationGrid) -> Heatmap: + heatmap = Heatmap(grid.get_width(), grid.get_height()) + for cell in grid.get_cells(): + if cell.get_state() in self.get_blocked(): + heatmap.set_cell_at_pos(cell, Heatmap.INFINITY) + + min_distance = min([self._distancing.calculate_distance(cell, target_cell) for target_cell in target]) + heatmap.set_cell_at_pos(cell, min_distance) + + return heatmap \ No newline at end of file diff --git a/tests/simulation/locomotionAlgorithms/testDijkstra.py b/simulation/heatmaps/distancing/__init__.py similarity index 100% rename from tests/simulation/locomotionAlgorithms/testDijkstra.py rename to simulation/heatmaps/distancing/__init__.py diff --git a/simulation/heatmaps/distancing/base_distance.py b/simulation/heatmaps/distancing/base_distance.py new file mode 100644 index 0000000..ffe2c64 --- /dev/null +++ b/simulation/heatmaps/distancing/base_distance.py @@ -0,0 +1,19 @@ +from abc import abstractmethod, ABC + +from simulation.core.position import Position + + +class DistanceBase(ABC): + def __init__(self, scale: float = 1.0): + self._scale = scale + pass + + @abstractmethod + def _calculate_distance(self, pos1: Position, pos2: Position) -> float: + pass + + def get_scale(self) -> float: + return self._scale + + def calculate_distance(self, pos1: Position, pos2: Position) -> float: + return self._calculate_distance(pos1, pos2) * self._scale \ No newline at end of file diff --git a/simulation/heatmaps/distancing/euclidean_distance.py b/simulation/heatmaps/distancing/euclidean_distance.py new file mode 100644 index 0000000..34ccf93 --- /dev/null +++ b/simulation/heatmaps/distancing/euclidean_distance.py @@ -0,0 +1,15 @@ +import math + +from simulation.core.position import Position +from simulation.heatmaps.distancing.base_distance import DistanceBase + + +class EuclideanDistance(DistanceBase): + def __init__(self, scale: float): + """ + :param scale: scale of the distance calculation + """ + super().__init__(scale) + + def _calculate_distance(self, pos1: Position, pos2: Position) -> float: + return math.sqrt(math.pow (pos1.get_x() - pos2.get_x(), 2) + math.pow(pos1.get_y() - pos2.get_y(), 2)) diff --git a/simulation/heatmaps/distancing/taxi_distance.py b/simulation/heatmaps/distancing/taxi_distance.py new file mode 100644 index 0000000..f1ff5db --- /dev/null +++ b/simulation/heatmaps/distancing/taxi_distance.py @@ -0,0 +1,13 @@ +from simulation.core.position import Position +from simulation.heatmaps.distancing.base_distance import DistanceBase + + +class TaxiDistance(DistanceBase): + def __init__(self, scale: float): + """ + :param scale: scale of the distance calculation + """ + super().__init__(scale) + + def _calculate_distance(self, pos1: Position, pos2: Position) -> float: + return abs(pos1.get_x() - pos2.get_x()) + abs(pos1.get_y() - pos2.get_y()) \ No newline at end of file diff --git a/simulation/heatmaps/djisktra_heatmap_generator.py b/simulation/heatmaps/djisktra_heatmap_generator.py new file mode 100644 index 0000000..f7ba381 --- /dev/null +++ b/simulation/heatmaps/djisktra_heatmap_generator.py @@ -0,0 +1,51 @@ +import heapq +from typing import Iterable + +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.grid_base import GridBase +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase +from simulation.heatmaps.pathfinding_queue import PathfindingQueue + + +class DijkstraHeatmapGenerator(HeatmapGeneratorBase): + """ + Heatmap generator using the Dijkstra algorithm + """ + def __init__(self, distancing: DistanceBase, blocked: set[CellState] = None): + """ + :param distancing: algorithm for calculating distance between cells + :param blocked: set of CellStates that are considered blocked, default is {CellState.OBSTACLE} + """ + super().__init__(blocked if blocked is not None else {CellState.OBSTACLE}) + self._distancing = distancing + + + def generate_heatmap(self, target: Iterable[Cell], grid: SimulationGrid) -> Heatmap: + heatmap = Heatmap(grid.get_width(), grid.get_height()) + visited: PathfindingQueue[Cell] = PathfindingQueue() + + for cell in target: + heatmap.set_cell(cell.get_x(), cell.get_y(), 0) + visited.push(cell, 0) + + for cell in grid.get_cells(): + if cell.get_state() in self._blocked: # can't enter cells which aren't free + heatmap.set_cell(cell.get_x(), cell.get_y(), Heatmap.INFINITY) + visited.mark_visited(cell) + + while len(visited) > 0: + current = visited.pop() + current_distance = heatmap.get_cell_at_pos(current) + neighbours = grid.get_neighbours_at(current, 1) + for neighbour in neighbours: + if neighbour not in visited: + distance = current_distance + self._distancing.calculate_distance(current, neighbour) + if heatmap.get_cell_at_pos(neighbour) > distance: + heatmap.set_cell(neighbour.get_x(), neighbour.get_y(), distance) + visited.push(neighbour, distance) + + return heatmap diff --git a/simulation/heatmaps/fast_marching_heatmap_generator.py b/simulation/heatmaps/fast_marching_heatmap_generator.py new file mode 100644 index 0000000..d1f57a0 --- /dev/null +++ b/simulation/heatmaps/fast_marching_heatmap_generator.py @@ -0,0 +1,98 @@ +import math +from typing import Iterable, Generator + +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase +from simulation.heatmaps.pathfinding_queue import PathfindingQueue +from simulation.neighbourhood.base_neighbourhood import NeighbourhoodBase +from simulation.neighbourhood.moore_neighbourhood import MooreNeighbourhood +from simulation.neighbourhood.neumann_neighbourhood import NeumannNeighbourhood + +class FastMarchingHeatmapGenerator(HeatmapGeneratorBase): + """ + Heatmap generator using the Fast Marching Method + """ + def __init__(self, distancing: DistanceBase, blocked=None): + """ + :param distancing: algorithm for calculating distance between cells + :param delta_x: distance between cells + :param blocked: set of CellStates that are considered blocked, default is {CellState.OBSTACLE} + """ + super().__init__(blocked if blocked is not None else {CellState.OBSTACLE}) + self._neumann = NeumannNeighbourhood(1, 1) + self._distancing = distancing + self._delta_x = distancing.get_scale() + + def _get_narrow_band(self, cell: Cell, grid: SimulationGrid) -> Generator[Cell]: + for x, y in self._neumann.get_neighbours(cell.get_x(), cell.get_y(), 1, 1): + if grid.is_in_bounds(x, y): + yield grid.get_cell(x, y) + + + def _get_fixed_neighbours(self, cell: Cell, fixed: set[Cell], grid: SimulationGrid) -> Generator[Cell]: + narrow_band = self._get_narrow_band(cell, grid) + for neighbour in narrow_band: + if neighbour in fixed and neighbour.get_state() != CellState.OBSTACLE: + yield neighbour + + + def _are_opposite(self, a: Cell, b: Cell) -> bool: + return a.get_x() == b.get_x() or a.get_y() == b.get_y() + + def _calculate_travel_time(self, cell: Cell, heatmap: Heatmap, fixed: set[Cell], grid: SimulationGrid) -> float: + fixed_neighbours = list(self._get_fixed_neighbours(cell, fixed, grid)) + if len(fixed_neighbours) == 0: + return Heatmap.INFINITY + elif len(fixed_neighbours) == 1: + return heatmap.get_cell_at_pos(fixed_neighbours[0]) + self._distancing.calculate_distance(cell, fixed_neighbours[0]) + elif len(fixed_neighbours) == 2: + a = heatmap.get_cell_at_pos(fixed_neighbours[0]) + b = heatmap.get_cell_at_pos(fixed_neighbours[1]) + return (a + b + math.sqrt(2 * (1/self._delta_x)**2 - ((a - b) ** 2))) / 2 + elif len(fixed_neighbours) == 3: + single = fixed_neighbours[0] if self._are_opposite(fixed_neighbours[1], fixed_neighbours[2]) else fixed_neighbours[1] if self._are_opposite(fixed_neighbours[0], fixed_neighbours[2]) else fixed_neighbours[2] + others = [heatmap.get_cell_at_pos(neighbour) for neighbour in fixed_neighbours if neighbour != single] + a = heatmap.get_cell_at_pos(single) + b = min(others[0], others[1]) + return (a + b + math.sqrt(2 * (1/self._delta_x)**2 - ((a - b) ** 2))) / 2 + + + + def generate_heatmap(self, target: Iterable[Cell], grid: SimulationGrid) -> Heatmap: + heatmap: Heatmap = Heatmap(grid.get_width(), grid.get_height()) + visited: PathfindingQueue[Cell] = PathfindingQueue() + fixed: set[Cell] = set() + + for cell in target: + visited.mark_visited(cell) + heatmap.set_cell(cell.get_x(), cell.get_y(), 0) + fixed.add(cell) + + for cell in target: + for neighbour in self._get_narrow_band(cell, grid): + if neighbour not in visited: + distance = self._calculate_travel_time(neighbour, heatmap, fixed, grid) + heatmap.set_cell(neighbour.get_x(), neighbour.get_y(), distance) + visited.push(neighbour, distance) + + while not visited.is_empty(): + lowest = visited.pop() + fixed.add(lowest) + for neighbour in self._get_narrow_band(lowest, grid): + if neighbour not in visited: + if neighbour.get_state() in self._blocked: + heatmap.set_cell(neighbour.get_x(), neighbour.get_y(), Heatmap.INFINITY) + visited.mark_visited(neighbour) + else: + distance = self._calculate_travel_time(neighbour, heatmap, fixed, grid) + if distance < heatmap.get_cell_at_pos(neighbour): + heatmap.set_cell(neighbour.get_x(), neighbour.get_y(), distance) + visited.push(neighbour, distance) + + return heatmap \ No newline at end of file diff --git a/simulation/heatmaps/heatmap.py b/simulation/heatmaps/heatmap.py new file mode 100644 index 0000000..344a620 --- /dev/null +++ b/simulation/heatmaps/heatmap.py @@ -0,0 +1,28 @@ +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode +from simulation.core.grid_base import GridBase +from simulation.core.position import Position + + +class Heatmap(GridBase[float]): + """ + Heatmap represents a special grid of float values, it's main usage is for building 2D navigation maps + width: int - width of the heatmap + height: int - height of the heatmap + initial_value: float - initial value for each cell, default is INFINITY + """ + + INFINITY = float("inf") + def __init__(self, width: int, height: int, initial_value: float = INFINITY): + self._initial_value = initial_value + super().__init__(width, height) + + def _init_cell(self, x: int, y: int) -> float: + return self._initial_value + + def set_cell(self, x: int, y: int, value: float) -> None: + self.check_bounds(x, y) + self.cells[self._get_cell_index(x, y)] = value + + def set_cell_at_pos(self, pos: Position, value: float) -> None: + self.set_cell(pos.get_x(), pos.get_y(), value) \ No newline at end of file diff --git a/simulation/heatmaps/heatmap_generator_base.py b/simulation/heatmaps/heatmap_generator_base.py new file mode 100644 index 0000000..8802162 --- /dev/null +++ b/simulation/heatmaps/heatmap_generator_base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import Iterable + +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.grid_base import GridBase +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap + + +class HeatmapGeneratorBase(ABC): + """ + Base class for heatmap generators. Heatmap generators generate heatmaps based on the given target and grid + """ + + def __init__(self, blocked: set[CellState] ): + self._blocked: set[CellState] = blocked + pass + + + @abstractmethod + def generate_heatmap(self, target: Iterable[Cell], grid: SimulationGrid) -> Heatmap: + """ + Generates heatmap based on the given target and grid + :param target: target cells off the heatmap + :param grid: the simulation grid the heatmap should be generated for + :return: a heatmap where each cell is assigned a distance to the closest target cell + """ + pass + + def get_blocked(self) -> set[CellState]: + return self._blocked \ No newline at end of file diff --git a/simulation/heatmaps/infinity_heatmap_generator.py b/simulation/heatmaps/infinity_heatmap_generator.py new file mode 100644 index 0000000..4a21eb4 --- /dev/null +++ b/simulation/heatmaps/infinity_heatmap_generator.py @@ -0,0 +1,27 @@ +from typing import Iterable + +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase +from simulation.heatmaps.distancing.base_distance import DistanceBase + + +class InfinityHeatmapGenerator(HeatmapGeneratorBase): + def __init__(self, distancing: DistanceBase, blocked=None): + """ + :param distancing: algorithm for calculating distance between cells + :param delta_x: distance between cells + :param blocked: set of CellStates that are considered blocked, default is {CellState.OBSTACLE} + """ + super().__init__(blocked if blocked is not None else {CellState.OBSTACLE}) + self._distancing = distancing + self._delta_x = distancing.get_scale() + + def generate_heatmap(self, target: Iterable[Cell], grid: SimulationGrid) -> Heatmap: + heatmap = Heatmap(grid.get_width(), grid.get_height()) + for cell in grid.get_cells(): + heatmap.set_cell_at_pos(cell, Heatmap.INFINITY) + + return heatmap \ No newline at end of file diff --git a/simulation/heatmaps/pathfinding_queue.py b/simulation/heatmaps/pathfinding_queue.py new file mode 100644 index 0000000..fd031fb --- /dev/null +++ b/simulation/heatmaps/pathfinding_queue.py @@ -0,0 +1,66 @@ +import heapq + + +class PriorityItem[T]: + """ + Wrapper class for items in the PathfindingQueue, holds a float for priority and an item of generic type T + """ + def __init__(self, item: T, priority: float): + self.item = item + self.priority = priority + + def __lt__(self, other): + return self.priority < other.priority + + def __eq__(self, other): + return self.priority == other.priority + + def __gt__(self, other): + return self.priority > other.priority + + def get_item(self) -> T: + return self.item + +class PathfindingQueue[T]: + """ + Priority queue for pathfinding algorithms, which also keeps track of visited items + """ + def __init__(self): + self._queue: list[PriorityItem[T]] = [] + self._items: set[T] = set() + + def __contains__(self, item: T) -> bool: + return item in self._items + + def __len__(self): + return len(self._queue) + + def mark_visited(self, item: T) -> None: + """ + Marks an item as visited + :param item: item which should be marked as visited + """ + self._items.add(item) + + def pop(self) -> T: + """ + Pops the item with the lowest priority from the queue + :return: popped item + """ + item = heapq.heappop(self._queue) + return item.get_item() + + def push(self, item: T, priority: float) -> None: + """ + Pushes an item to the queue with the given priority and marks it as visited + :param item: the item to push + :param priority: the priority of the item, lowest gets popped first + """ + self._items.add(item) + heapq.heappush(self._queue, PriorityItem(item, priority)) + + def is_empty(self) -> bool: + """ + :return: return true if the queue is empty + """ + return len(self._queue) == 0 \ No newline at end of file diff --git a/simulation/heatmaps/social_distancing_heatmap_generator.py b/simulation/heatmaps/social_distancing_heatmap_generator.py new file mode 100644 index 0000000..8508ed5 --- /dev/null +++ b/simulation/heatmaps/social_distancing_heatmap_generator.py @@ -0,0 +1,63 @@ +import math +from typing import Generator, Iterable + +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from simulation.core.grid_base import GridBase +from simulation.core.pedestrian import Pedestrian +from simulation.core.position import Position +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase + + +class SocialDistancingHeatmapGenerator(HeatmapGeneratorBase): + """ + Heatmap generator which adds a social distancing force from each occupied cell + """ + + def __init__(self, distancing: DistanceBase, width: float, height: float, blocked: set[CellState] = None): + """ + :param distancing: algorithm for calculating distance between cells + :param width: width of the social distancing force + :param height: height of the social distancing force + :param blocked: set of CellStates that are considered blocked, default is {CellState.OCCUPIED} + """ + super().__init__( blocked or {CellState.OCCUPIED}) + self._distancing: DistanceBase = distancing + self._width: float = width + self._height: float = height + self._neighbour_width: int = math.ceil(width / 2) + self._neighbour_height: int = math.ceil(height / 2) + self._intensity: float = distancing.get_scale() + + def get_bias(self, center: Position, neighbour: Position): + return self._calculate_value(self._distancing.calculate_distance(center, neighbour)) + + def get_max_value(self): + return 8 * self._calculate_value(self._distancing.get_scale()) + + def _calculate_value(self, distance: float): + if abs(distance) < self._width: + return self._intensity * (self._height * math.exp(1/(math.pow(distance/self._width, 2) - 1))) + else: + return 0 + + def generate_heatmap(self, target: Iterable[Cell], grid: SimulationGrid) -> Heatmap: + """ + :param target: a list of cells which should be used to generate repulsive force, only cells with state in blocked are considered + :param grid: the simulation grid the heatmap should be generated for + :return: a heatmap where each obstacle creates a repulsive force + """ + heatmap = Heatmap(grid.get_width(), grid.get_height(), 0.0) + for cell in target: + if cell.get_state() in self._blocked: + heatmap.set_cell_at_pos(cell, self._calculate_value(0)) + for neighbour in grid.get_neighbours_at(cell, self._neighbour_width, self._neighbour_height): + current_value = heatmap.get_cell_at_pos(neighbour) + if current_value is not Heatmap.INFINITY : + value = self._calculate_value(self._distancing.calculate_distance(cell, neighbour)) + heatmap.set_cell_at_pos(neighbour, current_value + value) + + return heatmap \ No newline at end of file diff --git a/simulation/neighbourhood/__init__.py b/simulation/neighbourhood/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simulation/neighbourhood/base_neighbourhood.py b/simulation/neighbourhood/base_neighbourhood.py new file mode 100644 index 0000000..a1b3534 --- /dev/null +++ b/simulation/neighbourhood/base_neighbourhood.py @@ -0,0 +1,20 @@ +from abc import abstractmethod, ABC +from typing import Generator + +class NeighbourhoodBase(ABC): + + + """ + NeighbourhoodBase represents a basic algorithm which returns a list of neighbouring coordinates given grid dimensions and a radius + """ + def __init__(self, width: int, height: int): + """ + :param width: width of the neighbourhood + :param height: height of the neighbourhood + """ + self._height = height + self._width = width + + @abstractmethod + def get_neighbours(self, x: int, y: int, width: int, height: int) -> Generator[tuple[int, int]]: + pass \ No newline at end of file diff --git a/simulation/neighbourhood/moore_neighbourhood.py b/simulation/neighbourhood/moore_neighbourhood.py new file mode 100644 index 0000000..6fb1446 --- /dev/null +++ b/simulation/neighbourhood/moore_neighbourhood.py @@ -0,0 +1,22 @@ +from typing import Generator + +from simulation.neighbourhood.base_neighbourhood import NeighbourhoodBase + + +class MooreNeighbourhood(NeighbourhoodBase): + """ + MooreNeighbourhood represents a neighbourhood which returns all neighbours in a rectangle defined by width and height around the given cell + """ + def __init__(self, width: int, height: int): + super().__init__(width, height) + + def get_neighbours(self, x: int, y: int, width: int, height: int) -> Generator[tuple[int, int]]: + for i in range(x - width, x + width + 1): + for j in range(y - height, y + height + 1): + if i == x and j == y: + continue + + if i < 0 or i >= self._width or j < 0 or j >= self._height: + continue + + yield i, j diff --git a/simulation/neighbourhood/neumann_neighbourhood.py b/simulation/neighbourhood/neumann_neighbourhood.py new file mode 100644 index 0000000..8be5d57 --- /dev/null +++ b/simulation/neighbourhood/neumann_neighbourhood.py @@ -0,0 +1,17 @@ +from typing import Generator + +from simulation.neighbourhood.base_neighbourhood import NeighbourhoodBase + + +class NeumannNeighbourhood(NeighbourhoodBase): + """ + NeumannNeighbourhood represents a neighbourhood which returns all neighbours in cardinal directions around the given cell + """ + def get_neighbours(self, x: int, y: int, width: int, height: int) -> Generator[tuple[int, int]]: + for i in range(x - width, x + width + 1): + if i != x: + yield i, y + + for j in range(y - height, y + height + 1): + if j != y: + yield x, j \ No newline at end of file diff --git a/simulation_config/__init__.py b/simulation_config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simulation_config/chicken_test_euklid.json b/simulation_config/chicken_test_euklid.json new file mode 100644 index 0000000..3c2f46d --- /dev/null +++ b/simulation_config/chicken_test_euklid.json @@ -0,0 +1,54 @@ +{ + "grid": { + "width": 70, + "height": 30, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "u-upper", + "rect": [10, 7, 30, 7] + }, + { + "name": "u-lower", + "rect": [10, 22, 30, 22] + }, + { + "name": "u-vertical", + "rect": [30, 7, 30, 22] + } + ], + "spawners": [ + { + "name": "Spawner", + "cells": [[0, 13], [0, 14], [0, 15], [0, 16]], + "targets": ["Target"], + "total_spawns": 20, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "Target", + "cells": [[69, 13], [69, 14], [69, 15], [69, 16]], + "heatmap_generator": "DistanceHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 2.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + }, + "log_file": "chicken_test_{0}.json" +} \ No newline at end of file diff --git a/simulation_config/chicken_test_fast_marching.json b/simulation_config/chicken_test_fast_marching.json new file mode 100644 index 0000000..212d11c --- /dev/null +++ b/simulation_config/chicken_test_fast_marching.json @@ -0,0 +1,54 @@ +{ + "grid": { + "width": 70, + "height": 30, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "u-upper", + "rect": [10, 7, 30, 7] + }, + { + "name": "u-lower", + "rect": [10, 22, 30, 22] + }, + { + "name": "u-vertical", + "rect": [30, 7, 30, 22] + } + ], + "spawners": [ + { + "name": "Spawner", + "cells": [[0, 13], [0, 14], [0, 15], [0, 16]], + "targets": ["Target"], + "total_spawns": 20, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "Target", + "cells": [[69, 13], [69, 14], [69, 15], [69, 16]], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 2.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + }, + "log_file": "chicken_test_{0}.json" +} \ No newline at end of file diff --git a/simulation_config/chicken_test_flood_fill.json b/simulation_config/chicken_test_flood_fill.json new file mode 100644 index 0000000..c1d836d --- /dev/null +++ b/simulation_config/chicken_test_flood_fill.json @@ -0,0 +1,54 @@ +{ + "grid": { + "width": 70, + "height": 30, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "u-upper", + "rect": [10, 7, 30, 7] + }, + { + "name": "u-lower", + "rect": [10, 22, 30, 22] + }, + { + "name": "u-vertical", + "rect": [30, 7, 30, 22] + } + ], + "spawners": [ + { + "name": "Spawner", + "cells": [[0, 13], [0, 14], [0, 15], [0, 16]], + "targets": ["Target"], + "total_spawns": 20, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "Target", + "cells": [[69, 13], [69, 14], [69, 15], [69, 16]], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 2.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + }, + "log_file": "chicken_test_{0}.json" +} \ No newline at end of file diff --git a/simulation_config/config_loader.py b/simulation_config/config_loader.py new file mode 100644 index 0000000..46ea3fb --- /dev/null +++ b/simulation_config/config_loader.py @@ -0,0 +1,64 @@ +import json +from simulation.core.cell_state import CellState +from simulation.heatmaps.distance_heatmap_generator import DistanceHeatmapGenerator +from simulation.heatmaps.infinity_heatmap_generator import InfinityHeatmapGenerator +from simulation.heatmaps.distancing.euclidean_distance import EuclideanDistance +from simulation.heatmaps.djisktra_heatmap_generator import DijkstraHeatmapGenerator +from simulation.heatmaps.fast_marching_heatmap_generator import FastMarchingHeatmapGenerator +from simulation.neighbourhood.moore_neighbourhood import MooreNeighbourhood + + +class SimulationConfigLoader: + """Utility class to load and process simulation configuration.""" + + @staticmethod + def load_config(config_file): + """Load the JSON configuration from a file.""" + with open(config_file, 'r') as file: + return json.load(file) + + @staticmethod + def create_heatmap_generator(generator_name, distancing, blocked_states): + """Create the appropriate heatmap generator based on configuration.""" + if generator_name == "FastMarchingHeatmapGenerator": + return FastMarchingHeatmapGenerator(distancing, blocked_states) + elif generator_name == "DijkstraHeatmapGenerator": + return DijkstraHeatmapGenerator(distancing, blocked_states) + elif generator_name == "DistanceHeatmapGenerator": + return DistanceHeatmapGenerator(distancing, blocked_states) + elif generator_name == "InfinityHeatmapGenerator": + return InfinityHeatmapGenerator(distancing, blocked_states) + elif generator_name is None: + return None + else: + raise ValueError(f"Unsupported heatmap generator: {generator_name}") + + @staticmethod + def get_neighbourhood_class(neighbourhood_name): + """Map the neighbourhood name from JSON to the actual class.""" + neighbourhood_mapping = { + "MooreNeighbourhood": MooreNeighbourhood, + } + if neighbourhood_name in neighbourhood_mapping: + return neighbourhood_mapping[neighbourhood_name] + raise ValueError(f"Unsupported neighbourhood type: {neighbourhood_name}") + + @staticmethod + def get_distance_class(distance_name): + """Map the distance name from JSON to the actual class.""" + distance_mapping = { + "EuclideanDistance": EuclideanDistance + } + if distance_name in distance_mapping: + return distance_mapping[distance_name] + raise ValueError(f"Unsupported distance type: {distance_name}") + + @staticmethod + def get_cell_states(cellstate_names): + """Map the cell state names from JSON to the actual CellState objects.""" + try: + return None if cellstate_names is None else {CellState[state_name] for state_name in cellstate_names} + except KeyError as e: + raise ValueError( + f"Invalid cellstate name: {e.args[0]}. Ensure it matches the CellState enum." + ) from e diff --git a/simulation_config/demo.json b/simulation_config/demo.json new file mode 100644 index 0000000..05e4c30 --- /dev/null +++ b/simulation_config/demo.json @@ -0,0 +1,84 @@ +{ + "grid": { + "width": 105, + "height": 11, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "left_center", + "rect": [23, 4, 25, 6] + }, + { + "name": "middle_center", + "rect": [67, 4, 70, 8] + }, + { + "name": "upper_wall", + "rect": [0, 0, 104, 0] + }, + { + "name": "lower_wall", + "rect": [0, 10, 104, 10] + } + ], + "spawners": [ + { + "name": "initial Spawner", + "rect": [0, 1, 104, 9], + "targets": ["Target"], + "total_spawns": 500, + "batch_size": 500, + "spawn_delay": 1.0, + "initial_delay": 0 + }, + { + "name": "Spawner", + "rect": [0, 1, 104, 9], + "targets": ["Target"], + "total_spawns": 700, + "batch_size": 30, + "spawn_delay": 45, + "initial_delay": 5.0 + } + ], + "targets": [ + { + "name": "Target", + "rect": [104, 1, 104, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "teleports": { + "name": "teleporter", + "rect": [0,1,0,9] + }, + "social_distancing": { + "width": 3, + "height": 3 + }, + "obstacle_repulsion": { + "width": 1, + "height": 1, + "intensity": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "flow_meters": [ + { + "name": "FlowMeter", + "rect": [46, 1, 46, 9], + "time_span": 44, + "logfile": "flowMeterHigh.csv", + "log_interval": 45 + } + ], + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + } +} \ No newline at end of file diff --git a/simulation_config/experiment.json b/simulation_config/experiment.json new file mode 100644 index 0000000..0158fb2 --- /dev/null +++ b/simulation_config/experiment.json @@ -0,0 +1,70 @@ +{ + "grid": { + "width": 70, + "height": 35, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + ], + "spawners": [ + { + "name": "firstWall", + "rect": [35, 16, 35, 16], + "targets": ["infinity"], + "total_spawns": 1, + "batch_size": 3, + "spawn_delay": 1, + "initial_delay": 0.0 + }, + { + "name": "secondWall", + "rect": [35,18,35,18], + "targets": ["infinity"], + "total_spawns": 1, + "batch_size": 3, + "spawn_delay": 1, + "initial_delay": 0.0 + }, + { + "name": "pedestrian", + "rect": [0,17,0,17], + "targets": ["Target"], + "total_spawns": 1, + "batch_size": 3, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "infinity", + "rect": [8, 0, 8, 0], + "heatmap_generator": "InfinityHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "Target", + "rect": [69, 17, 69, 17], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 50, + "height": 50 + }, + "obstacle_repulsion": { + "width": 2, + "height": 2, + "intensity": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + } +} \ No newline at end of file diff --git a/simulation_config/freeflow/freeflow_any.json b/simulation_config/freeflow/freeflow_any.json new file mode 100644 index 0000000..c1c174e --- /dev/null +++ b/simulation_config/freeflow/freeflow_any.json @@ -0,0 +1,88 @@ +{ + "grid": { + "width": 51, + "height": 51, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "upper_left", + "rect": [24, 24, 24, 24], + "targets": ["door_upper_left"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + }, + { + "name": "upper_right", + "rect": [25, 25, 25, 25], + "targets": ["door_upper_right"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + }, + { + "name": "lower_left", + "rect": [24, 24, 25, 25], + "targets": ["door_lower_left"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + }, + { + "name": "lower_right", + "rect": [25, 25, 25, 25], + "targets": ["door_lower_right"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "door_upper_left", + "rect": [13, 0, 13, 0], + "heatmap_generator": "DistanceHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "door_upper_right", + "rect": [50, 13, 50, 13], + "heatmap_generator": "DistanceHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "door_lower_right", + "rect": [37, 50, 37, 50], + "heatmap_generator": "DistanceHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "door_lower_left", + "rect": [0, 37, 0, 37], + "heatmap_generator": "DistanceHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/freeflow/freeflow_bottom_top.json b/simulation_config/freeflow/freeflow_bottom_top.json new file mode 100644 index 0000000..07d4cd1 --- /dev/null +++ b/simulation_config/freeflow/freeflow_bottom_top.json @@ -0,0 +1,40 @@ +{ + "grid": { + "width": 50, + "height": 50, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "Spawner", + "rect": [24, 24, 25, 25], + "targets": ["door"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "door", + "rect": [25, 0, 25, 0], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/freeflow/freeflow_diagonal.json b/simulation_config/freeflow/freeflow_diagonal.json new file mode 100644 index 0000000..e7f83ca --- /dev/null +++ b/simulation_config/freeflow/freeflow_diagonal.json @@ -0,0 +1,88 @@ +{ + "grid": { + "width": 51, + "height": 51, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "upper_left", + "rect": [24, 24, 24, 24], + "targets": ["door_upper_left"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + }, + { + "name": "upper_right", + "rect": [25, 25, 25, 25], + "targets": ["door_upper_right"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + }, + { + "name": "lower_left", + "rect": [24, 24, 25, 25], + "targets": ["door_lower_left"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + }, + { + "name": "lower_right", + "rect": [25, 25, 25, 25], + "targets": ["door_lower_right"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "door_upper_left", + "rect": [0, 0, 0, 0], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "door_upper_right", + "rect": [50, 0, 50, 0], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "door_lower_right", + "rect": [0, 50, 0, 50], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "door_lower_left", + "rect": [50, 50, 50, 50], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/freeflow/freeflow_left_right.json b/simulation_config/freeflow/freeflow_left_right.json new file mode 100644 index 0000000..c28440a --- /dev/null +++ b/simulation_config/freeflow/freeflow_left_right.json @@ -0,0 +1,40 @@ +{ + "grid": { + "width": 51, + "height": 51, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "Spawner", + "rect": [25, 25, 25, 25], + "targets": ["door"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "door", + "rect": [50, 25, 50, 25], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/freeflow/freeflow_right_left.json b/simulation_config/freeflow/freeflow_right_left.json new file mode 100644 index 0000000..c0c4b12 --- /dev/null +++ b/simulation_config/freeflow/freeflow_right_left.json @@ -0,0 +1,40 @@ +{ + "grid": { + "width": 51, + "height": 51, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "Spawner", + "rect": [25, 25, 25, 25], + "targets": ["door"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "door", + "rect": [0, 25, 0, 25], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/freeflow/freeflow_top_bottom.json b/simulation_config/freeflow/freeflow_top_bottom.json new file mode 100644 index 0000000..07d4cd1 --- /dev/null +++ b/simulation_config/freeflow/freeflow_top_bottom.json @@ -0,0 +1,40 @@ +{ + "grid": { + "width": 50, + "height": 50, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "Spawner", + "rect": [24, 24, 25, 25], + "targets": ["door"], + "total_spawns": 1, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "door", + "rect": [25, 0, 25, 0], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/rimea_4/high.json b/simulation_config/rimea_4/high.json new file mode 100644 index 0000000..1891cf8 --- /dev/null +++ b/simulation_config/rimea_4/high.json @@ -0,0 +1,84 @@ +{ + "grid": { + "width": 105, + "height": 11, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "left_center", + "rect": [23, 4, 25, 6] + }, + { + "name": "middle_center", + "rect": [67, 4, 70, 8] + }, + { + "name": "upper_wall", + "rect": [0, 0, 104, 0] + }, + { + "name": "lower_wall", + "rect": [0, 10, 104, 10] + } + ], + "spawners": [ + { + "name": "initial Spawner", + "rect": [0, 1, 104, 9], + "targets": ["Target"], + "total_spawns": 600, + "batch_size": 600, + "spawn_delay": 1.0, + "initial_delay": 0 + }, + { + "name": "Spawner", + "rect": [0, 1, 104, 9], + "targets": ["Target"], + "total_spawns": 600, + "batch_size": 30, + "spawn_delay": 45, + "initial_delay": 5.0 + } + ], + "targets": [ + { + "name": "Target", + "rect": [104, 1, 104, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "teleports": { + "name": "teleporter", + "rect": [0,1,0,9] + }, + "social_distancing": { + "width": 3, + "height": 3 + }, + "obstacle_repulsion": { + "width": 1, + "height": 1, + "intensity": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "flow_meters": [ + { + "name": "FlowMeter", + "rect": [46, 1, 46, 9], + "time_span": 44, + "logfile": "flowMeterHigh.csv", + "log_interval": 45 + } + ], + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + } +} \ No newline at end of file diff --git a/simulation_config/rimea_4/low.json b/simulation_config/rimea_4/low.json new file mode 100644 index 0000000..02ec74b --- /dev/null +++ b/simulation_config/rimea_4/low.json @@ -0,0 +1,75 @@ +{ + "grid": { + "width": 105, + "height": 11, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "left_center", + "rect": [23, 4, 25, 6] + }, + { + "name": "middle_center", + "rect": [67, 4, 70, 8] + }, + { + "name": "upper_wall", + "rect": [0, 0, 104, 0] + }, + { + "name": "lower_wall", + "rect": [0, 10, 104, 10] + } + ], + "spawners": [ + { + "name": "Spawner", + "rect": [0, 1, 104, 9], + "targets": ["Target"], + "total_spawns": 600, + "batch_size": 10, + "spawn_delay": 45, + "initial_delay": 5.0 + } + ], + "targets": [ + { + "name": "Target", + "rect": [104, 1, 104, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "teleports": { + "name": "teleporter", + "rect": [0,1,0,9] + }, + "social_distancing": { + "width": 3, + "height": 3 + }, + "obstacle_repulsion": { + "width": 1, + "height": 1, + "intensity": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "flow_meters": [ + { + "name": "FlowMeter", + "rect": [46, 1, 46, 9], + "time_span": 44, + "logfile": "flowMeter.csv", + "log_interval": 45 + } + ], + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + } +} \ No newline at end of file diff --git a/simulation_config/rimea_test_4.json b/simulation_config/rimea_test_4.json new file mode 100644 index 0000000..9a4e53a --- /dev/null +++ b/simulation_config/rimea_test_4.json @@ -0,0 +1,69 @@ +{ + "grid": { + "width": 210, + "height": 22, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "left_center", + "rect": [7, 8, 11, 12] + }, + { + "name": "middle_center", + "rect": [97, 8, 101, 16] + }, + { + "name": "upper_wall", + "rect": [0, 0, 209, 0] + }, + { + "name": "lower_wall", + "rect": [0, 21, 209, 21] + } + ], + "spawners": [ + { + "name": "Spawner", + "rect": [0, 1, 0, 20], + "targets": ["Target"], + "total_spawns": 100, + "batch_size": 3, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "Target", + "rect": [209, 1, 209, 20], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "obstacle_repulsion": { + "width": 2, + "height": 2, + "intensity": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "flow_meters": [ + { + "name": "FlowMeter", + "rect": [95, 1, 95, 20], + "time_span": 5.0 + } + ], + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + } +} \ No newline at end of file diff --git a/simulation_config/rimea_test_4_half_size.json b/simulation_config/rimea_test_4_half_size.json new file mode 100644 index 0000000..753e3b5 --- /dev/null +++ b/simulation_config/rimea_test_4_half_size.json @@ -0,0 +1,69 @@ +{ + "grid": { + "width": 105, + "height": 11, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "left_center", + "rect": [3, 4, 5, 6] + }, + { + "name": "middle_center", + "rect": [47, 4, 50, 8] + }, + { + "name": "upper_wall", + "rect": [0, 0, 104, 0] + }, + { + "name": "lower_wall", + "rect": [0, 10, 104, 10] + } + ], + "spawners": [ + { + "name": "Spawner", + "rect": [0, 1, 0, 9], + "targets": ["Target"], + "total_spawns": 100, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "Target", + "rect": [104, 1, 104, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "obstacle_repulsion": { + "width": 1, + "height": 1, + "intensity": 1.0 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "flow_meters": [ + { + "name": "FlowMeter", + "rect": [46, 1, 46, 9], + "time_span": 5.0 + } + ], + "distancing": { + "type": "EuclideanDistance", + "scale": 0.5 + } +} \ No newline at end of file diff --git a/simulation_config/rimea_test_9_four_doors.json b/simulation_config/rimea_test_9_four_doors.json new file mode 100644 index 0000000..911f43b --- /dev/null +++ b/simulation_config/rimea_test_9_four_doors.json @@ -0,0 +1,58 @@ +{ + "grid": { + "width": 75, + "height": 50, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "Spawner", + "rect": [5, 5, 70, 45], + "targets": ["upper-left-door", "upper-right-door", "lower-left-door", "lower-right-door"], + "total_spawns": 1000, + "batch_size": 1000, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "upper-left-door", + "rect": [10, 0, 13, 0], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "upper-right-door", + "rect": [62, 0, 65, 0], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "lower-left-door", + "rect": [10, 49, 13, 49], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "lower-right-door", + "rect": [62, 49, 65, 49], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/rimea_test_9_two_doors.json b/simulation_config/rimea_test_9_two_doors.json new file mode 100644 index 0000000..2c09b99 --- /dev/null +++ b/simulation_config/rimea_test_9_two_doors.json @@ -0,0 +1,46 @@ +{ + "grid": { + "width": 75, + "height": 50, + "neighbourhood": "MooreNeighbourhood" + }, + "spawners": [ + { + "name": "Spawner", + "rect": [5, 5, 70, 45], + "targets": ["lower-left-door", "lower-right-door"], + "total_spawns": 1000, + "batch_size": 1000, + "spawn_delay": 1, + "initial_delay": 0.0, + "targeting": "CLOSEST" + } + ], + "targets": [ + { + "name": "lower-left-door", + "rect": [10, 49, 13, 49], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "lower-right-door", + "rect": [62, 49, 65, 49], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 0.4 + } +} \ No newline at end of file diff --git a/simulation_config/simulation_config.json b/simulation_config/simulation_config.json new file mode 100644 index 0000000..0e01ca0 --- /dev/null +++ b/simulation_config/simulation_config.json @@ -0,0 +1,50 @@ +{ + "grid": { + "width": 10, + "height": 10, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "walls", + "cells": [[2, 0], [2, 1], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7]] + }, + { + "name": "barriers", + "cells": [[5, 9], [5, 8], [5, 7], [5, 6], [5, 5], [5, 4], [5, 3]] + } + ], + "targets": [ + { + "name": "Target", + "cells": [[9, 9], [9, 8], [9, 7]], + "heatmap_generator": "FastMarchingHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "spawners": [ + { + "name": "Spawner", + "cells": [[0, 0], [0, 1], [1, 0], [1, 1]], + "targets": ["Target"], + "total_spawns": 20, + "batch_size": 1, + "spawn_delay": 4, + "initial_delay": 0.0 + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 2.0, + "retargeting_threshold": -2.0 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 1 + } +} + \ No newline at end of file diff --git a/simulation_config/waypoint_test.json b/simulation_config/waypoint_test.json new file mode 100644 index 0000000..3dcced1 --- /dev/null +++ b/simulation_config/waypoint_test.json @@ -0,0 +1,63 @@ +{ + "grid": { + "width": 30, + "height": 10, + "neighbourhood": "MooreNeighbourhood" + }, + "obstacles": [ + { + "name": "block", + "rect": [4, 4, 15, 8] + } + ], + "spawners": [ + { + "name": "S_left", + "cells": [[4, 9]], + "targets": ["T_right"], + "total_spawns": 2, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0 + }, + { + "name": "S_right", + "cells": [[15, 9]], + "targets": ["T_left"], + "total_spawns": 2, + "batch_size": 1, + "spawn_delay": 1, + "initial_delay": 0.0 + } + ], + "targets": [ + { + "name": "T_left", + "rect": [0, 0, 0, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + }, + { + "name": "T_right", + "rect": [29, 0, 29, 9], + "heatmap_generator": "DijkstraHeatmapGenerator", + "cellstate": ["OBSTACLE"] + } + ], + "social_distancing": { + "width": 3, + "height": 3 + }, + "distancing": { + "type": "EuclideanDistance", + "scale": 1 + }, + "simulation": { + "time_resolution": 0.1, + "occupation_bias_modifier": 1.0, + "retargeting_threshold": -2.0, + "waypoint_threshold": -3.0, + "waypoint_heatmap_generator": "FastMarchingHeatmapGenerator", + "waypoint_distance": 15 + } +} \ No newline at end of file diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index ce6deff..0000000 Binary files a/src/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a892ee8..0000000 Binary files a/src/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/__pycache__/baseSimulation.cpython-312.pyc b/src/__pycache__/baseSimulation.cpython-312.pyc deleted file mode 100644 index c2ed80a..0000000 Binary files a/src/__pycache__/baseSimulation.cpython-312.pyc and /dev/null differ diff --git a/src/__pycache__/pedestrian.cpython-312.pyc b/src/__pycache__/pedestrian.cpython-312.pyc deleted file mode 100644 index 5fcd37c..0000000 Binary files a/src/__pycache__/pedestrian.cpython-312.pyc and /dev/null differ diff --git a/src/config/__pycache__/config.cpython-311.pyc b/src/config/__pycache__/config.cpython-311.pyc deleted file mode 100644 index 28d2a6c..0000000 Binary files a/src/config/__pycache__/config.cpython-311.pyc and /dev/null differ diff --git a/src/config/__pycache__/config.cpython-312.pyc b/src/config/__pycache__/config.cpython-312.pyc deleted file mode 100644 index c7d892b..0000000 Binary files a/src/config/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/src/config/config.py b/src/config/config.py deleted file mode 100644 index 4c9a407..0000000 --- a/src/config/config.py +++ /dev/null @@ -1,46 +0,0 @@ -import os - -class _Config(): - - _DEFAULT_MAX_NUMBER_OF_ITTERATIONS: int = 200 - - def __init__(self): - self.savePath = self._getStandartPath() - self.maxItterations = self._DEFAULT_MAX_NUMBER_OF_ITTERATIONS - pass - - def getSavePath(self) -> str: - return self.savePath - - def setSavePath(self, path: str) -> None: - self.savePath = path - - def getMaxItterations(self) -> int: - return self.maxItterations - - def setMaxNumberOfItterations(self, maxItterations) -> None: - self.maxItterations = maxItterations - - def _getStandartPath(self) -> str: - pwd = os.getcwd() - return os.path.join(pwd, "data") - - def __repr__(self) -> str: - return f"---Config---\nsavePath: <{self.savePath}>\nMaxItterations: <{self.maxItterations}>" - - -class ConfigBuilder(): - - def __init__(self): - self.config = _Config() - - def setSavePath(self, path: str): - self.config.setSavePath(path) - return self - - def setNumberOfMaxItterations(self, maxItterations): - self.config.setMaxNumberOfItterations(maxItterations) - return self - - def build(self) -> _Config: - return self.config \ No newline at end of file diff --git a/src/dtoModule/__pycache__/tileDTO.cpython-311.pyc b/src/dtoModule/__pycache__/tileDTO.cpython-311.pyc deleted file mode 100644 index a5475de..0000000 Binary files a/src/dtoModule/__pycache__/tileDTO.cpython-311.pyc and /dev/null differ diff --git a/src/dtoModule/__pycache__/tileDTO.cpython-312.pyc b/src/dtoModule/__pycache__/tileDTO.cpython-312.pyc deleted file mode 100644 index b98c3f0..0000000 Binary files a/src/dtoModule/__pycache__/tileDTO.cpython-312.pyc and /dev/null differ diff --git a/src/dtoModule/tileDTO.py b/src/dtoModule/tileDTO.py deleted file mode 100644 index 4cb7c81..0000000 --- a/src/dtoModule/tileDTO.py +++ /dev/null @@ -1,19 +0,0 @@ -from src.enums.tileStatus import TileStatus - -class TileDTO(): - - def __init__(self, tileStatus: TileStatus, pedastrianValue: float) -> None: - self.tileStatus = tileStatus - self.pedastrianValue = pedastrianValue - - def getTileStatus(self) -> TileStatus: - return self.tileStatus - - def setTileStatus(self, tileStatus: TileStatus) -> None: - self.tileStatus = tileStatus - - def getPedestrianValue(self) -> float: - return self.pedastrianValue - - def setPedestrianValue(self, pedastrianValue: float) -> None: - self.pedastrianValue = pedastrianValue \ No newline at end of file diff --git a/src/dtoModule/tileValueDTO.py b/src/dtoModule/tileValueDTO.py deleted file mode 100644 index 77e388a..0000000 --- a/src/dtoModule/tileValueDTO.py +++ /dev/null @@ -1,20 +0,0 @@ - - -class TileValueDTO(): - - def __init__(self, x: int, y: int, value: float = float("inf")) -> None: - self.x = x - self.y = y - self.value = value - - def getX(self) -> int: - return self.x - - def setX(self, x) -> None: - self.x = x - - def getY(self) -> int: - return self.y - - def setY(self, y) -> None: - self.y = y \ No newline at end of file diff --git a/src/enums/__pycache__/locomotionAlgorithms.cpython-311.pyc b/src/enums/__pycache__/locomotionAlgorithms.cpython-311.pyc deleted file mode 100644 index 5cf892b..0000000 Binary files a/src/enums/__pycache__/locomotionAlgorithms.cpython-311.pyc and /dev/null differ diff --git a/src/enums/__pycache__/locomotionAlgorithms.cpython-312.pyc b/src/enums/__pycache__/locomotionAlgorithms.cpython-312.pyc deleted file mode 100644 index 3de644a..0000000 Binary files a/src/enums/__pycache__/locomotionAlgorithms.cpython-312.pyc and /dev/null differ diff --git a/src/enums/__pycache__/tileStatus.cpython-311.pyc b/src/enums/__pycache__/tileStatus.cpython-311.pyc deleted file mode 100644 index 6f5a5f6..0000000 Binary files a/src/enums/__pycache__/tileStatus.cpython-311.pyc and /dev/null differ diff --git a/src/enums/__pycache__/tileStatus.cpython-312.pyc b/src/enums/__pycache__/tileStatus.cpython-312.pyc deleted file mode 100644 index 4f305fa..0000000 Binary files a/src/enums/__pycache__/tileStatus.cpython-312.pyc and /dev/null differ diff --git a/src/enums/locomotionAlgorithms.py b/src/enums/locomotionAlgorithms.py deleted file mode 100644 index 00f7daa..0000000 --- a/src/enums/locomotionAlgorithms.py +++ /dev/null @@ -1,4 +0,0 @@ -from enum import Enum - -class LocomationAlgorihms(Enum): - dijksta = 0 \ No newline at end of file diff --git a/src/enums/tileStatus.py b/src/enums/tileStatus.py deleted file mode 100644 index 934528b..0000000 --- a/src/enums/tileStatus.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - -class TileStatus(Enum): - FREE = 1 - BLOCKED = 2 - PEDESTRIAN = 3 - SOURCE = 4 - DESTINATION = 5 \ No newline at end of file diff --git a/src/exceptions/__pycache__/SimulationException.cpython-312.pyc b/src/exceptions/__pycache__/SimulationException.cpython-312.pyc deleted file mode 100644 index b79d38b..0000000 Binary files a/src/exceptions/__pycache__/SimulationException.cpython-312.pyc and /dev/null differ diff --git a/src/exceptions/__pycache__/__init__.cpython-311.pyc b/src/exceptions/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 07f2710..0000000 Binary files a/src/exceptions/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/exceptions/__pycache__/__init__.cpython-312.pyc b/src/exceptions/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 58ca203..0000000 Binary files a/src/exceptions/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/exceptions/__pycache__/simulationErrorCodes.cpython-311.pyc b/src/exceptions/__pycache__/simulationErrorCodes.cpython-311.pyc deleted file mode 100644 index 8b94b7b..0000000 Binary files a/src/exceptions/__pycache__/simulationErrorCodes.cpython-311.pyc and /dev/null differ diff --git a/src/exceptions/__pycache__/simulationErrorCodes.cpython-312.pyc b/src/exceptions/__pycache__/simulationErrorCodes.cpython-312.pyc deleted file mode 100644 index 3e4c11b..0000000 Binary files a/src/exceptions/__pycache__/simulationErrorCodes.cpython-312.pyc and /dev/null differ diff --git a/src/exceptions/__pycache__/simulationException.cpython-311.pyc b/src/exceptions/__pycache__/simulationException.cpython-311.pyc deleted file mode 100644 index a987f3f..0000000 Binary files a/src/exceptions/__pycache__/simulationException.cpython-311.pyc and /dev/null differ diff --git a/src/exceptions/simulationErrorCodes.py b/src/exceptions/simulationErrorCodes.py deleted file mode 100644 index 97ea740..0000000 --- a/src/exceptions/simulationErrorCodes.py +++ /dev/null @@ -1,18 +0,0 @@ -from enum import Enum -from dataclasses import dataclass, field - -class ErrorCodeDataMixin: - errorcode: int - message: str - -class SimulationErrorCodes(ErrorCodeDataMixin, Enum): - ExampleError = 1, "Example ErrorCode" - NOT_IMPLEMENTED_IN_SIMULATION = 2, "A Function in the simulation is not implemented" - LENGTH_OF_GRID_INVALID = 3, "The legth of the grid has to be more than 0 and of type int" - POINT_NOT_ON_GRID = 4, "The requested point is outside of the simulated grid." - PEDESTRIAN_HEAT_MAP_CALCULATION_FAILED = 5, "The calculation of the pedestrian heat map failed" - PEDESTRIAN_HEAT_MAP_NOT_FOUND = 6, "The Pedestrian Heat Map could not be found." - -if __name__ == "__main__": - example = SimulationErrorCodes.ExampleError - print(example.value) \ No newline at end of file diff --git a/src/exceptions/simulationException.py b/src/exceptions/simulationException.py deleted file mode 100644 index 93db7ad..0000000 --- a/src/exceptions/simulationException.py +++ /dev/null @@ -1,11 +0,0 @@ -from src.exceptions.simulationErrorCodes import SimulationErrorCodes - -class SimulationException(Exception): - def __init__(self, errorCode: SimulationErrorCodes) -> None: - self.errorCode = errorCode - - def getMessage(self) -> str: - return self.errorCode.value[1] - - def getCode(self) -> str: - return self.errorCode.value[0] \ No newline at end of file diff --git a/src/simulation/__pycache__/baseSimulation.cpython-311.pyc b/src/simulation/__pycache__/baseSimulation.cpython-311.pyc deleted file mode 100644 index 7529682..0000000 Binary files a/src/simulation/__pycache__/baseSimulation.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/__pycache__/baseSimulation.cpython-312.pyc b/src/simulation/__pycache__/baseSimulation.cpython-312.pyc deleted file mode 100644 index b98c1ca..0000000 Binary files a/src/simulation/__pycache__/baseSimulation.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/__pycache__/grid.cpython-311.pyc b/src/simulation/__pycache__/grid.cpython-311.pyc deleted file mode 100644 index 36eabd8..0000000 Binary files a/src/simulation/__pycache__/grid.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/__pycache__/grid.cpython-312.pyc b/src/simulation/__pycache__/grid.cpython-312.pyc deleted file mode 100644 index 65ba268..0000000 Binary files a/src/simulation/__pycache__/grid.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/__pycache__/tile.cpython-311.pyc b/src/simulation/__pycache__/tile.cpython-311.pyc deleted file mode 100644 index f43d4fd..0000000 Binary files a/src/simulation/__pycache__/tile.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/__pycache__/tile.cpython-312.pyc b/src/simulation/__pycache__/tile.cpython-312.pyc deleted file mode 100644 index d935cdc..0000000 Binary files a/src/simulation/__pycache__/tile.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/agent/__pycache__/baseAgent.cpython-312.pyc b/src/simulation/agent/__pycache__/baseAgent.cpython-312.pyc deleted file mode 100644 index 3c377c7..0000000 Binary files a/src/simulation/agent/__pycache__/baseAgent.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/agent/baseAgent.py b/src/simulation/agent/baseAgent.py deleted file mode 100644 index 9fc2142..0000000 --- a/src/simulation/agent/baseAgent.py +++ /dev/null @@ -1,26 +0,0 @@ - - -class BaseAgent(): - - def __init__(self, currentLocationX: int, currentLocationY: int, locomotionHeatMapName: str) -> None: - self.locomotionHeatMapName = locomotionHeatMapName - self.currentLocationX = currentLocationX - self.currentLocationY = currentLocationY - - def getCurrentLocationX(self) -> int: - return self.currentLocationX - - def setCurrentLocationX(self, currentLocationX: int) -> None: - self.currentLocationX = currentLocationX - - def getCurrentLocationY(self) -> int: - return self.currentLocationY - - def setCurrentLocationY(self, currentLocationY: int) -> None: - self.currentLocationY - - def getLocomotionHeatMapName(self) -> str: - return self.locomotionHeatMapName - - def setLocomotionHeatMapName(self, locomotionHeatMapName: str) -> None: - self.locomotionHeatMapName = locomotionHeatMapName \ No newline at end of file diff --git a/src/simulation/baseSimulation.py b/src/simulation/baseSimulation.py deleted file mode 100644 index ccf0c7e..0000000 --- a/src/simulation/baseSimulation.py +++ /dev/null @@ -1,119 +0,0 @@ -from src.enums.tileStatus import TileStatus -from src.exceptions.simulationErrorCodes import SimulationErrorCodes -from src.exceptions.simulationException import SimulationException -from src.simulation.spawner.spawnerConfiguration import SpawnerConfiguration -from src.simulation.destination.baseDestination import BaseDestination -from src.simulation.destination.destinationConfiguration import DestinationConfiguration -from src.simulation.spawner.baseSpawner import BaseSpawner -from src.simulation.agent.baseAgent import BaseAgent -from src.simulation.destination.pedestrianHeapMap import PedestrianHeapMap -from src.simulation.grid import Grid -from src.simulation.tile import Tile -from src.dtoModule.tileDTO import TileDTO -from src.config.config import _Config -from typing import List, Tuple -import os -import numpy as np - -class BaseSimulation(): - - def __init__(self, grid: Grid, config: _Config) -> None: - self.grid: Grid = grid - self.config: _Config = config - self.agents: List[BaseAgent] = [] - self.tilesOverTime: List[List[List[int]]] = [] - self.spawners: List[BaseSpawner] = [] - self.destinations: List[BaseDestination] = [] - - def simulateStep(self) -> None: - self._calculateDestinationHeatMaps() - self._spawn() - self._step() - self._saveStep() - - def setSpawner(self, spawnerConfiguration: SpawnerConfiguration) -> None: - spawnerTiles: List[Tile] = [] - for x, y in spawnerConfiguration.getSpawnerTilesCoordinates(): - spawnerTiles.append(self.grid.getTile(x, y)) - baseSpawner: BaseSpawner = BaseSpawner(spawnerTiles, **spawnerConfiguration.getArgs()) - self.spawners.append(baseSpawner) - - def setDestination(self, destinationConfiguration: DestinationConfiguration) -> None: - baseDestination: BaseDestination = BaseDestination(destinationName=destinationConfiguration.getName(), destinationTiles=destinationConfiguration.getDestinationTilesCoordination(), grid=self.grid, locomtionAlgorithm=destinationConfiguration.getLocomotionAlgorithm()) - self.destinations.append(baseDestination) - - def setBlockingCells(self, blockList: List[tuple]) -> None: - for x, y in blockList: - self.grid.setTileStatus(x, y, TileStatus.BLOCKED) - - def getTilesOverTime(self) -> List[List[List[Tile]]]: - return self.tilesOverTime - - def save(self, name: str) -> None: - if not os.path.exists(self.config.getSavePath()): - os.makedirs(self.config.getSavePath()) - savePath = os.path.join(self.config.getSavePath(), f"{name}.npy") - numpyArray = np.array(self.tilesOverTime) - np.save(savePath, numpyArray) - - def _calculateDestinationHeatMaps(self) -> None: - for destination in self.destinations: - destination.generatePedestrianHeatMap() - - def _spawn(self) -> None: - for spawner in self.spawners: - self.agents.extend(spawner.update()) - - def _step(self) -> None: - for agent in self.agents: - self._updateAgent(agent) - - def _updateAgent(self, agent: BaseAgent) -> None: - pedestrianHeatMap: PedestrianHeapMap = self._getPedestrianHeatMapByName(agent.getLocomotionHeatMapName()) - targetPostion: Tuple[int, int] = self._getLowestFreePosition(agent, pedestrianHeatMap) - if targetPostion: - self.grid.setTileStatus(*targetPostion, TileStatus.PEDESTRIAN) - self.grid.setTileStatus(agent.getCurrentLocationX(), agent.getCurrentLocationY(), TileStatus.FREE) - - agent.setCurrentLocationX(targetPostion[0]) - agent.setCurrentLocationY(targetPostion[1]) - - def _getPedestrianHeatMapByName(self, pedestrianHeatMapName: str) -> PedestrianHeapMap: - for destination in self.destinations: - if destination.getDestinationName() == pedestrianHeatMapName: - return destination.getPedestrianHeatMap() if destination.getPedestrianHeatMap() != None else destination.generatePedestrianHeatMap() - raise SimulationException(SimulationErrorCodes.PEDESTRIAN_HEAT_MAP_NOT_FOUND) - - def _getLowestFreePosition(self, agent: BaseAgent, pedestrianHeatMap: PedestrianHeapMap) -> Tuple[int, int]: - reachableFields: Tuple[int, int, float] = self._getReachableFields(agent, pedestrianHeatMap) - reachableFields = sorted(reachableFields, key=lambda x: x[2]) - for x, y, _ in reachableFields: - if (self.grid.getTileStatus(x, y) == TileStatus.FREE): - return (x, y) - return None - - def _getReachableFields(self, agent: BaseAgent, pedestrianHeatMap: PedestrianHeapMap) -> Tuple[int, int, float]: - returnList: Tuple[int, int, float] = [] - for x in range(agent.getCurrentLocationX() - 1, agent.getCurrentLocationX() + 2): - for y in range(agent.getCurrentLocationY() - 1, agent.getCurrentLocationY() + 2): - if (pedestrianHeatMap.isOnHeatMap(x, y)): - returnList.append((x, y, pedestrianHeatMap.getValue(x, y))) - return returnList - - def _saveStep(self) -> None: - self.tilesOverTime.append(self.grid.getTilesAsInteger()) - - def __repr__(self) -> str: - returnValue: str = "--- Base Simulation ---\n -- Grid --\n" - returnValue += str(self.grid) - returnValue += "\n -- Config --\n" - returnValue += str(self.config) - returnValue += "\n -- Spawners --\n" - for spawner in self.spawners: - returnValue += "---\n" - returnValue += str(spawner) - returnValue += "\n -- Destinations -- \n" - for destination in self.destinations: - returnValue += "---\n" - returnValue += str(destination) - return returnValue \ No newline at end of file diff --git a/src/simulation/destination/__pycache__/baseDestination.cpython-311.pyc b/src/simulation/destination/__pycache__/baseDestination.cpython-311.pyc deleted file mode 100644 index 9990c8a..0000000 Binary files a/src/simulation/destination/__pycache__/baseDestination.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/destination/__pycache__/baseDestination.cpython-312.pyc b/src/simulation/destination/__pycache__/baseDestination.cpython-312.pyc deleted file mode 100644 index d6022b7..0000000 Binary files a/src/simulation/destination/__pycache__/baseDestination.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/destination/__pycache__/destinationConfiguration.cpython-311.pyc b/src/simulation/destination/__pycache__/destinationConfiguration.cpython-311.pyc deleted file mode 100644 index 0a6b186..0000000 Binary files a/src/simulation/destination/__pycache__/destinationConfiguration.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/destination/__pycache__/destinationConfiguration.cpython-312.pyc b/src/simulation/destination/__pycache__/destinationConfiguration.cpython-312.pyc deleted file mode 100644 index 62d074f..0000000 Binary files a/src/simulation/destination/__pycache__/destinationConfiguration.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/destination/__pycache__/pedestrianHeapMap.cpython-311.pyc b/src/simulation/destination/__pycache__/pedestrianHeapMap.cpython-311.pyc deleted file mode 100644 index 5922ad8..0000000 Binary files a/src/simulation/destination/__pycache__/pedestrianHeapMap.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/destination/__pycache__/pedestrianHeapMap.cpython-312.pyc b/src/simulation/destination/__pycache__/pedestrianHeapMap.cpython-312.pyc deleted file mode 100644 index 60821ba..0000000 Binary files a/src/simulation/destination/__pycache__/pedestrianHeapMap.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/destination/baseDestination.py b/src/simulation/destination/baseDestination.py deleted file mode 100644 index cafe6a7..0000000 --- a/src/simulation/destination/baseDestination.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import List -from src.simulation.destination.pedestrianHeapMap import PedestrianHeapMap -from src.simulation.locomotionAlgorithms.dijkstra import Dijkstra -from src.simulation.tile import Tile -from src.simulation.grid import Grid -from src.enums.locomotionAlgorithms import LocomationAlgorihms - -class BaseDestination(): - def __init__(self, destinationName: str, destinationTiles: List[tuple], grid: Grid, locomtionAlgorithm: LocomationAlgorihms) -> None: - self.destinationName: str = destinationName - self.destinationTiles: List[tuple] = destinationTiles - self.grid: Grid = grid - self.locomotionAlgorithm: LocomationAlgorihms = locomtionAlgorithm - self.pedestrianHeatMap: PedestrianHeapMap = None - - def getDestinationName(self) -> str: - return self.destinationName - - def generatePedestrianHeatMap(self) -> PedestrianHeapMap: - if self.locomotionAlgorithm == LocomationAlgorihms.dijksta: - self.pedestrianHeatMap = Dijkstra.generatePedestrianHeatMap(self.grid, self.destinationTiles) - return self.pedestrianHeatMap - - def getPedestrianHeatMap(self) -> PedestrianHeapMap: - return self.pedestrianHeatMap - - def __repr__(self) -> str: - return f"DestiantionName: <{self.destinationName}>\nlocomationAlgorithm: <{self.locomotionAlgorithm}>\n-pedestrianHeatMap- \n{str(self.pedestrianHeatMap)}" - - \ No newline at end of file diff --git a/src/simulation/destination/destinationConfiguration.py b/src/simulation/destination/destinationConfiguration.py deleted file mode 100644 index 5b9eef9..0000000 --- a/src/simulation/destination/destinationConfiguration.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import List - -from src.enums.locomotionAlgorithms import LocomationAlgorihms - - -class DestinationConfiguration(): - - def __init__(self, name:str, destinationTilesCoordination: List[tuple], locomotionAlgorithm: LocomationAlgorihms) -> None: - self.name = name - self.destinationTilesCoordination: List[tuple] = destinationTilesCoordination - self.locomotionAlgorithm = locomotionAlgorithm - - def getName(self) -> str: - return self.name - - def getDestinationTilesCoordination(self) -> List[tuple]: - return self.destinationTilesCoordination - - def getLocomotionAlgorithm(self) -> LocomationAlgorihms: - return self.locomotionAlgorithm \ No newline at end of file diff --git a/src/simulation/destination/pedestrianHeapMap.py b/src/simulation/destination/pedestrianHeapMap.py deleted file mode 100644 index 279ee43..0000000 --- a/src/simulation/destination/pedestrianHeapMap.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import List - - -class PedestrianHeapMap(): - - def __init__(self, length: int, width: int) -> None: - self.heatMap: List[List[float]] = [[float("inf") for _ in range(width)] for _ in range(length)] - - def updateValue(self, x: int, y: int, value: float) -> None: - #TODO Check for bounderies - self.heatMap[x][y] = value - - def isOnHeatMap(self, x: int, y: int) -> bool: - return not (x < 0 or y < 0 or x >= len(self.heatMap) or y >= len(self.heatMap[0])) - - def getValue(self, x: int, y:int) -> float: - return self.heatMap[x][y] - - def __repr__(self) -> str: - returnValue = "" - for line in self.heatMap: - returnValue += "[" - for value in line: - returnValue +=f"{value: .2f}, " - returnValue += "]\n" - return returnValue \ No newline at end of file diff --git a/src/simulation/grid.py b/src/simulation/grid.py deleted file mode 100644 index 51c1ce7..0000000 --- a/src/simulation/grid.py +++ /dev/null @@ -1,59 +0,0 @@ -from src.dtoModule.tileDTO import TileDTO -from src.simulation.tile import Tile -from src.enums.tileStatus import TileStatus -from src.exceptions.simulationException import SimulationException -from src.exceptions.simulationErrorCodes import SimulationErrorCodes -from typing import List -import copy - -class Grid(): - - def __init__(self, length, width) -> None: - self.length = length - self.width = width - self.tiles: List[List[Tile]] = self._initTiles() - - def __repr__(self) -> str: - returnString = "" - for line in self.tiles: - for tile in line: - returnString += str(tile) - returnString += "\n" - return returnString - - def getTiles(self) -> list[list]: - return self.tiles - - def getLength(self) -> int: - return self.length - - def getWidth(self) -> int: - return self.width - - def getTile(self, x: int, y: int) -> Tile: - if not self.contains(x, y): - raise SimulationException(SimulationErrorCodes.POINT_NOT_ON_GRID) - return self.tiles[x][y] - - def getTiles(self) -> List[List[Tile]]: - return copy.deepcopy(self.tiles) - - def getTilesAsInteger(self) -> List[List[int]]: - return copy.deepcopy([[tile.getTileStatus().value for tile in x] for x in self.tiles]) - - def getTileStatus(self, x, y) -> TileStatus: - tile = self.getTile(x,y) - return tile.getTileStatus() - - def setTileStatus(self, x, y, tileStatus: TileStatus) -> None: - self.getTile(x,y).changeState(tileStatus) - - def contains(self, x: int, y: int) -> bool: - return (-1 < x < self.length) and (-1 < y < self.width) - - def _initTiles(self) -> List[List[Tile]]: - return [ - [Tile(x, y, TileStatus.FREE) for y in range(self.width)] - for x in range(self.length) - ] - \ No newline at end of file diff --git a/src/simulation/locomotionAlgorithms/__pycache__/dijkstra.cpython-311.pyc b/src/simulation/locomotionAlgorithms/__pycache__/dijkstra.cpython-311.pyc deleted file mode 100644 index 29dce8c..0000000 Binary files a/src/simulation/locomotionAlgorithms/__pycache__/dijkstra.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/locomotionAlgorithms/__pycache__/dijkstra.cpython-312.pyc b/src/simulation/locomotionAlgorithms/__pycache__/dijkstra.cpython-312.pyc deleted file mode 100644 index 6b82d86..0000000 Binary files a/src/simulation/locomotionAlgorithms/__pycache__/dijkstra.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/locomotionAlgorithms/dijkstra.py b/src/simulation/locomotionAlgorithms/dijkstra.py deleted file mode 100644 index 6527d02..0000000 --- a/src/simulation/locomotionAlgorithms/dijkstra.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import List -from src.enums.tileStatus import TileStatus -from src.simulation.destination.pedestrianHeapMap import PedestrianHeapMap -from src.exceptions.simulationException import SimulationException -from src.exceptions.simulationErrorCodes import SimulationErrorCodes -from src.simulation.grid import Grid -from src.simulation.tile import Tile - -import numpy as np - -class Dijkstra(): - - def __init__(self, grid: Grid, destinationTiles: List[tuple]) -> None: - #Dont use the constructor directly - self.grid = grid - self.destinationTiles = destinationTiles - self.stack: List[tuple] = [] - self.pedestrianHeatMap: PedestrianHeapMap = PedestrianHeapMap(grid.length, grid.width) - - def calculatePedestrianHeapMap(self) -> PedestrianHeapMap: - self._setStartNodes() - while len(self.stack) > 0: - currentNode = self._getNextNode() - self._evaluateNode(currentNode) - return self.pedestrianHeatMap - - def _setStartNodes(self): - for x, y in self.destinationTiles: - self.pedestrianHeatMap.updateValue(x, y, 0) - self.stack.append((x, y, 0)) - - def _getNextNode(self) -> tuple: - returnTuple = min(self.stack, key=lambda x: x[2]) - self.stack.remove(returnTuple) - return returnTuple - - def _evaluateNode(self, currentNode) -> None: - for newX in range(currentNode[0] - 1, currentNode[0] + 2): - for newY in range(currentNode[1] - 1, currentNode[1] + 2): - if self.grid.contains(newX, newY) and self.grid.getTileStatus(newX, newY) != TileStatus.BLOCKED: - distance = np.sqrt(2) if self._isDiagonalStep(currentNode[0], currentNode[1], newX, newY) else 1 - newValue = currentNode[2] + distance - if (self.pedestrianHeatMap.getValue(newX, newY) > newValue): - self.pedestrianHeatMap.updateValue(newX, newY, newValue) - self.stack.append((newX, newY, newValue)) - - def _isDiagonalStep(self, x:int, y:int, xNew:int, yNew: int) -> bool: - return (x - xNew)*(y - yNew) != 0 - - - @classmethod - def generatePedestrianHeatMap(cls, grid: Grid, destinationTiles: List[tuple]) -> PedestrianHeapMap: - cls._checkInput(grid, destinationTiles) - dijkstra = Dijkstra(grid, destinationTiles) - return dijkstra.calculatePedestrianHeapMap() - - @classmethod - def _checkInput(cls, grid: Grid, destinationTiles: List[tuple]) -> None: - if grid == None or destinationTiles == None or len(destinationTiles) < 1: - raise SimulationException(SimulationErrorCodes.PEDESTRIAN_HEAT_MAP_CALCULATION_FAILED) - for x, y in destinationTiles: - if not grid.contains(x, y): - raise SimulationException(SimulationErrorCodes.PEDESTRIAN_HEAT_MAP_CALCULATION_FAILED) - \ No newline at end of file diff --git a/src/simulation/spawner/__pycache__/baseSpawner.cpython-311.pyc b/src/simulation/spawner/__pycache__/baseSpawner.cpython-311.pyc deleted file mode 100644 index b711435..0000000 Binary files a/src/simulation/spawner/__pycache__/baseSpawner.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/spawner/__pycache__/baseSpawner.cpython-312.pyc b/src/simulation/spawner/__pycache__/baseSpawner.cpython-312.pyc deleted file mode 100644 index 5a76851..0000000 Binary files a/src/simulation/spawner/__pycache__/baseSpawner.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/spawner/__pycache__/spawnerConfiguration.cpython-311.pyc b/src/simulation/spawner/__pycache__/spawnerConfiguration.cpython-311.pyc deleted file mode 100644 index 7417474..0000000 Binary files a/src/simulation/spawner/__pycache__/spawnerConfiguration.cpython-311.pyc and /dev/null differ diff --git a/src/simulation/spawner/__pycache__/spawnerConfiguration.cpython-312.pyc b/src/simulation/spawner/__pycache__/spawnerConfiguration.cpython-312.pyc deleted file mode 100644 index 0117efb..0000000 Binary files a/src/simulation/spawner/__pycache__/spawnerConfiguration.cpython-312.pyc and /dev/null differ diff --git a/src/simulation/spawner/baseSpawner.py b/src/simulation/spawner/baseSpawner.py deleted file mode 100644 index b77b214..0000000 --- a/src/simulation/spawner/baseSpawner.py +++ /dev/null @@ -1,55 +0,0 @@ -from src.enums.tileStatus import TileStatus -from src.simulation.tile import Tile -from src.simulation.agent.baseAgent import BaseAgent -from typing import List -import random - -class BaseSpawner(): - def __init__(self, spawnerTiles: List[Tile], locomotionHeatMapName: str, numberOfTotalSpawns: float=float("inf"), maxSpawnsPerBatch:float=float("inf"), delayBeforeNextSpawn:int = 0, initialDelay:int=0) -> None: - self.spawnerTiles = spawnerTiles - self.locomotionHeatMapName = locomotionHeatMapName - self.numberOfTotalSpawns = numberOfTotalSpawns - self.maxSpawnsPerBatch = maxSpawnsPerBatch - self.delayBeforeNextSpawn = delayBeforeNextSpawn - self.initalDelay = initialDelay - self.timestampsElapsedSinceLastSpawn = 0 - self.totalSpawend = 0 - - def update(self) -> List[BaseAgent]: - if(self.timestampsElapsedSinceLastSpawn <= self.delayBeforeNextSpawn): - self.timestampsElapsedSinceLastSpawn = 0 - return self._spawn() - else: - self.timestampsElapsedSinceLastSpawn += 1 - return [] - - def _spawn(self) -> List[BaseAgent]: - numberOfSpawns = self._getNumberOfSpawns() - random.shuffle(self.spawnerTiles) - spawnedAgents = [] - currentSpawnerTilePosition = 0 - while len(spawnedAgents) < numberOfSpawns: - if (self.spawnerTiles[currentSpawnerTilePosition].getTileStatus() == TileStatus.FREE): - self.spawnerTiles[currentSpawnerTilePosition].changeState(TileStatus.PEDESTRIAN) - spawnedXPosition = self.spawnerTiles[currentSpawnerTilePosition].getXPositionOnGrid() - spawnedYPosition = self.spawnerTiles[currentSpawnerTilePosition].getYPositionOnGrid() - spawnedAgents.append(BaseAgent(spawnedXPosition, spawnedYPosition, self.locomotionHeatMapName)) - currentSpawnerTilePosition += 1 - self.totalSpawend += len(spawnedAgents) - return spawnedAgents - - - def _getNumberOfSpawns(self) -> float: - if(self.totalSpawend >= self.numberOfTotalSpawns): - return 0 - return min(self.maxSpawnsPerBatch, self.numberOfTotalSpawns - self.totalSpawend, self._getNumberOfFreeTiles()) - - def _getNumberOfFreeTiles(self) -> float: - freeTiles: float = 0.0 - for tile in self.spawnerTiles: - if tile.getTileStatus() == TileStatus.FREE: - freeTiles += 1.0 - return freeTiles - - def __repr__(self) -> str: - return str(self.totalSpawend) \ No newline at end of file diff --git a/src/simulation/spawner/spawnerConfiguration.py b/src/simulation/spawner/spawnerConfiguration.py deleted file mode 100644 index cc7cfe8..0000000 --- a/src/simulation/spawner/spawnerConfiguration.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import List - -class SpawnerConfiguration(): - def __init__(self, - spawnerTilesCoordinates: List[tuple], - locomotionHeatMapName: str, - numberOfTotalSpawns: float=float("inf"), - maxSpawnsPerBatch:float=float("inf"), - delayBeforeNextSpawn:int = 0, - initialDelay:int=0): - self.spawnerTilesCoordinates = spawnerTilesCoordinates - self.locomotionHeatMapName = locomotionHeatMapName - self.numberOfTotalSpawns = numberOfTotalSpawns - self.maxSpawnsPerBatch = maxSpawnsPerBatch - self.delayBeforeNextSpawn = delayBeforeNextSpawn - self.initialDelay = initialDelay - - def getArgs(self)-> dict: - return { - "numberOfTotalSpawns": self.numberOfTotalSpawns, - "locomotionHeatMapName": self.locomotionHeatMapName, - "maxSpawnsPerBatch": self.maxSpawnsPerBatch, - "delayBeforeNextSpawn": self.delayBeforeNextSpawn, - "initialDelay": self.initialDelay - } - - def getSpawnerTilesCoordinates(self) -> List[tuple]: - return self.spawnerTilesCoordinates \ No newline at end of file diff --git a/src/simulation/tile.py b/src/simulation/tile.py deleted file mode 100644 index ff84e2c..0000000 --- a/src/simulation/tile.py +++ /dev/null @@ -1,38 +0,0 @@ -from src.dtoModule.tileDTO import TileDTO -from src.enums.tileStatus import TileStatus - -from random import uniform - -class Tile(): - - def __init__(self, xPositionOnGrid: int, yPositionOnGrid: int, tileStatus: TileStatus) -> None: - self.xPositionOnGrid = xPositionOnGrid - self.yPositionOnGrid= yPositionOnGrid - self.tileStatus: TileStatus = tileStatus - - def getTileStatus(self) -> TileStatus: - return self.tileStatus - - def getXPositionOnGrid(self) -> int: - return self.xPositionOnGrid - - def getYPositionOnGrid(self) -> int: - return self.yPositionOnGrid - - def changeState(self, tileStatus: TileStatus) -> bool: - # TODO make some changeChecks - self.tileStatus = tileStatus - return True - - def __repr__(self) -> str: - if self.tileStatus == TileStatus.FREE: - return "-" - if self.tileStatus == TileStatus.BLOCKED: - return "X" - if self.tileStatus == TileStatus.PEDESTRIAN: - return "O" - if self.tileStatus == TileStatus.SOURCE: - return "S" - if self.tileStatus == TileStatus.DESTINATION: - return "D" - return "N" \ No newline at end of file diff --git a/src/userSimulation/__pycache__/exampleSimulation.cpython-311.pyc b/src/userSimulation/__pycache__/exampleSimulation.cpython-311.pyc deleted file mode 100644 index f78e9e1..0000000 Binary files a/src/userSimulation/__pycache__/exampleSimulation.cpython-311.pyc and /dev/null differ diff --git a/src/userSimulation/__pycache__/exampleSimulation.cpython-312.pyc b/src/userSimulation/__pycache__/exampleSimulation.cpython-312.pyc deleted file mode 100644 index 6b49ea0..0000000 Binary files a/src/userSimulation/__pycache__/exampleSimulation.cpython-312.pyc and /dev/null differ diff --git a/src/userSimulation/exampleSimulation.py b/src/userSimulation/exampleSimulation.py deleted file mode 100644 index 5420745..0000000 --- a/src/userSimulation/exampleSimulation.py +++ /dev/null @@ -1,35 +0,0 @@ -from src.enums.locomotionAlgorithms import LocomationAlgorihms -from src.simulation.baseSimulation import BaseSimulation -from src.config.config import ConfigBuilder -from src.simulation.destination.destinationConfiguration import DestinationConfiguration -from src.simulation.spawner.spawnerConfiguration import SpawnerConfiguration -from src.simulation.grid import Grid -import os - -def run(): - configBuilder = ConfigBuilder() - config = configBuilder.build() - grid = Grid(10, 10) - baseSimulation: BaseSimulation = BaseSimulation(grid=grid, config=config) - - blocks = [(4,3),(5,3),(4,5),(5,4),(5,5),] - baseSimulation.setBlockingCells(blocks) - - spawnerTilesCoordinates = [(0, 0), (0, 1), (1, 0), (1, 1)] - spawnerConfiguration = SpawnerConfiguration(spawnerTilesCoordinates, "Final", 4, 4) - baseSimulation.setSpawner(spawnerConfiguration) - - destinationCoordinates = [(9,9), (9,8), (9,7)] - destinationName = "Final" - locomotionAlgorithm = LocomationAlgorihms.dijksta - destinationConfiguration = DestinationConfiguration( - name=destinationName, - destinationTilesCoordination=destinationCoordinates, - locomotionAlgorithm=locomotionAlgorithm) - baseSimulation.setDestination(destinationConfiguration) - - for i in range(20): - baseSimulation.simulateStep() - print(baseSimulation) - - baseSimulation.save("exampleSimulation") \ No newline at end of file diff --git a/src/visualization/__pycache__/visualization.cpython-311.pyc b/src/visualization/__pycache__/visualization.cpython-311.pyc deleted file mode 100644 index 5a59dc9..0000000 Binary files a/src/visualization/__pycache__/visualization.cpython-311.pyc and /dev/null differ diff --git a/src/visualization/__pycache__/visualization.cpython-312.pyc b/src/visualization/__pycache__/visualization.cpython-312.pyc deleted file mode 100644 index d03f4d4..0000000 Binary files a/src/visualization/__pycache__/visualization.cpython-312.pyc and /dev/null differ diff --git a/src/visualization/visualization.py b/src/visualization/visualization.py deleted file mode 100644 index bf12851..0000000 --- a/src/visualization/visualization.py +++ /dev/null @@ -1,43 +0,0 @@ -import pygame -import sys -from src.enums.tileStatus import TileStatus - - -class Visualization: - def __init__(self, width=800, height=600): - pygame.init() - self.width = width - self.height = height - self.screen = pygame.display.set_mode((width, height)) - self.clock = pygame.time.Clock() - self.running = True - self.fps = 60 - - def handle_events(self): - for event in pygame.event.get(): - if event.type == pygame.QUIT: - self.running = False - # TODO Add more event handlers - - def update(self): - # TODO simulation state - pass - - def draw(self): - self.screen.fill((255, 255, 255)) - # TODO drawing code - pygame.display.flip() - - def run(self): - while self.running: - self.handle_events() - self.update() - self.draw() - self.clock.tick(self.fps) - - pygame.quit() - sys.exit() - -if __name__ == "__main__": - sim = Visualization() - sim.run() \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index b5ee836..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -import src \ No newline at end of file diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e907c2d..0000000 Binary files a/tests/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/__pycache__/baseSimulationTest.cpython-312.pyc b/tests/__pycache__/baseSimulationTest.cpython-312.pyc deleted file mode 100644 index ccac076..0000000 Binary files a/tests/__pycache__/baseSimulationTest.cpython-312.pyc and /dev/null differ diff --git a/tests/config/__pycache__/__init__.cpython-312.pyc b/tests/config/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 38872c6..0000000 Binary files a/tests/config/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/config/__pycache__/configTest.cpython-312.pyc b/tests/config/__pycache__/configTest.cpython-312.pyc deleted file mode 100644 index 7531890..0000000 Binary files a/tests/config/__pycache__/configTest.cpython-312.pyc and /dev/null differ diff --git a/tests/config/configTest.py b/tests/config/configTest.py deleted file mode 100644 index 37db815..0000000 --- a/tests/config/configTest.py +++ /dev/null @@ -1,48 +0,0 @@ -import unittest -from src.config.config import ConfigBuilder -from src.config.config import _Config - -class ConfigBuilderTest(unittest.TestCase): - - def test_build_returnsDefaultConfig(self): - # ARRANGE - configBuilder: ConfigBuilder = ConfigBuilder() - expectedMaxItterations: int = _Config._DEFAULT_MAX_NUMBER_OF_ITTERATIONS - expectedPath: str =_Config()._getStandartPath() - - # ACT - config: _Config = configBuilder.build() - - # ASSERT - self.assertEqual(expectedMaxItterations, config.getMaxItterations()) - self.assertEqual(expectedPath, config.getSavePath()) - - def test_setSavePath_hasNewPath(self): - # ARRANGE - configBuilder: ConfigBuilder = ConfigBuilder() - expectedMaxItterations: int = _Config._DEFAULT_MAX_NUMBER_OF_ITTERATIONS - expectedPath: str = "AnOtherPath" - - # ACT - config: _Config = configBuilder.setSavePath(expectedPath).build() - - # ASSERT - self.assertEqual(expectedMaxItterations, config.getMaxItterations()) - self.assertEqual(expectedPath, config.getSavePath()) - - def test_setMaxItterations_hasNewMaxItterations(self): - # ARRANGE - configBuilder: ConfigBuilder = ConfigBuilder() - expectedMaxItterations: int = 20 - expectedPath: str =_Config()._getStandartPath() - - # ACT - config: _Config = configBuilder.setNumberOfMaxItterations(expectedMaxItterations).build() - - # ASSERT - self.assertEqual(expectedMaxItterations, config.getMaxItterations()) - self.assertEqual(expectedPath, config.getSavePath()) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/core_tests/test_cell.py b/tests/core_tests/test_cell.py new file mode 100644 index 0000000..b97d0db --- /dev/null +++ b/tests/core_tests/test_cell.py @@ -0,0 +1,88 @@ +import unittest +from unittest.mock import Mock +from simulation.core.cell import Cell +from simulation.core.cell_state import CellState +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode + +class TestCell(unittest.TestCase): + def setUp(self): + self.cell = Cell(1, 2) + + def test_initialization(self): + """Tests the initialization of the cell with the default state.""" + self.assertEqual(self.cell.get_state(), CellState.FREE) + self.assertIsNone(self.cell.get_pedestrian()) + + def test_set_obstacle(self): + """Tests setting the cell to OBSTACLE.""" + self.cell.set_osbtacle() + self.assertEqual(self.cell.get_state(), CellState.OBSTACLE) + + def test_set_pedestrian_success(self): + """Tests successfully placing a pedestrian in a free cell.""" + pedestrian = Mock() + self.cell.set_pedestrian(pedestrian) + self.assertEqual(self.cell.get_state(), CellState.OCCUPIED) + self.assertEqual(self.cell.get_pedestrian(), pedestrian) + + def test_set_pedestrian_obstacle(self): + """Tests that placing a pedestrian in an obstacle cell raises an exception.""" + self.cell.set_osbtacle() + pedestrian = Mock() + with self.assertRaises(SimulationError) as context: + self.cell.set_pedestrian(pedestrian) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.CELL_BLOCKED.value[0]) + + def test_eq_method(self): + """Tests the equality method of the cell.""" + other_cell = Cell(1, 2) + different_cell = Cell(2, 3) + self.assertEqual(self.cell, other_cell) + self.assertNotEqual(self.cell, different_cell) + + def test_hash_method(self): + """Tests the hash method of the cell.""" + cell_set = {self.cell, Cell(1, 2), Cell(2, 3)} + self.assertIn(Cell(1, 2), cell_set) + self.assertIn(Cell(2, 3), cell_set) + self.assertEqual(len(cell_set), 2) + + def test_remove_pedestrian_success(self): + """Tests successfully removing a pedestrian from an occupied cell.""" + pedestrian = Mock() + self.cell.set_pedestrian(pedestrian) + self.cell.remove_pedestrian() + self.assertEqual(self.cell.get_state(), CellState.FREE) + self.assertIsNone(self.cell.get_pedestrian()) + + def test_remove_pedestrian_not_occupied(self): + """Tests that removing a pedestrian from a free cell raises an exception.""" + with self.assertRaises(SimulationError) as context: + self.cell.remove_pedestrian() + + self.assertEqual(context.exception.get_code(), SimulationErrorCode.CELL_NOT_OCCUPIED.value[0]) + + def test_is_free(self): + """Tests the is_free method.""" + self.assertTrue(self.cell.is_free()) + pedestrian = Mock() + self.cell.set_pedestrian(pedestrian) + self.assertFalse(self.cell.is_free()) + + def test_is_occupied(self): + """Tests the is_occupied method.""" + self.assertFalse(self.cell.is_occupied()) + pedestrian = Mock() + self.cell.set_pedestrian(pedestrian) + self.assertTrue(self.cell.is_occupied()) + + def test_get_pedestrian(self): + """Tests the get_pedestrian method.""" + self.assertIsNone(self.cell.get_pedestrian()) + pedestrian = Mock() + self.cell.set_pedestrian(pedestrian) + self.assertEqual(self.cell.get_pedestrian(), pedestrian) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/core_tests/test_cell_state.py b/tests/core_tests/test_cell_state.py new file mode 100644 index 0000000..f104da7 --- /dev/null +++ b/tests/core_tests/test_cell_state.py @@ -0,0 +1,30 @@ +import unittest +from simulation.core.cell_state import CellState + +class TestCellState(unittest.TestCase): + def test_cell_state_values_exist(self): + """Test that all expected CellState values exist""" + self.assertTrue(hasattr(CellState, 'FREE')) + self.assertTrue(hasattr(CellState, 'OCCUPIED')) + self.assertTrue(hasattr(CellState, 'OBSTACLE')) + + def test_cell_state_values(self): + """Test that CellState values are unique""" + self.assertNotEqual(CellState.FREE, CellState.OCCUPIED) + self.assertNotEqual(CellState.FREE, CellState.OBSTACLE) + self.assertNotEqual(CellState.OCCUPIED, CellState.OBSTACLE) + + def test_cell_state_type(self): + """Test that CellState is an Enum""" + self.assertTrue(isinstance(CellState.FREE, CellState)) + self.assertTrue(isinstance(CellState.OCCUPIED, CellState)) + self.assertTrue(isinstance(CellState.OBSTACLE, CellState)) + + def test_cell_state_comparison(self): + """Test that CellState values can be compared""" + states = [CellState.FREE, CellState.OCCUPIED, CellState.OBSTACLE] + for state in states: + self.assertEqual(state, state) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/core_tests/test_grid_base.py b/tests/core_tests/test_grid_base.py new file mode 100644 index 0000000..0988603 --- /dev/null +++ b/tests/core_tests/test_grid_base.py @@ -0,0 +1,114 @@ +import unittest +from typing import TypeVar +from abc import ABC + +from simulation.core.grid_base import GridBase +from simulation.core.cell import Cell +from simulation.core.position import Position +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode + +T = TypeVar('T') + +class MockGrid(GridBase[Cell], ABC): + def __init__(self, width: int, height: int): + self._width = width + self._height = height + self.cells = [self._init_cell(x, y) for y in range(height) for x in range(width)] + + def _init_cell(self, x: int, y: int) -> Cell: + return Cell(x, y) + + def _get_cell_index(self, x: int, y: int) -> int: + return y * self._width + x + +class TestGridBase(unittest.TestCase): + def setUp(self): + self.width = 5 + self.height = 5 + self.grid = MockGrid(self.width, self.height) + + def test_is_in_bounds_true(self): + """Tests whether is_in_bounds returns True for coordinates within bounds.""" + self.assertTrue(self.grid.is_in_bounds(0, 0)) + self.assertTrue(self.grid.is_in_bounds(4, 4)) + self.assertTrue(self.grid.is_in_bounds(2, 3)) + + def test_is_in_bounds_false(self): + """Tests whether is_in_bounds returns False for coordinates outside the bounds.""" + self.assertFalse(self.grid.is_in_bounds(-1, 0)) + self.assertFalse(self.grid.is_in_bounds(0, -1)) + self.assertFalse(self.grid.is_in_bounds(5, 5)) + self.assertFalse(self.grid.is_in_bounds(6, 2)) + + def test_check_bounds_inside(self): + """Tests that check_bounds does not raise an exception for valid coordinates.""" + try: + self.grid.check_bounds(2, 2) + except SimulationError: + self.fail("check_bounds raised a SimulationError for valid coordinates.") + + def test_check_bounds_outside(self): + """Tests that check_bounds raises a SimulationError for invalid coordinates.""" + with self.assertRaises(SimulationError) as context: + self.grid.check_bounds(5, 5) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.INVALID_COORDINATES.value[0]) + self.assertEqual(context.exception._context, {"x": 5, "y": 5}) + + def test_get_cell_valid(self): + """Tests whether get_cell returns the correct cell for valid coordinates.""" + cell = self.grid.get_cell(1, 1) + self.assertIsInstance(cell, Cell) + self.assertEqual(cell.get_x(), 1) + self.assertEqual(cell.get_y(), 1) + + def test_get_cell_invalid(self): + """Tests whether get_cell raises a SimulationError for invalid coordinates.""" + with self.assertRaises(SimulationError) as context: + self.grid.get_cell(-1, 0) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.INVALID_COORDINATES.value[0]) + + def test_get_cell_at_pos_valid(self): + """Tests whether get_cell_at_pos returns the correct cell for a valid position.""" + pos = Position(3, 3) + cell = self.grid.get_cell_at_pos(pos) + self.assertIsInstance(cell, Cell) + self.assertEqual(cell.get_x(), 3) + self.assertEqual(cell.get_y(), 3) + + def test_get_cell_at_pos_invalid(self): + """Tests whether get_cell_at_pos raises a SimulationError for an invalid position.""" + pos = Position(5, 5) + with self.assertRaises(SimulationError) as context: + self.grid.get_cell_at_pos(pos) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.INVALID_COORDINATES.value[0]) + + def test_contains_with_cell(self): + """Tests the __contains__ method with a Cell.""" + cell = self.grid.get_cell(2, 2) + self.assertIn(cell, self.grid) + other_cell = Cell(2, 2) + self.assertIn(other_cell, self.grid) + non_existent_cell = Cell(5, 5) + self.assertNotIn(non_existent_cell, self.grid) + + def test_contains_with_position(self): + """Tests the __contains__ method with a Position.""" + pos_inside = Position(1, 1) + pos_outside = Position(5, 5) + self.assertIn(pos_inside, self.grid) + self.assertNotIn(pos_outside, self.grid) + + def test_contains_with_tuple(self): + """Tests the __contains__ method with a tuple.""" + self.assertIn((0, 0), self.grid) + self.assertIn((4, 4), self.grid) + self.assertNotIn((5, 5), self.grid) + + def test_contains_with_invalid_type(self): + """Tests the __contains__ method with an invalid type.""" + self.assertNotIn("invalid", self.grid) + self.assertNotIn(123, self.grid) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/core_tests/test_pedestrian.py b/tests/core_tests/test_pedestrian.py new file mode 100644 index 0000000..38f61e9 --- /dev/null +++ b/tests/core_tests/test_pedestrian.py @@ -0,0 +1,118 @@ +import unittest +from unittest.mock import Mock +from simulation.core.pedestrian import Pedestrian +from simulation.core.position import Position +from simulation.core.cell import Cell +from simulation.core.target import Target +from simulation.core.spawner import Spawner +from simulation.heatmaps.distancing.base_distance import DistanceBase +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode + +class TestPedestrian(unittest.TestCase): + def setUp(self): + self.distancing = Mock(spec=DistanceBase) + self.distancing.calculate_distance.return_value = 1.0 + + self.spawner = Mock(spec=Spawner) + self.target = Mock(spec=Target) + self.target.is_inside_target.return_value = False + + self.real_cell = Cell(1, 2) + self.cell = Mock(spec=Cell, wraps=self.real_cell) + self.cell.is_free.return_value = True + + self.pedestrian = Pedestrian( + x=0, + y=0, + speed=1.5, + spawner=self.spawner, + target=self.target, + distancing=self.distancing + ) + + def test_initialization(self): + """Tests the correct initialization of a Pedestrian.""" + self.assertEqual(self.pedestrian.get_x(), 0) + self.assertEqual(self.pedestrian.get_y(), 0) + self.assertEqual(self.pedestrian.get_optimal_speed(), 1.5) + self.assertEqual(self.pedestrian.get_current_distance(), float('inf')) + self.assertFalse(self.pedestrian.has_reached_target()) + + def test_set_reached_target(self): + """Tests the methods set_reached_target and has_reached_target.""" + self.pedestrian.set_reached_target() + self.assertTrue(self.pedestrian.has_reached_target()) + + def test_set_target_cell_success(self): + """Tests successful setting of a target cell.""" + self.pedestrian.set_target_cell(self.cell) + self.assertEqual(self.pedestrian.get_targeted_cell(), self.cell) + self.assertEqual(self.pedestrian.get_current_distance(), 1.0) + + def test_set_target_cell_none(self): + """Tests setting the target cell to None.""" + self.pedestrian.set_target_cell(None) + self.assertIsNone(self.pedestrian.get_targeted_cell()) + self.assertEqual(self.pedestrian.get_current_distance(), float('-inf')) + + def test_set_target_cell_already_in_cell(self): + """Tests that an exception is raised when the target cell is the current position.""" + cell = Cell(0, 0) + with self.assertRaises(SimulationError) as context: + self.pedestrian.set_target_cell(cell) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.ALREADY_IN_CELL.value[0]) + + def test_can_move_true(self): + """Tests that can_move returns True when the pedestrian can move.""" + self.pedestrian.set_target_cell(self.cell) + self.pedestrian._current_distance = -0.1 # Simulate time has passed + self.cell.is_free.return_value = True + self.assertTrue(self.pedestrian.can_move()) + + def test_can_move_false(self): + """Tests that can_move returns False when the pedestrian cannot move.""" + self.pedestrian.set_target_cell(self.cell) + self.pedestrian._current_distance = 0.5 + self.cell.is_free.return_value = True + self.assertFalse(self.pedestrian.can_move()) + + def test_move_success(self): + """Tests successful movement of the pedestrian.""" + self.pedestrian.set_target_cell(self.cell) + self.pedestrian._current_distance = -0.1 + self.cell.is_free.return_value = True + self.pedestrian.move() + self.assertEqual(self.pedestrian.get_x(), 1) + self.assertEqual(self.pedestrian.get_y(), 2) + self.assertEqual(self.pedestrian._total_distance_moved, 1.0) + self.assertEqual(self.pedestrian._distance_to_target, float('inf')) + + def test_move_failure(self): + """Tests that an exception is raised when the pedestrian cannot move.""" + self.pedestrian.set_target_cell(self.cell) + self.pedestrian._current_distance = 0.5 + with self.assertRaises(SimulationError) as context: + self.pedestrian.move() + self.assertEqual(context.exception.get_code(), SimulationErrorCode.CANNOT_MOVE.value[0]) + + def test_update(self): + """Tests the update method.""" + self.pedestrian._current_distance = 1.0 + self.pedestrian.update(0.5) + self.assertEqual(self.pedestrian._current_distance, 0.25) + self.assertEqual(self.pedestrian._time_alive, 0.5) + + def test_is_inside_target(self): + """Tests the is_inside_target method.""" + self.target.is_inside_target.return_value = True + self.assertTrue(self.pedestrian.is_inside_target()) + + def test_getter_methods(self): + """Tests the getter methods.""" + self.assertEqual(self.pedestrian.get_spawner(), self.spawner) + self.assertEqual(self.pedestrian.get_target(), self.target) + self.assertIsNotNone(self.pedestrian.get_id()) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/core_tests/test_position.py b/tests/core_tests/test_position.py new file mode 100644 index 0000000..2ec0026 --- /dev/null +++ b/tests/core_tests/test_position.py @@ -0,0 +1,41 @@ +import unittest +from simulation.core.position import Position + +class ConcretePosition(Position): + pass + +class TestPosition(unittest.TestCase): + def setUp(self): + self.position = ConcretePosition(1, 2) + self.same_position = ConcretePosition(1, 2) + self.different_position = ConcretePosition(3, 4) + + def test_initialization(self): + """Tests initialization and getters.""" + self.assertEqual(self.position.get_x(), 1) + self.assertEqual(self.position.get_y(), 2) + self.assertEqual(self.position.as_tuple(), (1, 2)) + + def test_equality(self): + """Tests the __eq__ and __ne__ methods.""" + self.assertEqual(self.position, self.same_position) + self.assertNotEqual(self.position, self.different_position) + self.assertFalse(self.position == (1, 2)) + + def test_equals_method(self): + """Tests the equals method.""" + self.assertTrue(self.position.pos_equals(self.same_position)) + self.assertFalse(self.position.pos_equals(self.different_position)) + self.assertFalse(self.position.pos_equals((1, 2))) + + def test_hash(self): + """Tests the __hash__ method.""" + self.assertEqual(hash(self.position), hash(self.same_position)) + self.assertNotEqual(hash(self.position), hash(self.different_position)) + + def test_str(self): + """Tests the __str__ method.""" + self.assertEqual(str(self.position), "(1, 2)") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/core_tests/test_simulation_grid.py b/tests/core_tests/test_simulation_grid.py new file mode 100644 index 0000000..1fc4c1d --- /dev/null +++ b/tests/core_tests/test_simulation_grid.py @@ -0,0 +1,85 @@ +import unittest +from unittest.mock import Mock +from simulation.core.simulation_grid import SimulationGrid +from simulation.core.cell import Cell +from simulation.core.position import Position +from simulation.neighbourhood.base_neighbourhood import NeighbourhoodBase +from exceptions.simulation_error import SimulationError +from exceptions.simulation_error_codes import SimulationErrorCode + +class MockNeighbourhood(NeighbourhoodBase): + def get_neighbours(self, x, y, width=1, height=1): + # Return a fixed set of neighbour coordinates for testing + neighbours = [] + if x > 0: + neighbours.append((x - 1, y)) + if x < self._width - 1: + neighbours.append((x + 1, y)) + if y > 0: + neighbours.append((x, y - 1)) + if y < self._height - 1: + neighbours.append((x, y + 1)) + return neighbours + +class TestSimulationGrid(unittest.TestCase): + def setUp(self): + self.width = 5 + self.height = 5 + self.grid = SimulationGrid(self.width, self.height, MockNeighbourhood) + + def test_initialization(self): + """Tests grid initialization.""" + self.assertEqual(self.grid.get_width(), self.width) + self.assertEqual(self.grid.get_height(), self.height) + cells = self.grid.get_cells() + self.assertEqual(len(cells), self.width * self.height) + for cell in cells: + self.assertIsInstance(cell, Cell) + + def test_get_cell_valid(self): + """Tests get_cell with valid coordinates.""" + cell = self.grid.get_cell(2, 2) + self.assertEqual(cell.get_x(), 2) + self.assertEqual(cell.get_y(), 2) + + def test_get_cell_invalid(self): + """Tests get_cell with invalid coordinates.""" + with self.assertRaises(SimulationError) as context: + self.grid.get_cell(-1, 0) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.INVALID_COORDINATES.value[0]) + + def test_get_cell_at_pos_valid(self): + """Tests get_cell_at_pos with a valid position.""" + pos = Position(1, 1) + cell = self.grid.get_cell_at_pos(pos) + self.assertEqual(cell.get_x(), 1) + self.assertEqual(cell.get_y(), 1) + + def test_get_cell_at_pos_invalid(self): + """Tests get_cell_at_pos with an invalid position.""" + pos = Position(5, 5) + with self.assertRaises(SimulationError) as context: + self.grid.get_cell_at_pos(pos) + self.assertEqual(context.exception.get_code(), SimulationErrorCode.INVALID_COORDINATES.value[0]) + + def test_get_neighbours(self): + """Tests get_neighbours method.""" + neighbours = list(self.grid.get_neighbours(2, 2)) + expected_positions = [(1, 2), (3, 2), (2, 1), (2, 3)] + self.assertEqual(len(neighbours), len(expected_positions)) + for neighbour in neighbours: + self.assertIsInstance(neighbour, Cell) + self.assertIn((neighbour.get_x(), neighbour.get_y()), expected_positions) + + def test_get_neighbours_at(self): + """Tests get_neighbours_at method.""" + pos = Position(2, 2) + neighbours = list(self.grid.get_neighbours_at(pos)) + expected_positions = [(1, 2), (3, 2), (2, 1), (2, 3)] + self.assertEqual(len(neighbours), len(expected_positions)) + for neighbour in neighbours: + self.assertIsInstance(neighbour, Cell) + self.assertIn((neighbour.get_x(), neighbour.get_y()), expected_positions) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/core_tests/test_spawner.py b/tests/core_tests/test_spawner.py new file mode 100644 index 0000000..eb8bbc8 --- /dev/null +++ b/tests/core_tests/test_spawner.py @@ -0,0 +1,100 @@ +import unittest +from unittest.mock import Mock, patch +from simulation.core.spawner import Spawner +from simulation.core.cell import Cell +from simulation.core.target import Target +from simulation.heatmaps.distancing.base_distance import DistanceBase +from utils.immutable_list import ImmutableList + +class TestSpawner(unittest.TestCase): + def setUp(self): + self.cells = [Mock(spec=Cell) for _ in range(5)] + self.targets = [Mock(spec=Target) for _ in range(2)] + self.distancing = Mock(spec=DistanceBase) + self.spawner = Spawner( + name="TestSpawner", + distancing=self.distancing, + cells=self.cells, + targets=self.targets, + total_spawns=10, + batch_size=2, + spawn_delay=1.0, + initial_delay=0.0 + ) + + def test_initialization(self): + """Test spawner initialization.""" + self.assertEqual(self.spawner.get_name(), "TestSpawner") + self.assertEqual(self.spawner.get_cells()._data, self.cells) + self.assertEqual(self.spawner._total_spawns, 10) + self.assertEqual(self.spawner._batch_size, 2) + self.assertEqual(self.spawner._spawn_delay, 1.0) + self.assertEqual(self.spawner._current_delay, 0.0) + self.assertEqual(self.spawner._distancing, self.distancing) + self.assertEqual(self.spawner._targets, self.targets) + + def test_get_name(self): + """Test get_name method.""" + self.assertEqual(self.spawner.get_name(), "TestSpawner") + + def test_get_cells(self): + """Test get_cells method.""" + self.assertEqual(list(self.spawner.get_cells()), self.cells) + + def test_can_spawn_initial(self): + """Test can_spawn initially.""" + self.assertTrue(self.spawner.can_spawn()) + + def test_is_done_initial(self): + """Test is_done initially.""" + self.assertFalse(self.spawner.is_done()) + + def test_can_spawn_after_delay(self): + """Test can_spawn after increasing delay.""" + self.spawner._current_delay = 1.0 + self.assertFalse(self.spawner.can_spawn()) + self.spawner._current_delay = 0.0 + self.assertTrue(self.spawner.can_spawn()) + + def test_is_done_after_spawns(self): + """Test is_done after all spawns are done.""" + self.spawner._total_spawns = 0 + self.assertTrue(self.spawner.is_done()) + + def test_update_spawning(self): + """Test update method when spawning occurs.""" + pedestrians = list(self.spawner.update(1.0)) + self.assertEqual(len(pedestrians), 2) + self.assertEqual(self.spawner._current_delay, 1.0) + self.assertEqual(self.spawner._total_spawns, 8) + + @patch.object(Spawner, 'spawn') + def test_update_no_spawn_due_to_delay(self, mock_spawn): + """Test update method when spawn delay hasn't elapsed.""" + self.spawner._current_delay = 0.5 + pedestrians = list(self.spawner.update(0.4)) + mock_spawn.assert_not_called() + self.assertEqual(pedestrians, []) + # compare with .5 - .4 = because of floating point error (0.0999999999999998) + self.assertEqual(self.spawner._current_delay, 0.5 - 0.4) + + @patch.object(Spawner, 'spawn') + def test_update_no_spawn_due_to_total_spawns(self, mock_spawn): + """Test update method when total_spawns reached zero.""" + self.spawner._total_spawns = 0 + pedestrians = list(self.spawner.update(1.0)) + mock_spawn.assert_not_called() + self.assertEqual(pedestrians, []) + self.assertTrue(self.spawner.is_done()) + + def test_update_with_none_total_spawns(self): + """Test update when total_spawns is None (infinite spawns).""" + self.spawner._total_spawns = None + with patch.object(Spawner, 'spawn', return_value=[Mock(), Mock()]) as mock_spawn: + pedestrians = list(self.spawner.update(1.0)) + mock_spawn.assert_called_once() + self.assertEqual(len(pedestrians), 2) + self.assertIsNone(self.spawner._total_spawns) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/core_tests/test_target.py b/tests/core_tests/test_target.py new file mode 100644 index 0000000..1714903 --- /dev/null +++ b/tests/core_tests/test_target.py @@ -0,0 +1,90 @@ +import unittest +from unittest.mock import Mock + +from simulation.core.cell_state import CellState +from simulation.core.target import Target +from simulation.core.cell import Cell +from simulation.core.position import Position +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap_generator_base import HeatmapGeneratorBase +from simulation.heatmaps.heatmap import Heatmap +from utils.immutable_list import ImmutableList + +class TestTarget(unittest.TestCase): + def setUp(self): + self.name = "TestTarget" + self.cells = [Mock(spec=Cell) for _ in range(3)] + self.grid = Mock(spec=SimulationGrid) + self.heatmap_generator = Mock(spec=HeatmapGeneratorBase) + self.heatmap = Mock(spec=Heatmap) + + self.heatmap_generator.generate_heatmap.return_value = self.heatmap + self.heatmap_generator.get_blocked.return_value = {CellState.OCCUPIED, CellState.OBSTACLE} + + self.target = Target( + name=self.name, + cells=self.cells, + grid=self.grid, + heatmap_generator=self.heatmap_generator + ) + + def test_initialization(self): + """Tests the initialization of the Target.""" + self.assertEqual(self.target.get_name(), self.name) + self.assertEqual(list(self.target.get_cells()), self.cells) + self.assertEqual(self.target._grid, self.grid) + self.assertEqual(self.target._heatmap_generator, self.heatmap_generator) + self.assertIsNone(self.target._heatmap) + self.assertEqual(self.target._exit_count, 0) + + def test_get_name(self): + """Tests the get_name method.""" + self.assertEqual(self.target.get_name(), self.name) + + def test_get_cells(self): + """Tests the get_cells method.""" + cells = self.target.get_cells() + self.assertIsInstance(cells, ImmutableList) + self.assertEqual(list(cells), self.cells) + + def test_generate_heatmap(self): + """Tests the generate_heatmap method.""" + self.target.update_heatmap() + self.heatmap_generator.generate_heatmap.assert_called_once_with(self.cells, self.grid) + self.assertEqual(self.target.get_heatmap(), self.heatmap) + + def test_get_heatmap_without_generation(self): + """Tests get_heatmap method before heatmap is generated.""" + with self.assertRaises(Exception): + self.target.get_heatmap() + + def test_is_inside_target(self): + """Tests the is_inside_target method.""" + positions = [Position(x, y) for x, y in [(1, 1), (2, 2), (3, 3)]] + for cell, pos in zip(self.cells, positions): + cell.get_x.return_value = pos.get_x() + cell.get_y.return_value = pos.get_y() + cell.as_tuple.return_value = (pos.get_x(), pos.get_y()) + + position_inside = positions[0] + position_outside = Position(4, 4) + + self.assertTrue(self.target.is_inside_target(position_inside)) + self.assertFalse(self.target.is_inside_target(position_outside)) + + def test_get_exit_count(self): + """Tests the get_exit_count method.""" + self.assertEqual(self.target.get_exit_count(), 0) + self.target._exit_count = 5 + self.assertEqual(self.target.get_exit_count(), 5) + + def test_increment_exit_count(self): + """Tests the increment_exit_count method.""" + self.assertEqual(self.target.get_exit_count(), 0) + self.target.increment_exit_count() + self.assertEqual(self.target.get_exit_count(), 1) + self.target.increment_exit_count() + self.assertEqual(self.target.get_exit_count(), 2) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/exceptions/__pycache__/__init__.cpython-312.pyc b/tests/exceptions/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 16d7bdf..0000000 Binary files a/tests/exceptions/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/exceptions/__pycache__/simulationExceptionTest.cpython-312.pyc b/tests/exceptions/__pycache__/simulationExceptionTest.cpython-312.pyc deleted file mode 100644 index 7923751..0000000 Binary files a/tests/exceptions/__pycache__/simulationExceptionTest.cpython-312.pyc and /dev/null differ diff --git a/tests/exceptions/__pycache__/testModul.cpython-312.pyc b/tests/exceptions/__pycache__/testModul.cpython-312.pyc deleted file mode 100644 index c0c9b33..0000000 Binary files a/tests/exceptions/__pycache__/testModul.cpython-312.pyc and /dev/null differ diff --git a/tests/exceptions/simulationExceptionTest.py b/tests/exceptions/simulationExceptionTest.py deleted file mode 100644 index 77f0b5d..0000000 --- a/tests/exceptions/simulationExceptionTest.py +++ /dev/null @@ -1,17 +0,0 @@ -import unittest - -from src.exceptions.simulationException import SimulationException -from src.exceptions.simulationErrorCodes import SimulationErrorCodes - -class SimulationExceptionTest(unittest.TestCase): - - def test_setsMessage(self): - # ARRANGE - simulationException = SimulationException(SimulationErrorCodes.ExampleError) - - # ACT / ASSERT - self.assertEqual("Example ErrorCode", simulationException.getMessage()) - self.assertEqual(1, simulationException.getCode()) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/distancing_tests/test_euclidean_distance.py b/tests/heatmaps_tests/distancing_tests/test_euclidean_distance.py new file mode 100644 index 0000000..d881418 --- /dev/null +++ b/tests/heatmaps_tests/distancing_tests/test_euclidean_distance.py @@ -0,0 +1,21 @@ +import unittest +from simulation.heatmaps.distancing.euclidean_distance import EuclideanDistance +from simulation.core.position import Position + +class TestEuclideanDistance(unittest.TestCase): + def setUp(self): + self.distance = EuclideanDistance(scale=2.0) + self.pos1 = Position(0, 0) + self.pos2 = Position(3, 4) + + def test_initialization(self): + """Tests initialization and get_scale method.""" + self.assertEqual(self.distance.get_scale(), 2.0) + + def test_calculate_distance(self): + """Tests the calculate_distance method.""" + expected_distance = 5.0 * 2.0 + self.assertEqual(self.distance.calculate_distance(self.pos1, self.pos2), expected_distance) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/distancing_tests/test_taxi_distance.py b/tests/heatmaps_tests/distancing_tests/test_taxi_distance.py new file mode 100644 index 0000000..f13fc08 --- /dev/null +++ b/tests/heatmaps_tests/distancing_tests/test_taxi_distance.py @@ -0,0 +1,21 @@ +import unittest +from simulation.heatmaps.distancing.taxi_distance import TaxiDistance +from simulation.core.position import Position + +class TestTaxiDistance(unittest.TestCase): + def setUp(self): + self.distance = TaxiDistance(scale=2.0) + self.pos1 = Position(0, 0) + self.pos2 = Position(3, 4) + + def test_initialization(self): + """Tests initialization and get_scale method.""" + self.assertEqual(self.distance.get_scale(), 2.0) + + def test_calculate_distance(self): + """Tests the calculate_distance method.""" + expected_distance = (3 + 4) * 2.0 + self.assertEqual(self.distance.calculate_distance(self.pos1, self.pos2), expected_distance) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/neighbourhood_tests/test_moore_neighbourhood.py b/tests/heatmaps_tests/neighbourhood_tests/test_moore_neighbourhood.py new file mode 100644 index 0000000..04e7bc9 --- /dev/null +++ b/tests/heatmaps_tests/neighbourhood_tests/test_moore_neighbourhood.py @@ -0,0 +1,45 @@ +import unittest +from simulation.neighbourhood.moore_neighbourhood import MooreNeighbourhood + +class TestMooreNeighbourhood(unittest.TestCase): + def setUp(self): + self.width = 5 + self.height = 5 + self.neighbourhood = MooreNeighbourhood(self.width, self.height) + + def test_initialization(self): + """Tests initialization of the MooreNeighbourhood.""" + self.assertEqual(self.neighbourhood._width, self.width) + self.assertEqual(self.neighbourhood._height, self.height) + + def test_get_neighbours(self): + """Tests the get_neighbours method.""" + x, y = 2, 2 + neighbours = list(self.neighbourhood.get_neighbours(x, y, 1, 1)) + expected_neighbours = [ + (1, 1), (1, 2), (1, 3), + (2, 1), (2, 3), + (3, 1), (3, 2), (3, 3) + ] + self.assertEqual(sorted(neighbours), sorted(expected_neighbours)) + + def test_get_neighbours_edge(self): + """Tests the get_neighbours method at the edge of the grid.""" + x, y = 0, 0 + neighbours = list(self.neighbourhood.get_neighbours(x, y, 1, 1)) + expected_neighbours = [ + (0, 1), (1, 0), (1, 1) + ] + self.assertEqual(sorted(neighbours), sorted(expected_neighbours)) + + def test_get_neighbours_corner(self): + """Tests the get_neighbours method at the corner of the grid.""" + x, y = 4, 4 + neighbours = list(self.neighbourhood.get_neighbours(x, y, 1, 1)) + expected_neighbours = [ + (3, 3), (3, 4), (4, 3) + ] + self.assertEqual(sorted(neighbours), sorted(expected_neighbours)) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/neighbourhood_tests/test_neumann_neighbourhood.py b/tests/heatmaps_tests/neighbourhood_tests/test_neumann_neighbourhood.py new file mode 100644 index 0000000..539bfcd --- /dev/null +++ b/tests/heatmaps_tests/neighbourhood_tests/test_neumann_neighbourhood.py @@ -0,0 +1,40 @@ +import unittest +from simulation.neighbourhood.neumann_neighbourhood import NeumannNeighbourhood + +class TestNeumannNeighbourhood(unittest.TestCase): + def setUp(self): + self.width = 5 + self.height = 5 + self.neighbourhood = NeumannNeighbourhood(self.width, self.height) + + def test_get_neighbours(self): + """Tests the get_neighbours method.""" + x, y = 2, 2 + neighbours = list(self.neighbourhood.get_neighbours(x, y, 1, 1)) + expected_neighbours = [ + (1, 2), (3, 2), + (2, 1), (2, 3) + ] + self.assertEqual(sorted(neighbours), sorted(expected_neighbours)) + + # Grid bound checking only appears in SimulationGrid, Neighbourhood is grid size agnostic + # def test_get_neighbours_edge(self): + # """Tests the get_neighbours method at the edge of the grid.""" + # x, y = 0, 0 + # neighbours = list(self.neighbourhood.get_neighbours(x, y, 1, 1)) + # expected_neighbours = [ + # (1, 0), (0, 1) + # ] + # self.assertEqual(sorted(neighbours), sorted(expected_neighbours)) + # + # def test_get_neighbours_corner(self): + # """Tests the get_neighbours method at the corner of the grid.""" + # x, y = 4, 4 + # neighbours = list(self.neighbourhood.get_neighbours(x, y, 1, 1)) + # expected_neighbours = [ + # (3, 4), (4, 3) + # ] + # self.assertEqual(sorted(neighbours), sorted(expected_neighbours)) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/test_djisktra_heatmap_generator.py b/tests/heatmaps_tests/test_djisktra_heatmap_generator.py new file mode 100644 index 0000000..1a5b49c --- /dev/null +++ b/tests/heatmaps_tests/test_djisktra_heatmap_generator.py @@ -0,0 +1,111 @@ +import unittest +from unittest.mock import Mock + +from simulation.core.cell_state import CellState +from simulation.heatmaps.distancing.euclidean_distance import EuclideanDistance +from simulation.heatmaps.djisktra_heatmap_generator import DijkstraHeatmapGenerator +from simulation.core.cell import Cell +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.neighbourhood.moore_neighbourhood import MooreNeighbourhood + + +class TestDijkstraHeatmapGenerator(unittest.TestCase): + def setUp(self): + self.distancing = Mock(spec=DistanceBase) + self.grid = Mock(spec=SimulationGrid) + self.cells = [Mock(spec=Cell) for _ in range(3)] + self.heatmap_generator = DijkstraHeatmapGenerator(self.distancing) + + for i, cell in enumerate(self.cells): + cell.get_x.return_value = i + cell.get_y.return_value = i + + def test_initialization(self): + """Tests initialization of the DijkstraHeatmapGenerator.""" + self.assertEqual(self.heatmap_generator._distancing, self.distancing) + + def test_generate_heatmap(self): + """Tests the generate_heatmap method.""" + self.grid.get_width.return_value = 5 + self.grid.get_height.return_value = 5 + self.grid.get_cells.return_value = self.cells + self.grid.get_neighbours_at.return_value = [] + + heatmap = self.heatmap_generator.generate_heatmap(self.cells, self.grid) + + self.assertIsInstance(heatmap, Heatmap) + self.assertEqual(heatmap.get_width(), 5) + self.assertEqual(heatmap.get_height(), 5) + for cell in self.cells: + heatmap.set_cell_at_pos(cell, 0) + self.assertEqual(heatmap.get_cell_at_pos(cell), 0) + + def test_call_generate_heatmap__generates__expected_values_of_dijkstra(self): + """Tests the generate_heatmap method to see if dijkstra algorithm is correctly implemented.""" + # Arrange + grid = SimulationGrid(3, 3, MooreNeighbourhood) + distance = EuclideanDistance(1) + generator = DijkstraHeatmapGenerator(distance) + + # Act + heatmap = generator.generate_heatmap([grid.get_cell(1, 1)], grid) + + # Assert + expected: list[float] = [ + 1.4, 1.0, 1.4, + 1.0, 0.0, 1.0, + 1.4, 1.0, 1.4 + ] + + for i, value in enumerate(heatmap.get_cells()): + self.assertAlmostEqual(value, expected[i], places=1) + + def test_call_generate_heatmap__generates__expected_values_of_dijkstra__with_obstacle(self): + """Tests the generate_heatmap method to see if dijkstra algorithm is correctly implemented.""" + # Arrange + grid = SimulationGrid(3, 3, MooreNeighbourhood) + distance = EuclideanDistance(1) + generator = DijkstraHeatmapGenerator(distance, {CellState.OBSTACLE}) + + grid.get_cell(1, 0).set_osbtacle() + + # Act + heatmap = generator.generate_heatmap([grid.get_cell(1, 1)], grid) + + # Assert + expected: list[float] = [ + 1.4, float('inf'), 1.4, + 1.0, 0.0, 1.0, + 1.4, 1.0, 1.4 + ] + + for i, value in enumerate(heatmap.get_cells()): + self.assertAlmostEqual(value, expected[i], places=1) + + def test_call_generate_heatmap__ignores_obstacle__when_not_in_block_list(self): + """Tests the generate_heatmap method to see if dijkstra algorithm is correctly implemented.""" + # Arrange + grid = SimulationGrid(3, 3, MooreNeighbourhood) + distance = EuclideanDistance(1) + generator = DijkstraHeatmapGenerator(distance, set()) + + grid.get_cell(1, 0).set_osbtacle() + + # Act + heatmap = generator.generate_heatmap([grid.get_cell(1, 1)], grid) + + # Assert + expected: list[float] = [ + 1.4, 1.0, 1.4, + 1.0, 0.0, 1.0, + 1.4, 1.0, 1.4 + ] + + for i, value in enumerate(heatmap.get_cells()): + self.assertAlmostEqual(value, expected[i], places=1) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/test_fast_marching_heatmap_generator.py b/tests/heatmaps_tests/test_fast_marching_heatmap_generator.py new file mode 100644 index 0000000..22cfde4 --- /dev/null +++ b/tests/heatmaps_tests/test_fast_marching_heatmap_generator.py @@ -0,0 +1,53 @@ +import unittest +from unittest.mock import Mock + +from simulation.heatmaps.distancing.euclidean_distance import EuclideanDistance +from simulation.heatmaps.fast_marching_heatmap_generator import FastMarchingHeatmapGenerator +from simulation.core.cell import Cell +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.core.cell_state import CellState +from simulation.neighbourhood.moore_neighbourhood import MooreNeighbourhood + + +class TestFastMarchingHeatmapGenerator(unittest.TestCase): + def setUp(self): + self.distancing = Mock(spec=DistanceBase) + self.grid = Mock(spec=SimulationGrid) + self.cells = [Mock(spec=Cell) for _ in range(3)] + self.heatmap_generator = FastMarchingHeatmapGenerator(self.distancing) + + for i, cell in enumerate(self.cells): + cell.get_x.return_value = i + cell.get_y.return_value = i + cell.get_state.return_value = CellState.FREE + + def test_initialization(self): + """Tests initialization of the FastMarchingHeatmapGenerator.""" + self.assertEqual(self.heatmap_generator._distancing, self.distancing) + self.assertEqual(self.heatmap_generator._blocked, {CellState.OBSTACLE}) + + def test_generate_heatmap__generates__expected_values_of_fast_marching(self): + """Tests the implementation of the Fast Marching Method. Comparing it to the values of CDS307 Slides of day 4 page 19""" + # Arrange + grid = SimulationGrid(3, 3, MooreNeighbourhood) + distance = EuclideanDistance(1.0) + generator = FastMarchingHeatmapGenerator(distance) + + # Act + heatmap = generator.generate_heatmap([grid.get_cell(0, 2)], grid) + + # Assert + expected = [ + 2.0, 2.2, 2.9, + 1.0, 1.7, 2.2, + 0.0, 1.0, 2.0 + ] + + for i, value in enumerate(heatmap.get_cells()): + self.assertAlmostEqual(value, expected[i], places=1) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/heatmaps_tests/test_social_distancing_heatmap_generator.py b/tests/heatmaps_tests/test_social_distancing_heatmap_generator.py new file mode 100644 index 0000000..936d419 --- /dev/null +++ b/tests/heatmaps_tests/test_social_distancing_heatmap_generator.py @@ -0,0 +1,94 @@ +import unittest +from collections.abc import generator +from unittest.mock import Mock + +from simulation.core.pedestrian import Pedestrian +from simulation.heatmaps.distancing.euclidean_distance import EuclideanDistance +from simulation.heatmaps.social_distancing_heatmap_generator import SocialDistancingHeatmapGenerator +from simulation.core.cell import Cell +from simulation.core.simulation_grid import SimulationGrid +from simulation.heatmaps.heatmap import Heatmap +from simulation.heatmaps.distancing.base_distance import DistanceBase +from simulation.core.cell_state import CellState +from simulation.neighbourhood.moore_neighbourhood import MooreNeighbourhood + + +class TestSocialDistancingHeatmapGenerator(unittest.TestCase): + def setUp(self): + self.distancing = Mock(spec=DistanceBase) + self.grid = Mock(spec=SimulationGrid) + self.cells = [Mock(spec=Cell) for _ in range(3)] + self.heatmap_generator = SocialDistancingHeatmapGenerator(self.distancing, width=3.0, height=3.0) + + for i, cell in enumerate(self.cells): + cell.get_x.return_value = i + cell.get_y.return_value = i + cell.get_state.return_value = CellState.FREE + + def test_initialization(self): + """Tests initialization of the SocialDistancingHeatmapGenerator.""" + self.assertEqual(self.heatmap_generator._distancing, self.distancing) + self.assertEqual(self.heatmap_generator._width, 3.0) + self.assertEqual(self.heatmap_generator._height, 3.0) + self.assertEqual(self.heatmap_generator._blocked, {CellState.OCCUPIED}) + + def test_generate_heatmap(self): + """Tests the generate_heatmap method.""" + self.grid.get_width.return_value = 5 + self.grid.get_height.return_value = 5 + self.grid.get_cells.return_value = self.cells + self.grid.get_neighbours_at.side_effect = lambda pos: [ + cell for cell in self.cells if cell.get_x() != pos.get_x() or cell.get_y() != pos.get_y() + ] + + heatmap = self.heatmap_generator.generate_heatmap(self.cells, self.grid) + + self.assertIsInstance(heatmap, Heatmap) + self.assertEqual(heatmap.get_width(), 5) + self.assertEqual(heatmap.get_height(), 5) + for cell in self.cells: + self.assertEqual(heatmap.get_cell_at_pos(cell), 0) + + def test_generate_heatmap__generates__expected_heatmap_values(self): + """Tests if social distancing algorithm works as expected.""" + # Arrange + distancing = EuclideanDistance(1.0) + grid = SimulationGrid(3, 3, MooreNeighbourhood) + pedestrian = Mock(spec=Pedestrian) + grid.get_cell(0,0).set_pedestrian(pedestrian) + generator = SocialDistancingHeatmapGenerator(distancing, 3, 3, {CellState.OCCUPIED}) + + # Act + heatmap = generator.generate_heatmap([grid.get_cell(0,0)], grid) + + # Assert + # Setup expected with manually calculating the formula from the document CDS307-Aufgabe-Zellularautomat.pdf page 2 + # w = h = 3 + # up_d = h * exp(1/((d/w)^2 - 1)) if abs(d) < w else 0 (minus got omitted since a positive heatmap value means higher repulsion) + expected = [ + 1.103, 0.973, 0.495, + 0.973, 0.829, 0.316, + 0.495, 0.316, 0.000 + ] + + for i, value in enumerate(heatmap.get_cells()): + self.assertAlmostEqual(value, expected[i], 2) + + def test_generate_heatmap__only_ignores_cells__not_in_blocked_list(self): + """Tests if the heatmap generator ignores cells that are not in the blocked list.""" + # Arrange + distancing = EuclideanDistance(1.0) + grid = SimulationGrid(3, 3, MooreNeighbourhood) + pedestrian = Mock(spec=Pedestrian) + grid.get_cell(0,0).set_pedestrian(pedestrian) + generator = SocialDistancingHeatmapGenerator(distancing, 3, 3, {}) + + # Act + heatmap = generator.generate_heatmap([grid.get_cell(0,0)], grid) + + # Assert + for value in heatmap.get_cells(): + self.assertGreaterEqual(value, 0.0) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/simulation/__pycache__/__init__.cpython-312.pyc b/tests/simulation/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 990b9dc..0000000 Binary files a/tests/simulation/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/simulation/__pycache__/baseSimulationTest.cpython-312.pyc b/tests/simulation/__pycache__/baseSimulationTest.cpython-312.pyc deleted file mode 100644 index 2f3af0e..0000000 Binary files a/tests/simulation/__pycache__/baseSimulationTest.cpython-312.pyc and /dev/null differ diff --git a/tests/simulation/__pycache__/testGrid.cpython-312.pyc b/tests/simulation/__pycache__/testGrid.cpython-312.pyc deleted file mode 100644 index c60f359..0000000 Binary files a/tests/simulation/__pycache__/testGrid.cpython-312.pyc and /dev/null differ diff --git a/tests/simulation/baseSimulationTest.py b/tests/simulation/baseSimulationTest.py deleted file mode 100644 index 9e9d176..0000000 --- a/tests/simulation/baseSimulationTest.py +++ /dev/null @@ -1,16 +0,0 @@ -import unittest -from src.simulation.baseSimulation import BaseSimulation -from src.exceptions.simulationException import SimulationException - -class SimulationExceptionTest(unittest.TestCase): - - def test_simulateStep_throws(self): - # ARRANGE - baseSilumlation = BaseSimulation(None, None) - - # ACT - self.assertRaises(SimulationException, baseSilumlation.simulateStep) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/simulation/locomotionAlgorithms/__init__.py b/tests/simulation/locomotionAlgorithms/__init__.py deleted file mode 100644 index 5ad2b93..0000000 --- a/tests/simulation/locomotionAlgorithms/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from src.simulation.locomotionAlgorithms.dijkstra import Dijkstra - diff --git a/tests/simulation/testGrid.py b/tests/simulation/testGrid.py deleted file mode 100644 index d3b278a..0000000 --- a/tests/simulation/testGrid.py +++ /dev/null @@ -1,25 +0,0 @@ -import unittest -from src.simulation.grid import Grid -from src.enums.tileStatus import TileStatus -from src.simulation.tile import Tile - -class SimulationExceptionTest(unittest.TestCase): - - def test_gridWith10x10Tile_setsTiles(self): - # ARRANGE - length: int = 10 - width: int = 10 - - # ACT - grid = Grid(length, width) - - # ASSERT - self.assertEqual(length, len(grid.getTiles())) - for row in grid.getTiles(): - self.assertEqual(width, len(row)) - for tile in row: - self.assertEqual(TileStatus.FREE, tile.getTileDTO().getTileStatus()) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/clipped_normal_distribution.py b/utils/clipped_normal_distribution.py new file mode 100644 index 0000000..657c5d7 --- /dev/null +++ b/utils/clipped_normal_distribution.py @@ -0,0 +1,25 @@ +import numpy as np + +class ClippedNormalDistribution: + """ + Helper class which randomly samples an absolute value from a normal distribution and clips the value to a given range. + """ + def __init__(self, mean: float, sigma:float, min:float, max:float): + """ + :param mean: mean of the normal distribution + :param sigma: standard deviation of the normal distribution + :param min: minimum value of the clipped range + :param max: maximum value of the clipped range + """ + self._mean: float = mean + self._sigma: float = sigma + self._min: float = min + self._max:float = max + + def sample(self) -> float: + """ + Samples a value from the normal distribution and clips it to the given range + :return: positive value clipped to the given range + """ + value = np.random.normal(self._mean, self._sigma) + return np.clip(abs(value), self._min, self._max) \ No newline at end of file diff --git a/utils/immutable_list.py b/utils/immutable_list.py new file mode 100644 index 0000000..434a144 --- /dev/null +++ b/utils/immutable_list.py @@ -0,0 +1,24 @@ +class ImmutableList[T]: + """ + List which cannot be modified, inspired by https://stackoverflow.com/questions/23474648/python-read-only-lists-using-the-property-decorator + """ + def __init__(self, data: list[T]): + """ + :param data: list to be wrapped + """ + self._data = data + + def __getitem__(self, index: int) -> T: + return self._data[index] + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __contains__(self, item): + return item in self._data + + def index(self, target): + return self._data.index(target) \ No newline at end of file diff --git a/utils/utils.py b/utils/utils.py new file mode 100644 index 0000000..98b35da --- /dev/null +++ b/utils/utils.py @@ -0,0 +1,74 @@ +from typing import Tuple +import base64 +import struct + +from simulation.heatmaps.heatmap import Heatmap + + +def get_none_fields(**kwargs) -> list: + return [k for k, v in kwargs.items() if v is None] + +def if_all_or_none(*args) -> bool: + """ + Check if all or none of the arguments are None + :param args: + :return: + """ + return all(x is None for x in args) or all(x is not None for x in args) + +def none_check(**kwargs) -> Tuple[bool, list[str]]: + """ + Check if all or none of the arguments are None + :param args: the arguments to check + :return: if all or none of the arguments are None, list of fields that are None + """ + none_fields = get_none_fields(**kwargs) + return if_all_or_none(*kwargs.values()), none_fields + + +def heatmap_to_bytes(heatmap: Heatmap) -> bytes: + """ + Converts a heatmap to a byte array + :param heatmap: heatmap to convert + :return: byte array + """ + cell_count = heatmap.get_width() * heatmap.get_height() + struct_fmt = 'II' + 'd' * cell_count + return struct.pack(struct_fmt, heatmap.get_width(), heatmap.get_height(), *heatmap.cells) + +def heatmap_to_base64(heatmap: Heatmap) -> str|None: + """ + Converts a heatmap to a base64 string + :param heatmap: heatmap to convert + :return: base64 string + """ + if heatmap is None: + return None + + data = heatmap_to_bytes(heatmap) + return base64.b64encode(data).decode() + +def heatmap_from_bytes(data: bytes) -> Heatmap: + """ + Converts a byte array to a heatmap + :param data: byte array + :return: heatmap + """ + struct_fmt = ' Heatmap: + """ + Converts a base64 string to a heatmap + :param data: base64 string + :return: heatmap + """ + decoded = base64.b64decode(data) + return heatmap_from_bytes(decoded) \ No newline at end of file diff --git a/visualisation/__init__.py b/visualisation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/visualisation/button.py b/visualisation/button.py new file mode 100644 index 0000000..11e9bc8 --- /dev/null +++ b/visualisation/button.py @@ -0,0 +1,78 @@ +from collections.abc import Callable +from typing import Tuple + +import pygame +from pygame import Rect, Surface + +from visualisation.theme import Theme, DEFAULT_THEME +from visualisation.visualisation_helper import VisualisationHelper + + +class Button: + def __init__(self, min_size: Rect, text: str, theme: Theme = DEFAULT_THEME, action: Callable = None): + self._min_size: Rect = min_size + self._bounds: Rect = min_size.copy() + self._theme: Theme = theme + self._millis_since_pressed = 0 + self._action: Callable = action + self._is_hovered = False + self._text: str = None + self._rendered_text: Surface = None + self.set_text(text) + + def update(self, delta_time: float) -> None: + if self._millis_since_pressed > 0: + self._millis_since_pressed -= delta_time + + def is_pressed(self) -> bool: + return self._millis_since_pressed > 0 + + def press(self) -> None: + self._millis_since_pressed = 250 + if self._action is not None: + self._action() + + def is_hovered(self) -> bool: + return self._is_hovered + + def set_hovered(self, hovered: bool) -> None: + self._is_hovered = hovered + + def set_text(self, text: str) -> None: + if text != self._text: + self._rendered_text = VisualisationHelper.render_multiline_text_to_surface(self._theme.font, text, self._theme.foreground, True) + #self._rendered_text = self._theme.font.render(text, True, self._theme.foreground) + self._text = text + self._bounds.update(self._bounds.left, self._bounds.top, max(self._min_size.width, self._rendered_text.get_width()) + self._theme.padding.get_horizontal(), max(self._min_size.height, self._rendered_text.get_height()) + self._theme.padding.get_vertical()) + + def set_action(self, action) -> None: + self._action = action + + def set_theme(self, theme: Theme) -> None: + self._theme = theme + + def is_inside(self, x: int, y: int) -> bool: + return self._bounds.collidepoint(x, y) + + def get_width(self) -> int: + return self._bounds.width + + def get_height(self) -> int: + return self._bounds.height + + def get_size(self) -> Tuple[int, int]: + return self._bounds.width, self._bounds.height + + def set_position(self, x: int, y: int) -> None: + self._bounds.update(x, y, self._bounds.width, self._bounds.height) + + def get_bounds(self) -> Rect: + return self._bounds + + def render(self, surface) -> None: + color = self._theme.pressed_color if self.is_pressed() else self._theme.hover_color if self.is_hovered() else self._theme.color + pygame.draw.rect(surface, color, self._bounds) + center = self._bounds.center + text_pos = self._rendered_text.get_rect(center=center) + surface.blit(self._rendered_text, text_pos) + diff --git a/visualisation/features/__init__.py b/visualisation/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/visualisation/features/flowmeters_visualisation.py b/visualisation/features/flowmeters_visualisation.py new file mode 100644 index 0000000..f456224 --- /dev/null +++ b/visualisation/features/flowmeters_visualisation.py @@ -0,0 +1,47 @@ +import pygame +from pygame import Surface + +from typing import TYPE_CHECKING + +from pygame.font import SysFont + +from visualisation.visualisation_feature import VisualisationFeatureBase + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class FlowMetersVisualisation(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._small_font = SysFont(self._font_name, self._small_font_size) + self._render_names = self._helper.get_cell_size() > 40 + + def set_render_names(self, render_names: bool) -> None: + self._render_names = render_names + + def get_render_names(self) -> bool: + return self._render_names + + + def _describe_state(self) -> str: + if self._visualisation.get_flow_meters() is None: + return "No Flow Meters" + + return f"\nShow names: {self._render_names}\n".join([f"{flow_meter.get_name()}: {flow_meter.get_flow_rate()}" for flow_meter in self._visualisation.get_flow_meters()]) + + def _render(self, surface: Surface) -> None: + flow_meters = self._visualisation.get_flow_meters() + if flow_meters is not None: + for flow_meter in flow_meters: + for cell in flow_meter.get_cells(): + cell_rect = self._helper.get_rect_at(cell) + pygame.draw.rect(surface, (200, 200, 200), cell_rect, 1) + + if self._render_names: + name = self._small_font.render(f"{flow_meter.get_name()}: {flow_meter.get_flow_rate():.3f}", True, self._text_color) + text_pos = self._helper.get_x_center_pos_at(cell, name.get_height()) + surface.blit(name, name.get_rect(center=text_pos)) + diff --git a/visualisation/features/grid_visualisation_feature.py b/visualisation/features/grid_visualisation_feature.py new file mode 100644 index 0000000..25eb5d3 --- /dev/null +++ b/visualisation/features/grid_visualisation_feature.py @@ -0,0 +1,37 @@ +import pygame +from pygame import Surface + +from simulation.core.cell_state import CellState +from visualisation.visualisation_feature import VisualisationFeatureBase +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class GridVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._show_lines = True + + def set_show_lines(self, show_lines: bool) -> None: + self._show_lines = show_lines + + def get_show_lines(self) -> bool: + return self._show_lines + + def _describe_state(self) -> str: + return None + + def _render(self, surface: Surface) -> None: + for x in range(self._simulation.get_grid().get_width()): + for y in range(self._simulation.get_grid().get_height()): + cell = self._simulation.get_grid().get_cell(x, y) + cell_rect = self._helper.get_rect_at(cell) + if cell.get_state() == CellState.OBSTACLE: + pygame.draw.rect(surface, (0, 0, 0), cell_rect) + + if self._show_lines: + pygame.draw.rect(surface, (0, 0, 0), cell_rect, 1) diff --git a/visualisation/features/path_visualisation_feature.py b/visualisation/features/path_visualisation_feature.py new file mode 100644 index 0000000..7d93f99 --- /dev/null +++ b/visualisation/features/path_visualisation_feature.py @@ -0,0 +1,89 @@ +import pygame.draw +from pygame import Surface + +from simulation.core.position import Position +from visualisation.visualisation_feature import VisualisationFeatureBase + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.pedestrian import Pedestrian + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class PathVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._selected_pedestrian: Pedestrian = None + self._selected_pedestrian_index = 0 + + def next_pedestrian(self): + pedestrians = self._simulation.get_pedestrians() + self._selected_pedestrian_index = (self._selected_pedestrian_index + 1) % (len(pedestrians) + 1) + self._selected_pedestrian = None if self._selected_pedestrian_index == 0 else pedestrians[self._selected_pedestrian_index - 1] + + def previous_pedestrian(self): + pedestrians = self._simulation.get_pedestrians() + self._selected_pedestrian_index = (self._selected_pedestrian_index - 1) % (len(pedestrians) + 1) + self._selected_pedestrian = None if self._selected_pedestrian_index == 0 else pedestrians[self._selected_pedestrian_index - 1] + + def set_pedestrian(self, pedestrian: 'Pedestrian|None'): + if pedestrian is None: + self._selected_pedestrian_index = 0 + self._selected_pedestrian = None + else: + self._selected_pedestrian_index = self._simulation.get_pedestrians().index(pedestrian) + 1 + self._selected_pedestrian = pedestrian + + def _describe_state(self) -> str: + return f"Selected pedestrian: {'None' if self._selected_pedestrian is None else self._selected_pedestrian.get_id()}" + + def _render_target(self, surface): + heatmap = self._selected_pedestrian.get_heatmap() + last_pos: Position = self._selected_pedestrian + visited: set[Position] = set() + visited.add(last_pos) + while last_pos is not None: + next_pos: Position = self._simulation._get_next_target_cell(heatmap, last_pos, last_pos) + if next_pos is None or next_pos in visited: + break + + pygame.draw.line(surface, (255, 255, 255), self._helper.get_centered_pos_at(last_pos), self._helper.get_centered_pos_at(next_pos), 2) + if self._selected_pedestrian.get_target().is_inside_target(next_pos): + break + + visited.add(next_pos) + last_pos = next_pos + + def _render_waypoint(self, surface): + waypoint = self._selected_pedestrian.get_waypoint() + heatmap = waypoint.get_heatmap() + last_pos: Position = self._selected_pedestrian + visited: set[Position] = set() + visited.add(last_pos) + while last_pos is not None: + next_pos: Position = waypoint.next_cell(last_pos) + if next_pos is None or next_pos in visited: + break + + pygame.draw.line(surface, (255, 255, 255), self._helper.get_centered_pos_at(last_pos), self._helper.get_centered_pos_at(next_pos), 2) + if waypoint.is_inside_waypoint(next_pos): + break + + visited.add(next_pos) + last_pos = next_pos + + def _render(self, surface: Surface) -> None: + if self._selected_pedestrian is not None: + if self._selected_pedestrian.has_reached_target(): + self.set_pedestrian(None) + return + + origin = self._helper.get_centered_pos_at(self._selected_pedestrian) + pygame.draw.circle(surface, (255, 255, 255), origin, self._helper.get_cell_size() // 2, 2) + if self._selected_pedestrian.has_waypoint(): + self._render_waypoint(surface) + else: + self._render_target(surface) diff --git a/visualisation/features/pedestrian_visualisation_feature.py b/visualisation/features/pedestrian_visualisation_feature.py new file mode 100644 index 0000000..6b63c07 --- /dev/null +++ b/visualisation/features/pedestrian_visualisation_feature.py @@ -0,0 +1,61 @@ +import math + +import pygame.font +from pygame import Surface + +from visualisation.visualisation_feature import VisualisationFeatureBase +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class PedestrianVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._render_details = self._helper.get_cell_size() > 20 + self._render_target_line = self._helper.get_cell_size() > 30 + self._font = pygame.font.SysFont(self._font_name, self._font_size) + + def set_render_details(self, state: bool) -> None: + self._render_details = state + + def set_render_target_line(self, state: bool) -> None: + self._render_target_line = state + + def get_render_details(self) -> bool: + return self._render_details + + def get_render_target_line(self) -> bool: + return self._render_target_line + + def _describe_state(self) -> str: + return f"Show details: {self._render_details}\nShow target line: {self._render_target_line}" + + def _render(self, surface: Surface) -> None: + for pedestrian in self._simulation.get_pedestrians(): + spawner_color = self._visualisation.get_spawner_color(pedestrian.get_spawner()) + + if pedestrian.has_targeted_cell(): + target = pedestrian.get_targeted_cell() + target_color = self._visualisation.get_target_color(pedestrian.get_target()) + if self._render_target_line: + from_pos = self._helper.get_centered_pos_at(pedestrian) + to_pos = self._helper.get_centered_pos_at(target) + pygame.draw.line(surface, target_color, from_pos, to_pos, 2) + + # calculate the angle between the pedestrian and the target + angle = math.atan2(target.get_y() - pedestrian.get_y(), target.get_x() - pedestrian.get_x()) + triangle = self._helper.get_small_triangle_at(pedestrian, angle) + pygame.draw.polygon(surface, spawner_color, triangle) + pygame.draw.lines(surface, target_color, True, triangle, 2) + else: + rect = self._helper.get_small_rect_at(pedestrian) + pygame.draw.rect(surface, spawner_color, rect) + + if self._render_details: + speed_info = self._font.render(f"[{pedestrian.get_id()}] {pedestrian.get_average_speed():.2f}m/s, {pedestrian.get_optimal_speed():.2f}m/s, {pedestrian.get_current_distance():.2f}m", True, self._text_color) + text_pos = self._helper.get_x_center_pos_at(pedestrian, speed_info.get_height()) + surface.blit(speed_info, speed_info.get_rect(center=text_pos)) diff --git a/visualisation/features/simulation_info_visualisation_feature.py b/visualisation/features/simulation_info_visualisation_feature.py new file mode 100644 index 0000000..17a4959 --- /dev/null +++ b/visualisation/features/simulation_info_visualisation_feature.py @@ -0,0 +1,39 @@ +from os import supports_fd + +import pygame.font +from pygame import Surface + +from visualisation.visualisation_feature import VisualisationFeatureBase +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class SimulationInfoVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._font_size = 20 + self._font = pygame.font.SysFont(self._font_name, self._helper.get_cell_size(), bold=True) + + def _describe_state(self) -> str: + return f"FPS: {self._visualisation._clock.get_fps()}\nStep: {self._simulation.get_steps()}\nTime: {self._simulation.get_run_time():.2f}s\nPedestrians: {len(self._simulation.get_pedestrians())}\nIs paused: {self._visualisation.is_paused()}" + + def _translate(self, pos: tuple[int, int], x: int, y: int) -> tuple[int, int]: + return pos[0] + x, pos[1] + y + + def _next_line(self, pos: tuple[int, int]) -> tuple[int, int]: + return pos[0], pos[1] + self._font_size + + def _render(self, surface: Surface) -> None: + pos = self._translate(self._helper.get_top_left(), 10, 10) + steps = self._font.render(f"Step: {self._simulation.get_steps()}", True, self._text_color) + surface.blit(steps, pos) + run_time = self._font.render(f"Time: {self._simulation.get_run_time():.2f}s", True, self._text_color) + surface.blit(run_time, pos := self._translate(pos, 0, steps.get_height())) + ped_count = self._font.render(f"Pedestrians: {len(self._simulation.get_pedestrians())}", True, self._text_color) + surface.blit(ped_count, pos := self._translate(pos, 0, run_time.get_height())) + paused = self._font.render(f"Is paused: {self._visualisation.is_paused()}", True, self._text_color) + surface.blit(paused, pos := self._translate(pos, 0, ped_count.get_height())) diff --git a/visualisation/features/spawner_visualisation_feature.py b/visualisation/features/spawner_visualisation_feature.py new file mode 100644 index 0000000..3e44967 --- /dev/null +++ b/visualisation/features/spawner_visualisation_feature.py @@ -0,0 +1,37 @@ +import pygame +from pygame import Surface + + +from visualisation.visualisation_feature import VisualisationFeatureBase +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class SpawnerVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._font = pygame.font.SysFont(self._font_name, self._font_size) + self._render_names = self._helper.get_cell_size() > 40 + + def get_render_names(self) -> bool: + return self._render_names + + def set_render_names(self, state: bool) -> None: + self._render_names = state + + def _describe_state(self) -> str: + return f"Show names: {self._render_names}" + + def _render(self, surface: Surface) -> None: + for spawner in self._simulation.get_spawners(): + color = self._visualisation.get_spawner_color(spawner) + for cell in spawner.get_cells(): + cell_rect = self._helper.get_small_rect_at(cell) + pygame.draw.rect(surface, color, cell_rect) + if self._render_names: + name = self._font.render(spawner.get_name(), True, self._text_color) + text_pos = self._helper.get_x_center_pos_at(cell, name.get_height()) + surface.blit(name, name.get_rect(center=text_pos)) diff --git a/visualisation/features/target_heatmap_visualisation_feature.py b/visualisation/features/target_heatmap_visualisation_feature.py new file mode 100644 index 0000000..b43dad6 --- /dev/null +++ b/visualisation/features/target_heatmap_visualisation_feature.py @@ -0,0 +1,81 @@ +import pygame +from pygame import Surface + +from simulation.core.target import Target +from visualisation.visualisation_feature import VisualisationFeatureBase +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class TargetHeatmapVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._selected_target_index = 0 + self._selected_target = None + self._font = pygame.font.SysFont(self._font_name, self._small_font_size) + self._include_social_distancing = True + self._max_social_distancing_value = sim._social_distancing_generator.get_max_value() + self._max_heatmap_value = sim.get_max_grid_distance() + self._max_social_distancing_value + self._render_details = self._helper.get_cell_size() > 10 + + def next_target(self): + self._selected_target_index = (self._selected_target_index + 1) % (len(self._simulation.get_targets()) + 1) + self._selected_target = None if self._selected_target_index == 0 else self._simulation.get_targets()[self._selected_target_index-1] + + def previous_target(self): + self._selected_target_index = (self._selected_target_index - 1) % (len(self._simulation.get_targets()) + 1) + self._selected_target = None if self._selected_target_index == 0 else self._simulation.get_targets()[self._selected_target_index-1] + + def set_target(self, target: 'Target'): + if target is None: + self._selected_target_index = 0 + self._selected_target = None + else: + self._selected_target_index = self._simulation.get_targets().index(target) + 1 + self._selected_target = target + + def set_social_distancing(self, value: bool): + self._include_social_distancing = value + + def get_social_distancing(self) -> bool: + return self._include_social_distancing + + def _describe_state(self) -> str: + return f"Selected target: {'None' if self._selected_target is None else self._selected_target.get_name()}\nShow social distancing: {self._include_social_distancing}" + + def _render(self, surface: Surface) -> None: + heatmap = self._selected_target.get_heatmap() if self._selected_target is not None else None + social_distancing_heatmap = self._simulation.get_distancing_heatmap() + + for x in range(self._simulation.get_grid().get_width()): + for y in range(self._simulation.get_grid().get_height()): + value = heatmap.get_cell(x, y) if heatmap is not None else 0 + + min_r = 0 + if self._include_social_distancing: + social_distance = social_distancing_heatmap.get_cell(x, y) + min_r = min(200, int(social_distance/(self._max_social_distancing_value // 2) * 200)) + value += social_distance + + ratio = min(value, self._max_heatmap_value) / self._max_heatmap_value + color = self._helper.mix_colors((255, 200, 0), (min_r, 100, 255), ratio) + rect = self._helper.get_rect(x, y) + pygame.draw.rect(surface, color, rect) + if self._render_details: + value_text = self._font.render(f"{value:.2f}", True, (40, 40, 40)) + text_pos = self._helper.get_centered_pos(x, y) + surface.blit(value_text, value_text.get_rect(center=text_pos)) + + if self._selected_target is not None: + min_x, min_y, max_x, max_y = float('inf'), float('inf'), 0, 0 + for cell in self._selected_target.get_cells(): + min_x = min(min_x, cell.get_x()) + min_y = min(min_y, cell.get_y()) + max_x = max(max_x, cell.get_x()) + max_y = max(max_y, cell.get_y()) + + pygame.draw.rect(surface, (255, 255, 255), self._helper.get_rect_with_size(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1), 2) \ No newline at end of file diff --git a/visualisation/features/target_visualisation_feature.py b/visualisation/features/target_visualisation_feature.py new file mode 100644 index 0000000..1480f36 --- /dev/null +++ b/visualisation/features/target_visualisation_feature.py @@ -0,0 +1,38 @@ +import pygame +from pygame import Surface + +from visualisation.visualisation_feature import VisualisationFeatureBase + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class TargetVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._font = pygame.font.SysFont(self._font_name, self._font_size) + self._render_names = self._helper.get_cell_size() > 40 + + def set_render_names(self, render_names: bool) -> None: + self._render_names = render_names + + def get_render_names(self) -> bool: + return self._render_names + + def _describe_state(self) -> str: + return f"Show names: {self._render_names}" + + def _render(self, surface: Surface) -> None: + radius = self._helper.get_cell_size() // 4 + for target in self._simulation.get_targets(): + color = self._visualisation.get_target_color(target) + for cell in target.get_cells(): + pygame.draw.circle(surface, color, self._helper.get_centered_pos_at(cell), radius) + if self._render_names: + name = self._font.render(target.get_name(), True, self._text_color) + text_pos = self._helper.get_x_center_pos_at(cell, name.get_height()) + surface.blit(name, name.get_rect(center=text_pos)) \ No newline at end of file diff --git a/visualisation/features/waypoint_visualisation_feature.py b/visualisation/features/waypoint_visualisation_feature.py new file mode 100644 index 0000000..03e52ba --- /dev/null +++ b/visualisation/features/waypoint_visualisation_feature.py @@ -0,0 +1,39 @@ +import pygame +from pygame import Surface + +from visualisation.visualisation_feature import VisualisationFeatureBase +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + from visualisation.visualisation_helper import VisualisationHelper + + +class WaypointVisualisationFeature(VisualisationFeatureBase): + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: 'VisualisationHelper'): + super().__init__(sim, vis, vis_helper) + self._show_pedestrian_line = True + + def set_show_pedestrian_line(self, show_pedestrian_line: bool) -> None: + self._show_pedestrian_line = show_pedestrian_line + + def get_show_pedestrian_line(self) -> bool: + return self._show_pedestrian_line + + def _describe_state(self) -> str: + pass + + def _render(self, surface: Surface) -> None: + for waypoint in self._simulation.get_waypoints(): + color = (120, 30, 220) + center_pos = self._helper.get_centered_pos_at(waypoint.get_cell()) + pygame.draw.circle(surface, color, center_pos, self._helper.get_cell_size() // 6) + pygame.draw.circle(surface, color, center_pos, self._helper.get_cell_size() // 4, 3) + + if self._show_pedestrian_line: + pedestrian = waypoint.get_pedestrian() + if pedestrian is not None: + pygame.draw.line(surface, color, center_pos, self._helper.get_centered_pos_at(pedestrian), 3) + + diff --git a/visualisation/flow_meter.py b/visualisation/flow_meter.py new file mode 100644 index 0000000..2e01305 --- /dev/null +++ b/visualisation/flow_meter.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +from serialization.serializable import Serializable +import csv + +if TYPE_CHECKING: + from simulation.core.cell import Cell + from simulation.core.simulation import Simulation + + +class FlowMeter: + HEADERS = ['runTime', 'flowRate', 'pedestrianDensity'] + + def __init__(self, name: str, time_span: float, cells: 'list[Cell]', logfile: str = None, log_interval: float = float("inf")): + self._name = name + self._flow_rate = 0 + self._time_span: float = time_span + self._cells: 'list[Cell]' = cells + self._seen_pedestrians: dict[int, float] = {} + self._logfile = logfile + self._log_interval = log_interval + self._delta_time = log_interval + + def get_initial_data(self) -> dict[str, any]: + return { + "name": self._name, + "time_span": self._time_span, + "cells": [[cell.get_x(), cell.get_y()] for cell in self._cells], + } + + def get_name(self) -> str: + return self._name + + def get_flow_rate(self) -> float: + return self._flow_rate + + def update(self, simulation: 'Simulation') -> None: + for cell in self._cells: + pedestrian = cell.get_pedestrian() + if pedestrian is not None and pedestrian.get_id() not in self._seen_pedestrians: + self._seen_pedestrians[pedestrian.get_id()] = simulation.get_run_time() + + pedestrians_in_timespan = sum(1 for time in self._seen_pedestrians.values() if simulation.get_run_time() - time <= self._time_span) + self._flow_rate = pedestrians_in_timespan / self._time_span + + def get_cells(self) -> 'list[Cell]': + return self._cells + + def create_log(self) -> None: + with open(self._logfile, mode="w", newline="") as file: + writer = csv.writer(file) + writer.writerow(self.HEADERS) + + def log(self, delta_time: float, runtime: float, pedestrian_density: float) -> None: + self._delta_time -= delta_time + if self._delta_time < 0: + self._log(runtime, pedestrian_density) + self._delta_time = self._log_interval + + def _log(self, runtime, pedestrian_density: float) -> None: + with open(self._logfile, "a", newline="") as file: + writer = csv.writer(file) + writer.writerow([runtime, self.get_flow_rate(), pedestrian_density]) \ No newline at end of file diff --git a/visualisation/shortcut.py b/visualisation/shortcut.py new file mode 100644 index 0000000..1c6df28 --- /dev/null +++ b/visualisation/shortcut.py @@ -0,0 +1,38 @@ +class Shortcut: + def __init__(self, name: str, key: int, modifiers: int, action, toggable: bool = False, initial_state: bool = True): + self.name = name + self.key = key + self.modifiers = modifiers + self.action = action + self.code = Shortcut.calculate_code(key, modifiers) + self._is_toggle = toggable + self._state = initial_state + + @staticmethod + def calculate_code(key: int, modifiers: int): + return (modifiers & 0xFF) | (key << 8) + + def get_code(self): + return self.code + + def execute(self): + if self._is_toggle: + self._state = not self._state + self.action(self._state) + else: + self.action() + + def is_toggle(self): + return self._is_toggle + + def get_state(self): + return self._state + + def get_action(self): + return self.action + + def set_action(self, action): + self.action = action + + def get_text(self): + return self.name \ No newline at end of file diff --git a/visualisation/theme.py b/visualisation/theme.py new file mode 100644 index 0000000..0fa1a99 --- /dev/null +++ b/visualisation/theme.py @@ -0,0 +1,59 @@ +from typing import Tuple + +import pygame +from pygame.font import Font + +from visualisation.visualisation_helper import VisualisationHelper + + +class Padding(Tuple[int, int, int, int]): + def __new__(cls, top: int, right: int, bottom: int, left: int): + return super().__new__(cls, (top, right, bottom, left)) + + def get_left(self) -> int: + return self[3] + + def get_right(self) -> int: + return self[1] + + def get_top(self) -> int: + return self[0] + + def get_bottom(self) -> int: + return self[2] + + def get_horizontal(self) -> int: + return self[1] + self[3] + + def get_vertical(self) -> int: + return self[0] + self[2] + + +class Theme: + def __init__(self, color: Tuple[int, int, int], hover_color: Tuple[int, int, int], active_color: Tuple[int, int, int], pressed_color: Tuple[int, int, int], foreground: Tuple[int, int, int], background: Tuple[int, int, int], font: Font = None, padding: Padding = None): + self.color: Tuple[int, int, int] = color + self.hover_color: Tuple[int, int, int] = hover_color + self.active_color: Tuple[int, int, int] = active_color + self.pressed_color: Tuple[int, int, int] = pressed_color + self.foreground: Tuple[int, int, int] = foreground + self.background: Tuple[int, int, int] = background + pygame.font.init() + self.font: Font = font or pygame.font.SysFont("Arial", 12) + self.padding: Padding = padding or Padding(5, 5, 5, 5) + + @classmethod + def create_theme(cls, base_color: Tuple[int, int, int], font: Font = None, padding: Padding = None) -> 'Theme': + hsl = VisualisationHelper.rgb_to_hsl(base_color) + if hsl[2] < 0.5: + hover_color = VisualisationHelper.hsl_to_rgb((hsl[0], hsl[1] * 0.8, hsl[2] * 1.2)) + active_color = VisualisationHelper.hsl_to_rgb((hsl[0], hsl[1] * 0.7, hsl[2] * 1.3)) + pressed_color = VisualisationHelper.hsl_to_rgb((hsl[0], hsl[1] * 0.6, hsl[2] * 1.4)) + return cls(base_color, hover_color, active_color, pressed_color, (250, 250, 250), (20, 20, 20), font, padding) + else: + hover_color = VisualisationHelper.hsl_to_rgb((hsl[0], hsl[1] * 0.8, hsl[2] * 0.8)) + active_color = VisualisationHelper.hsl_to_rgb((hsl[0], hsl[1] * 0.7, hsl[2] * 0.7)) + pressed_color = VisualisationHelper.hsl_to_rgb((hsl[0], hsl[1] * 0.6, hsl[2] * 0.6)) + return cls(base_color, hover_color, active_color, pressed_color, (20, 20, 20), (250, 250, 250), font, padding) + + +DEFAULT_THEME = Theme.create_theme((100, 149, 237)) diff --git a/visualisation/toggle_button.py b/visualisation/toggle_button.py new file mode 100644 index 0000000..61421d6 --- /dev/null +++ b/visualisation/toggle_button.py @@ -0,0 +1,33 @@ +from collections.abc import Callable + +from pygame import Rect + +from visualisation.button import Button +from visualisation.theme import Theme + + +class ToggleButton(Button): + def __init__(self, min_size: Rect, on_format: str, off_format: str, action: Callable, on_theme: Theme, off_theme: Theme, state_getter: Callable[[],bool]): + super().__init__(min_size, "", on_theme, action) + self._on_theme = on_theme + self._off_theme = off_theme + self._state_getter: Callable[[], bool] = state_getter + self._on_format = on_format + self._off_format = off_format + self._last_state = None + self._update_state() + + def _update_state(self) -> None: + state = self._state_getter() + if state != self._last_state: + self._last_state = state + self._theme = self._on_theme if state else self._off_theme + self.set_text((self._on_format if state else self._off_format).format("On" if state else "Off")) + + def update(self, delta: float) -> None: + super().update(delta) + self._update_state() + + def press(self) -> None: + super().press() + self._update_state() \ No newline at end of file diff --git a/visualisation/visualisation.py b/visualisation/visualisation.py new file mode 100644 index 0000000..e4d6350 --- /dev/null +++ b/visualisation/visualisation.py @@ -0,0 +1,327 @@ +import datetime +import json +import math +from random import random +from typing import Tuple, Type, TypeVar + +import pygame +from pygame import Color, Rect, Surface +from pygame.font import Font + +from serialization.serializer import Serializer +from simulation.core.cell_state import CellState +from simulation.core.position import Position +from simulation.core.simulation import Simulation +from visualisation.button import Button +from visualisation.features.flowmeters_visualisation import FlowMetersVisualisation +from visualisation.features.grid_visualisation_feature import GridVisualisationFeature +from visualisation.features.path_visualisation_feature import PathVisualisationFeature +from visualisation.features.pedestrian_visualisation_feature import PedestrianVisualisationFeature +from visualisation.features.simulation_info_visualisation_feature import SimulationInfoVisualisationFeature +from visualisation.features.spawner_visualisation_feature import SpawnerVisualisationFeature +from visualisation.features.target_heatmap_visualisation_feature import TargetHeatmapVisualisationFeature +from visualisation.features.target_visualisation_feature import TargetVisualisationFeature +from visualisation.features.waypoint_visualisation_feature import WaypointVisualisationFeature +from visualisation.flow_meter import FlowMeter +from visualisation.shortcut import Shortcut +from visualisation.theme import Theme, DEFAULT_THEME +from visualisation.toggle_button import ToggleButton +from visualisation.visualisation_feature import VisualisationFeatureBase +from visualisation.visualisation_helper import VisualisationHelper + + +class Visualisation: + GRID_OFFSET = 200 + + def __init__(self, simulation: Simulation, cell_size: int | None = None, fps: float = 30, log_file: str = None, flow_meters: list[FlowMeter] = None): + pygame.init() + simulation.update(0) # Update simulation once to get the initial state + self.simulation = simulation + if cell_size is None: + info = pygame.display.Info() + w, h = info.current_w, info.current_h + cell_size = math.ceil(self.calculate_cell_size(w, h) * 0.9) + + self._cell_size: int = cell_size + self._screen = pygame.display.set_mode((simulation.get_grid().get_width() * cell_size, Visualisation.GRID_OFFSET + simulation.get_grid().get_height() * cell_size), pygame.RESIZABLE) + pygame.display.set_caption("Simulation Visualisation") + self._clock = pygame.time.Clock() + self._is_paused = False + self._running = True + self._fps = fps + self._spawner_colors = {spawner: VisualisationHelper.get_random_color() for spawner in simulation.get_spawners()} + self._target_colors = {target: VisualisationHelper.get_random_color() for target in simulation.get_targets()} + self._helper = VisualisationHelper(self) + self._shortcuts: dict[int, Shortcut] = {} + self._features: list[VisualisationFeatureBase] = [] + self._font = pygame.font.SysFont("Arial", min(int(self._cell_size * 0.5), 16)) + self._buttons: list[Button] = [] + self._click_theme = DEFAULT_THEME + self._on_theme = Theme.create_theme((100, 250, 30)) + self._off_theme = Theme.create_theme((250, 30, 30)) + self._max_feature_width = 0 + self._simulation_delta = 0 + self._show_feature_details = self._screen.get_width() > 800 + self._show_buttons = True + self._init_features() + self._flow_meters: list[FlowMeter] = flow_meters or None + self._flow_log = open(datetime.datetime.now().strftime("%d%m%y_%H%M%S") + "_flow_log.json", "w") if self._flow_meters is not None else None + self._is_first_flow_log = True + self._serializer = Serializer(simulation, log_file.format(datetime.datetime.now().strftime("%d%m%y_%H%M%S"))) if log_file is not None else None + self._init_flow_log() + + TFeature = TypeVar('TFeature', bound=VisualisationFeatureBase) + + + def get_flow_meters(self) -> list[FlowMeter]: + return self._flow_meters + + def get_cell_size(self) -> int: + return self._cell_size + + def is_paused(self) -> bool: + return self._is_paused + + def set_pause(self, pause: bool) -> None: + self._is_paused = pause + + def calculate_cell_size(self, width: int, height: int) -> int: + return min(width // self.simulation.get_grid().get_width(), (height - Visualisation.GRID_OFFSET) // self.simulation.get_grid().get_height()) + + def get_feature(self, type: Type[TFeature]) -> TFeature | None: + for feature in self._features: + if isinstance(feature, type): + return feature + + return None + + def add_feature[T](self, feature: T, enabled: bool = True) -> T: + self._features.append(feature) + feature.set_enabled(enabled) + return feature + + def add_shortcut(self, shortcut: Shortcut, add_button: bool = False, button_text: str = None, off_text: str = None): + button_text = button_text or shortcut.get_text() + self._shortcuts[shortcut.get_code()] = shortcut + if add_button: + if shortcut.is_toggle(): + button = ToggleButton(Rect(0, 0, 100, 35), button_text, off_text or button_text, shortcut.execute, self._on_theme, self._off_theme, shortcut.get_state) + self._buttons.append(button) + else: + button = Button(Rect(0, 0, 100, 35), button_text, self._click_theme, shortcut.execute) + self._buttons.append(button) + + def _init_features(self): + heatmap = self.add_feature(TargetHeatmapVisualisationFeature(self.simulation, self, self._helper)) + spawner = self.add_feature(SpawnerVisualisationFeature(self.simulation, self, self._helper)) + target = self.add_feature(TargetVisualisationFeature(self.simulation, self, self._helper)) + grid = self.add_feature(GridVisualisationFeature(self.simulation, self, self._helper)) + path = self.add_feature(PathVisualisationFeature(self.simulation, self, self._helper)) + flow_meter = self.add_feature(FlowMetersVisualisation(self.simulation, self, self._helper), True) + waypoint = self.add_feature(WaypointVisualisationFeature(self.simulation, self, self._helper)) + pedestrian = self.add_feature(PedestrianVisualisationFeature(self.simulation, self, self._helper)) + info = self.add_feature(SimulationInfoVisualisationFeature(self.simulation, self, self._helper), False) + self.add_shortcut(Shortcut("Toggle heatmap", pygame.K_h, 0, heatmap.set_enabled, True, heatmap.is_enabled()), True, "Heatmap {0}") + self.add_shortcut(Shortcut("Toggle spawner", pygame.K_s, 0, spawner.set_enabled, True, spawner.is_enabled()), True, "Spawners {0}") + self.add_shortcut(Shortcut("Toggle target", pygame.K_t, 0, target.set_enabled, True, target.is_enabled()), True, "Targets {0}") + self.add_shortcut(Shortcut("Toggle grid", pygame.K_g, 0, grid.set_enabled, True, grid.is_enabled()), True, "Grid {0}") + self.add_shortcut(Shortcut("Toggle pedestrian", pygame.K_p, 0, pedestrian.set_enabled, True, pedestrian.is_enabled()), True, "Pedestrians {0}") + self.add_shortcut(Shortcut("Toggle info", pygame.K_i, 0, info.set_enabled, True, info.is_enabled()), True, "Info {0}") + self.add_shortcut(Shortcut("Toggle social distancing", pygame.K_d, 0, heatmap.set_social_distancing, True, heatmap.get_social_distancing()), True, "Social distancing\n{0}") + self.add_shortcut(Shortcut("Toggle route", pygame.K_r, 0, path.set_enabled, True, path.is_enabled()), True, "Pathing {0}") + self.add_shortcut(Shortcut("Toggle flow meters", pygame.K_f, 0, flow_meter.set_enabled, True, flow_meter.is_enabled()), True, "Flow meters {0}") + self.add_shortcut(Shortcut("Toggle grid lines", pygame.K_l, 0, grid.set_show_lines, True, grid.get_show_lines()), True, "Grid lines\n{0}") + self.add_shortcut(Shortcut("Toggle waypoint", pygame.K_w, 0, waypoint.set_enabled, True, waypoint.is_enabled()), True, "Waypoints {0}") + self.add_shortcut(Shortcut("Show object names", pygame.K_n, 0, self._set_show_names, True, False), True, "Show names\n{0}") + self.add_shortcut(Shortcut("Show pedestrian details", pygame.K_p, pygame.KMOD_LSHIFT, pedestrian.set_render_details, True, pedestrian.get_render_details()), True, "Pedestrian details\n{0}") + self.add_shortcut(Shortcut("Show pedestrian target cell", pygame.K_p, pygame.KMOD_LCTRL, pedestrian.set_render_target_line, True, pedestrian.get_render_target_line()), True, "Pedestrian target\n{0}") + self.add_shortcut(Shortcut("Show pedestrian line", pygame.K_w, pygame.KMOD_LSHIFT, waypoint.set_show_pedestrian_line, True, waypoint.get_show_pedestrian_line()), True, "Waypoint pedestrian\nline: {0}") + self.add_shortcut(Shortcut("Pause", pygame.K_SPACE, 0, self.set_pause, True, self.is_paused()), True, "Unpause", "Pause") + self.add_shortcut(Shortcut("Next target", pygame.K_RIGHT, 0, heatmap.next_target, False), True, "Next target\nheatmap") + self.add_shortcut(Shortcut("Previous target", pygame.K_LEFT, 0, heatmap.previous_target, False), True, "Previous target\nheatmap") + self.add_shortcut(Shortcut("Next pedestrian", pygame.K_DOWN, 0, path.next_pedestrian, False), True, "Next pedestrian") + self.add_shortcut(Shortcut("Previous pedestrian", pygame.K_UP, 0, path.previous_pedestrian, False), True, "Previous pedestrian") + + def _set_show_names(self, state: bool) -> None: + for feature in self._features: + if hasattr(feature, "set_render_names"): + feature.set_render_names(state) + + def get_target_color(self, target): + return self._target_colors[target] + + def get_spawner_color(self, spawner): + return self._spawner_colors[spawner] + + def handle_events(self): + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self._handle_quit() + if event.type == pygame.KEYDOWN: + self._handle_key_event(event.key, event.mod) + elif event.type == pygame.VIDEORESIZE: + w, h = event.dict['size'] + self._handle_resize_event(w, h) + elif event.type == pygame.MOUSEMOTION: + self._handle_mouse_move_event(event.pos[0], event.pos[1]) + elif event.type == pygame.MOUSEBUTTONDOWN: + self._handle_click_event(event.button, event.pos[0], event.pos[1]) + + def _handle_quit(self): + pygame.quit() + self._running = False + + if self._serializer is not None: + self._serializer.close() + + if self._flow_log is not None: + self._close_flow_log() + + + def _handle_key_event(self, key: int, mod: int) -> None: + # handle key press events, check for shortcuts + code = Shortcut.calculate_code(key, mod) + if code in self._shortcuts: + self._shortcuts[code].execute() + + def _handle_resize_event(self, width: int, height: int) -> None: + # handle window resize, recalculate cell size and update grid + self._cell_size = self.calculate_cell_size(width, height) + self._max_feature_width = 0 + pygame.display.update() + self._show_feature_details = self._screen.get_width() > 800 + + def _handle_mouse_move_event(self, x: int, y: int) -> None: + # handles hover logic for buttons + for button in self._buttons: + button.set_hovered(button.is_inside(x, y)) + + def _handle_click_event(self, button, click_x: int, click_y: int) -> None: + # handles click in visualisation + if button == 1: + if click_y < Visualisation.GRID_OFFSET: + if self._show_buttons: + self._handle_button_click(click_x, click_y) + else: + self._handle_grid_click(click_x, click_y) + + def _handle_button_click(self, click_x: int, click_y: int) -> None: + # handle button click logic + if self._show_buttons: + for button in self._buttons: + if button.is_inside(click_x, click_y): + button.press() + + def _handle_grid_click(self, click_x: int, click_y: int) -> None: + # check for clicked targets and update selected target + x, y = self._helper.screen_to_grid(click_x, click_y) + heatmap = self.get_feature(TargetHeatmapVisualisationFeature) + target = self.simulation.get_target(x, y) + if heatmap is not None and target is not None: + heatmap.set_target(target) + heatmap.set_enabled(True) + + # check for clicked pedestrians and update selected pedestrian + path = self.get_feature(PathVisualisationFeature) + pedestrian = self.simulation.get_grid().get_cell(x, y).get_pedestrian() + if path is not None and pedestrian is not None: + path.set_pedestrian(pedestrian) + path.set_enabled(True) + + def _init_flow_log(self): + if self._flow_log is not None: + setup_data = { + "time_resolution": self.simulation.get_time_resolution(), + "flow_meters": [meter.get_initial_data() for meter in self._flow_meters], + } + self._flow_log.write(f"{{\n \"setup\": {json.dumps(setup_data, indent=4)},\n \"flow_data\": [\n") + + def _close_flow_log(self): + if self._flow_log is not None: + self._flow_log.write("\n]}") + self._flow_log.close() + self._flow_log = None + + def _write_flow_log(self): + if self._flow_log is not None: + if self._is_first_flow_log: + self._is_first_flow_log = False + else: + self._flow_log.write(",\n") + + for meter in self._flow_meters: + meter.update(self.simulation) + + data = { + "step": self.simulation.get_steps(), + "time": self.simulation.get_run_time(), + "pedestrian_density": len(self.simulation.get_pedestrians()) / (self.simulation.get_grid().get_width() * self.simulation.get_grid().get_height()), + "flow": {meter.get_name(): meter.get_flow_rate() for meter in self._flow_meters} + } + self._flow_log.write(json.dumps(data, indent=4)) + + def update(self, delta) -> None: + # update simulation if not paused and bookkeep time delta + if self.is_paused() is False and self.simulation.is_done() is False: + self._simulation_delta += delta + if self._simulation_delta >= self.simulation.get_time_resolution(): + self.simulation.update(self._simulation_delta) + self._simulation_delta = 0 + self._write_flow_log() + if self._serializer is not None: + self._serializer.write_current_state() + + + def render_features(self, surface) -> None: + # render all visualisation features and their status messages + description_x = 0 + description_y = 0 + max_description_width = 0 + pygame.draw.rect(surface, (255, 255, 255), Rect(0, 0, surface.get_width(), Visualisation.GRID_OFFSET)) + for feature in self._features: + feature.render(surface) + + if self._show_feature_details: + # only render if the feature is enabled if buttons are not shown since buttons already cover this information + texts, width, height = self._helper.get_multiline_texts(self._font, feature.describe_state(self._show_buttons == False), (0, 0, 0)) + if description_y + height > Visualisation.GRID_OFFSET: + description_x += max_description_width + 20 + description_y = 0 + max_description_width = 0 + + for text in texts: + surface.blit(text, (description_x, description_y)) + description_y += text.get_height() + + max_description_width = max(max_description_width, width) + description_y += 20 + + self._max_feature_width = max(description_x + max_description_width, self._max_feature_width) + + def render_buttons(self, dt, surface, x, y): + max_width = 0 + for button in self._buttons: + button.update(dt) + if button.get_height() + y > Visualisation.GRID_OFFSET: + x += max_width + 10 + max_width = 0 + y = 10 + + button.set_position(x, y) + button.render(surface) + y += button.get_height() + 10 + max_width = max(max_width, button.get_width()) + + def run(self): + dt = 0.0 + while self._running: + self.handle_events() + self._screen.fill((40, 40, 40)) + self.update(dt / 1000) + self.render_features(self._screen) + if self._show_buttons: + self.render_buttons(dt, self._screen, self._max_feature_width + 10, 10) + pygame.display.flip() + dt = self._clock.tick(self._fps) + + pygame.quit() diff --git a/visualisation/visualisation_feature.py b/visualisation/visualisation_feature.py new file mode 100644 index 0000000..569bb1e --- /dev/null +++ b/visualisation/visualisation_feature.py @@ -0,0 +1,89 @@ +from abc import ABC, abstractmethod +from typing import Type + +from pygame import Surface + +from typing import TYPE_CHECKING +from visualisation.visualisation_helper import VisualisationHelper + +if TYPE_CHECKING: + from simulation.core.simulation import Simulation + from visualisation.visualisation import Visualisation + + + +class VisualisationFeatureBase(ABC): + """ + Base class for visualisation features. Visualisation features are used to generate visualisations based on the given grid + :param sim: the simulation the visualisation should be generated for + """ + def __init__(self, sim: 'Simulation', vis: 'Visualisation', vis_helper: VisualisationHelper): + self._is_enabled = False + self._simulation = sim + self._visualisation = vis + self._helper: VisualisationHelper = vis_helper + self._text_color = (255, 255, 255) + self._font_name = "Arial" + self._mono_font_name = "Consolas" + self._small_font_size = 12 if self._helper is None else min(int(self._helper.get_cell_size() * 0.35), 12) + self._font_size = 18 if self._helper is None else min(int(self._helper.get_cell_size() * 0.75), 18) + pass + + def enable(self) -> None: + """ + Enables the visualisation feature + """ + self._is_enabled = True + + def disable(self) -> None: + """ + Disables the visualisation feature + """ + self._is_enabled = False + + def set_enabled(self, enabled: bool) -> None: + """ + Sets the state of the visualisation feature + :param enabled: the state of the visualisation feature + """ + self._is_enabled = enabled + + def is_enabled(self) -> bool: + """ + Returns whether the visualisation feature is enabled + :return: whether the visualisation feature is enabled + """ + return self._is_enabled + + def render(self, surface: Surface) -> None: + """ + Generates visualisation based on the given grid + :param surface: the surface the visualisation should be rendered onto + """ + if self.is_enabled(): + self._render(surface) + + def describe_state(self, include_enabled: bool = True) -> str: + """ + Describes the state of the visualisation feature + :param include_enabled: whether to include if the feature is enabled in the description as the first line + :return: a string describing the state of the visualisation feature + """ + description = f"{self.__class__.__name__} is enabled: {self.is_enabled()}" if include_enabled else self.__class__.__name__ + state_description = self._describe_state() + if state_description: + description += f"\n{state_description}" + + return description + + @abstractmethod + def _describe_state(self) -> str: + """ + Describes the state of the visualisation feature + :return: a string describing the state of the visualisation feature + """ + pass + + @abstractmethod + def _render(self, surface: Surface) -> None: + pass \ No newline at end of file diff --git a/visualisation/visualisation_helper.py b/visualisation/visualisation_helper.py new file mode 100644 index 0000000..6e54a58 --- /dev/null +++ b/visualisation/visualisation_helper.py @@ -0,0 +1,192 @@ +import math +from typing import Tuple, List + +import numpy as np +from pygame import Color, Surface, Rect +from pygame.font import Font + +from simulation.core.position import Position +import pygame + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from visualisation.visualisation import Visualisation + + +class VisualisationHelper: + CURRENT_COLOR_ANGLE = 0 + + def __init__(self, vis: 'Visualisation'): + self._vis = vis + + def get_cell_size(self): + return self._vis.get_cell_size() + + def get_grid_top_offset(self): + return self._vis.GRID_OFFSET + + def get_grid_left_offset(self): + return 0 + + def _transform_offset(self, pos: Tuple) -> Tuple: + return pos[0] + self.get_grid_left_offset(), pos[1] + self.get_grid_top_offset(), *pos[2:] + + @staticmethod + def hsl_to_rgb(hsl: Tuple[float, float, float]) -> Tuple[int, int, int]: + color = Color(0) + color.hsla = np.clip(hsl[0], 0, 360), np.clip(hsl[1], 0, 100), np.clip(hsl[2], 0, 100), 100 + return color.r, color.g, color.b + + @staticmethod + def rgb_to_hsl(color: Tuple[int, int, int]) -> Tuple[float, float, float]: + color = Color(color) + return color.hsla[0], color.hsla[1], color.hsla[2] + + @staticmethod + def translate_polygon_result(func): + def wrapper(*args): + self = args[0] + return [self._transform_offset(point) for point in func(*args)] + + return wrapper + + @staticmethod + def translate_rect_result(func): + def wrapper(*args): + self = args[0] + return self._transform_offset(func(*args)) + + return wrapper + + @staticmethod + def get_random_color() -> (int, int, int): + angle = VisualisationHelper.CURRENT_COLOR_ANGLE + VisualisationHelper.CURRENT_COLOR_ANGLE += 23 + color = Color(0) + color.hsla = angle, 100, 50, 100 + return color.r, color.g, color.b + + @staticmethod + def rotate(point: Tuple[float, float], origin: Tuple[float, float], angle: float) -> Tuple[float, float]: + ox, oy = origin + px, py = point + qx = ox + math.cos(angle) * (px - ox) - math.sin(angle) * (py - oy) + qy = oy + math.sin(angle) * (px - ox) + math.cos(angle) * (py - oy) + return qx, qy + + @staticmethod + def mix_colors(color1, color2, ratio): + return tuple(int(c1 * ratio + c2 * (1 - ratio)) for c1, c2 in zip(color1, color2)) + + def screen_to_grid(self, x, y): + return x // self.get_cell_size(), (y - self.get_grid_top_offset()) // self.get_cell_size() + + def get_rect_at(self, pos: Position) -> pygame.Rect: + return self.get_rect(pos.get_x(), pos.get_y()) + + @translate_rect_result + def get_rect(self, x: int, y: int) -> pygame.Rect: + cell_size = self.get_cell_size() + return pygame.Rect(x * cell_size, y * cell_size, cell_size, cell_size) + + @translate_rect_result + def get_rect_with_size(self, x: int, y: int, width: int, height: int) -> pygame.Rect: + cell_size = self.get_cell_size() + return pygame.Rect(x * cell_size, y * cell_size, width * cell_size, height * cell_size) + + def get_small_rect_at(self, pos: Position) -> pygame.Rect: + return self.get_small_rect(pos.get_x(), pos.get_y()) + + @translate_rect_result + def get_small_rect(self, x: int, y: int) -> pygame.Rect: + cell_size = self.get_cell_size() + cell_size_half = self.get_cell_size() // 2 + cell_size_fourth = self.get_cell_size() // 4 + return pygame.Rect(x * cell_size + cell_size_fourth, y * cell_size + cell_size_fourth, cell_size_half, + cell_size_half) + + def get_centered_pos_at(self, pos: Position) -> Tuple[int, int]: + return self.get_centered_pos(pos.get_x(), pos.get_y()) + + @translate_rect_result + def get_centered_pos(self, x: int, y: int) -> Tuple[int, int]: + cell_size = self.get_cell_size() + cell_size_half = self.get_cell_size() // 2 + return x * cell_size + cell_size_half, y * cell_size + cell_size_half + + def get_x_center_pos_at(self, pos: Position, y_offset=0) -> Tuple[int, int]: + return self.get_x_centered_pos(pos.get_x(), pos.get_y(), y_offset) + + @translate_rect_result + def get_x_centered_pos(self, x: int, y: int, y_offset=0) -> Tuple[int, int]: + cell_size = self.get_cell_size() + cell_size_half = self.get_cell_size() // 2 + return x * cell_size + cell_size_half, y * cell_size + y_offset + + def get_y_centered_pos_at(self, pos: Position, x_offset=0): + return self.get_y_centered_pos(pos.get_x(), pos.get_y(), x_offset) + + @translate_rect_result + def get_y_centered_pos(self, x: int, y: int, x_offset=0) -> Tuple[int, int]: + cell_size = self.get_cell_size() + cell_size_half = self.get_cell_size() // 2 + return x * cell_size + x_offset, y * cell_size + cell_size_half + + def get_small_triangle_at(self, pos: Position, rotation: float = 0.0) -> List[Tuple[float, float]]: + return self.get_small_triangle(pos.get_x(), pos.get_y(), rotation) + + @translate_polygon_result + def get_small_triangle(self, x: int, y: int, rotation: float = 0.0) -> list[tuple[float, float]]: + cell_size = self.get_cell_size() + cell_half_size = cell_size // 2 + cell_fourth_size = cell_size // 4 + origin = (x * cell_size + cell_half_size, y * cell_size + cell_half_size) + left = x * cell_size + bottom = y * cell_size + cell_fourth_size + cell_half_size + top = y * cell_size + a = (left + cell_fourth_size, bottom) + b = (left + cell_fourth_size + cell_half_size, bottom) + c = (left + cell_half_size, top + cell_fourth_size) + + triangle = [a, b, c] + return [VisualisationHelper.rotate(point, origin, rotation + math.pi / 2) for point in triangle] + + @translate_rect_result + def get_top_left(self): + return (0, 0) + + @staticmethod + def get_multiline_texts(font: Font, text: str, color: Tuple) -> Tuple[list[Surface], int, int]: + lines = text.split("\n") + max_width = 0 + total_height = 0 + surfaces = [] + for line in lines: + text = font.render(line, True, color) + surfaces.append(text) + total_height += text.get_height() + max_width = max(max_width, text.get_width()) + + return surfaces, max_width, total_height + + @staticmethod + def render_multiline_text(surface: Surface, rect: Rect, font: Font, text: str, color: Tuple, center_x: bool = False, center_y: bool = False): + lines, max_width, total_height = VisualisationHelper.get_multiline_texts(font, text, color) + off_y = (rect.height - total_height) // 2 if center_y else 0 + for line in lines: + off_x = (max_width - line.get_width()) // 2 if center_x else 0 + surface.blit(line, (rect.x + off_x, rect.y + off_y)) + off_y += line.get_height() + + @staticmethod + def render_multiline_text_to_surface(font: Font, text: str, color: Tuple, center_x: bool = False) -> Surface: + lines, max_width, total_height = VisualisationHelper.get_multiline_texts(font, text, color) + surface = Surface((max_width, total_height), pygame.SRCALPHA) + off_y = 0 + for line in lines: + off_x = (max_width - line.get_width()) // 2 if center_x else 0 + surface.blit(line, (off_x, off_y)) + off_y += line.get_height() + + return surface \ No newline at end of file