├── .github ├── CODEOWNERS └── workflows │ ├── deploy-docs.yaml │ ├── lint-test.yml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── .nojekyll ├── _autosummary │ ├── mappymatch.constructs.coordinate.rst │ ├── mappymatch.constructs.geofence.rst │ ├── mappymatch.constructs.match.rst │ ├── mappymatch.constructs.road.rst │ ├── mappymatch.constructs.rst │ ├── mappymatch.constructs.trace.rst │ ├── mappymatch.maps.igraph.igraph_map.rst │ ├── mappymatch.maps.igraph.rst │ ├── mappymatch.maps.map_interface.rst │ ├── mappymatch.maps.nx.nx_map.rst │ ├── mappymatch.maps.nx.readers.osm_readers.rst │ ├── mappymatch.maps.nx.readers.rst │ ├── mappymatch.maps.nx.rst │ ├── mappymatch.maps.rst │ ├── mappymatch.matchers.lcss.constructs.rst │ ├── mappymatch.matchers.lcss.lcss.rst │ ├── mappymatch.matchers.lcss.ops.rst │ ├── mappymatch.matchers.lcss.rst │ ├── mappymatch.matchers.lcss.utils.rst │ ├── mappymatch.matchers.line_snap.rst │ ├── mappymatch.matchers.match_result.rst │ ├── mappymatch.matchers.matcher_interface.rst │ ├── mappymatch.matchers.osrm.rst │ ├── mappymatch.matchers.rst │ ├── mappymatch.matchers.valhalla.rst │ ├── mappymatch.utils.crs.rst │ ├── mappymatch.utils.exceptions.rst │ ├── mappymatch.utils.geo.rst │ ├── mappymatch.utils.plot.rst │ ├── mappymatch.utils.process_trace.rst │ ├── mappymatch.utils.rst │ └── mappymatch.utils.url.rst ├── _config.yml ├── _toc.yml ├── api-docs.md ├── home.md ├── images │ └── map-matching.gif ├── install.md ├── lcss-example.ipynb └── quick-start.md ├── environment.yml ├── environment_dev.yml ├── mappymatch ├── __about__.py ├── __init__.py ├── constructs │ ├── __init__.py │ ├── coordinate.py │ ├── geofence.py │ ├── match.py │ ├── road.py │ └── trace.py ├── maps │ ├── __init__.py │ ├── igraph │ │ ├── __init__.py │ │ └── igraph_map.py │ ├── map_interface.py │ └── nx │ │ ├── __init__.py │ │ ├── nx_map.py │ │ └── readers │ │ ├── __init__.py │ │ └── osm_readers.py ├── matchers │ ├── __init__.py │ ├── lcss │ │ ├── __init__.py │ │ ├── constructs.py │ │ ├── lcss.py │ │ ├── ops.py │ │ └── utils.py │ ├── line_snap.py │ ├── match_result.py │ ├── matcher_interface.py │ ├── osrm.py │ └── valhalla.py ├── resources │ ├── __init__.py │ └── traces │ │ ├── sample_trace_1.csv │ │ ├── sample_trace_2.csv │ │ └── sample_trace_3.csv └── utils │ ├── __init__.py │ ├── crs.py │ ├── exceptions.py │ ├── geo.py │ ├── plot.py │ ├── process_trace.py │ └── url.py ├── pyproject.toml └── tests ├── __init__.py ├── test_assets ├── downtown_denver.geojson ├── osmnx_drive_graph.graphml ├── pull_osm_map.py ├── test_trace.geojson ├── test_trace.gpx ├── test_trace_stationary_points.geojson └── trace_bad_start.geojson ├── test_coordinate.py ├── test_geo.py ├── test_geofence.py ├── test_lcss_add_match_for_stationary.py ├── test_lcss_compress.py ├── test_lcss_drop_stationary_points.py ├── test_lcss_find_stationary_points.py ├── test_lcss_forward_merge.py ├── test_lcss_merge.py ├── test_lcss_reverse_merge.py ├── test_lcss_same_trajectory_scheme.py ├── test_match_result.py ├── test_osm.py ├── test_process_trace.py ├── test_trace.py └── test_valhalla.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nreinicke @jhoshiko -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yaml: -------------------------------------------------------------------------------- 1 | name: deploy-docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - docs/** 9 | - .github/workflows/deploy-docs.yaml 10 | 11 | jobs: 12 | deploy-docs: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | defaults: 17 | run: 18 | shell: bash -el {0} 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: conda-incubator/setup-miniconda@v3 23 | with: 24 | auto-update-conda: true 25 | python-version: "3.12" 26 | 27 | - name: Install package 28 | run: | 29 | conda install -c conda-forge osmnx 30 | pip install ".[dev]" 31 | 32 | - name: Build book 33 | run: | 34 | jupyter-book build docs/ 35 | 36 | # Push the book's HTML to github-pages 37 | - name: GitHub Pages action 38 | uses: peaceiris/actions-gh-pages@v3 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | publish_dir: ./docs/_build/html 42 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install .[tests] 25 | - name: Run ruff linting 26 | run: ruff check . 27 | - name: Run ruff formatting 28 | run: ruff format --check 29 | - name: Run mypy 30 | run: mypy . 31 | - name: Run Tests 32 | run: pytest tests 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/project/mappymatch/ 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.9" 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install hatch 25 | - name: Build package 26 | run: | 27 | hatch build 28 | - name: Upload artifacts 29 | uses: actions/upload-artifact@v3 30 | with: 31 | path: ./dist/* 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.local* 2 | 3 | .DS_Store 4 | 5 | .idea/ 6 | cache/ 7 | .vscode/ 8 | *.code-workspace 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | cover/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | .pybuilder/ 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | py-notebooks/ 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # Cython debug symbols 148 | cython_debug/ 149 | 150 | # HTMLs 151 | *.html 152 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | rev: v0.8.0 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | 8 | - repo: https://github.com/pre-commit/mirrors-mypy 9 | rev: "v1.13.0" 10 | hooks: 11 | - id: mypy 12 | additional_dependencies: 13 | [matplotlib, pandas-stubs, pytest, types-requests] 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to mappymatch 2 | 3 | Thank you for considering contributing to mappymatch. 4 | This document provides a high-level overview of how you can get involved. 5 | 6 | ## Asking Questions 7 | 8 | Have a question? Rather than opening an issue directly, please ask questions 9 | or post comments in [Q&A Discussions](https://github.com/NREL/mappymatch/discussions/categories/q-a). 10 | The NREL team or other members of the community will assist. Your well-worded 11 | question will serve as a resource to others searching for help. 12 | 13 | ## Providing Feedback 14 | 15 | Your comments and feedback are very welcome. Please post to 16 | [General Discussions](https://github.com/NREL/mappymatch/discussions/categories/general) 17 | with lots of information and detail. It is beneficial to consider 18 | how someone else will understand your comments in order to make 19 | them most effective. 20 | 21 | ## Reporting Issues 22 | 23 | Have you identified a reproducible problem in mappymatch? 24 | Have a feature request? We want to hear about it! Here's how you can make 25 | reporting your issue as effective as possible. 26 | 27 | ### Look For an Existing Issue 28 | 29 | Before you create a new issue, please do a search in 30 | [open issues](https://github.com/NREL/mappymatch/issues) to see if 31 | the issue or feature request has already been filed. 32 | 33 | If you find your issue already exists, make relevant comments and add your 34 | [reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). 35 | Use a reaction in place of a "+1" comment: 36 | 37 | - 👍 - upvote 38 | - 👎 - downvote 39 | 40 | If you cannot find an existing issue that describes your bug or feature, 41 | create a new issue using the guidelines below. 42 | 43 | ### Writing Good Bug Reports and Feature Requests 44 | 45 | File a single issue per problem and feature request. Do not enumerate 46 | multiple bugs or feature requests in the same issue. 47 | 48 | Do not add your issue as a comment to an existing issue unless it's for the 49 | identical input. Many issues look similar, but have different causes. 50 | 51 | The more information you can provide, the more likely someone will 52 | be successful at reproducing the issue and finding a fix. 53 | 54 | Please follow the issue template guidelines to include relevant information 55 | that will help in diagnosing the problem. 56 | 57 | ### Final Checklist 58 | 59 | Please remember to do the following: 60 | 61 | - [ ] Search the issue repository to ensure your report is a new issue 62 | 63 | - [ ] Recreate the issue with a minimally descriptive example 64 | 65 | - [ ] Simplify your code around the issue to better isolate the problem 66 | 67 | ## Contributing Fixes 68 | 69 | If you are interested in writing code to fix an issue or 70 | submit a new feature, let us know in 71 | [Ideas Discussions](https://github.com/NREL/mappymatch/discussions/categories/ideas)! 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Alliance for Sustainable Energy, LLC 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mappymatch 2 | 3 | Mappymatch is a pure-python package developed and open sourced by the National Renewable Energy Laboratory. It contains a collection of "Matchers" that enable matching a GPS trace (series of GPS coordinates) to a map. 4 | 5 | ![Map Matching Animation](docs/images/map-matching.gif?raw=true) 6 | 7 | The current matchers are: 8 | 9 | - `LCSSMatcher`: A matcher that implements the LCSS algorithm described in this [paper](https://doi.org/10.3141%2F2645-08). Works best with high resolution GPS traces. 10 | - `OsrmMatcher`: A light matcher that pings an OSRM server to request map matching results. See the [official documentation](http://project-osrm.org/) for more info. 11 | - `ValhallaMatcher`: A matcher to ping a [Valhalla](https://www.interline.io/valhalla/) server for map matching results. 12 | 13 | Currently supported map formats are: 14 | 15 | - Open Street Maps 16 | 17 | ## Installation 18 | 19 | ```console 20 | pip install mappymatch 21 | ``` 22 | 23 | If you have trouble with that, check out [the docs](https://nrel.github.io/mappymatch/install.html) for more detailed install instructions. 24 | 25 | ## Example Usage 26 | 27 | The current primary workflow is to use [osmnx](https://github.com/gboeing/osmnx) to download a road network and match it using the `LCSSMatcher`. 28 | 29 | The `LCSSMatcher` implements the map matching algorithm described in this paper: 30 | 31 | [Zhu, Lei, Jacob R. Holden, and Jeffrey D. Gonder. 32 | "Trajectory Segmentation Map-Matching Approach for Large-Scale, High-Resolution GPS Data." 33 | Transportation Research Record: Journal of the Transportation Research Board 2645 (2017): 67-75.](https://doi.org/10.3141%2F2645-08) 34 | 35 | usage: 36 | 37 | ```python 38 | from mappymatch import package_root 39 | from mappymatch.constructs.geofence import Geofence 40 | from mappymatch.constructs.trace import Trace 41 | from mappymatch.maps.nx.nx_map import NxMap 42 | from mappymatch.matchers.lcss.lcss import LCSSMatcher 43 | 44 | trace = Trace.from_csv(package_root() / "resources/traces/sample_trace_1.csv") 45 | 46 | # generate a geofence polygon that surrounds the trace; units are in meters; 47 | # this is used to query OSM for a small map that we can match to 48 | geofence = Geofence.from_trace(trace, padding=1e3) 49 | 50 | # uses osmnx to pull a networkx map from the OSM database 51 | nx_map = NxMap.from_geofence(geofence) 52 | 53 | matcher = LCSSMatcher(nx_map) 54 | 55 | matches = matcher.match_trace(trace) 56 | 57 | # convert the matches to a dataframe 58 | df = matches.matches_to_dataframe() 59 | ``` 60 | 61 | ## Example Notebooks 62 | 63 | Check out the [LCSS Example](https://nrel.github.io/mappymatch/lcss-example.html) for a more detailed example of working with the LCSSMatcher. 64 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/docs/.nojekyll -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.constructs.coordinate.rst: -------------------------------------------------------------------------------- 1 | mappymatch.constructs.coordinate 2 | ================================ 3 | 4 | .. automodule:: mappymatch.constructs.coordinate 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | Coordinate 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.constructs.geofence.rst: -------------------------------------------------------------------------------- 1 | mappymatch.constructs.geofence 2 | ============================== 3 | 4 | .. automodule:: mappymatch.constructs.geofence 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | Geofence 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.constructs.match.rst: -------------------------------------------------------------------------------- 1 | mappymatch.constructs.match 2 | =========================== 3 | 4 | .. automodule:: mappymatch.constructs.match 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | Match 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.constructs.road.rst: -------------------------------------------------------------------------------- 1 | mappymatch.constructs.road 2 | ========================== 3 | 4 | .. automodule:: mappymatch.constructs.road 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | Road 12 | RoadId 13 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.constructs.rst: -------------------------------------------------------------------------------- 1 | mappymatch.constructs 2 | ===================== 3 | 4 | .. automodule:: mappymatch.constructs 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | coordinate 14 | geofence 15 | match 16 | road 17 | trace 18 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.constructs.trace.rst: -------------------------------------------------------------------------------- 1 | mappymatch.constructs.trace 2 | =========================== 3 | 4 | .. automodule:: mappymatch.constructs.trace 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | Trace 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.igraph.igraph_map.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.igraph.igraph\_map 2 | ================================== 3 | 4 | .. automodule:: mappymatch.maps.igraph.igraph_map 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | IGraphMap 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.igraph.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.igraph 2 | ====================== 3 | 4 | .. automodule:: mappymatch.maps.igraph 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | igraph_map 14 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.map_interface.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.map\_interface 2 | ============================== 3 | 4 | .. automodule:: mappymatch.maps.map_interface 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | MapInterface 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.nx.nx_map.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.nx.nx\_map 2 | ========================== 3 | 4 | .. automodule:: mappymatch.maps.nx.nx_map 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | NxMap 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.nx.readers.osm_readers.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.nx.readers.osm\_readers 2 | ======================================= 3 | 4 | .. automodule:: mappymatch.maps.nx.readers.osm_readers 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | compress 12 | nx_graph_from_osmnx 13 | parse_osmnx_graph 14 | 15 | .. rubric:: Classes 16 | 17 | .. autosummary:: 18 | 19 | NetworkType 20 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.nx.readers.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.nx.readers 2 | ========================== 3 | 4 | .. automodule:: mappymatch.maps.nx.readers 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | osm_readers 14 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.nx.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps.nx 2 | ================== 3 | 4 | .. automodule:: mappymatch.maps.nx 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | nx_map 14 | readers 15 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.maps.rst: -------------------------------------------------------------------------------- 1 | mappymatch.maps 2 | =============== 3 | 4 | .. automodule:: mappymatch.maps 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | igraph 14 | map_interface 15 | nx 16 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.lcss.constructs.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.lcss.constructs 2 | =================================== 3 | 4 | .. automodule:: mappymatch.matchers.lcss.constructs 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | CuttingPoint 12 | TrajectorySegment 13 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.lcss.lcss.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.lcss.lcss 2 | ============================= 3 | 4 | .. automodule:: mappymatch.matchers.lcss.lcss 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | LCSSMatcher 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.lcss.ops.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.lcss.ops 2 | ============================ 3 | 4 | .. automodule:: mappymatch.matchers.lcss.ops 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | add_matches_for_stationary_points 12 | drop_stationary_points 13 | find_stationary_points 14 | new_path 15 | same_trajectory_scheme 16 | split_trajectory_segment 17 | 18 | .. rubric:: Classes 19 | 20 | .. autosummary:: 21 | 22 | StationaryIndex 23 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.lcss.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.lcss 2 | ======================== 3 | 4 | .. automodule:: mappymatch.matchers.lcss 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | constructs 14 | lcss 15 | ops 16 | utils 17 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.lcss.utils.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.lcss.utils 2 | ============================== 3 | 4 | .. automodule:: mappymatch.matchers.lcss.utils 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | compress 12 | forward_merge 13 | merge 14 | reverse_merge 15 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.line_snap.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.line\_snap 2 | ============================== 3 | 4 | .. automodule:: mappymatch.matchers.line_snap 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | LineSnapMatcher 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.match_result.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.match\_result 2 | ================================= 3 | 4 | .. automodule:: mappymatch.matchers.match_result 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | MatchResult 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.matcher_interface.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.matcher\_interface 2 | ====================================== 3 | 4 | .. automodule:: mappymatch.matchers.matcher_interface 5 | 6 | 7 | .. rubric:: Classes 8 | 9 | .. autosummary:: 10 | 11 | MatcherInterface 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.osrm.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.osrm 2 | ======================== 3 | 4 | .. automodule:: mappymatch.matchers.osrm 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | parse_osrm_json 12 | 13 | .. rubric:: Classes 14 | 15 | .. autosummary:: 16 | 17 | OsrmMatcher 18 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers 2 | =================== 3 | 4 | .. automodule:: mappymatch.matchers 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | lcss 14 | line_snap 15 | match_result 16 | matcher_interface 17 | osrm 18 | valhalla 19 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.matchers.valhalla.rst: -------------------------------------------------------------------------------- 1 | mappymatch.matchers.valhalla 2 | ============================ 3 | 4 | .. automodule:: mappymatch.matchers.valhalla 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | build_match_result 12 | build_path_from_result 13 | 14 | .. rubric:: Classes 15 | 16 | .. autosummary:: 17 | 18 | ValhallaMatcher 19 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.crs.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils.crs 2 | ==================== 3 | 4 | .. automodule:: mappymatch.utils.crs 5 | 6 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.exceptions.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils.exceptions 2 | =========================== 3 | 4 | .. automodule:: mappymatch.utils.exceptions 5 | 6 | 7 | .. rubric:: Exceptions 8 | 9 | .. autosummary:: 10 | 11 | MapException 12 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.geo.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils.geo 2 | ==================== 3 | 4 | .. automodule:: mappymatch.utils.geo 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | coord_to_coord_dist 12 | latlon_to_xy 13 | xy_to_latlon 14 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.plot.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils.plot 2 | ===================== 3 | 4 | .. automodule:: mappymatch.utils.plot 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | plot_geofence 12 | plot_map 13 | plot_match_distances 14 | plot_matches 15 | plot_path 16 | plot_trace 17 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.process_trace.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils.process\_trace 2 | =============================== 3 | 4 | .. automodule:: mappymatch.utils.process_trace 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | remove_bad_start_from_trace 12 | split_large_trace 13 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils 2 | ================ 3 | 4 | .. automodule:: mappymatch.utils 5 | 6 | 7 | .. rubric:: Modules 8 | 9 | .. autosummary:: 10 | :toctree: 11 | :recursive: 12 | 13 | crs 14 | exceptions 15 | geo 16 | plot 17 | process_trace 18 | url 19 | -------------------------------------------------------------------------------- /docs/_autosummary/mappymatch.utils.url.rst: -------------------------------------------------------------------------------- 1 | mappymatch.utils.url 2 | ==================== 3 | 4 | .. automodule:: mappymatch.utils.url 5 | 6 | 7 | .. rubric:: Functions 8 | 9 | .. autosummary:: 10 | 11 | multiurljoin 12 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Book settings 2 | # Learn more at https://jupyterbook.org/customize/config.html 3 | 4 | title: mappymatch 5 | author: National Renewable Energy Laboratory 6 | 7 | # Force re-execution of notebooks on each build. 8 | # See https://jupyterbook.org/content/execute.html 9 | execute: 10 | execute_notebooks: force 11 | timeout: 300 12 | 13 | # Define the name of the latex output file for PDF builds 14 | latex: 15 | latex_documents: 16 | targetname: mappymatch.tex 17 | 18 | # Information about where the book exists on the web 19 | repository: 20 | url: https://github.com/NREL/mappymatch # Online location of your book 21 | path_to_book: docs # Optional path to your book, relative to the repository root 22 | branch: main # Which branch of the repository should be used when creating links (optional) 23 | 24 | # Add GitHub buttons to your book 25 | # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository 26 | html: 27 | use_issues_button: true 28 | use_repository_button: true 29 | 30 | # Sphinx for API doc generation 31 | sphinx: 32 | extra_extensions: 33 | - "sphinx.ext.autodoc" 34 | - "sphinx.ext.autosummary" 35 | - "sphinx.ext.viewcode" 36 | - "sphinx_autodoc_typehints" 37 | - "sphinxcontrib.autoyaml" 38 | - "sphinxcontrib.mermaid" 39 | config: 40 | html_theme: sphinx_book_theme 41 | language: "python" 42 | html_context: 43 | default_mode: light 44 | nb_execution_show_tb: true # Shows the stack trace in stdout; its suppressed otherwise. 45 | nb_execution_raise_on_error: true # Stops the Sphinx build if there is an error in a notebook. See https://github.com/executablebooks/jupyter-book/issues/2011 46 | suppress_warnings: 47 | - etoc.toctree # autodoc output contains toctrees, so suppress this warning. See https://github.com/executablebooks/sphinx-external-toc/issues/36 48 | autoyaml_level: 3 49 | autosummary_generate: true 50 | 51 | # Autodoc config reference 52 | # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration 53 | autodoc_default_options: 54 | members: true 55 | member-order: bysource 56 | undoc-members: true 57 | private-members: false 58 | autodoc_typehints: both 59 | mermaid_version: "10.8" 60 | -------------------------------------------------------------------------------- /docs/_toc.yml: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | # Learn more at https://jupyterbook.org/customize/toc.html 3 | 4 | format: jb-book 5 | root: home 6 | parts: 7 | - caption: Quick Start 8 | chapters: 9 | - file: quick-start 10 | 11 | - caption: Installation 12 | chapters: 13 | - file: install 14 | 15 | - caption: Example 16 | chapters: 17 | - file: lcss-example 18 | 19 | - caption: Reference 20 | chapters: 21 | - file: api-docs 22 | -------------------------------------------------------------------------------- /docs/api-docs.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | ```{eval-rst} 4 | .. autosummary:: 5 | :toctree: _autosummary 6 | :recursive: 7 | 8 | mappymatch.constructs 9 | mappymatch.maps 10 | mappymatch.matchers 11 | mappymatch.utils 12 | ``` 13 | -------------------------------------------------------------------------------- /docs/home.md: -------------------------------------------------------------------------------- 1 | # Mappymatch 2 | 3 | Mappymatch is a pure-Python package developed and open-sourced by the National Renewable Energy Laboratory. It contains a collection of "Matchers" that enable matching a GPS trace (series of GPS coordinates) to a map. 4 | 5 | ## The Current Matchers 6 | 7 | - **`LCSSMatcher`**: A matcher that implements the LCSS algorithm described in this [paper](https://doi.org/10.3141%2F2645-08). Works best with high-resolution GPS traces. 8 | - **`OsrmMatcher`**: A light matcher that pings an OSRM server to request map matching results. See the [official documentation](http://project-osrm.org/) for more info. 9 | - **`ValhallaMatcher`**: A matcher to ping a [Valhalla](https://www.interline.io/valhalla/) server for map matching results. 10 | 11 | ## Currently Supported Map Formats 12 | 13 | - **Open Street Maps** 14 | -------------------------------------------------------------------------------- /docs/images/map-matching.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/docs/images/map-matching.gif -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | ## From PyPI 4 | 5 | ```bash 6 | pip install mappymatch 7 | ``` 8 | 9 | ```{note} 10 | While mappymatch is a pure python package, some of the geospatial dependnecies can be hard to install via pip. 11 | If you encounted issues, our recommended solution is to install the package from the source code, using conda 12 | to facilitate the packages that can be challenging to install. 13 | 14 | We hope to eventually provide a conda distribution (help doing this would be greatly appreciated!) 15 | ``` 16 | 17 | ## From Source 18 | 19 | Clone the repo: 20 | 21 | ```bash 22 | git clone https://github.com/NREL/mappymatch.git && cd mappymatch 23 | ``` 24 | 25 | Get [Anaconda](https://www.anaconda.com/download) or [miniconda](https://docs.anaconda.com/miniconda/). 26 | 27 | Then, use the `environment.yml` file (in the repo) to install dependencies: 28 | 29 | ```bash 30 | conda env create -f environment.yml 31 | ``` 32 | 33 | To activate the `mappymatch` environment: 34 | 35 | ```bash 36 | conda activate mappymatch 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | First, follow the [installation instructions](install). 4 | 5 | Then, checkout the [LCSS example](lcss-example). 6 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: mappymatch 2 | channels: 3 | - conda-forge 4 | - defaults 5 | dependencies: 6 | - python=3.12 7 | - folium 8 | - geopandas 9 | - matplotlib 10 | - numpy 11 | - osmnx 12 | - pandas 13 | - pip 14 | - pip: 15 | - . 16 | - pyproj 17 | - requests 18 | - rtree 19 | - shapely 20 | -------------------------------------------------------------------------------- /environment_dev.yml: -------------------------------------------------------------------------------- 1 | # Add dependencies in alphabetical order. 2 | name: mappymatch-dev 3 | channels: 4 | - conda-forge 5 | - defaults 6 | dependencies: 7 | - python=3.10 8 | - black 9 | - build 10 | - coverage 11 | - folium 12 | - geopandas 13 | - interrogate 14 | - pyproj 15 | - matplotlib 16 | - mypy 17 | - networkx 18 | - numpy 19 | - osmnx 20 | - pandas 21 | - pip 22 | - pip: 23 | - -e . 24 | - ruff 25 | - sphinxemoji==0.2.* 26 | - sphinx-autobuild 27 | - tbump 28 | - types-requests 29 | - pip-tools 30 | - pre-commit 31 | - pytest 32 | - requests 33 | - rtree 34 | - shapely 35 | - sphinx=4.5.* 36 | - sphinx_rtd_theme=1.0.* 37 | -------------------------------------------------------------------------------- /mappymatch/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.0" 2 | -------------------------------------------------------------------------------- /mappymatch/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def package_root() -> Path: 5 | return Path(__file__).parent 6 | -------------------------------------------------------------------------------- /mappymatch/constructs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/constructs/__init__.py -------------------------------------------------------------------------------- /mappymatch/constructs/coordinate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | from typing import Any, NamedTuple 5 | 6 | from pyproj import CRS, Transformer 7 | from pyproj.exceptions import ProjError 8 | from shapely.geometry import Point 9 | 10 | from mappymatch.utils.crs import LATLON_CRS 11 | 12 | 13 | class Coordinate(NamedTuple): 14 | """ 15 | Represents a single coordinate with a CRS and a geometry 16 | 17 | Attributes: 18 | coordinate_id: The unique identifier for this coordinate 19 | geom: The geometry of this coordinate 20 | crs: The CRS of this coordinate 21 | x: The x value of this coordinate 22 | y: The y value of this coordinate 23 | """ 24 | 25 | coordinate_id: Any 26 | geom: Point 27 | crs: CRS 28 | 29 | def __repr__(self): 30 | crs_a = self.crs.to_authority() if self.crs else "Null" 31 | return f"Coordinate(coordinate_id={self.coordinate_id}, x={self.x}, y={self.y}, crs={crs_a})" 32 | 33 | @classmethod 34 | def from_lat_lon(cls, lat: float, lon: float) -> Coordinate: 35 | """ 36 | Build a coordinate from a latitude/longitude 37 | 38 | Args: 39 | lat: The latitude 40 | lon: The longitude 41 | 42 | Returns: 43 | A new coordinate 44 | """ 45 | return cls(coordinate_id=None, geom=Point(lon, lat), crs=LATLON_CRS) 46 | 47 | @property 48 | def x(self) -> float: 49 | return self.geom.x 50 | 51 | @property 52 | def y(self) -> float: 53 | return self.geom.y 54 | 55 | def to_crs(self, new_crs: Any) -> Coordinate: 56 | """ 57 | Convert this coordinate to a new CRS 58 | 59 | Args: 60 | new_crs: The new CRS to convert to 61 | 62 | Returns: 63 | A new coordinate with the new CRS 64 | 65 | Raises: 66 | A ValueError if it fails to convert the coordinate 67 | """ 68 | # convert the incoming crs to an pyproj.crs.CRS object; this could fail 69 | try: 70 | new_crs = CRS(new_crs) 71 | except ProjError as e: 72 | raise ValueError( 73 | f"Could not parse incoming `new_crs` parameter: {new_crs}" 74 | ) from e 75 | 76 | if new_crs == self.crs: 77 | return self 78 | 79 | transformer = Transformer.from_crs(self.crs, new_crs) 80 | new_x, new_y = transformer.transform(self.geom.y, self.geom.x) 81 | 82 | if math.isinf(new_x) or math.isinf(new_y): 83 | raise ValueError( 84 | f"Unable to convert {self.crs} ({self.geom.x}, {self.geom.y}) -> {new_crs} ({new_x}, {new_y})" 85 | ) 86 | 87 | return Coordinate( 88 | coordinate_id=self.coordinate_id, 89 | geom=Point(new_x, new_y), 90 | crs=new_crs, 91 | ) 92 | -------------------------------------------------------------------------------- /mappymatch/constructs/geofence.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from pathlib import Path 5 | from typing import Union 6 | 7 | from geopandas import read_file 8 | from pyproj import CRS, Transformer 9 | from shapely.geometry import LineString, Polygon, mapping 10 | from shapely.ops import transform 11 | 12 | from mappymatch.constructs.trace import Trace 13 | from mappymatch.utils.crs import LATLON_CRS 14 | 15 | 16 | class Geofence: 17 | """ 18 | A geofence is basically a shapely polygon with a CRS 19 | 20 | Args: 21 | geom: The polygon geometry of the geofence 22 | crs: The CRS of the geofence 23 | """ 24 | 25 | def __init__(self, crs: CRS, geometry: Polygon): 26 | self.crs = crs 27 | self.geometry = geometry 28 | 29 | @classmethod 30 | def from_geojson(cls, file: Union[Path, str]) -> Geofence: 31 | """ 32 | Creates a new geofence from a geojson file. 33 | 34 | Args: 35 | file: The path to the geojson file 36 | 37 | Returns: 38 | A new geofence 39 | """ 40 | filepath = Path(file) 41 | frame = read_file(filepath) 42 | 43 | if len(frame) > 1: 44 | raise TypeError( 45 | "found multiple polygons in the input; please only provide one" 46 | ) 47 | elif frame.crs is None: 48 | raise TypeError( 49 | "no crs information found in the file; please make sure file has a crs" 50 | ) 51 | 52 | polygon = frame.iloc[0].geometry 53 | 54 | return Geofence(crs=frame.crs, geometry=polygon) 55 | 56 | @classmethod 57 | def from_trace( 58 | cls, 59 | trace: Trace, 60 | padding: float = 1e3, 61 | crs: CRS = LATLON_CRS, 62 | buffer_res: int = 2, 63 | ) -> Geofence: 64 | """ 65 | Create a new geofence from a trace. 66 | 67 | This is done by computing a radial buffer around the 68 | entire trace (as a line). 69 | 70 | Args: 71 | trace: The trace to compute the bounding polygon for. 72 | padding: The padding (in meters) around the trace line. 73 | crs: The coordinate reference system to use. 74 | buffer_res: The resolution of the surrounding buffer. 75 | 76 | Returns: 77 | The computed bounding polygon. 78 | """ 79 | 80 | trace_line_string = LineString([c.geom for c in trace.coords]) 81 | 82 | # Add buffer to LineString. 83 | polygon = trace_line_string.buffer(padding, buffer_res) 84 | 85 | if trace.crs != crs: 86 | project = Transformer.from_crs(trace.crs, crs, always_xy=True).transform 87 | polygon = transform(project, polygon) 88 | return Geofence(crs=crs, geometry=polygon) 89 | 90 | return Geofence(crs=trace.crs, geometry=polygon) 91 | 92 | def to_geojson(self) -> str: 93 | """ 94 | Converts the geofence to a geojson string. 95 | """ 96 | if self.crs != LATLON_CRS: 97 | transformer = Transformer.from_crs(self.crs, LATLON_CRS) 98 | geometry: Polygon = transformer.transform(self.geometry) # type: ignore 99 | else: 100 | geometry = self.geometry 101 | 102 | return json.dumps(mapping(geometry)) 103 | -------------------------------------------------------------------------------- /mappymatch/constructs/match.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Optional 2 | 3 | from mappymatch.constructs.coordinate import Coordinate 4 | from mappymatch.constructs.road import Road 5 | 6 | 7 | class Match(NamedTuple): 8 | """ 9 | Represents a match made by a Matcher 10 | 11 | Attributes: 12 | road: The road that was matched; None if no road was found; 13 | coordinate: The original coordinate that was matched; 14 | distance: The distance to the matched road; If no road was found, this is infinite 15 | """ 16 | 17 | road: Optional[Road] 18 | coordinate: Coordinate 19 | distance: float 20 | 21 | def set_coordinate(self, c: Coordinate): 22 | """ 23 | Set the coordinate of this match 24 | 25 | Args: 26 | c: The new coordinate 27 | 28 | Returns: 29 | The match with the new coordinate 30 | """ 31 | return self._replace(coordinate=c) 32 | 33 | def to_flat_dict(self) -> dict: 34 | """ 35 | Convert this match to a flat dictionary 36 | 37 | Returns: 38 | A flat dictionary with all match information 39 | """ 40 | out = {"coordinate_id": self.coordinate.coordinate_id} 41 | 42 | if self.road is None: 43 | out["road_id"] = None 44 | return out 45 | else: 46 | out["distance_to_road"] = self.distance 47 | road_dict = self.road.to_flat_dict() 48 | out.update(road_dict) 49 | return out 50 | -------------------------------------------------------------------------------- /mappymatch/constructs/road.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict, NamedTuple, Optional, Union 4 | 5 | from shapely.geometry import LineString 6 | 7 | 8 | class RoadId(NamedTuple): 9 | start: Optional[Union[int, str]] 10 | end: Optional[Union[int, str]] 11 | key: Optional[Union[int, str]] 12 | 13 | def to_string(self) -> str: 14 | return f"{self.start},{self.end},{self.key}" 15 | 16 | def to_json(self) -> Dict[str, Any]: 17 | return self._asdict() 18 | 19 | @classmethod 20 | def from_string(cls, s: str) -> RoadId: 21 | start, end, key = s.split(",") 22 | return cls(start, end, key) 23 | 24 | @classmethod 25 | def from_json(cls, json: Dict[str, Any]) -> RoadId: 26 | return cls(**json) 27 | 28 | 29 | class Road(NamedTuple): 30 | """ 31 | Represents a road that can be matched to; 32 | 33 | Attributes: 34 | road_id: The unique identifier for this road 35 | geom: The geometry of this road 36 | origin_junction_id: The unique identifier of the origin junction of this road 37 | destination_junction_id: The unique identifier of the destination junction of this road 38 | metadata: an optional dictionary for storing additional metadata 39 | """ 40 | 41 | road_id: RoadId 42 | 43 | geom: LineString 44 | metadata: Optional[dict] = None 45 | 46 | def to_dict(self) -> Dict[str, Any]: 47 | """ 48 | Convert the road to a dictionary 49 | """ 50 | d = self._asdict() 51 | d["origin_junction_id"] = self.road_id.start 52 | d["destination_junction_id"] = self.road_id.end 53 | d["road_key"] = self.road_id.key 54 | 55 | return d 56 | 57 | def to_flat_dict(self) -> Dict[str, Any]: 58 | """ 59 | Convert the road to a flat dictionary 60 | """ 61 | if self.metadata is None: 62 | return self.to_dict() 63 | else: 64 | d = {**self.to_dict(), **self.metadata} 65 | del d["metadata"] 66 | return d 67 | -------------------------------------------------------------------------------- /mappymatch/constructs/trace.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from functools import cached_property 5 | from pathlib import Path 6 | from typing import List, Optional, Union 7 | 8 | import numpy as np 9 | import pandas as pd 10 | from geopandas import GeoDataFrame, points_from_xy, read_file, read_parquet 11 | from pyproj import CRS 12 | 13 | from mappymatch.constructs.coordinate import Coordinate 14 | from mappymatch.utils.crs import LATLON_CRS, XY_CRS 15 | 16 | 17 | class Trace: 18 | """ 19 | A Trace is a collection of coordinates that represents a trajectory to be matched. 20 | 21 | Attributes: 22 | coords: A list of all the coordinates 23 | crs: The CRS of the trace 24 | index: The index of the trace 25 | """ 26 | 27 | _frame: GeoDataFrame 28 | 29 | def __init__(self, frame: GeoDataFrame): 30 | if frame.index.has_duplicates: 31 | duplicates = frame.index[frame.index.duplicated()].values 32 | raise IndexError( 33 | f"Trace cannot have duplicates in the index but found {duplicates}" 34 | ) 35 | self._frame = frame 36 | 37 | def __getitem__(self, i) -> Trace: 38 | if isinstance(i, int): 39 | i = [i] 40 | new_frame = self._frame.iloc[i] 41 | return Trace(new_frame) 42 | 43 | def __add__(self, other: Trace) -> Trace: 44 | if self.crs != other.crs: 45 | raise TypeError("cannot add two traces together with different crs") 46 | new_frame = pd.concat([self._frame, other._frame]) 47 | return Trace(new_frame) 48 | 49 | def __len__(self): 50 | """Number of coordinate pairs.""" 51 | return len(self._frame) 52 | 53 | def __str__(self): 54 | output_lines = [ 55 | "Mappymatch Trace object", 56 | f"coords: {self.coords if hasattr(self, 'coords') else None}", 57 | f"frame: {self._frame}", 58 | ] 59 | return "\n".join(output_lines) 60 | 61 | def __repr__(self): 62 | return self.__str__() 63 | 64 | @property 65 | def index(self) -> pd.Index: 66 | """Get index to underlying GeoDataFrame.""" 67 | return self._frame.index 68 | 69 | @cached_property 70 | def coords(self) -> List[Coordinate]: 71 | """ 72 | Get coordinates as Coordinate objects. 73 | """ 74 | coords_list = [ 75 | Coordinate(i, g, self.crs) 76 | for i, g in zip(self._frame.index, self._frame.geometry) 77 | ] 78 | return coords_list 79 | 80 | @property 81 | def crs(self) -> CRS: 82 | """Get Coordinate Reference System(CRS) to underlying GeoDataFrame.""" 83 | return self._frame.crs 84 | 85 | @classmethod 86 | def from_geo_dataframe( 87 | cls, 88 | frame: GeoDataFrame, 89 | xy: bool = True, 90 | ) -> Trace: 91 | """ 92 | Builds a trace from a geopandas dataframe 93 | 94 | Expects the dataframe to have geometry column 95 | 96 | Args: 97 | frame: geopandas dataframe with _one_ trace 98 | xy: should the trace be projected to epsg 3857? 99 | 100 | Returns: 101 | The trace built from the geopandas dataframe 102 | """ 103 | # get rid of any extra info besides geometry and index 104 | frame = GeoDataFrame(geometry=frame.geometry, index=frame.index) 105 | if xy: 106 | frame = frame.to_crs(XY_CRS) 107 | return Trace(frame) 108 | 109 | @classmethod 110 | def from_dataframe( 111 | cls, 112 | dataframe: pd.DataFrame, 113 | xy: bool = True, 114 | lat_column: str = "latitude", 115 | lon_column: str = "longitude", 116 | ) -> Trace: 117 | """ 118 | Builds a trace from a pandas dataframe 119 | 120 | Expects the dataframe to have latitude / longitude information in the epsg 4326 format 121 | 122 | Args: 123 | dataframe: pandas dataframe with _one_ trace 124 | xy: should the trace be projected to epsg 3857? 125 | lat_column: the name of the latitude column 126 | lon_column: the name of the longitude column 127 | 128 | Returns: 129 | The trace built from the pandas dataframe 130 | """ 131 | frame = GeoDataFrame( 132 | geometry=points_from_xy(dataframe[lon_column], dataframe[lat_column]), 133 | index=dataframe.index, 134 | crs=LATLON_CRS, 135 | ) 136 | 137 | return Trace.from_geo_dataframe(frame, xy) 138 | 139 | @classmethod 140 | def from_gpx( 141 | cls, 142 | file: Union[str, Path], 143 | xy: bool = True, 144 | ) -> Trace: 145 | """ 146 | Builds a trace from a gpx file. 147 | 148 | Expects the file to have simple gpx structure: a sequence of lat, lon pairs 149 | 150 | Args: 151 | file: the gpx file 152 | xy: should the trace be projected to epsg 3857? 153 | 154 | Returns: 155 | The trace built from the gpx file 156 | """ 157 | filepath = Path(file) 158 | if not filepath.is_file(): 159 | raise FileNotFoundError(file) 160 | elif not filepath.suffix == ".gpx": 161 | raise TypeError( 162 | f"file of type {filepath.suffix} does not appear to be a gpx file" 163 | ) 164 | data = open(filepath).read() 165 | 166 | lat_column, lon_column = "lat", "lon" 167 | lat = np.array(re.findall(r'lat="([^"]+)', data), dtype=float) 168 | lon = np.array(re.findall(r'lon="([^"]+)', data), dtype=float) 169 | df = pd.DataFrame(zip(lat, lon), columns=[lat_column, lon_column]) 170 | return Trace.from_dataframe(df, xy, lat_column, lon_column) 171 | 172 | @classmethod 173 | def from_csv( 174 | cls, 175 | file: Union[str, Path], 176 | xy: bool = True, 177 | lat_column: str = "latitude", 178 | lon_column: str = "longitude", 179 | ) -> Trace: 180 | """ 181 | Builds a trace from a csv file. 182 | 183 | Expects the file to have latitude / longitude information in the epsg 4326 format 184 | 185 | Args: 186 | file: the csv file 187 | xy: should the trace be projected to epsg 3857? 188 | lat_column: the name of the latitude column 189 | lon_column: the name of the longitude column 190 | 191 | Returns: 192 | The trace built from the csv file 193 | """ 194 | filepath = Path(file) 195 | if not filepath.is_file(): 196 | raise FileNotFoundError(file) 197 | elif not filepath.suffix == ".csv": 198 | raise TypeError( 199 | f"file of type {filepath.suffix} does not appear to be a csv file" 200 | ) 201 | 202 | columns = pd.read_csv(filepath, nrows=0).columns.to_list() 203 | if lat_column in columns and lon_column in columns: 204 | df = pd.read_csv(filepath) 205 | return Trace.from_dataframe(df, xy, lat_column, lon_column) 206 | else: 207 | raise ValueError( 208 | "Could not find any geometry information in the file; " 209 | "Make sure there are latitude and longitude columns " 210 | "[and provide the lat/lon column names to this function]" 211 | ) 212 | 213 | @classmethod 214 | def from_parquet(cls, file: Union[str, Path], xy: bool = True): 215 | """ 216 | Read a trace from a parquet file 217 | 218 | Args: 219 | file: the parquet file 220 | xy: should the trace be projected to epsg 3857? 221 | 222 | Returns: 223 | The trace built from the parquet file 224 | """ 225 | filepath = Path(file) 226 | frame = read_parquet(filepath) 227 | 228 | return Trace.from_geo_dataframe(frame, xy) 229 | 230 | @classmethod 231 | def from_geojson( 232 | cls, 233 | file: Union[str, Path], 234 | index_property: Optional[str] = None, 235 | xy: bool = True, 236 | ): 237 | """ 238 | Reads a trace from a geojson file; 239 | If index_property is not specified, this will set any property columns as the index. 240 | 241 | Args: 242 | file: the geojson file 243 | index_property: the name of the property to use as the index 244 | xy: should the trace be projected to epsg 3857? 245 | 246 | Returns: 247 | The trace built from the geojson file 248 | """ 249 | filepath = Path(file) 250 | frame = read_file(filepath) 251 | if index_property and index_property in frame.columns: 252 | frame = frame.set_index(index_property) 253 | else: 254 | gname = frame.geometry.name 255 | index_cols = [c for c in frame.columns if c != gname] 256 | frame = frame.set_index(index_cols) 257 | 258 | return Trace.from_geo_dataframe(frame, xy) 259 | 260 | def downsample(self, npoints: int) -> Trace: 261 | """ 262 | Downsample the trace to a given number of points 263 | 264 | Args: 265 | npoints: the number of points to downsample to 266 | 267 | Returns: 268 | The downsampled trace 269 | """ 270 | s = list(np.linspace(0, len(self._frame) - 1, npoints).astype(int)) 271 | 272 | new_frame = self._frame.iloc[s] 273 | 274 | return Trace(new_frame) 275 | 276 | def drop(self, index=List) -> Trace: 277 | """ 278 | Remove points from the trace specified by the index parameter 279 | 280 | Args: 281 | index: the index of the points to drop (0 based index) 282 | 283 | Returns: 284 | The trace with the points removed 285 | """ 286 | new_frame = self._frame.drop(index) 287 | 288 | return Trace(new_frame) 289 | 290 | def to_crs(self, new_crs: CRS) -> Trace: 291 | """ 292 | Converts the crs of a trace to a new crs 293 | 294 | Args: 295 | new_crs: the new crs to convert to 296 | 297 | Returns: 298 | A new trace with the new crs 299 | """ 300 | new_frame = self._frame.to_crs(new_crs) 301 | return Trace(new_frame) 302 | 303 | def to_geojson(self, file: Union[str, Path]): 304 | """ 305 | Write the trace to a geojson file 306 | 307 | Args: 308 | file: the file to write to 309 | """ 310 | self._frame.to_file(file, driver="GeoJSON") 311 | -------------------------------------------------------------------------------- /mappymatch/maps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/maps/__init__.py -------------------------------------------------------------------------------- /mappymatch/maps/igraph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/maps/igraph/__init__.py -------------------------------------------------------------------------------- /mappymatch/maps/map_interface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABCMeta, abstractmethod 4 | from typing import Callable, List, Optional, Union 5 | 6 | from mappymatch.constructs.coordinate import Coordinate 7 | from mappymatch.constructs.road import Road, RoadId 8 | 9 | DEFAULT_DISTANCE_WEIGHT = "kilometers" 10 | DEFAULT_TIME_WEIGHT = "minutes" 11 | 12 | 13 | class MapInterface(metaclass=ABCMeta): 14 | """ 15 | Abstract base class for a Matcher 16 | """ 17 | 18 | @property 19 | @abstractmethod 20 | def distance_weight(self) -> str: 21 | """ 22 | Get the distance weight 23 | 24 | Returns: 25 | The distance weight 26 | """ 27 | return DEFAULT_DISTANCE_WEIGHT 28 | 29 | @property 30 | @abstractmethod 31 | def time_weight(self) -> str: 32 | """ 33 | Get the time weight 34 | 35 | Returns: 36 | The time weight 37 | """ 38 | return DEFAULT_TIME_WEIGHT 39 | 40 | @property 41 | @abstractmethod 42 | def roads(self) -> List[Road]: 43 | """ 44 | Get a list of all the roads in the map 45 | 46 | Returns: 47 | A list of all the roads in the map 48 | """ 49 | 50 | @abstractmethod 51 | def road_by_id(self, road_id: RoadId) -> Optional[Road]: 52 | """ 53 | Get a road by its id 54 | 55 | Args: 56 | road_id: The id of the road to get 57 | 58 | Returns: 59 | The road with the given id or None if it does not exist 60 | """ 61 | 62 | @abstractmethod 63 | def nearest_road( 64 | self, 65 | coord: Coordinate, 66 | ) -> Road: 67 | """ 68 | Return the nearest road to a coordinate 69 | 70 | Args: 71 | coord: The coordinate to find the nearest road to 72 | 73 | Returns: 74 | The nearest road to the coordinate 75 | """ 76 | 77 | @abstractmethod 78 | def shortest_path( 79 | self, 80 | origin: Coordinate, 81 | destination: Coordinate, 82 | weight: Optional[Union[str, Callable]] = None, 83 | ) -> List[Road]: 84 | """ 85 | Computes the shortest path on the road network 86 | 87 | Args: 88 | origin: The origin coordinate 89 | destination: The destination coordinate 90 | weight: The weight to use for the path 91 | 92 | Returns: 93 | A list of roads that form the shortest path 94 | """ 95 | -------------------------------------------------------------------------------- /mappymatch/maps/nx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/maps/nx/__init__.py -------------------------------------------------------------------------------- /mappymatch/maps/nx/readers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/maps/nx/readers/__init__.py -------------------------------------------------------------------------------- /mappymatch/maps/nx/readers/osm_readers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging as log 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | import networkx as nx 8 | from shapely.geometry import LineString 9 | 10 | from mappymatch.constructs.geofence import Geofence 11 | from mappymatch.utils.crs import XY_CRS 12 | from mappymatch.utils.exceptions import MapException 13 | 14 | log.basicConfig(level=log.INFO) 15 | 16 | 17 | METERS_TO_KM = 1 / 1000 18 | DEFAULT_MPH = 30 19 | 20 | 21 | class NetworkType(Enum): 22 | """ 23 | Enumerator for Network Types supported by osmnx. 24 | """ 25 | 26 | ALL_PRIVATE = "all_private" 27 | ALL = "all" 28 | BIKE = "bike" 29 | DRIVE = "drive" 30 | DRIVE_SERVICE = "drive_service" 31 | WALK = "walk" 32 | 33 | 34 | def nx_graph_from_osmnx( 35 | geofence: Geofence, 36 | network_type: NetworkType, 37 | xy: bool = True, 38 | custom_filter: Optional[str] = None, 39 | ) -> nx.MultiDiGraph: 40 | """ 41 | Build a networkx graph from OSM data 42 | 43 | Args: 44 | geofence: the geofence to clip the graph to 45 | network_type: the network type to use for the graph 46 | xy: whether to use xy coordinates or lat/lon 47 | custom_filter: a custom filter to pass to osmnx 48 | 49 | Returns: 50 | a networkx graph of the OSM network 51 | """ 52 | try: 53 | import osmnx as ox 54 | except ImportError: 55 | raise MapException("osmnx is not installed but is required for this map type") 56 | ox.settings.log_console = False 57 | 58 | raw_graph = ox.graph_from_polygon( 59 | geofence.geometry, 60 | network_type=network_type.value, 61 | custom_filter=custom_filter, 62 | ) 63 | return parse_osmnx_graph(raw_graph, network_type, xy=xy) 64 | 65 | 66 | def parse_osmnx_graph( 67 | graph: nx.MultiDiGraph, 68 | network_type: NetworkType, 69 | xy: bool = True, 70 | ) -> nx.MultiDiGraph: 71 | """ 72 | Parse the raw osmnx graph into a graph that we can use with our NxMap 73 | 74 | Args: 75 | geofence: the geofence to clip the graph to 76 | xy: whether to use xy coordinates or lat/lon 77 | network_type: the network type to use for the graph 78 | 79 | Returns: 80 | a cleaned networkx graph of the OSM network 81 | """ 82 | try: 83 | import osmnx as ox 84 | except ImportError: 85 | raise MapException("osmnx is not installed but is required for this map type") 86 | ox.settings.log_console = False 87 | g = graph 88 | 89 | if xy: 90 | g = ox.project_graph(g, to_crs=XY_CRS) 91 | 92 | g = ox.add_edge_speeds(g) 93 | g = ox.add_edge_travel_times(g) 94 | 95 | length_meters = nx.get_edge_attributes(g, "length") 96 | kilometers = {k: v * METERS_TO_KM for k, v in length_meters.items()} 97 | nx.set_edge_attributes(g, kilometers, "kilometers") 98 | 99 | # this makes sure there are no graph 'dead-ends' 100 | sg_components = nx.strongly_connected_components(g) 101 | 102 | if not sg_components: 103 | raise MapException( 104 | "road network has no strongly connected components and is not routable; " 105 | "check polygon boundaries." 106 | ) 107 | 108 | g = nx.MultiDiGraph(g.subgraph(max(sg_components, key=len))) 109 | 110 | no_geom = 0 111 | for u, v, d in g.edges(data=True): 112 | if "geometry" not in d: 113 | if no_geom < 10: 114 | print(d) 115 | # we'll build a pseudo-geometry using the x, y data from the nodes 116 | unode = g.nodes[u] 117 | vnode = g.nodes[v] 118 | line = LineString([(unode["x"], unode["y"]), (vnode["x"], vnode["y"])]) 119 | d["geometry"] = line 120 | no_geom += 1 121 | if no_geom > 0: 122 | total_links = len(g.edges) 123 | print( 124 | f"Warning: found {no_geom} of {total_links} links with no geometry; " 125 | "creating link geometries from the node endpoints" 126 | ) 127 | 128 | g = compress(g) 129 | 130 | # TODO: these should all be sourced from the same location 131 | g.graph["distance_weight"] = "kilometers" 132 | g.graph["time_weight"] = "travel_time" 133 | g.graph["geometry_key"] = "geometry" 134 | g.graph["network_type"] = network_type.value 135 | 136 | return g 137 | 138 | 139 | def compress(g: nx.MultiDiGraph) -> nx.MultiDiGraph: 140 | """ 141 | a hacky way to delete unnecessary data on the networkx graph 142 | 143 | Args: 144 | g: the networkx graph to compress 145 | 146 | Returns: 147 | the compressed networkx graph 148 | """ 149 | keys_to_delete = [ 150 | "oneway", 151 | "ref", 152 | "access", 153 | "lanes", 154 | "name", 155 | "maxspeed", 156 | "highway", 157 | "length", 158 | "speed_kph", 159 | "osmid", 160 | "street_count", 161 | "junction", 162 | "bridge", 163 | "tunnel", 164 | "reversed", 165 | "y", 166 | "x", 167 | ] 168 | 169 | for _, _, d in g.edges(data=True): 170 | for k in keys_to_delete: 171 | try: 172 | del d[k] 173 | except KeyError: 174 | continue 175 | 176 | for _, d in g.nodes(data=True): 177 | for k in keys_to_delete: 178 | try: 179 | del d[k] 180 | except KeyError: 181 | continue 182 | 183 | return g 184 | -------------------------------------------------------------------------------- /mappymatch/matchers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/matchers/__init__.py -------------------------------------------------------------------------------- /mappymatch/matchers/lcss/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/matchers/lcss/__init__.py -------------------------------------------------------------------------------- /mappymatch/matchers/lcss/constructs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import random 5 | from typing import List, NamedTuple, Union 6 | 7 | import numpy as np 8 | from numpy import ndarray, signedinteger 9 | 10 | from mappymatch.constructs.match import Match 11 | from mappymatch.constructs.road import Road 12 | from mappymatch.constructs.trace import Trace 13 | from mappymatch.matchers.lcss.utils import compress 14 | from mappymatch.utils.geo import coord_to_coord_dist 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | class CuttingPoint(NamedTuple): 20 | """ 21 | A cutting point represents where the LCSS algorithm cuts the trace into a sub-segment. 22 | 23 | Attributes: 24 | trace_index: An index of where to cut the trace 25 | """ 26 | 27 | trace_index: Union[signedinteger, int] 28 | 29 | 30 | class TrajectorySegment(NamedTuple): 31 | """ 32 | Represents a pairing of a trace and candidate path 33 | 34 | Attributes: 35 | trace: The trace in the segment 36 | path: The candidate path in the segment 37 | matches: The matches between the trace and the path 38 | score: The similarity score between the trace and the path 39 | cutting_points: The points where the trace and path are to be cut 40 | """ 41 | 42 | trace: Trace 43 | path: List[Road] 44 | 45 | matches: List[Match] = [] 46 | 47 | score: float = 0 48 | 49 | cutting_points: List[CuttingPoint] = [] 50 | 51 | def __add__(self, other): 52 | new_traces = self.trace + other.trace 53 | new_paths = self.path + other.path 54 | return TrajectorySegment(new_traces, new_paths) 55 | 56 | def set_score(self, score: float) -> TrajectorySegment: 57 | """ 58 | Sets the score of the trajectory segment 59 | 60 | Args: 61 | score: The score of the trajectory segment 62 | 63 | Returns: 64 | The updated trajectory segment 65 | """ 66 | return self._replace(score=score) 67 | 68 | def set_cutting_points(self, cutting_points) -> TrajectorySegment: 69 | """ 70 | Sets the cutting points of the trajectory segment 71 | 72 | Args: 73 | cutting_points: The cutting points of the trajectory segment 74 | 75 | Returns: 76 | The updated trajectory segment 77 | """ 78 | return self._replace(cutting_points=cutting_points) 79 | 80 | def set_matches(self, matches) -> TrajectorySegment: 81 | """ 82 | Sets the matches of the trajectory segment 83 | 84 | Args: 85 | matches: The matches of the trajectory segment 86 | 87 | Returns: 88 | The updated trajectory segment 89 | """ 90 | return self._replace(matches=matches) 91 | 92 | def score_and_match( 93 | self, 94 | distance_epsilon: float, 95 | max_distance: float, 96 | ) -> TrajectorySegment: 97 | """ 98 | Computes the score of a trace, pair matching and also matches the coordinates to the nearest road. 99 | 100 | Args: 101 | distance_epsilon: The distance threshold for matching 102 | max_distance: The maximum distance between the trace and the path 103 | 104 | Returns: 105 | The updated trajectory segment with a score and matches 106 | """ 107 | trace = self.trace 108 | path = self.path 109 | 110 | m = len(trace.coords) 111 | n = len(path) 112 | 113 | matched_roads = [] 114 | 115 | if m < 1: 116 | # todo: find a better way to handle this edge case 117 | raise Exception("traces of 0 points can't be matched") 118 | elif n < 2: 119 | # a path was not found for this segment; might not be matchable; 120 | # we set a score of zero and return a set of no-matches 121 | matches = [ 122 | Match(road=None, distance=np.inf, coordinate=c) 123 | for c in self.trace.coords 124 | ] 125 | return self.set_score(0).set_matches(matches) 126 | 127 | C = [[0 for i in range(n + 1)] for j in range(m + 1)] 128 | 129 | f = trace._frame 130 | distances = np.array([f.distance(r.geom).values for r in path]) 131 | 132 | for i in range(1, m + 1): 133 | nearest_road = None 134 | min_dist = np.inf 135 | coord = trace.coords[i - 1] 136 | for j in range(1, n + 1): 137 | road = path[j - 1] 138 | 139 | # dt = road_to_coord_dist(road, coord) 140 | dt = distances[j - 1][i - 1] 141 | 142 | if dt < min_dist: 143 | min_dist = dt 144 | nearest_road = road 145 | 146 | if dt < distance_epsilon: 147 | point_similarity = 1 - (dt / distance_epsilon) 148 | else: 149 | point_similarity = 0 150 | 151 | C[i][j] = max( 152 | (C[i - 1][j - 1] + point_similarity), 153 | C[i][j - 1], 154 | C[i - 1][j], 155 | ) 156 | 157 | if min_dist > max_distance: 158 | nearest_road = None 159 | min_dist = np.inf 160 | 161 | match = Match( 162 | road=nearest_road, 163 | distance=min_dist, 164 | coordinate=coord, 165 | ) 166 | matched_roads.append(match) 167 | 168 | sim_score = C[m][n] / float(min(m, n)) 169 | 170 | return self.set_score(sim_score).set_matches(matched_roads) 171 | 172 | def compute_cutting_points( 173 | self, 174 | distance_epsilon: float, 175 | cutting_thresh: float, 176 | random_cuts: int, 177 | ) -> TrajectorySegment: 178 | """ 179 | Computes the cutting points for a trajectory segment by: 180 | - computing the furthest point 181 | - adding points that are close to the distance epsilon 182 | 183 | Args: 184 | distance_epsilon: The distance threshold for matching 185 | cutting_thresh: The threshold for cutting the trace 186 | random_cuts: The number of random cuts to add 187 | 188 | Returns: 189 | The updated trajectory segment with cutting points 190 | """ 191 | cutting_points = [] 192 | 193 | no_match = all([not m.road for m in self.matches]) 194 | 195 | if not self.path or no_match: 196 | # no path computed or no matches found, possible edge cases: 197 | # 1. trace starts and ends in the same location: pick points far from the start and end 198 | start = self.trace.coords[0] 199 | end = self.trace.coords[-1] 200 | 201 | start_end_dist = start.geom.distance(end.geom) 202 | 203 | if start_end_dist < distance_epsilon: 204 | p1 = np.argmax( 205 | [coord_to_coord_dist(start, c) for c in self.trace.coords] 206 | ) 207 | p2 = np.argmax([coord_to_coord_dist(end, c) for c in self.trace.coords]) 208 | assert not isinstance(p1, ndarray) 209 | assert not isinstance(p2, ndarray) 210 | # To do - np.argmax returns array of indices where the highest value is found. 211 | # if there is only one highest value an int is returned. CuttingPoint takes an int. 212 | # if an array is returned by argmax, this throws an error 213 | cp1 = CuttingPoint(p1) 214 | cp2 = CuttingPoint(p2) 215 | 216 | cutting_points.extend([cp1, cp2]) 217 | else: 218 | # pick the middle point on the trace: 219 | mid = int(len(self.trace) / 2) 220 | cp = CuttingPoint(mid) 221 | cutting_points.append(cp) 222 | else: 223 | # find furthest point 224 | pre_i = np.argmax([m.distance for m in self.matches if m.road]) 225 | cutting_points.append(CuttingPoint(pre_i)) 226 | 227 | # collect points that are close to the distance threshold 228 | for i, m in enumerate(self.matches): 229 | if m.road: 230 | if abs(m.distance - distance_epsilon) < cutting_thresh: 231 | cutting_points.append(CuttingPoint(i)) 232 | 233 | # add random points 234 | for _ in range(random_cuts): 235 | cpi = random.randint(0, len(self.trace) - 1) 236 | cutting_points.append(CuttingPoint(cpi)) 237 | 238 | # merge cutting points that are adjacent to one another 239 | compressed_cuts = list(compress(cutting_points)) 240 | 241 | # it doesn't make sense to cut the trace at the start or end so discard any 242 | # points that apear in the [0, 1, -1, -2] position with respect to a trace 243 | n = len(self.trace) 244 | final_cuts = list( 245 | filter( 246 | lambda cp: cp.trace_index not in [0, 1, n - 2, n - 1], 247 | compressed_cuts, 248 | ) 249 | ) 250 | 251 | return self.set_cutting_points(final_cuts) 252 | 253 | 254 | TrajectoryScheme = List[TrajectorySegment] 255 | -------------------------------------------------------------------------------- /mappymatch/matchers/lcss/lcss.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | import logging 3 | 4 | from shapely.geometry import Point 5 | 6 | from mappymatch.constructs.coordinate import Coordinate 7 | from mappymatch.maps.map_interface import MapInterface 8 | from mappymatch.matchers.lcss.constructs import TrajectorySegment 9 | from mappymatch.matchers.lcss.ops import ( 10 | add_matches_for_stationary_points, 11 | drop_stationary_points, 12 | find_stationary_points, 13 | new_path, 14 | same_trajectory_scheme, 15 | split_trajectory_segment, 16 | ) 17 | from mappymatch.matchers.matcher_interface import ( 18 | List, 19 | MatcherInterface, 20 | MatchResult, 21 | Trace, 22 | ) 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | class LCSSMatcher(MatcherInterface): 28 | """ 29 | A map matcher based on the paper: 30 | 31 | Zhu, Lei, Jacob R. Holden, and Jeffrey D. Gonder. 32 | "Trajectory Segmentation Map-Matching Approach for Large-Scale, 33 | High-Resolution GPS Data." 34 | Transportation Research Record: Journal of the Transportation Research 35 | Board 2645 (2017): 67-75. 36 | 37 | Args: 38 | road_map: The road map to use for matching 39 | distance_epsilon: The distance epsilon to use for matching (default: 50 meters) 40 | similarity_cutoff: The similarity cutoff to use for stopping the algorithm (default: 0.9) 41 | cutting_threshold: The distance threshold to use for computing cutting points (default: 10 meters) 42 | random_cuts: The number of random cuts to add at each iteration (default: 0) 43 | distance_threshold: The distance threshold above which no match is made (default: 10000 meters) 44 | """ 45 | 46 | def __init__( 47 | self, 48 | road_map: MapInterface, 49 | distance_epsilon: float = 50.0, 50 | similarity_cutoff: float = 0.9, 51 | cutting_threshold: float = 10.0, 52 | random_cuts: int = 0, 53 | distance_threshold: float = 10000, 54 | ): 55 | self.road_map = road_map 56 | self.distance_epsilon = distance_epsilon 57 | self.similarity_cutoff = similarity_cutoff 58 | self.cutting_threshold = cutting_threshold 59 | self.random_cuts = random_cuts 60 | self.distance_threshold = distance_threshold 61 | 62 | def match_trace(self, trace: Trace) -> MatchResult: 63 | def _join_segment(a: TrajectorySegment, b: TrajectorySegment): 64 | new_traces = a.trace + b.trace 65 | new_path = a.path + b.path 66 | 67 | # test to see if there is a gap between the paths and if so, 68 | # try to connect it 69 | if len(a.path) > 1 and len(b.path) > 1: 70 | end_road = a.path[-1] 71 | start_road = b.path[0] 72 | if end_road.road_id.end != start_road.road_id.start: 73 | o = Coordinate( 74 | coordinate_id=None, 75 | geom=Point(end_road.geom.coords[-1]), 76 | crs=new_traces.crs, 77 | ) 78 | d = Coordinate( 79 | coordinate_id=None, 80 | geom=Point(start_road.geom.coords[0]), 81 | crs=new_traces.crs, 82 | ) 83 | path = self.road_map.shortest_path(o, d) 84 | new_path = a.path + path + b.path 85 | 86 | return TrajectorySegment(new_traces, new_path) 87 | 88 | stationary_index = find_stationary_points(trace) 89 | 90 | sub_trace = drop_stationary_points(trace, stationary_index) 91 | 92 | road_map = self.road_map 93 | de = self.distance_epsilon 94 | ct = self.cutting_threshold 95 | rc = self.random_cuts 96 | dt = self.distance_threshold 97 | initial_segment = ( 98 | TrajectorySegment(trace=sub_trace, path=new_path(road_map, sub_trace)) 99 | .score_and_match(de, dt) 100 | .compute_cutting_points(de, ct, rc) 101 | ) 102 | 103 | initial_scheme = split_trajectory_segment(road_map, initial_segment) 104 | scheme = initial_scheme 105 | 106 | n = 0 107 | while n < 10: 108 | next_scheme = [] 109 | for segment in scheme: 110 | scored_segment = segment.score_and_match(de, dt).compute_cutting_points( 111 | de, ct, rc 112 | ) 113 | if scored_segment.score >= self.similarity_cutoff: 114 | next_scheme.append(scored_segment) 115 | else: 116 | # split and check the score 117 | new_split = split_trajectory_segment(road_map, scored_segment) 118 | joined_segment = ft.reduce( 119 | _join_segment, new_split 120 | ).score_and_match(de, dt) 121 | if joined_segment.score > scored_segment.score: 122 | # we found a better fit 123 | next_scheme.extend(new_split) 124 | else: 125 | next_scheme.append(scored_segment) 126 | n += 1 127 | if same_trajectory_scheme(scheme, next_scheme): 128 | break 129 | 130 | scheme = next_scheme 131 | 132 | joined_segment = ft.reduce(_join_segment, scheme).score_and_match(de, dt) 133 | 134 | matches = joined_segment.matches 135 | 136 | matches_w_stationary_points = add_matches_for_stationary_points( 137 | matches, stationary_index 138 | ) 139 | 140 | return MatchResult(matches_w_stationary_points, joined_segment.path) 141 | 142 | def match_trace_batch( 143 | self, 144 | trace_batch: List[Trace], 145 | processes: int = 1, 146 | ) -> List[MatchResult]: 147 | if processes <= 1: 148 | results = [self.match_trace(t) for t in trace_batch] 149 | else: 150 | raise NotImplementedError( 151 | "Using `processes>1` is not available due to a known issue with rtree serialization." 152 | "See https://github.com/Toblerity/rtree/issues/87 for more information." 153 | ) 154 | # with Pool(processes=processes) as p: 155 | # results = p.map(self.match_trace, trace_batch) 156 | 157 | return results 158 | -------------------------------------------------------------------------------- /mappymatch/matchers/lcss/ops.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from copy import deepcopy 3 | from typing import Any, List, NamedTuple 4 | 5 | from mappymatch.constructs.coordinate import Coordinate 6 | from mappymatch.constructs.match import Match 7 | from mappymatch.constructs.road import Road 8 | from mappymatch.constructs.trace import Trace 9 | from mappymatch.maps.map_interface import MapInterface 10 | from mappymatch.matchers.lcss.constructs import ( 11 | TrajectoryScheme, 12 | TrajectorySegment, 13 | ) 14 | from mappymatch.matchers.lcss.utils import merge 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | def new_path( 20 | road_map: MapInterface, 21 | trace: Trace, 22 | ) -> List[Road]: 23 | """ 24 | Computes a shortest path and returns the path 25 | 26 | Args: 27 | road_map: the road map to match to 28 | trace: the trace to match 29 | 30 | Returns: 31 | the path that most closely matches the trace 32 | """ 33 | if len(trace.coords) < 1: 34 | return [] 35 | 36 | origin = trace.coords[0] 37 | destination = trace.coords[-1] 38 | 39 | new_path = road_map.shortest_path(origin, destination) 40 | 41 | return new_path 42 | 43 | 44 | def split_trajectory_segment( 45 | road_map: MapInterface, 46 | trajectory_segment: TrajectorySegment, 47 | ) -> List[TrajectorySegment]: 48 | """ 49 | Splits a trajectory segment based on the provided cutting points. 50 | 51 | Merge back any segments that are too short 52 | 53 | Args: 54 | road_map: the road map to match to 55 | trajectory_segment: the trajectory segment to split 56 | distance_epsilon: the distance epsilon 57 | 58 | Returns: 59 | a list of split segments or the original segment if it can't be split 60 | """ 61 | trace = trajectory_segment.trace 62 | cutting_points = trajectory_segment.cutting_points 63 | 64 | def _short_segment(ts: TrajectorySegment): 65 | if len(ts.trace) < 2 or len(ts.path) < 1: 66 | return True 67 | return False 68 | 69 | if len(trace.coords) < 2: 70 | # segment is too short to split 71 | return [trajectory_segment] 72 | elif len(cutting_points) < 1: 73 | # no points to cut 74 | return [trajectory_segment] 75 | 76 | new_paths = [] 77 | new_traces = [] 78 | 79 | # using type: ignore below because, trace_index can only be a signedinteger or integer 80 | # mypy wants it to only be an int, but this should never affect code functionality 81 | # start 82 | scp = cutting_points[0] 83 | new_trace = trace[: scp.trace_index] # type: ignore 84 | new_paths.append(new_path(road_map, new_trace)) 85 | new_traces.append(new_trace) 86 | 87 | # mids 88 | for i in range(len(cutting_points) - 1): 89 | cp = cutting_points[i] 90 | ncp = cutting_points[i + 1] 91 | new_trace = trace[cp.trace_index : ncp.trace_index] # type: ignore 92 | new_paths.append(new_path(road_map, new_trace)) 93 | new_traces.append(new_trace) 94 | 95 | # end 96 | ecp = cutting_points[-1] 97 | new_trace = trace[ecp.trace_index :] # type: ignore 98 | new_paths.append(new_path(road_map, new_trace)) 99 | new_traces.append(new_trace) 100 | 101 | if not any(new_paths): 102 | # can't split 103 | return [trajectory_segment] 104 | elif not any(new_traces): 105 | # can't split 106 | return [trajectory_segment] 107 | else: 108 | segments = [TrajectorySegment(t, p) for t, p in zip(new_traces, new_paths)] 109 | 110 | merged_segments = merge(segments, _short_segment) 111 | 112 | return merged_segments 113 | 114 | 115 | def same_trajectory_scheme( 116 | scheme1: TrajectoryScheme, scheme2: TrajectoryScheme 117 | ) -> bool: 118 | """ 119 | Compares two trajectory schemes for equality 120 | 121 | Args: 122 | scheme1: the first trajectory scheme 123 | scheme2: the second trajectory scheme 124 | 125 | Returns: 126 | True if the two schemes are equal, False otherwise 127 | """ 128 | same_paths = all(map(lambda a, b: a.path == b.path, scheme1, scheme2)) 129 | same_traces = all( 130 | map(lambda a, b: a.trace.coords == b.trace.coords, scheme1, scheme2) 131 | ) 132 | 133 | return same_paths and same_traces 134 | 135 | 136 | class StationaryIndex(NamedTuple): 137 | """ 138 | An index of a stationary point in a trajectory 139 | 140 | Attributes: 141 | trace_index: the index of the trace 142 | coord_index: the index of the coordinate 143 | """ 144 | 145 | i_index: List[int] # i based index on the trace 146 | c_index: List[Any] # coordinate ids 147 | 148 | 149 | def find_stationary_points(trace: Trace) -> List[StationaryIndex]: 150 | """ 151 | Find the positional index of all stationary points in a trace 152 | 153 | Args: 154 | trace: the trace to find the stationary points in 155 | 156 | Returns: 157 | a list of stationary indices 158 | """ 159 | f = trace._frame 160 | coords = trace.coords 161 | dist = f.distance(f.shift()) 162 | index_collections = [] 163 | index = set() 164 | for i in range(1, len(dist)): 165 | d = dist.iloc[i] # distance to previous point 166 | if d < 0.001: 167 | index.add(i - 1) 168 | index.add(i) 169 | else: 170 | # there is distance between this point and the previous 171 | if index: 172 | l_index = sorted(list(index)) 173 | cids = [coords[li].coordinate_id for li in l_index] 174 | si = StationaryIndex(l_index, cids) 175 | index_collections.append(si) 176 | index = set() 177 | 178 | # catch any group of points at the end 179 | if index: 180 | l_index = sorted(list(index)) 181 | cids = [coords[li].coordinate_id for li in l_index] 182 | si = StationaryIndex(l_index, cids) 183 | index_collections.append(si) 184 | 185 | return index_collections 186 | 187 | 188 | def drop_stationary_points( 189 | trace: Trace, stationary_index: List[StationaryIndex] 190 | ) -> Trace: 191 | """ 192 | Drops stationary points from the trace, keeping the first point 193 | 194 | Args: 195 | trace: the trace to drop the stationary points from 196 | stationary_index: the stationary indices to drop 197 | 198 | Returns: 199 | the trace with the stationary points dropped 200 | """ 201 | for si in stationary_index: 202 | trace = trace.drop(si.c_index[1:]) 203 | 204 | return trace 205 | 206 | 207 | def add_matches_for_stationary_points( 208 | matches: List[Match], 209 | stationary_index: List[StationaryIndex], 210 | ) -> List[Match]: 211 | """ 212 | Takes a set of matches and adds duplicate match entries for stationary 213 | 214 | Args: 215 | matches: the matches to add the stationary points to 216 | stationary_index: the stationary indices to add 217 | 218 | Returns: 219 | the matches with the stationary points added 220 | """ 221 | matches = deepcopy(matches) 222 | 223 | for si in stationary_index: 224 | mi = si.i_index[0] 225 | m = matches[mi] 226 | new_matches = [ 227 | m.set_coordinate( 228 | Coordinate(ci, geom=m.coordinate.geom, crs=m.coordinate.crs) 229 | ) 230 | for ci in si.c_index[1:] 231 | ] 232 | matches[si.i_index[1] : si.i_index[1]] = new_matches 233 | 234 | return matches 235 | -------------------------------------------------------------------------------- /mappymatch/matchers/lcss/utils.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | from itertools import groupby 3 | from operator import itemgetter 4 | from typing import Any, Callable, Generator, List 5 | 6 | 7 | def forward_merge(merge_list: List, condition: Callable[[Any], bool]) -> List: 8 | """ 9 | Helper function to merge items in a list by adding them to the next eligible element. 10 | This merge moves left to right. 11 | 12 | For example, given the list: 13 | 14 | [1, 2, 3, 4, 5] 15 | 16 | And the condition, x < 3, the function yields: 17 | 18 | >>> forward_merge([1,2,3,4,5], lambda x: x < 3) 19 | >>> [6, 4, 5] 20 | 21 | Args: 22 | merge_list: the list to merge 23 | condition: the merge condition 24 | 25 | Returns: 26 | a list of the merged items 27 | """ 28 | items = [] 29 | 30 | def _flatten(ml): 31 | return ft.reduce(lambda acc, x: acc + x, ml) 32 | 33 | merge_items = [] 34 | for i, item in enumerate(merge_list): 35 | if condition(item): 36 | merge_items.append(item) 37 | elif merge_items: 38 | # we found a large item and have short items to merge 39 | merge_items.append(item) 40 | items.append(_flatten(merge_items)) 41 | merge_items = [] 42 | else: 43 | items.append(item) 44 | 45 | if merge_items: 46 | # we got to the end but still have merge items; 47 | items.append(_flatten(merge_items)) 48 | 49 | return items 50 | 51 | 52 | def reverse_merge(merge_list: List, condition: Callable[[Any], bool]) -> List: 53 | """ 54 | Helper function to merge items in a list by adding them to the next eligible element. 55 | This merge moves right to left. 56 | 57 | For example, given the list: 58 | 59 | [1, 2, 3, 4, 5] 60 | 61 | And the condition, x < 3, the function yields: 62 | 63 | >>> list(reverse_merge([1,2,3,4,5], lambda x: x < 3)) 64 | >>> [3, 3, 4, 5] 65 | 66 | Args: 67 | merge_list: the list to merge 68 | condition: the merge condition 69 | 70 | Returns: 71 | a list of the merged items 72 | """ 73 | items = [] 74 | 75 | def _flatten(ml): 76 | return ft.reduce(lambda acc, x: x + acc, ml) 77 | 78 | merge_items = [] 79 | for i in reversed(range(len(merge_list))): 80 | item = merge_list[i] 81 | if condition(item): 82 | merge_items.append(item) 83 | elif merge_items: 84 | # we found a large item and have short items to merge 85 | merge_items.append(item) 86 | items.append(_flatten(merge_items)) 87 | merge_items = [] 88 | else: 89 | items.append(item) 90 | 91 | if merge_items: 92 | # we got to the end but still have merge items; 93 | items.append(_flatten(merge_items)) 94 | 95 | return list(reversed(items)) 96 | 97 | 98 | def merge(merge_list: List, condition: Callable[[Any], bool]) -> List: 99 | """ 100 | Combines the forward and reverse merges to catch edge cases at the tail ends of the list 101 | 102 | Args: 103 | merge_list: the list to merge 104 | condition: the merge condition 105 | 106 | Returns: 107 | a list of the merged items 108 | """ 109 | f_merge = forward_merge(merge_list, condition) 110 | 111 | if any(map(condition, f_merge)): 112 | return reverse_merge(f_merge, condition) 113 | else: 114 | return f_merge 115 | 116 | 117 | def compress(cutting_points: List) -> Generator: 118 | """ 119 | Compress a list of cutting points if they happen to be directly adjacent to another 120 | 121 | Args: 122 | cutting_points: the list of cutting points 123 | 124 | Returns: 125 | a generator of compressed cutting points 126 | """ 127 | sorted_cuts = sorted(cutting_points, key=lambda c: c.trace_index) 128 | for k, g in groupby(enumerate(sorted_cuts), lambda x: x[0] - x[1].trace_index): 129 | all_cps = list(map(itemgetter(1), g)) 130 | yield all_cps[int(len(all_cps) / 2)] 131 | -------------------------------------------------------------------------------- /mappymatch/matchers/line_snap.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | from mappymatch.constructs.match import Match 5 | from mappymatch.constructs.trace import Trace 6 | from mappymatch.maps.map_interface import MapInterface 7 | from mappymatch.matchers.matcher_interface import MatcherInterface, MatchResult 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class LineSnapMatcher(MatcherInterface): 13 | """ 14 | A crude (but fast) map matcher that just snaps points to the nearest road network link. 15 | 16 | Attributes: 17 | map: The map to match against 18 | """ 19 | 20 | def __init__(self, road_map: MapInterface): 21 | self.map = road_map 22 | 23 | def match_trace(self, trace: Trace) -> MatchResult: 24 | matches = [] 25 | 26 | for coord in trace.coords: 27 | nearest_road = self.map.nearest_road(coord) 28 | nearest_point = nearest_road.geom.interpolate( 29 | nearest_road.geom.project(coord.geom) 30 | ) 31 | dist = nearest_road.geom.distance(nearest_point) 32 | match = Match(nearest_road, coord, dist) 33 | matches.append(match) 34 | 35 | return MatchResult(matches) 36 | 37 | def match_trace_batch(self, trace_batch: List[Trace]) -> List[MatchResult]: 38 | return [self.match_trace(t) for t in trace_batch] 39 | -------------------------------------------------------------------------------- /mappymatch/matchers/match_result.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional 3 | 4 | import geopandas as gpd 5 | import numpy as np 6 | import pandas as pd 7 | 8 | from mappymatch.constructs.match import Match 9 | from mappymatch.constructs.road import Road 10 | 11 | 12 | @dataclass 13 | class MatchResult: 14 | matches: List[Match] 15 | path: Optional[List[Road]] = None 16 | 17 | @property 18 | def crs(self): 19 | first_crs = self.matches[0].coordinate.crs 20 | if not all([first_crs.equals(m.coordinate.crs) for m in self.matches]): 21 | raise ValueError( 22 | "Found that there were different CRS within the matches. " 23 | "These must all be equal to use this function" 24 | ) 25 | return first_crs 26 | 27 | def matches_to_geodataframe(self) -> gpd.GeoDataFrame: 28 | """ 29 | Returns a geodataframe with all the coordinates and their resulting match (or NA if no match) in each row 30 | """ 31 | df = self.matches_to_dataframe() 32 | gdf = gpd.GeoDataFrame(df, geometry="geom") 33 | 34 | if len(self.matches) == 0: 35 | return gdf 36 | 37 | gdf = gdf.set_crs(self.crs) 38 | 39 | return gdf 40 | 41 | def matches_to_dataframe(self) -> pd.DataFrame: 42 | """ 43 | Returns a dataframe with all the coordinates and their resulting match (or NA if no match) in each row. 44 | 45 | Returns: 46 | A pandas dataframe 47 | """ 48 | df = pd.DataFrame([m.to_flat_dict() for m in self.matches]) 49 | df = df.fillna(np.nan) 50 | 51 | return df 52 | 53 | def path_to_dataframe(self) -> pd.DataFrame: 54 | """ 55 | Returns a dataframe with the resulting estimated trace path through the road network. 56 | The dataframe is empty if there was no path. 57 | 58 | Returns: 59 | A pandas dataframe 60 | """ 61 | if self.path is None: 62 | return pd.DataFrame() 63 | 64 | df = pd.DataFrame([r.to_flat_dict() for r in self.path]) 65 | df = df.fillna(np.nan) 66 | 67 | return df 68 | 69 | def path_to_geodataframe(self) -> gpd.GeoDataFrame: 70 | """ 71 | Returns a geodataframe with the resulting estimated trace path through the road network. 72 | The geodataframe is empty if there was no path. 73 | 74 | Returns: 75 | A geopandas geodataframe 76 | """ 77 | if self.path is None: 78 | return gpd.GeoDataFrame() 79 | 80 | df = self.path_to_dataframe() 81 | gdf = gpd.GeoDataFrame(df, geometry="geom") 82 | 83 | gdf = gdf.set_crs(self.crs) 84 | 85 | return gdf 86 | -------------------------------------------------------------------------------- /mappymatch/matchers/matcher_interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import List 3 | 4 | from mappymatch.constructs.trace import Trace 5 | from mappymatch.matchers.match_result import MatchResult 6 | 7 | 8 | class MatcherInterface(metaclass=ABCMeta): 9 | """ 10 | Abstract base class for a Matcher 11 | """ 12 | 13 | @abstractmethod 14 | def match_trace(self, trace: Trace) -> MatchResult: 15 | """ 16 | Take in a trace of gps points and return a list of matching link ids 17 | 18 | Args: 19 | trace: The trace to match 20 | 21 | Returns: 22 | A list of Match objects 23 | """ 24 | 25 | @abstractmethod 26 | def match_trace_batch(self, trace_batch: List[Trace]) -> List[MatchResult]: 27 | """ 28 | Take in a batch of traces and return a batch of matching link ids 29 | 30 | Args: 31 | trace_batch: The batch of traces to match 32 | 33 | Returns: 34 | A batch of Match objects 35 | """ 36 | -------------------------------------------------------------------------------- /mappymatch/matchers/osrm.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | import requests 6 | 7 | from mappymatch.constructs.match import Match 8 | from mappymatch.constructs.road import Road, RoadId 9 | from mappymatch.constructs.trace import Trace 10 | from mappymatch.matchers.matcher_interface import MatcherInterface, MatchResult 11 | from mappymatch.utils.crs import LATLON_CRS 12 | from mappymatch.utils.url import multiurljoin 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | DEFAULT_OSRM_ADDRESS = "http://router.project-osrm.org" 17 | 18 | 19 | def parse_osrm_json(j: dict, trace: Trace) -> list[Match]: 20 | """ 21 | parse the json response from the osrm match service 22 | 23 | :param j: the json object 24 | 25 | :return: a list of matches 26 | """ 27 | matchings = j.get("matchings") 28 | if not matchings: 29 | raise ValueError("could not find any link matchings in response") 30 | 31 | legs = matchings[0].get("legs") 32 | if not legs: 33 | raise ValueError("could not find any link legs in response") 34 | 35 | def _parse_leg(d: dict, i: int) -> Match: 36 | annotation = d.get("annotation") 37 | if not annotation: 38 | raise ValueError("leg has no annotation information") 39 | nodes = annotation.get("nodes") 40 | if not nodes: 41 | raise ValueError("leg has no osm node information") 42 | origin_junction_id = f"{nodes[0]}" 43 | destination_junction_id = f"{nodes[0]}" 44 | 45 | # TODO: we need to get geometry, distance info from OSRM if available 46 | road_id = RoadId(origin_junction_id, destination_junction_id, 0) 47 | road = Road( 48 | road_id=road_id, 49 | geom=None, 50 | ) 51 | match = Match(road=road, coordinate=trace.coords[i], distance=float("infinity")) 52 | return match 53 | 54 | return [_parse_leg(d, i) for i, d in enumerate(legs)] 55 | 56 | 57 | class OsrmMatcher(MatcherInterface): 58 | """ 59 | pings an OSRM server for map matching 60 | """ 61 | 62 | def __init__( 63 | self, 64 | osrm_address=DEFAULT_OSRM_ADDRESS, 65 | osrm_profile="driving", 66 | osrm_version="v1", 67 | ): 68 | self.osrm_api_base = multiurljoin( 69 | [osrm_address, "match", osrm_version, osrm_profile] 70 | ) 71 | 72 | def match_trace(self, trace: Trace) -> MatchResult: 73 | if not trace.crs == LATLON_CRS: 74 | raise TypeError( 75 | f"this matcher requires traces to be in the CRS of EPSG:{LATLON_CRS.to_epsg()} " 76 | f"but found EPSG:{trace.crs.to_epsg()}" 77 | ) 78 | 79 | if len(trace.coords) > 100: 80 | trace = trace.downsample(100) 81 | 82 | coordinate_str = "" 83 | for coord in trace.coords: 84 | coordinate_str += f"{coord.x},{coord.y};" 85 | 86 | # remove the trailing semicolon 87 | coordinate_str = coordinate_str[:-1] 88 | 89 | osrm_request = self.osrm_api_base + coordinate_str + "?annotations=true" 90 | print(osrm_request) 91 | 92 | r = requests.get(osrm_request) 93 | 94 | if not r.status_code == requests.codes.ok: 95 | r.raise_for_status() 96 | 97 | result = parse_osrm_json(r.json(), trace) 98 | 99 | return MatchResult(result) 100 | 101 | def match_trace_batch(self, trace_batch: list[Trace]) -> list[MatchResult]: 102 | return [self.match_trace(t) for t in trace_batch] 103 | -------------------------------------------------------------------------------- /mappymatch/matchers/valhalla.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | from typing import List, Tuple 6 | 7 | import numpy as np 8 | import polyline 9 | import requests 10 | from shapely.geometry import LineString 11 | 12 | from mappymatch.constructs.match import Match 13 | from mappymatch.constructs.road import Road, RoadId 14 | from mappymatch.constructs.trace import Trace 15 | from mappymatch.matchers.matcher_interface import MatcherInterface, MatchResult 16 | from mappymatch.utils.crs import LATLON_CRS 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | DEMO_VALHALLA_ADDRESS = "https://valhalla1.openstreetmap.de/trace_attributes" 21 | REQUIRED_ATTRIBUTES = set( 22 | [ 23 | "edge.way_id", 24 | "matched.distance_from_trace_point", 25 | "shape", 26 | "edge.begin_shape_index", 27 | "edge.end_shape_index", 28 | "matched.edge_index", 29 | ] 30 | ) 31 | DEFAULT_ATTRIBUTES = set( 32 | [ 33 | "edge.length", 34 | "edge.speed", 35 | ] 36 | ) 37 | 38 | 39 | def build_path_from_result( 40 | edges: List[dict], shape: List[Tuple[float, float]] 41 | ) -> List[Road]: 42 | """ 43 | builds a mappymatch path from the result of a Valhalla map matching request 44 | """ 45 | path = [] 46 | for edge in edges: 47 | way_id = edge["way_id"] 48 | road_id = RoadId(start=None, end=None, key=way_id) 49 | start_point_i = edge["begin_shape_index"] 50 | end_point_i = edge["end_shape_index"] 51 | start_point = shape[start_point_i] 52 | end_point = shape[end_point_i] 53 | geom = LineString([start_point, end_point]) 54 | 55 | speed = edge["speed"] 56 | length = edge["length"] 57 | 58 | metadata = { 59 | "speed_mph": speed, 60 | "length_miles": length, 61 | } 62 | 63 | road = Road(road_id=road_id, geom=geom, metadata=metadata) 64 | 65 | path.append(road) 66 | 67 | return path 68 | 69 | 70 | def build_match_result( 71 | trace: Trace, matched_points: List[dict], path: List[Road] 72 | ) -> MatchResult: 73 | """ 74 | builds a mappymatch MatchResult from the result of a Valhalla map matching request 75 | """ 76 | matches = [] 77 | for i, coord in enumerate(trace.coords): 78 | mp = matched_points[i] 79 | ei = mp.get("edge_index") 80 | dist = mp.get("distance_from_trace_point") 81 | if ei is None: 82 | road = None 83 | else: 84 | try: 85 | road = path[ei] 86 | except IndexError: 87 | road = None 88 | 89 | if dist is None: 90 | dist = np.inf 91 | 92 | match = Match(road, coord, dist) 93 | 94 | matches.append(match) 95 | 96 | return MatchResult(matches=matches, path=path) 97 | 98 | 99 | class ValhallaMatcher(MatcherInterface): 100 | """ 101 | pings a Valhalla server for map matching 102 | """ 103 | 104 | def __init__( 105 | self, 106 | valhalla_url=DEMO_VALHALLA_ADDRESS, 107 | cost_model="auto", 108 | shape_match="map_snap", 109 | attributes=DEFAULT_ATTRIBUTES, 110 | ): 111 | self.url_base = valhalla_url 112 | self.cost_model = cost_model 113 | self.shape_match = shape_match 114 | 115 | all_attributes = list(REQUIRED_ATTRIBUTES.union(set(attributes))) 116 | self.attributes = all_attributes 117 | 118 | def match_trace(self, trace: Trace) -> MatchResult: 119 | if not trace.crs == LATLON_CRS: 120 | trace = trace.to_crs(LATLON_CRS) 121 | 122 | points = [{"lat": c.y, "lon": c.x} for c in trace.coords] 123 | 124 | json_payload = json.dumps( 125 | { 126 | "shape": points, 127 | "costing": self.cost_model, 128 | "shape_match": self.shape_match, 129 | "filters": { 130 | "attributes": self.attributes, 131 | "action": "include", 132 | }, 133 | "units": "miles", 134 | } 135 | ) 136 | 137 | valhalla_request = f"{self.url_base}?json={json_payload}" 138 | 139 | r = requests.get(valhalla_request) 140 | 141 | if not r.status_code == requests.codes.ok: 142 | r.raise_for_status() 143 | 144 | j = r.json() 145 | 146 | edges = j["edges"] 147 | shape = polyline.decode(j["shape"], precision=6, geojson=True) 148 | matched_points = j["matched_points"] 149 | 150 | path = build_path_from_result(edges, shape) 151 | result = build_match_result(trace, matched_points, path) 152 | 153 | return result 154 | 155 | def match_trace_batch(self, trace_batch: list[Trace]) -> list[MatchResult]: 156 | return [self.match_trace(t) for t in trace_batch] 157 | -------------------------------------------------------------------------------- /mappymatch/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/resources/__init__.py -------------------------------------------------------------------------------- /mappymatch/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NREL/mappymatch/48ca4c392769e5c4f975037af0d0fb25d4edf6a2/mappymatch/utils/__init__.py -------------------------------------------------------------------------------- /mappymatch/utils/crs.py: -------------------------------------------------------------------------------- 1 | from pyproj import CRS 2 | 3 | LATLON_CRS = CRS(4326) 4 | XY_CRS = CRS(3857) 5 | -------------------------------------------------------------------------------- /mappymatch/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | class MapException(Exception): 2 | """ 3 | An exception for any errors that occur with the MapInterface 4 | """ 5 | 6 | pass 7 | -------------------------------------------------------------------------------- /mappymatch/utils/geo.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from pyproj import Transformer 4 | 5 | from mappymatch.constructs.coordinate import Coordinate 6 | from mappymatch.utils.crs import LATLON_CRS, XY_CRS 7 | 8 | 9 | def xy_to_latlon(x: float, y: float) -> Tuple[float, float]: 10 | """ 11 | Tramsform x,y coordinates to lat and lon 12 | 13 | Args: 14 | x: X. 15 | y: Y. 16 | 17 | Returns: 18 | Transformed lat and lon as lat, lon. 19 | """ 20 | transformer = Transformer.from_crs(XY_CRS, LATLON_CRS) 21 | lat, lon = transformer.transform(x, y) 22 | 23 | return lat, lon 24 | 25 | 26 | def latlon_to_xy(lat: float, lon: float) -> Tuple[float, float]: 27 | """ 28 | Tramsform lat,lon coordinates to x and y. 29 | 30 | Args: 31 | lat: The latitude. 32 | lon: The longitude. 33 | 34 | Returns: 35 | Transformed x and y as x, y. 36 | """ 37 | transformer = Transformer.from_crs(LATLON_CRS, XY_CRS) 38 | x, y = transformer.transform(lat, lon) 39 | 40 | return x, y 41 | 42 | 43 | def coord_to_coord_dist(a: Coordinate, b: Coordinate) -> float: 44 | """ 45 | Compute the distance between two coordinates. 46 | 47 | Args: 48 | a: The first coordinate 49 | b: The second coordinate 50 | 51 | Returns: 52 | The distance in meters 53 | """ 54 | dist = a.geom.distance(b.geom) 55 | 56 | return dist 57 | -------------------------------------------------------------------------------- /mappymatch/utils/plot.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | import folium 4 | import geopandas as gpd 5 | import matplotlib.pyplot as plt 6 | import pandas as pd 7 | from pyproj import CRS 8 | from shapely.geometry import Point 9 | 10 | from mappymatch.constructs.geofence import Geofence 11 | from mappymatch.constructs.match import Match 12 | from mappymatch.constructs.road import Road 13 | from mappymatch.constructs.trace import Trace 14 | from mappymatch.maps.nx.nx_map import NxMap 15 | from mappymatch.matchers.matcher_interface import MatchResult 16 | from mappymatch.utils.crs import LATLON_CRS, XY_CRS 17 | 18 | 19 | def plot_geofence(geofence: Geofence, m: Optional[folium.Map] = None): 20 | """ 21 | Plot geofence. 22 | 23 | Args: 24 | geofence: The geofence to plot 25 | m: the folium map to plot on 26 | 27 | Returns: 28 | The updated folium map with the geofence. 29 | """ 30 | if not geofence.crs == LATLON_CRS: 31 | raise NotImplementedError("can currently only plot a geofence with lat lon crs") 32 | 33 | if not m: 34 | c = geofence.geometry.centroid.coords[0] 35 | m = folium.Map(location=[c[1], c[0]], zoom_start=11) 36 | 37 | folium.GeoJson(geofence.geometry).add_to(m) 38 | 39 | return m 40 | 41 | 42 | def plot_trace( 43 | trace: Trace, 44 | m: Optional[folium.Map] = None, 45 | point_color: str = "black", 46 | line_color: Optional[str] = "green", 47 | ): 48 | """ 49 | Plot a trace. 50 | 51 | Args: 52 | trace: The trace. 53 | m: the folium map to plot on 54 | point_color: The color the points will be plotted in. 55 | line_color: The color for lines. If None, no lines will be plotted. 56 | 57 | Returns: 58 | An updated folium map with a plot of trace. 59 | """ 60 | 61 | if not trace.crs == LATLON_CRS: 62 | trace = trace.to_crs(LATLON_CRS) 63 | 64 | if not m: 65 | mid_coord = trace.coords[int(len(trace) / 2)] 66 | m = folium.Map(location=[mid_coord.y, mid_coord.x], zoom_start=11) 67 | 68 | for i, c in enumerate(trace.coords): 69 | folium.Circle( 70 | location=(c.y, c.x), 71 | radius=5, 72 | color=point_color, 73 | tooltip=str(i), 74 | fill=True, 75 | fill_opacity=0.8, 76 | fill_color=point_color, 77 | ).add_to(m) 78 | 79 | if line_color is not None: 80 | folium.PolyLine([(p.y, p.x) for p in trace.coords], color=line_color).add_to(m) 81 | 82 | return m 83 | 84 | 85 | def plot_matches(matches: Union[MatchResult, List[Match]], crs=XY_CRS): 86 | """ 87 | Plots a trace and the relevant matches on a folium map. 88 | 89 | Args: 90 | matches: A list of matches or a MatchResult. 91 | crs: what crs to plot in. Defaults to XY_CRS. 92 | 93 | Returns: 94 | A folium map with trace and matches plotted. 95 | """ 96 | if isinstance(matches, MatchResult): 97 | matches = matches.matches 98 | 99 | def _match_to_road(m): 100 | """Private function.""" 101 | d = {"road_id": m.road.road_id, "geom": m.road.geom} 102 | return d 103 | 104 | def _match_to_coord(m): 105 | """Private function.""" 106 | d = { 107 | "road_id": m.road.road_id, 108 | "geom": Point(m.coordinate.x, m.coordinate.y), 109 | "distance": m.distance, 110 | } 111 | 112 | return d 113 | 114 | road_df = pd.DataFrame([_match_to_road(m) for m in matches if m.road]) 115 | road_df = road_df.loc[road_df.road_id.shift() != road_df.road_id] 116 | road_gdf = gpd.GeoDataFrame(road_df, geometry=road_df.geom, crs=crs).drop( 117 | columns=["geom"] 118 | ) 119 | road_gdf = road_gdf.to_crs(LATLON_CRS) 120 | 121 | coord_df = pd.DataFrame([_match_to_coord(m) for m in matches if m.road]) 122 | 123 | coord_gdf = gpd.GeoDataFrame(coord_df, geometry=coord_df.geom, crs=crs).drop( 124 | columns=["geom"] 125 | ) 126 | coord_gdf = coord_gdf.to_crs(LATLON_CRS) 127 | 128 | mid_i = int(len(coord_gdf) / 2) 129 | mid_coord = coord_gdf.iloc[mid_i].geometry 130 | 131 | fmap = folium.Map(location=[mid_coord.y, mid_coord.x], zoom_start=11) 132 | 133 | for coord in coord_gdf.itertuples(): 134 | folium.Circle( 135 | location=(coord.geometry.y, coord.geometry.x), 136 | radius=5, 137 | tooltip=f"road_id: {coord.road_id}\ndistance: {coord.distance}", 138 | ).add_to(fmap) 139 | 140 | for road in road_gdf.itertuples(): 141 | folium.PolyLine( 142 | [(lat, lon) for lon, lat in road.geometry.coords], 143 | color="red", 144 | tooltip=road.road_id, 145 | ).add_to(fmap) 146 | 147 | return fmap 148 | 149 | 150 | def plot_map(tmap: NxMap, m: Optional[folium.Map] = None): 151 | """ 152 | Plot the roads on an NxMap. 153 | 154 | Args: 155 | tmap: The Nxmap to plot. 156 | m: the folium map to add to 157 | 158 | Returns: 159 | The folium map with the roads plotted. 160 | """ 161 | 162 | # TODO make this generic to all map types, not just NxMap 163 | roads = list(tmap.g.edges(data=True)) 164 | road_df = pd.DataFrame([r[2] for r in roads]) 165 | gdf = gpd.GeoDataFrame(road_df, geometry=road_df[tmap._geom_key], crs=tmap.crs) 166 | if gdf.crs != LATLON_CRS: 167 | gdf = gdf.to_crs(LATLON_CRS) 168 | 169 | if not m: 170 | c = gdf.iloc[int(len(gdf) / 2)].geometry.centroid.coords[0] 171 | m = folium.Map(location=[c[1], c[0]], zoom_start=11) 172 | 173 | for t in gdf.itertuples(): 174 | folium.PolyLine( 175 | [(lat, lon) for lon, lat in t.geometry.coords], 176 | color="red", 177 | ).add_to(m) 178 | 179 | return m 180 | 181 | 182 | def plot_match_distances(matches: MatchResult): 183 | """ 184 | Plot the points deviance from known roads with matplotlib. 185 | 186 | Args: 187 | matches (MatchResult): The coordinates of guessed points in the area in the form of a MatchResult object. 188 | """ 189 | 190 | y = [ 191 | m.distance for m in matches.matches 192 | ] # y contains distances to the expected line for all of the matches which will be plotted on the y-axis. 193 | x = [ 194 | i for i in range(0, len(y)) 195 | ] # x contains placeholder values for every y value (distance measurement) along the x-axis. 196 | 197 | plt.figure(figsize=(15, 7)) 198 | plt.autoscale(enable=True) 199 | plt.scatter(x, y) 200 | plt.title("Distance To Nearest Road") 201 | plt.ylabel("Meters") 202 | plt.xlabel("Point Along The Path") 203 | plt.show() 204 | 205 | 206 | def plot_path( 207 | path: List[Road], 208 | crs: CRS, 209 | m: Optional[folium.Map] = None, 210 | line_color="red", 211 | line_weight=10, 212 | line_opacity=0.8, 213 | ): 214 | """ 215 | Plot a list of roads. 216 | 217 | Args: 218 | path: The path to plot. 219 | crs: The crs of the path. 220 | m: The folium map to add to. 221 | line_color: The color of the line. 222 | line_weight: The weight of the line. 223 | line_opacity: The opacity of the line. 224 | """ 225 | road_df = pd.DataFrame([{"geom": r.geom} for r in path]) 226 | road_gdf = gpd.GeoDataFrame(road_df, geometry=road_df.geom, crs=crs) 227 | road_gdf = road_gdf.to_crs(LATLON_CRS) 228 | 229 | if m is None: 230 | mid_i = int(len(road_gdf) / 2) 231 | mid_coord = road_gdf.iloc[mid_i].geometry.coords[0] 232 | 233 | m = folium.Map(location=[mid_coord[1], mid_coord[0]], zoom_start=11) 234 | 235 | for i, road in enumerate(road_gdf.itertuples()): 236 | folium.PolyLine( 237 | [(lat, lon) for lon, lat in road.geometry.coords], 238 | color=line_color, 239 | tooltip=i, 240 | weight=line_weight, 241 | opacity=line_opacity, 242 | ).add_to(m) 243 | 244 | return m 245 | -------------------------------------------------------------------------------- /mappymatch/utils/process_trace.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mappymatch.constructs.trace import Trace 4 | 5 | 6 | def split_large_trace(trace: Trace, ideal_size: int) -> list[Trace]: 7 | """ 8 | Split up a trace into a list of smaller traces. 9 | 10 | Args: 11 | trace: the trace to split. 12 | ideal_size: the target number of coordinates for each new trace. 13 | 14 | Returns: 15 | A list of split traces. 16 | """ 17 | if ideal_size == 0: 18 | raise ValueError("ideal_size must be greater than 0") 19 | 20 | if len(trace) <= ideal_size: 21 | return [trace] 22 | else: 23 | ts = [trace[i : i + ideal_size] for i in range(0, len(trace), ideal_size)] 24 | 25 | # check to make sure the last trace isn't too small 26 | if len(ts[-1]) <= 10: 27 | last_trace = ts.pop() 28 | ts[-1] = ts[-1] + last_trace 29 | 30 | return ts 31 | 32 | 33 | def remove_bad_start_from_trace(trace: Trace, distance_threshold: float) -> Trace: 34 | """ 35 | Remove points at the beginning of a trace if it is a gap is too big. 36 | 37 | Too big is defined by distance threshold. 38 | 39 | Args: 40 | trace: The trace. 41 | distance_threshold: The distance threshold. 42 | 43 | Returns: 44 | The new trace. 45 | """ 46 | 47 | def _trim_frame(frame): 48 | for index in range(len(frame)): 49 | rows = frame.iloc[index : index + 2] 50 | 51 | if len(rows) < 2: 52 | return frame 53 | 54 | current_point = rows.geometry.iloc[0] 55 | next_point = rows.geometry.iloc[1] 56 | 57 | if current_point != next_point: 58 | dist = current_point.distance(next_point) 59 | if dist > distance_threshold: 60 | return frame.iloc[index + 1 :] 61 | else: 62 | return frame 63 | 64 | return Trace.from_geo_dataframe(_trim_frame(trace._frame)) 65 | -------------------------------------------------------------------------------- /mappymatch/utils/url.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from typing import List 3 | from urllib.parse import urljoin 4 | 5 | 6 | def _parse_uri(uri: str) -> str: 7 | """Internal use.""" 8 | return uri if uri.endswith("/") else f"{uri}/" 9 | 10 | 11 | def multiurljoin(urls: List[str]) -> str: 12 | """ 13 | Make a url from uri's. 14 | 15 | Args: 16 | urls: list of uri 17 | 18 | Returns: 19 | Url as uri/uri/... 20 | """ 21 | parsed_urls = [_parse_uri(uri) for uri in urls] 22 | return reduce(urljoin, parsed_urls) 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "mappymatch" 7 | dynamic = ["version"] 8 | description = "Pure python package for map-matching." 9 | readme = "README.md" 10 | authors = [{ name = "National Renewable Energy Laboratory" }] 11 | license = { text = "BSD 3-Clause License Copyright (c) 2022, Alliance for Sustainable Energy, LLC" } 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Science/Research", 15 | "License :: Other/Proprietary License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Topic :: Scientific/Engineering", 23 | ] 24 | keywords = ["GPS", "map", "match"] 25 | dependencies = [ 26 | "geopandas>=1", 27 | "osmnx>=2", 28 | "shapely>=2", 29 | "rtree", 30 | "pyproj", 31 | "pandas", 32 | "numpy", 33 | "matplotlib", 34 | "networkx", 35 | "igraph", 36 | "folium", 37 | "requests", 38 | "polyline", 39 | ] 40 | requires-python = ">=3.9" 41 | 42 | [project.optional-dependencies] 43 | # Used to run CI. 44 | tests = ["ruff", "mypy>=1", "types-requests", "pytest"] 45 | # Used to build the docs. 46 | docs = [ 47 | "jupyter-book>=1", 48 | "sphinx-book-theme", 49 | "sphinx-autodoc-typehints", 50 | "sphinxcontrib-autoyaml", 51 | "sphinxcontrib.mermaid", 52 | ] 53 | # Tests + docs + other. 54 | dev = [ 55 | "hatch", 56 | "mappymatch[tests]", 57 | "mappymatch[docs]", 58 | "coverage", 59 | "pre-commit", 60 | ] 61 | 62 | [project.urls] 63 | Homepage = "https://github.com/NREL/mappymatch" 64 | 65 | [tool.hatch.version] 66 | path = "mappymatch/__about__.py" 67 | 68 | [tool.hatch.build.targets.sdist] 69 | exclude = ["tests/", "docs/", "examples/"] 70 | 71 | [tool.ruff] 72 | exclude = [ 73 | "build/*", 74 | "dist/*", 75 | ] 76 | 77 | # Same as Black. 78 | line-length = 88 79 | indent-width = 4 80 | 81 | # Assume Python 3.9 82 | target-version = "py39" 83 | 84 | [tool.ruff.lint] 85 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 86 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 87 | # McCabe complexity (`C901`) by default. 88 | select = ["E4", "E7", "E9", "F"] 89 | ignore = [] 90 | 91 | # Allow fix for all enabled rules (when `--fix`) is provided. 92 | fixable = ["ALL"] 93 | unfixable = [] 94 | 95 | # Allow unused variables when underscore-prefixed. 96 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 97 | 98 | [tool.mypy] 99 | ignore_missing_imports = true 100 | exclude = ["docs/", "build/", "dist/", "py-notebooks/"] 101 | 102 | [tool.coverage.run] 103 | # Ensures coverage for all if, elif, else branches. 104 | # https://coverage.readthedocs.io/en/6.3.2/branch.html#branch 105 | branch = true 106 | 107 | [tool.coverage.report] 108 | precision = 1 109 | fail_under = 50.0 110 | skip_covered = false 111 | skip_empty = true 112 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def get_test_dir() -> Path: 5 | return Path(__file__).parent 6 | -------------------------------------------------------------------------------- /tests/test_assets/downtown_denver.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "downtown_denver", 4 | "crs": { 5 | "type": "name", 6 | "properties": { 7 | "name": "urn:ogc:def:crs:OGC:1.3:CRS84" 8 | } 9 | }, 10 | "features": [ 11 | { 12 | "type": "Feature", 13 | "properties": { 14 | "id": null 15 | }, 16 | "geometry": { 17 | "type": "Polygon", 18 | "coordinates": [ 19 | [ 20 | [ 21 | -105.000292276098648, 22 | 39.749625172240478 23 | ], 24 | [ 25 | -104.987380653208689, 26 | 39.739946396868781 27 | ], 28 | [ 29 | -104.97341667234025, 30 | 39.740008644140651 31 | ], 32 | [ 33 | -104.973376197033389, 34 | 39.767951988786585 35 | ], 36 | [ 37 | -104.975116635228588, 38 | 39.769196417472997 39 | ], 40 | [ 41 | -105.000292276098648, 42 | 39.749625172240478 43 | ] 44 | ] 45 | ] 46 | } 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /tests/test_assets/pull_osm_map.py: -------------------------------------------------------------------------------- 1 | # %% 2 | import osmnx as ox 3 | 4 | from mappymatch.constructs.geofence import Geofence 5 | 6 | # %% 7 | geofence = Geofence.from_geojson("downtown_denver.geojson") 8 | g = ox.graph_from_polygon( 9 | geofence.geometry, 10 | network_type="drive", 11 | ) 12 | 13 | ox.save_graphml(g, "osmnx_drive_graph.graphml") 14 | # %% 15 | -------------------------------------------------------------------------------- /tests/test_assets/test_trace.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "test_trace", 4 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, 5 | "features": [ 6 | { "type": "Feature", "properties": { "id": 1 }, "geometry": { "type": "Point", "coordinates": [ -104.986097142015026, 39.747533038401798 ] } }, 7 | { "type": "Feature", "properties": { "id": 2 }, "geometry": { "type": "Point", "coordinates": [ -104.986104009347784, 39.747951412043271 ] } }, 8 | { "type": "Feature", "properties": { "id": 3 }, "geometry": { "type": "Point", "coordinates": [ -104.986464034554146, 39.748213653231815 ] } }, 9 | { "type": "Feature", "properties": { "id": 4 }, "geometry": { "type": "Point", "coordinates": [ -104.986895117366998, 39.748541453313784 ] } }, 10 | { "type": "Feature", "properties": { "id": 5 }, "geometry": { "type": "Point", "coordinates": [ -104.987378309091312, 39.748931169159633 ] } }, 11 | { "type": "Feature", "properties": { "id": 6 }, "geometry": { "type": "Point", "coordinates": [ -104.987795180382875, 39.749218901874102 ] } }, 12 | { "type": "Feature", "properties": { "id": 7 }, "geometry": { "type": "Point", "coordinates": [ -104.98823100036951, 39.749564907977287 ] } }, 13 | { "type": "Feature", "properties": { "id": 8 }, "geometry": { "type": "Point", "coordinates": [ -104.987705174081285, 39.749965544452046 ] } }, 14 | { "type": "Feature", "properties": { "id": 9 }, "geometry": { "type": "Point", "coordinates": [ -104.987198296488131, 39.750355252242038 ] } }, 15 | { "type": "Feature", "properties": { "id": 10 }, "geometry": { "type": "Point", "coordinates": [ -104.9867198419376, 39.75073038943404 ] } }, 16 | { "type": "Feature", "properties": { "id": 11 }, "geometry": { "type": "Point", "coordinates": [ -104.986170329780549, 39.751141945362704 ] } }, 17 | { "type": "Feature", "properties": { "id": 12 }, "geometry": { "type": "Point", "coordinates": [ -104.98574398414145, 39.751495225924359 ] } }, 18 | { "type": "Feature", "properties": { "id": 13 }, "geometry": { "type": "Point", "coordinates": [ -104.985180182804498, 39.751939495899329 ] } }, 19 | { "type": "Feature", "properties": { "id": 14 }, "geometry": { "type": "Point", "coordinates": [ -104.984718890801531, 39.752269069409252 ] } }, 20 | { "type": "Feature", "properties": { "id": 15 }, "geometry": { "type": "Point", "coordinates": [ -104.985026418803514, 39.752716856977621 ] } }, 21 | { "type": "Feature", "properties": { "id": 16 }, "geometry": { "type": "Point", "coordinates": [ -104.985473732260914, 39.753060755853909 ] } } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tests/test_assets/test_trace_stationary_points.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "trace_bad_start", 4 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, 5 | "features": [ 6 | { "type": "Feature", "properties": { "id": -1 }, "geometry": { "type": "Point", "coordinates": [ -104.986097142015026, 39.747533038401798 ] } }, 7 | { "type": "Feature", "properties": { "id": 1 }, "geometry": { "type": "Point", "coordinates": [ -104.986097142015026, 39.747533038401798 ] } }, 8 | { "type": "Feature", "properties": { "id": 2 }, "geometry": { "type": "Point", "coordinates": [ -104.986104009347784, 39.747951412043271 ] } }, 9 | { "type": "Feature", "properties": { "id": 3 }, "geometry": { "type": "Point", "coordinates": [ -104.986464034554146, 39.748213653231815 ] } }, 10 | { "type": "Feature", "properties": { "id": 4 }, "geometry": { "type": "Point", "coordinates": [ -104.986895117366998, 39.748541453313784 ] } }, 11 | { "type": "Feature", "properties": { "id": 5 }, "geometry": { "type": "Point", "coordinates": [ -104.987378309091312, 39.748931169159633 ] } }, 12 | { "type": "Feature", "properties": { "id": 6 }, "geometry": { "type": "Point", "coordinates": [ -104.987795180382875, 39.749218901874102 ] } }, 13 | { "type": "Feature", "properties": { "id": 7 }, "geometry": { "type": "Point", "coordinates": [ -104.98823100036951, 39.749564907977287 ] } }, 14 | { "type": "Feature", "properties": { "id": 8 }, "geometry": { "type": "Point", "coordinates": [ -104.987705174081285, 39.749965544452046 ] } }, 15 | { "type": "Feature", "properties": { "id": -2 }, "geometry": { "type": "Point", "coordinates": [ -104.987705174081285, 39.749965544452046 ] } }, 16 | { "type": "Feature", "properties": { "id": -3 }, "geometry": { "type": "Point", "coordinates": [ -104.987705174081285, 39.749965544452046 ] } }, 17 | { "type": "Feature", "properties": { "id": 9 }, "geometry": { "type": "Point", "coordinates": [ -104.987198296488131, 39.750355252242038 ] } }, 18 | { "type": "Feature", "properties": { "id": 10 }, "geometry": { "type": "Point", "coordinates": [ -104.9867198419376, 39.75073038943404 ] } }, 19 | { "type": "Feature", "properties": { "id": 11 }, "geometry": { "type": "Point", "coordinates": [ -104.986170329780549, 39.751141945362704 ] } }, 20 | { "type": "Feature", "properties": { "id": 12 }, "geometry": { "type": "Point", "coordinates": [ -104.98574398414145, 39.751495225924359 ] } }, 21 | { "type": "Feature", "properties": { "id": 13 }, "geometry": { "type": "Point", "coordinates": [ -104.985180182804498, 39.751939495899329 ] } }, 22 | { "type": "Feature", "properties": { "id": 14 }, "geometry": { "type": "Point", "coordinates": [ -104.984718890801531, 39.752269069409252 ] } }, 23 | { "type": "Feature", "properties": { "id": 15 }, "geometry": { "type": "Point", "coordinates": [ -104.985026418803514, 39.752716856977621 ] } }, 24 | { "type": "Feature", "properties": { "id": 16 }, "geometry": { "type": "Point", "coordinates": [ -104.985473732260914, 39.753060755853909 ] } }, 25 | { "type": "Feature", "properties": { "id": -4 }, "geometry": { "type": "Point", "coordinates": [ -104.985473732260914, 39.753060755853909 ] } } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/test_assets/trace_bad_start.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "trace_bad_start", 4 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, 5 | "features": [ 6 | { "type": "Feature", "properties": { "id": 0 }, "geometry": { "type": "Point", "coordinates": [ -104.998562016101701, 39.714798238145441 ] } }, 7 | { "type": "Feature", "properties": { "id": 1 }, "geometry": { "type": "Point", "coordinates": [ -104.986097142015026, 39.747533038401798 ] } }, 8 | { "type": "Feature", "properties": { "id": 2 }, "geometry": { "type": "Point", "coordinates": [ -104.986104009347784, 39.747951412043271 ] } }, 9 | { "type": "Feature", "properties": { "id": 3 }, "geometry": { "type": "Point", "coordinates": [ -104.986464034554146, 39.748213653231815 ] } }, 10 | { "type": "Feature", "properties": { "id": 4 }, "geometry": { "type": "Point", "coordinates": [ -104.986895117366998, 39.748541453313784 ] } }, 11 | { "type": "Feature", "properties": { "id": 5 }, "geometry": { "type": "Point", "coordinates": [ -104.987378309091312, 39.748931169159633 ] } }, 12 | { "type": "Feature", "properties": { "id": 6 }, "geometry": { "type": "Point", "coordinates": [ -104.987795180382875, 39.749218901874102 ] } }, 13 | { "type": "Feature", "properties": { "id": 7 }, "geometry": { "type": "Point", "coordinates": [ -104.98823100036951, 39.749564907977287 ] } }, 14 | { "type": "Feature", "properties": { "id": 8 }, "geometry": { "type": "Point", "coordinates": [ -104.987705174081285, 39.749965544452046 ] } }, 15 | { "type": "Feature", "properties": { "id": 9 }, "geometry": { "type": "Point", "coordinates": [ -104.987198296488131, 39.750355252242038 ] } }, 16 | { "type": "Feature", "properties": { "id": 10 }, "geometry": { "type": "Point", "coordinates": [ -104.9867198419376, 39.75073038943404 ] } }, 17 | { "type": "Feature", "properties": { "id": 11 }, "geometry": { "type": "Point", "coordinates": [ -104.986170329780549, 39.751141945362704 ] } }, 18 | { "type": "Feature", "properties": { "id": 12 }, "geometry": { "type": "Point", "coordinates": [ -104.98574398414145, 39.751495225924359 ] } }, 19 | { "type": "Feature", "properties": { "id": 13 }, "geometry": { "type": "Point", "coordinates": [ -104.985180182804498, 39.751939495899329 ] } }, 20 | { "type": "Feature", "properties": { "id": 14 }, "geometry": { "type": "Point", "coordinates": [ -104.984718890801531, 39.752269069409252 ] } }, 21 | { "type": "Feature", "properties": { "id": 15 }, "geometry": { "type": "Point", "coordinates": [ -104.985026418803514, 39.752716856977621 ] } }, 22 | { "type": "Feature", "properties": { "id": 16 }, "geometry": { "type": "Point", "coordinates": [ -104.985473732260914, 39.753060755853909 ] } } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tests/test_coordinate.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mappymatch.constructs.coordinate import Coordinate 4 | from mappymatch.utils.crs import LATLON_CRS, XY_CRS 5 | 6 | 7 | class TestCoordinate(TestCase): 8 | def test_coordinate_to_same_crs(self): 9 | c = Coordinate.from_lat_lon(39.755720, -104.994206) 10 | 11 | self.assertEqual(c.crs, LATLON_CRS) 12 | 13 | new_c = c.to_crs(4326) 14 | 15 | self.assertEqual(new_c.crs, LATLON_CRS) 16 | 17 | def test_coordinate_to_new_crs(self): 18 | c = Coordinate.from_lat_lon(39.755720, -104.994206) 19 | 20 | new_c = c.to_crs(XY_CRS) 21 | 22 | self.assertEqual(new_c.crs, XY_CRS) 23 | 24 | def test_coordinate_to_bad_crs(self): 25 | c = Coordinate.from_lat_lon(39.755720, -104.994206) 26 | 27 | bad_crs = -1 28 | 29 | self.assertRaises(ValueError, c.to_crs, bad_crs) 30 | -------------------------------------------------------------------------------- /tests/test_geo.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mappymatch.constructs.coordinate import Coordinate 4 | from mappymatch.utils.geo import coord_to_coord_dist, latlon_to_xy, xy_to_latlon 5 | 6 | 7 | class TestGeoUtils(TestCase): 8 | def setUp(self): 9 | self.lat, self.lon = 40.7128, -74.0060 10 | self.x, self.y = -8238310.23, 4970071.58 11 | 12 | # We'll use simple x, y coordinates for testing 13 | self.coordinate_a = Coordinate.from_lat_lon(1, 1) 14 | self.coordinate_b = Coordinate.from_lat_lon(2, 2) 15 | 16 | def test_xy_to_latlon(self): 17 | lat, lon = xy_to_latlon(self.x, self.y) 18 | self.assertIsInstance(lat, float) 19 | self.assertIsInstance(lon, float) 20 | 21 | self.assertAlmostEqual(lat, self.lat, delta=0.01) 22 | self.assertAlmostEqual(lon, self.lon, delta=0.01) 23 | 24 | def test_latlon_to_xy(self): 25 | x, y = latlon_to_xy(self.lat, self.lon) 26 | self.assertIsInstance(x, float) 27 | self.assertIsInstance(y, float) 28 | 29 | self.assertAlmostEqual(x, self.x, delta=0.01) 30 | self.assertAlmostEqual(y, self.y, delta=0.01) 31 | 32 | def test_xy_to_latlon_and_back(self): 33 | # Test round-trip conversion 34 | lat, lon = xy_to_latlon(self.x, self.y) 35 | x_new, y_new = latlon_to_xy(lat, lon) 36 | 37 | # Ensure the round-trip results are consistent 38 | self.assertAlmostEqual(self.x, x_new, delta=0.01) 39 | self.assertAlmostEqual(self.y, y_new, delta=0.01) 40 | 41 | def test_coord_to_coord_dist(self): 42 | dist = coord_to_coord_dist(self.coordinate_a, self.coordinate_b) 43 | self.assertIsInstance(dist, float) 44 | 45 | self.assertGreater(dist, 0) 46 | self.assertAlmostEqual(dist, 1.41, delta=0.01) 47 | -------------------------------------------------------------------------------- /tests/test_geofence.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mappymatch.constructs.geofence import Geofence 4 | from mappymatch.utils.crs import LATLON_CRS 5 | from tests import get_test_dir 6 | 7 | 8 | class TestGeofence(TestCase): 9 | def test_trace_from_geojson(self): 10 | file = get_test_dir() / "test_assets" / "downtown_denver.geojson" 11 | 12 | gfence = Geofence.from_geojson(file) 13 | 14 | self.assertEqual(gfence.crs, LATLON_CRS) 15 | -------------------------------------------------------------------------------- /tests/test_lcss_add_match_for_stationary.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import deepcopy 4 | from unittest import TestCase 5 | 6 | from shapely.geometry import LineString 7 | 8 | from mappymatch.constructs.coordinate import Coordinate 9 | from mappymatch.constructs.match import Match 10 | from mappymatch.constructs.road import Road 11 | from mappymatch.matchers.lcss.ops import ( 12 | StationaryIndex, 13 | add_matches_for_stationary_points, 14 | ) 15 | 16 | 17 | class TestLCSSAddMatchForStationary(TestCase): 18 | def test_add_matches_no_stationary_points(self): 19 | """This will test the "null case" which is to say, no stationary points""" 20 | # Road(id: str|int, geom: LineString, metadata: dict) 21 | roads = [ 22 | Road("first st", LineString()), 23 | Road("second st", LineString()), 24 | Road("main st", LineString()), 25 | Road("second str", LineString()), 26 | Road(123, LineString()), 27 | Road(234, LineString()), 28 | ] 29 | 30 | lat_longs = [ 31 | (39.655193, -104.919294), 32 | (39.655494, -104.91943), 33 | (39.655801, -104.919567), 34 | (39.656103, -104.919698), 35 | (39.656406, -104.919831), 36 | (39.656707, -104.919964), 37 | ] 38 | coords = [Coordinate.from_lat_lon(lat, lon) for lat, lon in lat_longs] 39 | # Match(road, coordinate, distance) 40 | matches = [Match(r, c, 0.1) for r, c in zip(roads, coords)] 41 | 42 | # ensure that the expected matches are different from the matches that will be passed in 43 | expected_matches = deepcopy(matches) 44 | 45 | stationary_index: list[StationaryIndex] = [] 46 | 47 | resulting_matches = add_matches_for_stationary_points(matches, stationary_index) 48 | 49 | self.assertListEqual(expected_matches, resulting_matches) 50 | 51 | def test_add_matches_one_stationary_point_at_beginning(self): 52 | """Test adding a single stationary point at the beginning""" 53 | roads = [ 54 | Road("first st", LineString()), 55 | Road("second st", LineString()), 56 | Road("main st", LineString()), 57 | Road("second str", LineString()), 58 | Road(123, LineString()), 59 | Road(234, LineString()), 60 | ] 61 | 62 | lat_longs = [ 63 | (39.655193, -104.919294), 64 | (39.655494, -104.91943), 65 | (39.655801, -104.919567), 66 | (39.656103, -104.919698), 67 | (39.656406, -104.919831), 68 | (39.656707, -104.919964), 69 | ] 70 | coords = [Coordinate.from_lat_lon(lat, lon) for lat, lon in lat_longs] 71 | # Match(road, coordinate, distance) 72 | matches = [Match(r, c, 0.1) for r, c in zip(roads, coords)] 73 | 74 | # ensure that the expected matches are different from the matches that will be passed in 75 | expected_matches = deepcopy(matches) 76 | # now, add the expected points 77 | m = expected_matches[0] 78 | new_m = m.set_coordinate(Coordinate("new", m.coordinate.geom, m.coordinate.crs)) 79 | expected_matches.insert(0, new_m) 80 | 81 | # StationaryIndex( i_index: List[int], c_index: List[Any]) 82 | stationary_index = [StationaryIndex([0, 0], [None, "new"])] 83 | 84 | resulting_matches = add_matches_for_stationary_points(matches, stationary_index) 85 | 86 | self.assertListEqual(expected_matches, resulting_matches) 87 | 88 | def test_add_matches_one_stationary_point_at_end(self): 89 | """Test adding a single stationary point at the end""" 90 | # Road(id: str|int, geom: LineString, metadata: dict) 91 | roads = [ 92 | Road("first st", LineString()), 93 | Road("second st", LineString()), 94 | Road("main st", LineString()), 95 | Road("second str", LineString()), 96 | Road(123, LineString()), 97 | Road(234, LineString()), 98 | ] 99 | 100 | lat_longs = [ 101 | (39.655193, -104.919294), 102 | (39.655494, -104.91943), 103 | (39.655801, -104.919567), 104 | (39.656103, -104.919698), 105 | (39.656406, -104.919831), 106 | (39.656707, -104.919964), 107 | ] 108 | coords = [Coordinate.from_lat_lon(lat, lon) for lat, lon in lat_longs] 109 | # Match(road, coordinate, distance) 110 | matches = [Match(r, c, 0.1) for r, c in zip(roads, coords)] 111 | 112 | # ensure that the expected matches are different from the matches that will be passed in 113 | expected_matches = deepcopy(matches) 114 | # now, add the expected points 115 | m = expected_matches[-1] 116 | new_m = m.set_coordinate(Coordinate("new", m.coordinate.geom, m.coordinate.crs)) 117 | expected_matches.append(new_m) 118 | 119 | # StationaryIndex( i_index: List[int], c_index: List[Any]) 120 | stationary_index = [StationaryIndex([-1, len(matches)], [None, "new"])] 121 | 122 | resulting_matches = add_matches_for_stationary_points(matches, stationary_index) 123 | 124 | self.assertListEqual(expected_matches, resulting_matches) 125 | 126 | def test_add_matches_one_stationary_point_in_middle(self): 127 | """Test adding a single stationary point in the middle""" 128 | # Road(id: str|int, geom: LineString, metadata: dict) 129 | roads = [ 130 | Road("first st", LineString()), 131 | Road("second st", LineString()), 132 | Road("main st", LineString()), 133 | Road("second str", LineString()), 134 | Road(123, LineString()), 135 | Road(234, LineString()), 136 | ] 137 | 138 | lat_longs = [ 139 | (39.655193, -104.919294), 140 | (39.655494, -104.91943), 141 | (39.655801, -104.919567), 142 | (39.656103, -104.919698), 143 | (39.656406, -104.919831), 144 | (39.656707, -104.919964), 145 | ] 146 | coords = [Coordinate.from_lat_lon(lat, lon) for lat, lon in lat_longs] 147 | # Match(road, coordinate, distance) 148 | matches = [Match(r, c, 0.1) for r, c in zip(roads, coords)] 149 | 150 | # ensure that the expected matches are different from the matches that will be passed in 151 | expected_matches = deepcopy(matches) 152 | # now, add the expected points 153 | m = expected_matches[-1] 154 | new_m = m.set_coordinate(Coordinate("new", m.coordinate.geom, m.coordinate.crs)) 155 | expected_matches[-1:-1] = [new_m] 156 | 157 | # StationaryIndex( i_index: List[int], c_index: List[Any]) 158 | stationary_index = [StationaryIndex([-1, -1], [None, "new"])] 159 | 160 | resulting_matches = add_matches_for_stationary_points(matches, stationary_index) 161 | 162 | self.assertListEqual(expected_matches, resulting_matches) 163 | 164 | expected_matches = deepcopy(matches) 165 | indx = len(matches) // 2 166 | m = expected_matches[indx] 167 | new_m = m.set_coordinate(Coordinate("new", m.coordinate.geom, m.coordinate.crs)) 168 | expected_matches.insert(indx, new_m) 169 | stationary_index = [StationaryIndex([indx, indx], [None, "new"])] 170 | 171 | resulting_matches = add_matches_for_stationary_points(matches, stationary_index) 172 | 173 | self.assertListEqual(expected_matches, resulting_matches) 174 | 175 | def test_add_matches_multiple_stationary_points(self): 176 | """Test adding multiple stationary points""" 177 | # Road(id: str|int, geom: LineString, metadata: dict) 178 | roads = [ 179 | Road("first st", LineString()), 180 | Road("second st", LineString()), 181 | Road("main st", LineString()), 182 | Road("second str", LineString()), 183 | Road(123, LineString()), 184 | Road(234, LineString()), 185 | ] 186 | 187 | lat_longs = [ 188 | (39.655193, -104.919294), 189 | (39.655494, -104.91943), 190 | (39.655801, -104.919567), 191 | (39.656103, -104.919698), 192 | (39.656406, -104.919831), 193 | (39.656707, -104.919964), 194 | ] 195 | coords = [Coordinate.from_lat_lon(lat, lon) for lat, lon in lat_longs] 196 | # Match(road, coordinate, distance) 197 | matches: list[Match] = [Match(r, c, 0.1) for r, c in zip(roads, coords)] 198 | 199 | # ensure that the expected matches are different from the matches that will be passed in 200 | expected_matches = deepcopy(matches) 201 | # now, add the expected points 202 | indx = len(matches) // 2 203 | m = expected_matches[indx] 204 | coord_ids = ["alpha", "beta", "gamma"] 205 | new_matches = [ 206 | m.set_coordinate(Coordinate(id, m.coordinate.geom, m.coordinate.crs)) 207 | for id in coord_ids 208 | ] 209 | expected_matches[indx + 1 : indx + 1] = new_matches 210 | 211 | # StationaryIndex( i_index: List[int], c_index: List[Any]) 212 | stationary_index = [StationaryIndex([indx, indx + 1], [None] + coord_ids)] 213 | 214 | resulting_matches = add_matches_for_stationary_points(matches, stationary_index) 215 | 216 | self.assertListEqual(expected_matches, resulting_matches) 217 | -------------------------------------------------------------------------------- /tests/test_lcss_compress.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mappymatch.matchers.lcss.constructs import CuttingPoint 4 | from mappymatch.matchers.lcss.utils import compress 5 | 6 | 7 | class TestLCSSMatcherCompress(TestCase): 8 | def test_compress_one_group_sorted(self): 9 | """ 10 | This will test that a sorted list with one compressed group will return 11 | correctly 12 | """ 13 | starting_list = [ 14 | CuttingPoint(1), 15 | CuttingPoint(2), 16 | CuttingPoint(3), 17 | CuttingPoint(4), 18 | CuttingPoint(5), 19 | ] 20 | 21 | expected_stop = 1 22 | expected_list = [CuttingPoint(3)] 23 | count = 0 24 | for cutting_point in compress(starting_list): 25 | self.assertTrue(count < expected_stop) 26 | self.assertEqual(expected_list[count], cutting_point) 27 | count += 1 28 | 29 | def test_compress_one_group_unsorted(self): 30 | """ 31 | This will test that a unsorted list with one compressed group will return 32 | correctly 33 | """ 34 | starting_list = [ 35 | CuttingPoint(4), 36 | CuttingPoint(1), 37 | CuttingPoint(3), 38 | CuttingPoint(5), 39 | CuttingPoint(2), 40 | ] 41 | 42 | expected_stop = 1 43 | expected_list = [CuttingPoint(3)] 44 | count = 0 45 | for cutting_point in compress(starting_list): 46 | self.assertTrue(count < expected_stop) 47 | self.assertEqual(expected_list[count], cutting_point) 48 | count += 1 49 | 50 | def test_compress_multi_single_groups(self): 51 | """ 52 | This will test that a sorted list multiple compressed groups of size 1 will 53 | result correctly 54 | """ 55 | starting_list = [ 56 | CuttingPoint(1), 57 | CuttingPoint(3), 58 | CuttingPoint(6), 59 | CuttingPoint(10), 60 | CuttingPoint(15), 61 | ] 62 | 63 | expected_stop = 5 64 | expected_list = [ 65 | CuttingPoint(1), 66 | CuttingPoint(3), 67 | CuttingPoint(6), 68 | CuttingPoint(10), 69 | CuttingPoint(15), 70 | ] 71 | count = 0 72 | for cutting_point in compress(starting_list): 73 | self.assertTrue(count < expected_stop) 74 | self.assertEqual(expected_list[count], cutting_point) 75 | count += 1 76 | 77 | def test_compress_multi_groups(self): 78 | """ 79 | This will test that a sorted list multiple compressed groups of various size 80 | will result correctly 81 | """ 82 | starting_list = [ 83 | CuttingPoint(1), 84 | CuttingPoint(2), 85 | CuttingPoint(6), 86 | CuttingPoint(7), 87 | CuttingPoint(8), 88 | CuttingPoint(11), 89 | CuttingPoint(12), 90 | CuttingPoint(13), 91 | CuttingPoint(14), 92 | ] 93 | 94 | expected_stop = 3 95 | expected_list = [ 96 | CuttingPoint(2), 97 | CuttingPoint(7), 98 | CuttingPoint(13), 99 | ] 100 | count = 0 101 | for cutting_point in compress(starting_list): 102 | self.assertTrue(count < expected_stop) 103 | self.assertEqual(expected_list[count], cutting_point) 104 | count += 1 105 | -------------------------------------------------------------------------------- /tests/test_lcss_drop_stationary_points.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | 5 | from mappymatch.constructs.trace import Trace 6 | from mappymatch.matchers.lcss.ops import ( 7 | StationaryIndex, 8 | drop_stationary_points, 9 | ) 10 | 11 | 12 | class TestLCSSMatcherDropStationaryPoints(TestCase): 13 | def test_drop_stationary_points_matching_points_beginning(self): 14 | """ 15 | This will test that drop_stationary_point can drop the stationary points in 16 | the beginning of the trace 17 | """ 18 | trace = Trace.from_dataframe( 19 | pd.DataFrame( 20 | data={ 21 | "latitude": [39.655193, 39.655193, 39.655494, 39.655801], 22 | "longitude": [ 23 | -104.919294, 24 | -104.919294, 25 | -104.91943, 26 | -104.919567, 27 | ], 28 | } 29 | ) 30 | ) 31 | 32 | stationary_index = [ 33 | StationaryIndex( 34 | [0, 1], 35 | [trace.coords[0].coordinate_id, trace.coords[1].coordinate_id], 36 | ) 37 | ] 38 | 39 | expected_trace = Trace.from_dataframe( 40 | pd.DataFrame( 41 | data={ 42 | "latitude": [39.655193, 39.655494, 39.655801], 43 | "longitude": [-104.919294, -104.91943, -104.919567], 44 | } 45 | ) 46 | ) 47 | 48 | resulting_trace = drop_stationary_points(trace, stationary_index) 49 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 50 | for expected_coord, resulted_coord in zip( 51 | expected_trace.coords, resulting_trace.coords 52 | ): 53 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 54 | 55 | def test_drop_stationary_points_matching_points_ending(self): 56 | """ 57 | This will test that drop_stationary_point can drop the stationary points in 58 | the ending of the trace 59 | """ 60 | trace = Trace.from_dataframe( 61 | pd.DataFrame( 62 | data={ 63 | "latitude": [39.655193, 39.655494, 39.655801, 39.655801], 64 | "longitude": [ 65 | -104.919294, 66 | -104.91943, 67 | -104.919567, 68 | -104.919567, 69 | ], 70 | } 71 | ) 72 | ) 73 | 74 | stationary_index = [ 75 | StationaryIndex( 76 | [2, 3], 77 | [trace.coords[2].coordinate_id, trace.coords[3].coordinate_id], 78 | ) 79 | ] 80 | 81 | expected_trace = Trace.from_dataframe( 82 | pd.DataFrame( 83 | data={ 84 | "latitude": [39.655193, 39.655494, 39.655801], 85 | "longitude": [-104.919294, -104.91943, -104.919567], 86 | } 87 | ) 88 | ) 89 | 90 | resulting_trace = drop_stationary_points(trace, stationary_index) 91 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 92 | for expected_coord, resulted_coord in zip( 93 | expected_trace.coords, resulting_trace.coords 94 | ): 95 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 96 | 97 | def test_drop_stationary_points_matching_points_middle(self): 98 | """ 99 | This will test that drop_stationary_point can drop the stationary points in 100 | the middle of the trace 101 | """ 102 | trace = Trace.from_dataframe( 103 | pd.DataFrame( 104 | data={ 105 | "latitude": [39.655193, 39.655494, 39.655494, 39.655801], 106 | "longitude": [ 107 | -104.919294, 108 | -104.91943, 109 | -104.91943, 110 | -104.919567, 111 | ], 112 | } 113 | ) 114 | ) 115 | 116 | stationary_index = [ 117 | StationaryIndex( 118 | [1, 2], 119 | [trace.coords[1].coordinate_id, trace.coords[2].coordinate_id], 120 | ) 121 | ] 122 | 123 | expected_trace = Trace.from_dataframe( 124 | pd.DataFrame( 125 | data={ 126 | "latitude": [39.655193, 39.655494, 39.655801], 127 | "longitude": [-104.919294, -104.91943, -104.919567], 128 | } 129 | ) 130 | ) 131 | 132 | resulting_trace = drop_stationary_points(trace, stationary_index) 133 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 134 | for expected_coord, resulted_coord in zip( 135 | expected_trace.coords, resulting_trace.coords 136 | ): 137 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 138 | 139 | def test_drop_stationary_points_matching_points_multiple(self): 140 | """ 141 | This will test that drop_stationary_point can drop the multiple sets 142 | of stationary points 143 | """ 144 | trace = Trace.from_dataframe( 145 | pd.DataFrame( 146 | data={ 147 | "latitude": [ 148 | 39.655193, 149 | 39.655193, 150 | 39.655193, 151 | 39.655494, 152 | 39.655494, 153 | 39.655801, 154 | 39.656103, 155 | 39.656103, 156 | ], 157 | "longitude": [ 158 | -104.919294, 159 | -104.919294, 160 | -104.919294, 161 | -104.91943, 162 | -104.91943, 163 | -104.919567, 164 | -104.919698, 165 | -104.919698, 166 | ], 167 | } 168 | ) 169 | ) 170 | 171 | stationary_index = [ 172 | StationaryIndex( 173 | [0, 1, 2], 174 | [ 175 | trace.coords[0].coordinate_id, 176 | trace.coords[1].coordinate_id, 177 | trace.coords[2].coordinate_id, 178 | ], 179 | ), 180 | StationaryIndex( 181 | [3, 4], 182 | [trace.coords[3].coordinate_id, trace.coords[4].coordinate_id], 183 | ), 184 | StationaryIndex( 185 | [6, 7], 186 | [trace.coords[6].coordinate_id, trace.coords[7].coordinate_id], 187 | ), 188 | ] 189 | 190 | expected_trace = Trace.from_dataframe( 191 | pd.DataFrame( 192 | data={ 193 | "latitude": [39.655193, 39.655494, 39.655801, 39.656103], 194 | "longitude": [ 195 | -104.919294, 196 | -104.91943, 197 | -104.919567, 198 | -104.919698, 199 | ], 200 | } 201 | ) 202 | ) 203 | 204 | resulting_trace = drop_stationary_points(trace, stationary_index) 205 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 206 | for expected_coord, resulted_coord in zip( 207 | expected_trace.coords, resulting_trace.coords 208 | ): 209 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 210 | 211 | def test_drop_stationary_points_matching_points_slightly_different(self): 212 | """ 213 | This will test that drop_stationary_point can drop the stationary points that are 214 | slightly different, but close enough to be stationary 215 | """ 216 | trace = Trace.from_dataframe( 217 | pd.DataFrame( 218 | data={ 219 | "latitude": [ 220 | 39.655193, 221 | 39.655193001, 222 | 39.655494, 223 | 39.655801, 224 | ], 225 | "longitude": [ 226 | -104.919294, 227 | -104.919294, 228 | -104.91943, 229 | -104.919567, 230 | ], 231 | } 232 | ) 233 | ) 234 | 235 | stationary_index = [ 236 | StationaryIndex( 237 | [0, 1], 238 | [trace.coords[0].coordinate_id, trace.coords[1].coordinate_id], 239 | ) 240 | ] 241 | 242 | expected_trace = Trace.from_dataframe( 243 | pd.DataFrame( 244 | data={ 245 | "latitude": [39.655193, 39.655494, 39.655801], 246 | "longitude": [-104.919294, -104.91943, -104.919567], 247 | } 248 | ) 249 | ) 250 | 251 | resulting_trace = drop_stationary_points(trace, stationary_index) 252 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 253 | for expected_coord, resulted_coord in zip( 254 | expected_trace.coords, resulting_trace.coords 255 | ): 256 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 257 | 258 | def test_drop_stationary_points_mathing_points_just_under_limit(self): 259 | """ 260 | This will test that drop_stationary_point can drop the stationary points that 261 | have a distance difference just under .001, making them close enough to be 262 | stationary 263 | """ 264 | trace = Trace.from_dataframe( 265 | pd.DataFrame( 266 | data={ 267 | "latitude": [ 268 | 39.655193, 269 | 39.6551930069, 270 | 39.655494, 271 | 39.655801, 272 | ], 273 | "longitude": [ 274 | -104.919294, 275 | -104.919294, 276 | -104.91943, 277 | -104.919567, 278 | ], 279 | } 280 | ) 281 | ) 282 | 283 | stationary_index = [ 284 | StationaryIndex( 285 | [0, 1], 286 | [trace.coords[0].coordinate_id, trace.coords[1].coordinate_id], 287 | ) 288 | ] 289 | 290 | expected_trace = Trace.from_dataframe( 291 | pd.DataFrame( 292 | data={ 293 | "latitude": [39.655193, 39.655494, 39.655801], 294 | "longitude": [-104.919294, -104.91943, -104.919567], 295 | } 296 | ) 297 | ) 298 | 299 | resulting_trace = drop_stationary_points(trace, stationary_index) 300 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 301 | for expected_coord, resulted_coord in zip( 302 | expected_trace.coords, resulting_trace.coords 303 | ): 304 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 305 | 306 | def test_drop_stationary_points_mathing_points_just_over_limit(self): 307 | """ 308 | This will test that drop_stationary_point will not drop points that have a 309 | distance difference just over .001, making them different enough to not be 310 | stationary 311 | """ 312 | trace = Trace.from_dataframe( 313 | pd.DataFrame( 314 | data={ 315 | "latitude": [ 316 | 39.655193, 317 | 39.655193007, 318 | 39.655494, 319 | 39.655801, 320 | ], 321 | "longitude": [ 322 | -104.919294, 323 | -104.919294, 324 | -104.91943, 325 | -104.919567, 326 | ], 327 | } 328 | ) 329 | ) 330 | 331 | stationary_index = [] 332 | 333 | expected_trace = Trace.from_dataframe( 334 | pd.DataFrame( 335 | data={ 336 | "latitude": [ 337 | 39.655193, 338 | 39.655193007, 339 | 39.655494, 340 | 39.655801, 341 | ], 342 | "longitude": [ 343 | -104.919294, 344 | -104.919294, 345 | -104.91943, 346 | -104.919567, 347 | ], 348 | } 349 | ) 350 | ) 351 | 352 | resulting_trace = drop_stationary_points(trace, stationary_index) 353 | self.assertEqual(len(expected_trace.coords), len(resulting_trace.coords)) 354 | for expected_coord, resulted_coord in zip( 355 | expected_trace.coords, resulting_trace.coords 356 | ): 357 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 358 | -------------------------------------------------------------------------------- /tests/test_lcss_find_stationary_points.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | 5 | from mappymatch.constructs.trace import Trace 6 | from mappymatch.matchers.lcss.ops import ( 7 | StationaryIndex, 8 | find_stationary_points, 9 | ) 10 | 11 | 12 | class TestLCSSMatcherFindStationaryPoints(TestCase): 13 | def test_find_stationary_points_matching_points_beginning(self): 14 | """ 15 | This will test that find_stationary_point can find the stationary points in 16 | the beginning of the trace 17 | """ 18 | trace = Trace.from_dataframe( 19 | pd.DataFrame( 20 | data={ 21 | "latitude": [39.655193, 39.655193, 39.655494, 39.655801], 22 | "longitude": [ 23 | -104.919294, 24 | -104.919294, 25 | -104.91943, 26 | -104.919567, 27 | ], 28 | } 29 | ) 30 | ) 31 | 32 | expected_list = [ 33 | StationaryIndex( 34 | [0, 1], 35 | [trace.coords[0].coordinate_id, trace.coords[1].coordinate_id], 36 | ) 37 | ] 38 | 39 | resulting_list = find_stationary_points(trace=trace) 40 | 41 | self.assertListEqual(expected_list, resulting_list) 42 | 43 | def test_find_stationary_points_matching_points_ending(self): 44 | """ 45 | This will test that find_stationary_point can find the stationary points in 46 | the ending of the trace 47 | """ 48 | trace = Trace.from_dataframe( 49 | pd.DataFrame( 50 | data={ 51 | "latitude": [39.655193, 39.655494, 39.655801, 39.655801], 52 | "longitude": [ 53 | -104.919294, 54 | -104.91943, 55 | -104.919567, 56 | -104.919567, 57 | ], 58 | } 59 | ) 60 | ) 61 | 62 | expected_list = [ 63 | StationaryIndex( 64 | [2, 3], 65 | [trace.coords[2].coordinate_id, trace.coords[3].coordinate_id], 66 | ) 67 | ] 68 | 69 | resulting_list = find_stationary_points(trace=trace) 70 | 71 | self.assertListEqual(expected_list, resulting_list) 72 | 73 | def test_find_stationary_points_matching_points_middle(self): 74 | """ 75 | This will test that find_stationary_point can find the stationary points in 76 | the middle of the trace 77 | """ 78 | trace = Trace.from_dataframe( 79 | pd.DataFrame( 80 | data={ 81 | "latitude": [39.655193, 39.655494, 39.655494, 39.655801], 82 | "longitude": [ 83 | -104.919294, 84 | -104.91943, 85 | -104.91943, 86 | -104.919567, 87 | ], 88 | } 89 | ) 90 | ) 91 | 92 | expected_list = [ 93 | StationaryIndex( 94 | [1, 2], 95 | [trace.coords[1].coordinate_id, trace.coords[2].coordinate_id], 96 | ) 97 | ] 98 | 99 | resulting_list = find_stationary_points(trace=trace) 100 | 101 | self.assertListEqual(expected_list, resulting_list) 102 | 103 | def test_find_stationary_points_matching_points_multiple(self): 104 | """ 105 | This will test that find_stationary_point can find the multiple sets 106 | of stationary points 107 | """ 108 | trace = Trace.from_dataframe( 109 | pd.DataFrame( 110 | data={ 111 | "latitude": [ 112 | 39.655193, 113 | 39.655193, 114 | 39.655193, 115 | 39.655494, 116 | 39.655494, 117 | 39.655801, 118 | 39.656103, 119 | 39.656103, 120 | ], 121 | "longitude": [ 122 | -104.919294, 123 | -104.919294, 124 | -104.919294, 125 | -104.91943, 126 | -104.91943, 127 | -104.919567, 128 | -104.919698, 129 | -104.919698, 130 | ], 131 | } 132 | ) 133 | ) 134 | 135 | expected_list = [ 136 | StationaryIndex( 137 | [0, 1, 2], 138 | [ 139 | trace.coords[0].coordinate_id, 140 | trace.coords[1].coordinate_id, 141 | trace.coords[2].coordinate_id, 142 | ], 143 | ), 144 | StationaryIndex( 145 | [3, 4], 146 | [trace.coords[3].coordinate_id, trace.coords[4].coordinate_id], 147 | ), 148 | StationaryIndex( 149 | [6, 7], 150 | [trace.coords[6].coordinate_id, trace.coords[7].coordinate_id], 151 | ), 152 | ] 153 | 154 | resulting_list = find_stationary_points(trace=trace) 155 | 156 | self.assertListEqual(expected_list, resulting_list) 157 | 158 | def test_find_stationary_points_matching_points_slightly_different(self): 159 | """ 160 | This will test that find_stationary_point can find the stationary points that are 161 | slightly different, but close enough to be stationary 162 | """ 163 | trace = Trace.from_dataframe( 164 | pd.DataFrame( 165 | data={ 166 | "latitude": [ 167 | 39.655193, 168 | 39.655193001, 169 | 39.655494, 170 | 39.655801, 171 | ], 172 | "longitude": [ 173 | -104.919294, 174 | -104.919294, 175 | -104.91943, 176 | -104.919567, 177 | ], 178 | } 179 | ) 180 | ) 181 | 182 | expected_list = [ 183 | StationaryIndex( 184 | [0, 1], 185 | [trace.coords[0].coordinate_id, trace.coords[1].coordinate_id], 186 | ) 187 | ] 188 | 189 | resulting_list = find_stationary_points(trace=trace) 190 | 191 | self.assertListEqual(expected_list, resulting_list) 192 | 193 | def test_find_stationary_points_mathing_points_just_under_limit(self): 194 | """ 195 | This will test that find_stationary_point can find the stationary points that 196 | have a distance difference just under .001, making them close enough to be 197 | stationary 198 | """ 199 | trace = Trace.from_dataframe( 200 | pd.DataFrame( 201 | data={ 202 | "latitude": [ 203 | 39.655193, 204 | 39.6551930069, 205 | 39.655494, 206 | 39.655801, 207 | ], 208 | "longitude": [ 209 | -104.919294, 210 | -104.919294, 211 | -104.91943, 212 | -104.919567, 213 | ], 214 | } 215 | ) 216 | ) 217 | 218 | expected_list = [ 219 | StationaryIndex( 220 | [0, 1], 221 | [trace.coords[0].coordinate_id, trace.coords[1].coordinate_id], 222 | ) 223 | ] 224 | 225 | resulting_list = find_stationary_points(trace=trace) 226 | 227 | self.assertListEqual(expected_list, resulting_list) 228 | 229 | def test_find_stationary_points_mathing_points_just_over_limit(self): 230 | """ 231 | This will test that find_stationary_point can find points that have a distance 232 | difference just over .001, making them far enough to not be stationary 233 | """ 234 | trace = Trace.from_dataframe( 235 | pd.DataFrame( 236 | data={ 237 | "latitude": [ 238 | 39.655193, 239 | 39.655193007, 240 | 39.655494, 241 | 39.655801, 242 | ], 243 | "longitude": [ 244 | -104.919294, 245 | -104.919294, 246 | -104.91943, 247 | -104.919567, 248 | ], 249 | } 250 | ) 251 | ) 252 | 253 | expected_list = [] 254 | 255 | resulting_list = find_stationary_points(trace=trace) 256 | 257 | self.assertListEqual(expected_list, resulting_list) 258 | -------------------------------------------------------------------------------- /tests/test_lcss_forward_merge.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | from shapely.geometry import LineString 5 | 6 | from mappymatch.constructs.road import Road 7 | from mappymatch.constructs.trace import Trace 8 | from mappymatch.matchers.lcss.constructs import TrajectorySegment 9 | from mappymatch.matchers.lcss.utils import forward_merge 10 | 11 | 12 | class TestLCSSMatcherForwardMerge(TestCase): 13 | def test_forward_merge_beginning(self): 14 | """ 15 | This will test that forward_merge can merge items at the beginning of the list 16 | """ 17 | starting_list = [1, 2, 3, 4, 5] 18 | 19 | def condition(x): 20 | return x < 3 21 | 22 | expected_list = [6, 4, 5] 23 | 24 | resulting_list = forward_merge(starting_list, condition=condition) 25 | 26 | self.assertListEqual(expected_list, resulting_list) 27 | 28 | def test_forward_merge_ending_no_merge(self): 29 | """ 30 | This will test that forward_merge can merge items at the end of the list with 31 | no other merges in the list 32 | """ 33 | starting_list = [1, 2, 3, 4, 5] 34 | 35 | def condition(x): 36 | return x > 3 37 | 38 | expected_list = [1, 2, 3, 9] 39 | 40 | resulting_list = forward_merge(starting_list, condition=condition) 41 | 42 | self.assertListEqual(expected_list, resulting_list) 43 | 44 | def test_forward_merge_middle(self): 45 | """ 46 | This will test that forward_merge can merge items in the middle of the list 47 | """ 48 | starting_list = [1, 2, 4, 4, 2, 2] 49 | 50 | def condition(x): 51 | return x > 3 52 | 53 | expected_list = [1, 2, 10, 2] 54 | 55 | resulting_list = forward_merge(starting_list, condition=condition) 56 | 57 | self.assertListEqual(expected_list, resulting_list) 58 | 59 | def test_forward_merge_multi_merges(self): 60 | """ 61 | This will test that forward_merge works for multiple segments including a 62 | segment at the end 63 | """ 64 | starting_list = [1, 2, 3, 6, 4, 2, 3, 1, 6, 7, 3, 4, 3, 3] 65 | 66 | def condition(x): 67 | return x < 4 68 | 69 | expected_list = [12, 4, 12, 7, 7, 6] 70 | 71 | resulting_list = forward_merge(starting_list, condition=condition) 72 | 73 | self.assertListEqual(expected_list, resulting_list) 74 | 75 | def test_forward_merge_trajectory_segments(self): 76 | """ 77 | This will test that a list of trajectory segments can merge 78 | """ 79 | # setup inputted trajectory segments 80 | trace_1 = Trace.from_dataframe( 81 | pd.DataFrame( 82 | data={"latitude": [39.655193], "longitude": [-104.919294]}, 83 | index=[0], 84 | ) 85 | ) 86 | trace_2 = Trace.from_dataframe( 87 | pd.DataFrame( 88 | data={ 89 | "latitude": [39.655494, 39.655801], 90 | "longitude": [-104.91943, -104.919567], 91 | }, 92 | index=[1, 2], 93 | ) 94 | ) 95 | trace_3 = Trace.from_dataframe( 96 | pd.DataFrame( 97 | data={"latitude": [39.656103], "longitude": [-104.919698]}, 98 | index=[3], 99 | ) 100 | ) 101 | trace_4 = Trace.from_dataframe( 102 | pd.DataFrame( 103 | data={ 104 | "latitude": [39.656406, 39.656707, 39.657005], 105 | "longitude": [-104.919831, -104.919964, -104.920099], 106 | }, 107 | index=[4, 5, 6], 108 | ) 109 | ) 110 | trace_5 = Trace.from_dataframe( 111 | pd.DataFrame( 112 | data={ 113 | "latitude": [39.657303, 39.657601], 114 | "longitude": [-104.920229, -104.92036], 115 | }, 116 | index=[7, 8], 117 | ) 118 | ) 119 | 120 | road_1 = [ 121 | Road( 122 | "first st", 123 | LineString(), 124 | ) 125 | ] 126 | road_2 = [ 127 | Road( 128 | "second st", 129 | LineString(), 130 | ) 131 | ] 132 | road_3 = [Road(234, LineString())] 133 | road_4 = [ 134 | Road( 135 | "first st", 136 | LineString(), 137 | ), 138 | Road( 139 | "second st", 140 | LineString(), 141 | ), 142 | Road(123, LineString()), 143 | ] 144 | road_5 = [ 145 | Road( 146 | "main st", 147 | LineString(), 148 | ), 149 | Road( 150 | "second str", 151 | LineString(), 152 | ), 153 | ] 154 | 155 | segment_1 = TrajectorySegment(trace_1, road_1) 156 | segment_2 = TrajectorySegment(trace_2, road_2) 157 | segment_3 = TrajectorySegment(trace_3, road_3) 158 | segment_4 = TrajectorySegment(trace_4, road_4) 159 | segment_5 = TrajectorySegment(trace_5, road_5) 160 | 161 | starting_list = [segment_1, segment_2, segment_3, segment_4, segment_5] 162 | 163 | # create a condition function for the merge function 164 | def _merge_condition(ts: TrajectorySegment): 165 | if len(ts.trace) < 2: 166 | return True 167 | return False 168 | 169 | condition = _merge_condition 170 | 171 | # create the expected trajectory segments 172 | expected_trace_1 = Trace.from_dataframe( 173 | pd.DataFrame( 174 | data={ 175 | "latitude": [39.655193, 39.655494, 39.655801], 176 | "longitude": [-104.919294, -104.91943, -104.919567], 177 | } 178 | ) 179 | ) 180 | expected_trace_2 = Trace.from_dataframe( 181 | pd.DataFrame( 182 | data={ 183 | "latitude": [39.656103, 39.656406, 39.656707, 39.657005], 184 | "longitude": [ 185 | -104.919698, 186 | -104.919831, 187 | -104.919964, 188 | -104.920099, 189 | ], 190 | } 191 | ) 192 | ) 193 | expected_trace_3 = Trace.from_dataframe( 194 | pd.DataFrame( 195 | data={ 196 | "latitude": [39.657303, 39.657601], 197 | "longitude": [-104.920229, -104.92036], 198 | } 199 | ) 200 | ) 201 | 202 | expected_road_1 = [ 203 | Road( 204 | "first st", 205 | LineString(), 206 | ), 207 | Road( 208 | "second st", 209 | LineString(), 210 | ), 211 | ] 212 | expected_road_2 = [ 213 | Road(234, LineString()), 214 | Road( 215 | "first st", 216 | LineString(), 217 | ), 218 | Road( 219 | "second st", 220 | LineString(), 221 | ), 222 | Road(123, LineString()), 223 | ] 224 | expected_road_3 = [ 225 | Road( 226 | "main st", 227 | LineString(), 228 | ), 229 | Road( 230 | "second str", 231 | LineString(), 232 | ), 233 | ] 234 | 235 | expected_segment_1 = TrajectorySegment(expected_trace_1, expected_road_1) 236 | expected_segment_2 = TrajectorySegment(expected_trace_2, expected_road_2) 237 | expected_segment_3 = TrajectorySegment(expected_trace_3, expected_road_3) 238 | 239 | expected_list = [ 240 | expected_segment_1, 241 | expected_segment_2, 242 | expected_segment_3, 243 | ] 244 | 245 | resulting_list = forward_merge(starting_list, condition=condition) 246 | 247 | # confirm forward merge accuracy 248 | self.assertEqual(len(expected_list), len(resulting_list)) 249 | for expected_trajectory, resulted_trajectory in zip( 250 | expected_list, resulting_list 251 | ): 252 | # confirm that the coordinates are the same 253 | self.assertEqual( 254 | len(expected_trajectory.trace), len(resulted_trajectory.trace) 255 | ) 256 | for expected_trace, resulted_trace in zip( 257 | expected_trajectory.trace, resulted_trajectory.trace 258 | ): 259 | self.assertEqual(len(expected_trace.coords), len(resulted_trace.coords)) 260 | for expected_coord, resulted_coord in zip( 261 | expected_trace.coords, resulted_trace.coords 262 | ): 263 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 264 | 265 | # confirm that the paths are the same 266 | self.assertListEqual(expected_trajectory.path, resulted_trajectory.path) 267 | -------------------------------------------------------------------------------- /tests/test_lcss_merge.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | from shapely.geometry import LineString 5 | 6 | from mappymatch.constructs.road import Road 7 | from mappymatch.constructs.trace import Trace 8 | from mappymatch.matchers.lcss.constructs import TrajectorySegment 9 | from mappymatch.matchers.lcss.utils import merge 10 | 11 | 12 | class TestLCSSMatcherMerge(TestCase): 13 | def test_merge_beginning(self): 14 | """ 15 | This will test that merge can merge items at the beginning of the list 16 | """ 17 | starting_list = [1, 2, 3, 4, 5] 18 | 19 | def condition(x): 20 | return x < 3 21 | 22 | expected_list = [6, 4, 5] 23 | 24 | resulting_list = merge(starting_list, condition=condition) 25 | 26 | self.assertListEqual(expected_list, resulting_list) 27 | 28 | def test_merge_ending_merge(self): 29 | """ 30 | This will test that merge can merge items at the end of the list 31 | """ 32 | starting_list = [1, 2, 3, 4, 5] 33 | 34 | def condition(x): 35 | return x > 3 36 | 37 | expected_list = [1, 2, 12] 38 | 39 | resulting_list = merge(starting_list, condition=condition) 40 | 41 | self.assertListEqual(expected_list, resulting_list) 42 | 43 | def test_merge_middle(self): 44 | """ 45 | This will test that merge can merge items in the middle of the list 46 | """ 47 | starting_list = [1, 2, 4, 4, 2, 2] 48 | 49 | def condition(x): 50 | return x > 3 51 | 52 | expected_list = [1, 12, 2] 53 | 54 | resulting_list = merge(starting_list, condition=condition) 55 | 56 | self.assertListEqual(expected_list, resulting_list) 57 | 58 | def test_merge_multi_merges(self): 59 | """ 60 | This will test that merge works for multiple segments 61 | """ 62 | starting_list = [1, 2, 3, 6, 4, 2, 3, 1, 6, 7, 3, 4] 63 | 64 | def condition(x): 65 | return x < 4 66 | 67 | expected_list = [12, 4, 12, 7, 7] 68 | 69 | resulting_list = merge(starting_list, condition=condition) 70 | 71 | self.assertListEqual(expected_list, resulting_list) 72 | 73 | def test_merge_left_over_merging(self): 74 | """ 75 | This will test that the reverse merge will catch the left over merge 76 | candidate from forward merge 77 | """ 78 | starting_list = [1, 2, 3, 4, 5, 2] 79 | 80 | def condition(x): 81 | return x < 3 82 | 83 | expected_list = [6, 4, 7] 84 | 85 | resulting_list = merge(starting_list, condition=condition) 86 | 87 | self.assertListEqual(expected_list, resulting_list) 88 | 89 | def test_reverse_merge_trajectory_segments(self): 90 | """ 91 | This will test that a list of trajectory segments can merge 92 | """ 93 | # setup inputted trajectory segments 94 | trace_1 = Trace.from_dataframe( 95 | pd.DataFrame( 96 | data={"latitude": [39.655193], "longitude": [-104.919294]}, 97 | index=[0], 98 | ) 99 | ) 100 | trace_2 = Trace.from_dataframe( 101 | pd.DataFrame( 102 | data={ 103 | "latitude": [39.655494, 39.655801], 104 | "longitude": [-104.91943, -104.919567], 105 | }, 106 | index=[1, 2], 107 | ) 108 | ) 109 | trace_3 = Trace.from_dataframe( 110 | pd.DataFrame( 111 | data={"latitude": [39.656103], "longitude": [-104.919698]}, 112 | index=[3], 113 | ) 114 | ) 115 | trace_4 = Trace.from_dataframe( 116 | pd.DataFrame( 117 | data={ 118 | "latitude": [39.656406, 39.656707, 39.657005, 39.657303], 119 | "longitude": [ 120 | -104.919831, 121 | -104.919964, 122 | -104.920099, 123 | -104.920229, 124 | ], 125 | }, 126 | index=[4, 5, 6, 7], 127 | ) 128 | ) 129 | trace_5 = Trace.from_dataframe( 130 | pd.DataFrame( 131 | data={ 132 | "latitude": [39.657601], 133 | "longitude": [-104.92036], 134 | }, 135 | index=[8], 136 | ) 137 | ) 138 | 139 | road_1 = [Road("first st", LineString())] 140 | road_2 = [Road("second st", LineString())] 141 | road_3 = [Road(234, LineString())] 142 | road_4 = [ 143 | Road("first st", LineString()), 144 | Road("second st", LineString()), 145 | Road(123, LineString()), 146 | Road("main st", LineString()), 147 | ] 148 | road_5 = [Road("second str", LineString())] 149 | 150 | segment_1 = TrajectorySegment(trace_1, road_1) 151 | segment_2 = TrajectorySegment(trace_2, road_2) 152 | segment_3 = TrajectorySegment(trace_3, road_3) 153 | segment_4 = TrajectorySegment(trace_4, road_4) 154 | segment_5 = TrajectorySegment(trace_5, road_5) 155 | 156 | starting_list = [segment_1, segment_2, segment_3, segment_4, segment_5] 157 | 158 | # create a condition function for the merge function 159 | def _merge_condition(ts: TrajectorySegment): 160 | if len(ts.trace) < 2: 161 | return True 162 | return False 163 | 164 | condition = _merge_condition 165 | 166 | # create the expected trajectory segments 167 | expected_trace_1 = Trace.from_dataframe( 168 | pd.DataFrame( 169 | data={ 170 | "latitude": [39.655193, 39.655494, 39.655801], 171 | "longitude": [-104.919294, -104.91943, -104.919567], 172 | } 173 | ) 174 | ) 175 | expected_trace_2 = Trace.from_dataframe( 176 | pd.DataFrame( 177 | data={ 178 | "latitude": [ 179 | 39.656103, 180 | 39.656406, 181 | 39.656707, 182 | 39.657005, 183 | 39.657303, 184 | 39.657601, 185 | ], 186 | "longitude": [ 187 | -104.919698, 188 | -104.919831, 189 | -104.919964, 190 | -104.920099, 191 | -104.920229, 192 | -104.92036, 193 | ], 194 | } 195 | ) 196 | ) 197 | 198 | expected_road_1 = [ 199 | Road("first st", LineString()), 200 | Road("second st", LineString()), 201 | ] 202 | expected_road_2 = [ 203 | Road(234, LineString()), 204 | Road("first st", LineString()), 205 | Road("second st", LineString()), 206 | Road(123, LineString()), 207 | Road("main st", LineString()), 208 | Road("second str", LineString()), 209 | ] 210 | 211 | expected_segment_1 = TrajectorySegment(expected_trace_1, expected_road_1) 212 | expected_segment_2 = TrajectorySegment(expected_trace_2, expected_road_2) 213 | 214 | expected_list = [ 215 | expected_segment_1, 216 | expected_segment_2, 217 | ] 218 | 219 | resulting_list = merge(starting_list, condition=condition) 220 | 221 | # confirm forward merge accuracy 222 | self.assertEqual(len(expected_list), len(resulting_list)) 223 | for expected_trajectory, resulted_trajectory in zip( 224 | expected_list, resulting_list 225 | ): 226 | # confirm that the coordinates are the same 227 | self.assertEqual( 228 | len(expected_trajectory.trace), len(resulted_trajectory.trace) 229 | ) 230 | for expected_trace, resulted_trace in zip( 231 | expected_trajectory.trace, resulted_trajectory.trace 232 | ): 233 | self.assertEqual(len(expected_trace.coords), len(resulted_trace.coords)) 234 | for expected_coord, resulted_coord in zip( 235 | expected_trace.coords, resulted_trace.coords 236 | ): 237 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 238 | 239 | # confirm that the paths are the same 240 | self.assertListEqual(expected_trajectory.path, resulted_trajectory.path) 241 | -------------------------------------------------------------------------------- /tests/test_lcss_reverse_merge.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | from shapely.geometry import LineString 5 | 6 | from mappymatch.constructs.road import Road 7 | from mappymatch.constructs.trace import Trace 8 | from mappymatch.matchers.lcss.constructs import TrajectorySegment 9 | from mappymatch.matchers.lcss.utils import reverse_merge 10 | 11 | 12 | class TestLCSSMatcherReverseMerge(TestCase): 13 | def test_reverse_merge_beginning_no_merge(self): 14 | """ 15 | This will test that reverse_merge can merge items at the beginning of the list 16 | with no other merges in the list 17 | """ 18 | starting_list = [1, 2, 3, 4, 5] 19 | 20 | def condition(x): 21 | return x < 3 22 | 23 | expected_list = [3, 3, 4, 5] 24 | 25 | resulting_list = reverse_merge(starting_list, condition=condition) 26 | 27 | self.assertListEqual(expected_list, resulting_list) 28 | 29 | def test_reverse_merge_ending_merge(self): 30 | """ 31 | This will test that reverse_merge can merge items at the end of the list 32 | """ 33 | starting_list = [1, 2, 3, 4, 5] 34 | 35 | def condition(x): 36 | return x > 3 37 | 38 | expected_list = [1, 2, 12] 39 | 40 | resulting_list = reverse_merge(starting_list, condition=condition) 41 | 42 | self.assertListEqual(expected_list, resulting_list) 43 | 44 | def test_reverse_merge_middle(self): 45 | """ 46 | This will test that reverse_merge can merge items in the middle of the list 47 | """ 48 | starting_list = [1, 2, 4, 4, 2, 2] 49 | 50 | def condition(x): 51 | return x > 3 52 | 53 | expected_list = [1, 10, 2, 2] 54 | 55 | resulting_list = reverse_merge(starting_list, condition=condition) 56 | 57 | self.assertListEqual(expected_list, resulting_list) 58 | 59 | def test_reverse_merge_multi_merges(self): 60 | """ 61 | This will test that reverse_merge works for multiple segments including a 62 | segment in the beginning 63 | """ 64 | starting_list = [1, 2, 3, 6, 4, 2, 3, 1, 6, 7, 3, 4] 65 | 66 | def condition(x): 67 | return x < 4 68 | 69 | expected_list = [6, 6, 10, 6, 10, 4] 70 | 71 | resulting_list = reverse_merge(starting_list, condition=condition) 72 | 73 | self.assertListEqual(expected_list, resulting_list) 74 | 75 | def test_reverse_merge_trajectory_segments(self): 76 | """ 77 | This will test that a list of trajectory segments can merge 78 | """ 79 | # setup inputted trajectory segments 80 | trace_1 = Trace.from_dataframe( 81 | pd.DataFrame( 82 | data={"latitude": [39.655193], "longitude": [-104.919294]}, 83 | index=[0], 84 | ) 85 | ) 86 | trace_2 = Trace.from_dataframe( 87 | pd.DataFrame( 88 | data={ 89 | "latitude": [39.655494, 39.655801], 90 | "longitude": [-104.91943, -104.919567], 91 | }, 92 | index=[1, 2], 93 | ) 94 | ) 95 | trace_3 = Trace.from_dataframe( 96 | pd.DataFrame( 97 | data={"latitude": [39.656103], "longitude": [-104.919698]}, 98 | index=[3], 99 | ) 100 | ) 101 | trace_4 = Trace.from_dataframe( 102 | pd.DataFrame( 103 | data={ 104 | "latitude": [39.656406, 39.656707, 39.657005], 105 | "longitude": [-104.919831, -104.919964, -104.920099], 106 | }, 107 | index=[4, 5, 6], 108 | ) 109 | ) 110 | trace_5 = Trace.from_dataframe( 111 | pd.DataFrame( 112 | data={ 113 | "latitude": [39.657303, 39.657601], 114 | "longitude": [-104.920229, -104.92036], 115 | }, 116 | index=[7, 8], 117 | ) 118 | ) 119 | 120 | road_1 = [ 121 | Road( 122 | "first st", 123 | LineString(), 124 | ) 125 | ] 126 | road_2 = [ 127 | Road( 128 | "second st", 129 | LineString(), 130 | ) 131 | ] 132 | road_3 = [Road(234, LineString())] 133 | road_4 = [ 134 | Road( 135 | "first st", 136 | LineString(), 137 | ), 138 | Road( 139 | "second st", 140 | LineString(), 141 | ), 142 | Road(123, LineString()), 143 | ] 144 | road_5 = [ 145 | Road( 146 | "main st", 147 | LineString(), 148 | ), 149 | Road( 150 | "second str", 151 | LineString(), 152 | ), 153 | ] 154 | 155 | segment_1 = TrajectorySegment(trace_1, road_1) 156 | segment_2 = TrajectorySegment(trace_2, road_2) 157 | segment_3 = TrajectorySegment(trace_3, road_3) 158 | segment_4 = TrajectorySegment(trace_4, road_4) 159 | segment_5 = TrajectorySegment(trace_5, road_5) 160 | 161 | starting_list = [segment_1, segment_2, segment_3, segment_4, segment_5] 162 | 163 | # create a condition function for the merge function 164 | def _merge_condition(ts: TrajectorySegment): 165 | if len(ts.trace) < 2: 166 | return True 167 | return False 168 | 169 | condition = _merge_condition 170 | 171 | # create the expected trajectory segments 172 | expected_trace_1 = Trace.from_dataframe( 173 | pd.DataFrame( 174 | data={ 175 | "latitude": [39.655193], 176 | "longitude": [-104.919294], 177 | } 178 | ) 179 | ) 180 | expected_trace_2 = Trace.from_dataframe( 181 | pd.DataFrame( 182 | data={ 183 | "latitude": [39.655494, 39.655801, 39.656103], 184 | "longitude": [-104.91943, -104.919567, -104.919698], 185 | } 186 | ) 187 | ) 188 | expected_trace_3 = Trace.from_dataframe( 189 | pd.DataFrame( 190 | data={ 191 | "latitude": [39.656406, 39.656707, 39.657005], 192 | "longitude": [-104.919831, -104.919964, -104.920099], 193 | } 194 | ) 195 | ) 196 | expected_trace_4 = Trace.from_dataframe( 197 | pd.DataFrame( 198 | data={ 199 | "latitude": [39.657303, 39.657601], 200 | "longitude": [-104.920229, -104.92036], 201 | } 202 | ) 203 | ) 204 | 205 | expected_road_1 = [ 206 | Road( 207 | "first st", 208 | LineString(), 209 | ) 210 | ] 211 | expected_road_2 = [ 212 | Road( 213 | "second st", 214 | LineString(), 215 | ), 216 | Road(234, LineString()), 217 | ] 218 | expected_road_3 = [ 219 | Road( 220 | "first st", 221 | LineString(), 222 | ), 223 | Road( 224 | "second st", 225 | LineString(), 226 | ), 227 | Road( 228 | 123, 229 | LineString(), 230 | ), 231 | ] 232 | expected_road_4 = [ 233 | Road( 234 | "main st", 235 | LineString(), 236 | ), 237 | Road( 238 | "second str", 239 | LineString(), 240 | ), 241 | ] 242 | 243 | expected_segment_1 = TrajectorySegment(expected_trace_1, expected_road_1) 244 | expected_segment_2 = TrajectorySegment(expected_trace_2, expected_road_2) 245 | expected_segment_3 = TrajectorySegment(expected_trace_3, expected_road_3) 246 | expected_segment_4 = TrajectorySegment(expected_trace_4, expected_road_4) 247 | 248 | expected_list = [ 249 | expected_segment_1, 250 | expected_segment_2, 251 | expected_segment_3, 252 | expected_segment_4, 253 | ] 254 | 255 | resulting_list = reverse_merge(starting_list, condition=condition) 256 | 257 | # confirm forward merge accuracy 258 | self.assertEqual(len(expected_list), len(resulting_list)) 259 | for expected_trajectory, resulted_trajectory in zip( 260 | expected_list, resulting_list 261 | ): 262 | # confirm that the coordinates are the same 263 | self.assertEqual( 264 | len(expected_trajectory.trace), len(resulted_trajectory.trace) 265 | ) 266 | for expected_trace, resulted_trace in zip( 267 | expected_trajectory.trace, resulted_trajectory.trace 268 | ): 269 | self.assertEqual(len(expected_trace.coords), len(resulted_trace.coords)) 270 | for expected_coord, resulted_coord in zip( 271 | expected_trace.coords, resulted_trace.coords 272 | ): 273 | self.assertEqual(expected_coord.geom, resulted_coord.geom) 274 | 275 | # confirm that the paths are the same 276 | self.assertListEqual(expected_trajectory.path, resulted_trajectory.path) 277 | -------------------------------------------------------------------------------- /tests/test_lcss_same_trajectory_scheme.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | from shapely.geometry import LineString 5 | 6 | from mappymatch.constructs.road import Road, RoadId 7 | from mappymatch.constructs.trace import Trace 8 | from mappymatch.matchers.lcss.constructs import TrajectorySegment 9 | from mappymatch.matchers.lcss.ops import same_trajectory_scheme 10 | 11 | 12 | class TestLCSSMatcherSameTrajectoryScheme(TestCase): 13 | def test_same_trajectory_scheme_equal(self): 14 | """ 15 | This will test that two equal trajectory schemes are the same 16 | """ 17 | # setup inputted trajectory segments 18 | trace_1_a = Trace.from_dataframe( 19 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 20 | ) 21 | trace_2_a = Trace.from_dataframe( 22 | pd.DataFrame( 23 | data={ 24 | "latitude": [39.655494, 39.655801], 25 | "longitude": [-104.91943, -104.919567], 26 | } 27 | ) 28 | ) 29 | 30 | road_1_a = [Road(RoadId(1, 2, "first st"), LineString())] 31 | road_2_a = [Road(RoadId(1, 2, "second st"), LineString())] 32 | 33 | segment_1_a = TrajectorySegment(trace_1_a, road_1_a) 34 | segment_2_a = TrajectorySegment(trace_2_a, road_2_a) 35 | 36 | list_a = [segment_1_a, segment_2_a] 37 | 38 | trace_1_b = Trace.from_dataframe( 39 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 40 | ) 41 | trace_2_b = Trace.from_dataframe( 42 | pd.DataFrame( 43 | data={ 44 | "latitude": [39.655494, 39.655801], 45 | "longitude": [-104.91943, -104.919567], 46 | } 47 | ) 48 | ) 49 | 50 | road_1_b = [Road(RoadId(1, 2, "first st"), LineString())] 51 | road_2_b = [Road(RoadId(1, 2, "second st"), LineString())] 52 | 53 | segment_1_b = TrajectorySegment(trace_1_b, road_1_b) 54 | segment_2_b = TrajectorySegment(trace_2_b, road_2_b) 55 | 56 | list_b = [segment_1_b, segment_2_b] 57 | 58 | self.assertTrue(same_trajectory_scheme(list_a, list_b)) 59 | 60 | def test_same_trajectory_scheme_not_equal_paths(self): 61 | """ 62 | This will test that two trajectory schemes with same coords, but different paths will not be the same 63 | """ 64 | # setup inputted trajectory segments 65 | trace_1_a = Trace.from_dataframe( 66 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 67 | ) 68 | trace_2_a = Trace.from_dataframe( 69 | pd.DataFrame( 70 | data={ 71 | "latitude": [39.655494, 39.655801], 72 | "longitude": [-104.91943, -104.919567], 73 | } 74 | ) 75 | ) 76 | 77 | road_1_a = [Road(RoadId(1, 2, "first st"), LineString())] 78 | road_2_a = [Road(RoadId(1, 2, "second st"), LineString())] 79 | 80 | segment_1_a = TrajectorySegment(trace_1_a, road_1_a) 81 | segment_2_a = TrajectorySegment(trace_2_a, road_2_a) 82 | 83 | list_a = [segment_1_a, segment_2_a] 84 | 85 | trace_1_b = Trace.from_dataframe( 86 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 87 | ) 88 | trace_2_b = Trace.from_dataframe( 89 | pd.DataFrame( 90 | data={ 91 | "latitude": [39.655494, 39.655801], 92 | "longitude": [-104.91943, -104.919567], 93 | } 94 | ) 95 | ) 96 | 97 | road_1_b = [Road(RoadId(1, 2, "first st"), LineString())] 98 | road_2_b = [Road(RoadId(1, 2, "not second st"), LineString())] 99 | 100 | segment_1_b = TrajectorySegment(trace_1_b, road_1_b) 101 | segment_2_b = TrajectorySegment(trace_2_b, road_2_b) 102 | 103 | list_b = [segment_1_b, segment_2_b] 104 | 105 | self.assertFalse(same_trajectory_scheme(list_a, list_b)) 106 | 107 | def test_same_trajectory_scheme_not_equal_coords(self): 108 | """ 109 | This will test that two trajectory schemes with same paths, but different coords will not be the same 110 | """ 111 | # setup inputted trajectory segments 112 | trace_1_a = Trace.from_dataframe( 113 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 114 | ) 115 | trace_2_a = Trace.from_dataframe( 116 | pd.DataFrame( 117 | data={ 118 | "latitude": [39.655494, 39.655801], 119 | "longitude": [-105.91943, -104.919567], 120 | } 121 | ) 122 | ) 123 | 124 | road_1_a = [Road(RoadId(1, 2, "first st"), LineString())] 125 | road_2_a = [Road(RoadId(1, 2, "second st"), LineString())] 126 | 127 | segment_1_a = TrajectorySegment(trace_1_a, road_1_a) 128 | segment_2_a = TrajectorySegment(trace_2_a, road_2_a) 129 | 130 | list_a = [segment_1_a, segment_2_a] 131 | 132 | trace_1_b = Trace.from_dataframe( 133 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 134 | ) 135 | trace_2_b = Trace.from_dataframe( 136 | pd.DataFrame( 137 | data={ 138 | "latitude": [39.655494, 39.655801], 139 | "longitude": [-104.91943, -104.919567], 140 | } 141 | ) 142 | ) 143 | 144 | road_1_b = [Road(RoadId(1, 2, "first st"), LineString())] 145 | road_2_b = [Road(RoadId(1, 2, "second st"), LineString())] 146 | 147 | segment_1_b = TrajectorySegment(trace_1_b, road_1_b) 148 | segment_2_b = TrajectorySegment(trace_2_b, road_2_b) 149 | 150 | list_b = [segment_1_b, segment_2_b] 151 | 152 | self.assertFalse(same_trajectory_scheme(list_a, list_b)) 153 | 154 | def test_same_trajectory_scheme_not_equal_coords_nor_paths(self): 155 | """ 156 | This will test that two trajectory schemes with different coords and paths will not be the same 157 | """ 158 | # setup inputted trajectory segments 159 | trace_1_a = Trace.from_dataframe( 160 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 161 | ) 162 | trace_2_a = Trace.from_dataframe( 163 | pd.DataFrame( 164 | data={ 165 | "latitude": [39.655494, 39.655801], 166 | "longitude": [-105.91943, -104.919567], 167 | } 168 | ) 169 | ) 170 | 171 | road_1_a = [Road(RoadId(1, 2, "first st"), LineString())] 172 | road_2_a = [Road(RoadId(1, 2, "second st"), LineString())] 173 | 174 | segment_1_a = TrajectorySegment(trace_1_a, road_1_a) 175 | segment_2_a = TrajectorySegment(trace_2_a, road_2_a) 176 | 177 | list_a = [segment_1_a, segment_2_a] 178 | 179 | trace_1_b = Trace.from_dataframe( 180 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 181 | ) 182 | trace_2_b = Trace.from_dataframe( 183 | pd.DataFrame( 184 | data={ 185 | "latitude": [39.655494, 39.655801], 186 | "longitude": [-104.91943, -104.919567], 187 | } 188 | ) 189 | ) 190 | 191 | road_1_b = [Road(RoadId(1, 2, "first st"), LineString())] 192 | road_2_b = [Road("not second st", LineString())] 193 | 194 | segment_1_b = TrajectorySegment(trace_1_b, road_1_b) 195 | segment_2_b = TrajectorySegment(trace_2_b, road_2_b) 196 | 197 | list_b = [segment_1_b, segment_2_b] 198 | 199 | self.assertFalse(same_trajectory_scheme(list_a, list_b)) 200 | 201 | def test_same_trajectory_scheme_same_trace_equal(self): 202 | """ 203 | This will test that a trajectory schemes is equal to itself 204 | """ 205 | # setup inputted trajectory segments 206 | trace_1_a = Trace.from_dataframe( 207 | pd.DataFrame(data={"latitude": [39.655193], "longitude": [-104.919294]}) 208 | ) 209 | trace_2_a = Trace.from_dataframe( 210 | pd.DataFrame( 211 | data={ 212 | "latitude": [39.655494, 39.655801], 213 | "longitude": [-104.91943, -104.919567], 214 | } 215 | ) 216 | ) 217 | 218 | road_1_a = [Road(RoadId(1, 2, "first st"), LineString())] 219 | road_2_a = [Road(RoadId(1, 2, "second st"), LineString())] 220 | 221 | segment_1_a = TrajectorySegment(trace_1_a, road_1_a) 222 | segment_2_a = TrajectorySegment(trace_2_a, road_2_a) 223 | 224 | list_a = [segment_1_a, segment_2_a] 225 | 226 | self.assertTrue(same_trajectory_scheme(list_a, list_a)) 227 | -------------------------------------------------------------------------------- /tests/test_match_result.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from shapely.geometry import LineString, Point 6 | 7 | from mappymatch.constructs.coordinate import Coordinate 8 | from mappymatch.constructs.road import Road, RoadId 9 | from mappymatch.matchers.match_result import Match, MatchResult 10 | from mappymatch.utils.crs import LATLON_CRS 11 | 12 | dummy_coordinate_1 = Coordinate("1", Point(1, 1), LATLON_CRS) 13 | dummy_coordinate_2 = Coordinate("2", Point(1, 1), LATLON_CRS) 14 | dummy_line = LineString([(1, 1), (2, 2)]) 15 | dummy_road = Road( 16 | RoadId(1, 2, 3), 17 | geom=dummy_line, 18 | metadata={"a": 1, "b": 2}, 19 | ) 20 | dummy_matches = [ 21 | Match(None, dummy_coordinate_1, np.inf), 22 | Match(dummy_road, dummy_coordinate_2, 1.0), 23 | ] 24 | dummy_path = [dummy_road, dummy_road] 25 | dummy_match_result = MatchResult(dummy_matches, dummy_path) 26 | 27 | 28 | class TestMatchResult(TestCase): 29 | def test_matches_to_dataframe(self): 30 | df = dummy_match_result.matches_to_dataframe() 31 | self.assertTrue(pd.isna(df.iloc[0].road_id)) 32 | self.assertTrue(pd.isna(df.iloc[0].a)) 33 | self.assertTrue(df.iloc[1].road_id == RoadId(1, 2, 3)) 34 | self.assertTrue(df.iloc[1].a == 1) 35 | -------------------------------------------------------------------------------- /tests/test_osm.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import networkx as nx 4 | import osmnx as ox 5 | 6 | from mappymatch.maps.nx.readers.osm_readers import ( 7 | NetworkType, 8 | parse_osmnx_graph, 9 | ) 10 | from tests import get_test_dir 11 | 12 | 13 | class TestOSMap(TestCase): 14 | def test_osm_networkx_graph_drive(self): 15 | # This is just a raw osmnx graph pull using the script: 16 | # tests/test_assets/pull_osm_map.py 17 | gfile = get_test_dir() / "test_assets" / "osmnx_drive_graph.graphml" 18 | 19 | osmnx_graph = ox.load_graphml(gfile) 20 | 21 | cleaned_graph = parse_osmnx_graph(osmnx_graph, NetworkType.DRIVE) 22 | 23 | self.assertEqual(cleaned_graph.graph["network_type"], NetworkType.DRIVE.value) 24 | 25 | self.assertTrue( 26 | nx.is_strongly_connected(cleaned_graph), 27 | "Graph is not strongly connected", 28 | ) 29 | 30 | has_geom = all( 31 | [ 32 | d.get("geometry") is not None 33 | for _, _, d in cleaned_graph.edges(data=True) 34 | ] 35 | ) 36 | 37 | self.assertTrue(has_geom, "All edges should have geometry") 38 | 39 | # check to make sure we don't have any extra data stored in the edges 40 | expected_edge_keys = ["geometry", "travel_time", "kilometers"] 41 | expected_node_keys = [] 42 | 43 | edges_have_right_keys = all( 44 | [ 45 | set(d.keys()) == set(expected_edge_keys) 46 | for _, _, d in cleaned_graph.edges(data=True) 47 | ] 48 | ) 49 | 50 | self.assertTrue(edges_have_right_keys, "Edges have unexpected keys") 51 | 52 | nodes_have_right_keys = all( 53 | [ 54 | set(d.keys()) == set(expected_node_keys) 55 | for _, d in cleaned_graph.nodes(data=True) 56 | ] 57 | ) 58 | 59 | self.assertTrue(nodes_have_right_keys, "Nodes have unexpected keys") 60 | -------------------------------------------------------------------------------- /tests/test_process_trace.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mappymatch.constructs.trace import Trace 4 | from mappymatch.utils.process_trace import ( 5 | remove_bad_start_from_trace, 6 | split_large_trace, 7 | ) 8 | from tests import get_test_dir 9 | 10 | 11 | class TestProcessTrace(TestCase): 12 | def setUp(self) -> None: 13 | bad_trace_file = get_test_dir() / "test_assets" / "trace_bad_start.geojson" 14 | 15 | self.trace_bad_start = Trace.from_geojson(bad_trace_file, xy=True) 16 | 17 | trace_file = get_test_dir() / "test_assets" / "test_trace.geojson" 18 | 19 | # This trace has 16 points 20 | self.trace = Trace.from_geojson(trace_file, xy=True) 21 | 22 | def test_remove_bad_start_from_trace(self): 23 | """ 24 | a test to ensure that the gap in the beginning of the trace is removed 25 | """ 26 | bad_point = self.trace_bad_start.coords[0] 27 | 28 | new_trace = remove_bad_start_from_trace(self.trace_bad_start, 30) 29 | 30 | self.assertTrue( 31 | bad_point not in new_trace.coords, 32 | f"trace should have the first point {bad_point} removed", 33 | ) 34 | 35 | def test_trace_smaller_than_ideal_size(self): 36 | result = split_large_trace(self.trace, 20) 37 | self.assertEqual(len(result), 1) 38 | self.assertEqual(result[0], self.trace) 39 | 40 | def test_trace_equal_to_ideal_size(self): 41 | result = split_large_trace(self.trace, 16) 42 | self.assertEqual(len(result), 1) 43 | self.assertEqual(result[0], self.trace) 44 | 45 | def test_ideal_size_zero(self): 46 | with self.assertRaises(ValueError): 47 | split_large_trace(self.trace, 0) 48 | 49 | def test_ideal_size(self): 50 | result = split_large_trace(self.trace, 10) 51 | self.assertEqual(len(result), 1) 52 | self.assertEqual(len(result[0]), 16) 53 | 54 | def test_trace_larger_with_merging(self): 55 | result = split_large_trace(self.trace, 12) # Splitting into chunks of 12 56 | self.assertEqual(len(result), 1) # Expect merging to create a single chunk 57 | self.assertEqual(len(result[0]), 16) # All points are in one merged trace 58 | -------------------------------------------------------------------------------- /tests/test_trace.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pandas as pd 4 | 5 | from mappymatch import package_root 6 | from mappymatch.constructs.trace import Trace 7 | from mappymatch.utils.crs import XY_CRS 8 | from mappymatch.utils.geo import xy_to_latlon 9 | from tests import get_test_dir 10 | 11 | 12 | class TestTrace(TestCase): 13 | def test_trace_from_file(self): 14 | file = package_root() / "resources" / "traces" / "sample_trace_1.csv" 15 | 16 | trace = Trace.from_csv(file) 17 | 18 | self.assertEqual(trace.crs, XY_CRS) 19 | self.assertEqual(len(trace), 1053) 20 | 21 | def test_trace_from_dataframe(self): 22 | file = package_root() / "resources" / "traces" / "sample_trace_1.csv" 23 | 24 | df = pd.read_csv(file) 25 | 26 | trace = Trace.from_dataframe(df) 27 | 28 | self.assertEqual(trace.crs, XY_CRS) 29 | self.assertEqual(len(trace), 1053) 30 | 31 | def test_trace_from_gpx(self): 32 | file = get_test_dir() / "test_assets" / "test_trace.gpx" 33 | trace = Trace.from_gpx(file) 34 | 35 | self.assertEqual(trace.crs, XY_CRS) 36 | self.assertEqual(len(trace), 778) 37 | 38 | # check if the first / last point matches the gpx file 39 | # in wgs84 lat lon 40 | pt1 = xy_to_latlon(trace.coords[0].x, trace.coords[0].y) 41 | pt2 = xy_to_latlon(trace.coords[-1].x, trace.coords[-1].y) 42 | target_pt1, target_pt2 = ( 43 | (39.74445, -104.97347), 44 | ( 45 | 39.74392, 46 | -104.9734299, 47 | ), 48 | ) 49 | self.assertAlmostEqual(pt1[0], target_pt1[0]) 50 | self.assertAlmostEqual(pt1[1], target_pt1[1]) 51 | self.assertAlmostEqual(pt2[0], target_pt2[0]) 52 | self.assertAlmostEqual(pt2[1], target_pt2[1]) 53 | -------------------------------------------------------------------------------- /tests/test_valhalla.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mappymatch.constructs.trace import Trace 4 | from mappymatch.matchers.valhalla import ValhallaMatcher 5 | from tests import get_test_dir 6 | 7 | 8 | class TestTrace(TestCase): 9 | def test_valhalla_on_small_trace(self): 10 | file = get_test_dir() / "test_assets" / "test_trace.geojson" 11 | 12 | trace = Trace.from_geojson(file, xy=False) 13 | 14 | matcher = ValhallaMatcher() 15 | 16 | result = matcher.match_trace(trace) 17 | 18 | match_df = result.matches_to_dataframe() 19 | _ = result.path_to_dataframe() 20 | 21 | self.assertEqual(len(match_df), len(trace)) 22 | --------------------------------------------------------------------------------