├── docs
├── .nojekyll
├── images
│ └── map-matching.gif
├── _autosummary
│ ├── mappymatch.utils.crs.rst
│ ├── mappymatch.utils.url.rst
│ ├── mappymatch.maps.nx.nx_map.rst
│ ├── mappymatch.constructs.match.rst
│ ├── mappymatch.constructs.trace.rst
│ ├── mappymatch.maps.nx.rst
│ ├── mappymatch.maps.igraph.rst
│ ├── mappymatch.maps.rst
│ ├── mappymatch.constructs.road.rst
│ ├── mappymatch.matchers.lcss.lcss.rst
│ ├── mappymatch.utils.exceptions.rst
│ ├── mappymatch.constructs.geofence.rst
│ ├── mappymatch.maps.map_interface.rst
│ ├── mappymatch.matchers.line_snap.rst
│ ├── mappymatch.constructs.coordinate.rst
│ ├── mappymatch.maps.nx.readers.rst
│ ├── mappymatch.matchers.match_result.rst
│ ├── mappymatch.maps.igraph.igraph_map.rst
│ ├── mappymatch.utils.geo.rst
│ ├── mappymatch.matchers.matcher_interface.rst
│ ├── mappymatch.matchers.lcss.rst
│ ├── mappymatch.utils.rst
│ ├── mappymatch.constructs.rst
│ ├── mappymatch.matchers.lcss.constructs.rst
│ ├── mappymatch.utils.process_trace.rst
│ ├── mappymatch.matchers.lcss.utils.rst
│ ├── mappymatch.matchers.rst
│ ├── mappymatch.matchers.osrm.rst
│ ├── mappymatch.utils.plot.rst
│ ├── mappymatch.matchers.valhalla.rst
│ ├── mappymatch.maps.nx.readers.osm_readers.rst
│ └── mappymatch.matchers.lcss.ops.rst
├── quick-start.md
├── api-docs.md
├── _toc.yml
├── home.md
├── install.md
└── _config.yml
├── mappymatch
├── maps
│ ├── __init__.py
│ ├── nx
│ │ ├── __init__.py
│ │ └── readers
│ │ │ ├── __init__.py
│ │ │ └── osm_readers.py
│ ├── igraph
│ │ └── __init__.py
│ └── map_interface.py
├── utils
│ ├── __init__.py
│ ├── crs.py
│ ├── keys.py
│ ├── exceptions.py
│ ├── url.py
│ ├── plot
│ │ ├── __init__.py
│ │ ├── geofence.py
│ │ ├── trace.py
│ │ ├── path.py
│ │ ├── map.py
│ │ ├── matches.py
│ │ └── trajectory_segment.py
│ ├── geo.py
│ └── process_trace.py
├── constructs
│ ├── __init__.py
│ ├── match.py
│ ├── road.py
│ ├── coordinate.py
│ ├── geofence.py
│ └── trace.py
├── matchers
│ ├── __init__.py
│ ├── lcss
│ │ ├── __init__.py
│ │ ├── utils.py
│ │ ├── lcss.py
│ │ ├── ops.py
│ │ └── constructs.py
│ ├── matcher_interface.py
│ ├── line_snap.py
│ ├── match_result.py
│ ├── osrm.py
│ └── valhalla.py
├── resources
│ └── __init__.py
├── __about__.py
└── __init__.py
├── .github
├── CODEOWNERS
└── workflows
│ ├── lint-test.yml
│ ├── release.yaml
│ └── deploy-docs.yaml
├── tests
├── __init__.py
├── test_assets
│ ├── pull_osm_map.py
│ ├── downtown_denver.geojson
│ ├── test_trace.geojson
│ ├── trace_bad_start.geojson
│ └── test_trace_stationary_points.geojson
├── test_geofence.py
├── test_valhalla.py
├── test_coordinate.py
├── test_match_result.py
├── test_geo.py
├── test_trace.py
├── test_osm.py
├── test_process_trace.py
├── test_lcss_compress.py
├── test_lcss_merge.py
├── test_lcss_same_trajectory_scheme.py
├── test_lcss_find_stationary_points.py
├── test_lcss_forward_merge.py
├── test_lcss_add_match_for_stationary.py
├── test_lcss_reverse_merge.py
└── test_disconnected_components.py
├── .gitattributes
├── .pre-commit-config.yaml
├── CLAUDE.md
├── LICENSE
├── README.md
├── CONTRIBUTING.md
├── .gitignore
└── pyproject.toml
/docs/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/maps/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/constructs/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/maps/nx/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/matchers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/resources/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/maps/igraph/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/maps/nx/readers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mappymatch/matchers/lcss/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @nreinicke @jhoshiko
--------------------------------------------------------------------------------
/mappymatch/__about__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.7.1"
2 |
--------------------------------------------------------------------------------
/docs/images/map-matching.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NREL/mappymatch/HEAD/docs/images/map-matching.gif
--------------------------------------------------------------------------------
/mappymatch/utils/crs.py:
--------------------------------------------------------------------------------
1 | from pyproj import CRS
2 |
3 | LATLON_CRS = CRS(4326)
4 | XY_CRS = CRS(3857)
5 |
--------------------------------------------------------------------------------
/mappymatch/utils/keys.py:
--------------------------------------------------------------------------------
1 | DEFAULT_GEOMETRY_KEY = "geometry"
2 | DEFAULT_METADATA_KEY = "metadata"
3 | DEFAULT_CRS_KEY = "crs"
4 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 |
4 | def get_test_dir() -> Path:
5 | return Path(__file__).parent
6 |
--------------------------------------------------------------------------------
/mappymatch/__init__.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 |
4 | def package_root() -> Path:
5 | return Path(__file__).parent
6 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # SCM syntax highlighting & preventing 3-way merges
2 | pixi.lock merge=binary linguist-language=YAML linguist-generated=true
3 |
--------------------------------------------------------------------------------
/docs/_autosummary/mappymatch.utils.crs.rst:
--------------------------------------------------------------------------------
1 | mappymatch.utils.crs
2 | ====================
3 |
4 | .. automodule:: mappymatch.utils.crs
5 |
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/_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 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: local
3 | hooks:
4 | - id: pixi-check
5 | name: Run pixi checks
6 | entry: pixi run -e dev check
7 | language: system
8 | pass_filenames: false
9 | always_run: true
10 |
--------------------------------------------------------------------------------
/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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # Project Context
2 |
3 | When working with this codebase, prioritize readability over cleverness. Ask clarifying questions before making architectural changes.
4 |
5 | ## Project overview
6 |
7 | Mappymatch is a python package used to match a series of GPS waypoints (Trace) to a road network.
8 |
9 |
10 | ## Common Commands
11 |
12 | ### running full check (test, types, lint, format)
13 |
14 | ```
15 | pixi run -e dev check
16 | ```
17 |
18 |
--------------------------------------------------------------------------------
/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/_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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/mappymatch/matchers/matcher_interface.py:
--------------------------------------------------------------------------------
1 | from abc import ABCMeta, abstractmethod
2 |
3 | from mappymatch.constructs.trace import Trace
4 | from mappymatch.matchers.match_result import MatchResult
5 |
6 |
7 | class MatcherInterface(metaclass=ABCMeta):
8 | """
9 | Abstract base class for a Matcher
10 | """
11 |
12 | @abstractmethod
13 | def match_trace(self, trace: Trace) -> MatchResult:
14 | """
15 | Take in a trace of gps points and return a list of matching link ids
16 |
17 | Args:
18 | trace: The trace to match
19 |
20 | Returns:
21 | A list of Match objects
22 | """
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/mappymatch/utils/plot/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Plot module for mappymatch.
3 |
4 | This module provides plotting utilities for geofences, traces, matches, maps, paths, and trajectory segments.
5 | """
6 |
7 | from mappymatch.utils.plot.geofence import plot_geofence
8 | from mappymatch.utils.plot.map import plot_map
9 | from mappymatch.utils.plot.matches import plot_match_distances, plot_matches
10 | from mappymatch.utils.plot.path import plot_path
11 | from mappymatch.utils.plot.trace import plot_trace
12 | from mappymatch.utils.plot.trajectory_segment import plot_trajectory_segment
13 |
14 | __all__ = [
15 | "plot_geofence",
16 | "plot_trace",
17 | "plot_matches",
18 | "plot_match_distances",
19 | "plot_map",
20 | "plot_path",
21 | "plot_trajectory_segment",
22 | ]
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/mappymatch/utils/plot/geofence.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import folium
4 |
5 | from mappymatch.constructs.geofence import Geofence
6 | from mappymatch.utils.crs import LATLON_CRS
7 |
8 |
9 | def plot_geofence(geofence: Geofence, m: Optional[folium.Map] = None):
10 | """
11 | Plot geofence.
12 |
13 | Args:
14 | geofence: The geofence to plot
15 | m: the folium map to plot on
16 |
17 | Returns:
18 | The updated folium map with the geofence.
19 | """
20 | if not geofence.crs == LATLON_CRS:
21 | raise NotImplementedError("can currently only plot a geofence with lat lon crs")
22 |
23 | if not m:
24 | c = geofence.geometry.centroid.coords[0]
25 | m = folium.Map(location=[c[1], c[0]], zoom_start=11)
26 |
27 | folium.GeoJson(geofence.geometry).add_to(m)
28 |
29 | return m
30 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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.10", "3.11", "3.12", "3.13"]
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.11"
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@v4
30 | with:
31 | path: ./dist/*
32 | - name: Publish package
33 | uses: pypa/gh-action-pypi-publish@release/v1
34 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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/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/trace.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import folium
4 |
5 | from mappymatch.constructs.trace import Trace
6 | from mappymatch.utils.crs import LATLON_CRS
7 |
8 |
9 | def plot_trace(
10 | trace: Trace,
11 | m: Optional[folium.Map] = None,
12 | point_color: str = "black",
13 | line_color: Optional[str] = "green",
14 | ):
15 | """
16 | Plot a trace.
17 |
18 | Args:
19 | trace: The trace.
20 | m: the folium map to plot on
21 | point_color: The color the points will be plotted in.
22 | line_color: The color for lines. If None, no lines will be plotted.
23 |
24 | Returns:
25 | An updated folium map with a plot of trace.
26 | """
27 |
28 | if not trace.crs == LATLON_CRS:
29 | trace = trace.to_crs(LATLON_CRS)
30 |
31 | if not m:
32 | mid_coord = trace.coords[int(len(trace) / 2)]
33 | m = folium.Map(location=[mid_coord.y, mid_coord.x], zoom_start=11)
34 |
35 | for i, c in enumerate(trace.coords):
36 | folium.Circle(
37 | location=(c.y, c.x),
38 | radius=5,
39 | color=point_color,
40 | tooltip=str(i),
41 | fill=True,
42 | fill_opacity=0.8,
43 | fill_color=point_color,
44 | ).add_to(m)
45 |
46 | if line_color is not None:
47 | folium.PolyLine([(p.y, p.x) for p in trace.coords], color=line_color).add_to(m)
48 |
49 | return m
50 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/mappymatch/utils/plot/path.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 | import folium
4 | import geopandas as gpd
5 | import pandas as pd
6 | from pyproj import CRS
7 |
8 | from mappymatch.constructs.road import Road
9 | from mappymatch.utils.crs import LATLON_CRS
10 |
11 |
12 | def plot_path(
13 | path: List[Road],
14 | crs: CRS,
15 | m: Optional[folium.Map] = None,
16 | line_color="red",
17 | line_weight=10,
18 | line_opacity=0.8,
19 | ):
20 | """
21 | Plot a list of roads.
22 |
23 | Args:
24 | path: The path to plot.
25 | crs: The crs of the path.
26 | m: The folium map to add to.
27 | line_color: The color of the line.
28 | line_weight: The weight of the line.
29 | line_opacity: The opacity of the line.
30 | """
31 | road_df = pd.DataFrame([{"geom": r.geom} for r in path])
32 | road_gdf = gpd.GeoDataFrame(road_df, geometry=road_df.geom, crs=crs)
33 | road_gdf = road_gdf.to_crs(LATLON_CRS)
34 |
35 | if m is None:
36 | mid_i = int(len(road_gdf) / 2)
37 | mid_coord = road_gdf.iloc[mid_i].geometry.coords[0]
38 |
39 | m = folium.Map(location=[mid_coord[1], mid_coord[0]], zoom_start=11)
40 |
41 | for i, road in enumerate(road_gdf.itertuples()):
42 | folium.PolyLine(
43 | [(lat, lon) for lon, lat in road.geometry.coords],
44 | color=line_color,
45 | tooltip=i,
46 | weight=line_weight,
47 | opacity=line_opacity,
48 | ).add_to(m)
49 |
50 | return m
51 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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", "metadata"]
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 | print(list(cleaned_graph.nodes(data=True))[:5])
60 |
61 | self.assertTrue(nodes_have_right_keys, "Nodes have unexpected keys")
62 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/utils/plot/map.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import folium
4 | import geopandas as gpd
5 | import pandas as pd
6 |
7 | from mappymatch.constructs.road import RoadId
8 | from mappymatch.maps.nx.nx_map import NxMap
9 | from mappymatch.utils.crs import LATLON_CRS
10 |
11 |
12 | def plot_map(tmap: NxMap, m: Optional[folium.Map] = None, highlight: bool = False):
13 | """
14 | Plot the roads on an NxMap.
15 |
16 | Args:
17 | tmap: The Nxmap to plot.
18 | m: the folium map to add to
19 | highlight: Whether to enable hover highlighting and popups (default: False)
20 |
21 | Returns:
22 | The folium map with the roads plotted.
23 | """
24 |
25 | # TODO make this generic to all map types, not just NxMap
26 | roads = list(tmap.g.edges(data=True, keys=True))
27 | road_data = []
28 | for u, v, key, data in roads:
29 | road_id = RoadId(start=u, end=v, key=key)
30 | data_copy = data.copy()
31 | data_copy["road_id"] = road_id
32 | road_data.append(data_copy)
33 |
34 | road_df = pd.DataFrame(road_data)
35 | gdf = gpd.GeoDataFrame(road_df, geometry=road_df[tmap._geom_key], crs=tmap.crs)
36 | if gdf.crs != LATLON_CRS:
37 | gdf = gdf.to_crs(LATLON_CRS)
38 |
39 | if not m:
40 | c = gdf.iloc[int(len(gdf) / 2)].geometry.centroid.coords[0]
41 | m = folium.Map(location=[c[1], c[0]], zoom_start=11)
42 |
43 | # Convert road_id to string for GeoJSON compatibility
44 | gdf["road_id_str"] = gdf["road_id"].astype(str)
45 |
46 | # Create GeoJson layer with optional popup and highlighting
47 | if highlight:
48 | popup = folium.GeoJsonPopup(fields=["road_id_str"])
49 | tooltip = folium.GeoJsonTooltip(fields=["road_id_str"])
50 | folium.GeoJson(
51 | gdf.to_json(),
52 | style_function=lambda x: {
53 | "color": "red",
54 | "weight": 3,
55 | "opacity": 0.7,
56 | },
57 | highlight_function=lambda x: {
58 | "color": "yellow",
59 | "weight": 6,
60 | "opacity": 1.0,
61 | },
62 | popup=popup,
63 | tooltip=tooltip,
64 | popup_keep_highlighted=True,
65 | ).add_to(m)
66 | else:
67 | folium.GeoJson(
68 | gdf.to_json(),
69 | style_function=lambda x: {
70 | "color": "red",
71 | "weight": 3,
72 | "opacity": 0.7,
73 | },
74 | ).add_to(m)
75 |
76 | return m
77 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | # pixi environments
153 | .pixi/*
154 | !.pixi/config.toml
155 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
91 | r = requests.get(osrm_request)
92 |
93 | if not r.status_code == requests.codes.ok:
94 | r.raise_for_status()
95 |
96 | result = parse_osrm_json(r.json(), trace)
97 |
98 | return MatchResult(result)
99 |
--------------------------------------------------------------------------------
/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/utils/plot/matches.py:
--------------------------------------------------------------------------------
1 | from typing import List, Union
2 |
3 | import folium
4 | import geopandas as gpd
5 | import matplotlib.pyplot as plt
6 | import pandas as pd
7 | from shapely.geometry import Point
8 |
9 | from mappymatch.constructs.match import Match
10 | from mappymatch.matchers.matcher_interface import MatchResult
11 | from mappymatch.utils.crs import LATLON_CRS, XY_CRS
12 |
13 |
14 | def plot_matches(matches: Union[MatchResult, List[Match]], crs=XY_CRS):
15 | """
16 | Plots a trace and the relevant matches on a folium map.
17 |
18 | Args:
19 | matches: A list of matches or a MatchResult.
20 | crs: what crs to plot in. Defaults to XY_CRS.
21 |
22 | Returns:
23 | A folium map with trace and matches plotted.
24 | """
25 | if isinstance(matches, MatchResult):
26 | matches = matches.matches
27 |
28 | def _match_to_road(m):
29 | """Private function."""
30 | d = {"road_id": m.road.road_id, "geom": m.road.geom}
31 | return d
32 |
33 | def _match_to_coord(m):
34 | """Private function."""
35 | d = {
36 | "road_id": m.road.road_id,
37 | "geom": Point(m.coordinate.x, m.coordinate.y),
38 | "distance": m.distance,
39 | }
40 |
41 | return d
42 |
43 | road_df = pd.DataFrame([_match_to_road(m) for m in matches if m.road])
44 | road_df = road_df.loc[road_df.road_id.shift() != road_df.road_id]
45 | road_gdf = gpd.GeoDataFrame(road_df, geometry=road_df.geom, crs=crs).drop(
46 | columns=["geom"]
47 | )
48 | road_gdf = road_gdf.to_crs(LATLON_CRS)
49 |
50 | coord_df = pd.DataFrame([_match_to_coord(m) for m in matches if m.road])
51 |
52 | coord_gdf = gpd.GeoDataFrame(coord_df, geometry=coord_df.geom, crs=crs).drop(
53 | columns=["geom"]
54 | )
55 | coord_gdf = coord_gdf.to_crs(LATLON_CRS)
56 |
57 | mid_i = int(len(coord_gdf) / 2)
58 | mid_coord = coord_gdf.iloc[mid_i].geometry
59 |
60 | fmap = folium.Map(location=[mid_coord.y, mid_coord.x], zoom_start=11)
61 |
62 | for coord in coord_gdf.itertuples():
63 | folium.Circle(
64 | location=(coord.geometry.y, coord.geometry.x),
65 | radius=5,
66 | tooltip=f"road_id: {coord.road_id}\ndistance: {coord.distance}",
67 | ).add_to(fmap)
68 |
69 | for road in road_gdf.itertuples():
70 | folium.PolyLine(
71 | [(lat, lon) for lon, lat in road.geometry.coords],
72 | color="red",
73 | tooltip=road.road_id,
74 | ).add_to(fmap)
75 |
76 | return fmap
77 |
78 |
79 | def plot_match_distances(matches: MatchResult):
80 | """
81 | Plot the points deviance from known roads with matplotlib.
82 |
83 | Args:
84 | matches (MatchResult): The coordinates of guessed points in the area in the form of a MatchResult object.
85 | """
86 |
87 | y = [
88 | m.distance for m in matches.matches
89 | ] # y contains distances to the expected line for all of the matches which will be plotted on the y-axis.
90 | x = [
91 | i for i in range(0, len(y))
92 | ] # x contains placeholder values for every y value (distance measurement) along the x-axis.
93 |
94 | plt.figure(figsize=(15, 7))
95 | plt.autoscale(enable=True)
96 | plt.scatter(x, y)
97 | plt.title("Distance To Nearest Road")
98 | plt.ylabel("Meters")
99 | plt.xlabel("Point Along The Path")
100 | plt.show()
101 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.10",
19 | "Programming Language :: Python :: 3.11",
20 | "Programming Language :: Python :: 3.12",
21 | "Programming Language :: Python :: 3.13",
22 | "Topic :: Scientific/Engineering",
23 | ]
24 | keywords = ["GPS", "map", "match"]
25 | dependencies = [
26 | "geopandas>=1,<2",
27 | "osmnx>=2,<3",
28 | "shapely>=2,<3",
29 | "pyproj>=3,<4",
30 | "pandas>=2,<3",
31 | "numpy>=2,<3",
32 | "matplotlib>=3,<4",
33 | "networkx>=3,<4",
34 | "igraph>=1.0,<2",
35 | "folium>=0.20,<1",
36 | "requests>=2,<3",
37 | "polyline>=2,<3",
38 | ]
39 | requires-python = ">=3.10"
40 |
41 | [project.optional-dependencies]
42 | # Used to run CI.
43 | tests = ["ruff>=0.14,<1", "mypy>=1,<2", "types-requests", "pytest>=9,<10"]
44 | # Used to build the docs.
45 | docs = [
46 | "jupyter-book>=2",
47 | "sphinx-book-theme",
48 | "sphinx-autodoc-typehints",
49 | "sphinxcontrib-autoyaml",
50 | "sphinxcontrib.mermaid",
51 | ]
52 | # Tests + docs + other.
53 | dev = [
54 | "hatch>=1,<2",
55 | "mappymatch[tests]",
56 | "mappymatch[docs]",
57 | "coverage",
58 | "pre-commit",
59 | ]
60 |
61 | [project.urls]
62 | Homepage = "https://github.com/NREL/mappymatch"
63 |
64 | [tool.hatch.version]
65 | path = "mappymatch/__about__.py"
66 |
67 | [tool.hatch.build.targets.sdist]
68 | exclude = ["tests/", "docs/", "examples/"]
69 |
70 | [tool.ruff]
71 | exclude = ["build/*", "dist/*"]
72 |
73 | # Same as Black.
74 | line-length = 88
75 | indent-width = 4
76 |
77 | # Assume Python 3.9
78 | target-version = "py39"
79 |
80 | [tool.ruff.lint]
81 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
82 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
83 | # McCabe complexity (`C901`) by default.
84 | select = ["E4", "E7", "E9", "F"]
85 | ignore = []
86 |
87 | # Allow fix for all enabled rules (when `--fix`) is provided.
88 | fixable = ["ALL"]
89 | unfixable = []
90 |
91 | # Allow unused variables when underscore-prefixed.
92 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
93 |
94 | [tool.mypy]
95 | ignore_missing_imports = true
96 | exclude = ["docs/", "build/", "dist/", "py-notebooks/"]
97 |
98 | [tool.coverage.run]
99 | # Ensures coverage for all if, elif, else branches.
100 | # https://coverage.readthedocs.io/en/6.3.2/branch.html#branch
101 | branch = true
102 |
103 | [tool.coverage.report]
104 | precision = 1
105 | fail_under = 50.0
106 | skip_covered = false
107 | skip_empty = true
108 |
109 | [tool.pixi.workspace]
110 | channels = ["conda-forge"]
111 | platforms = ["osx-arm64"]
112 |
113 | [tool.pixi.pypi-dependencies]
114 | mappymatch = { path = ".", editable = true }
115 |
116 | [tool.pixi.environments]
117 | default = { solve-group = "default" }
118 | dev = { features = ["dev", "tests", "docs"], solve-group = "default" }
119 | docs = { features = ["docs"], solve-group = "default" }
120 | tests = { features = ["tests"], solve-group = "default" }
121 |
122 | [tool.pixi.feature.dev.tasks]
123 | fmt_fix = "ruff format"
124 | lint_fix = "ruff check --fix"
125 | typing = "mypy ."
126 | test = "pytest tests/"
127 | check = { depends-on = ["fmt_fix", "lint_fix", "typing", "test"] }
128 |
--------------------------------------------------------------------------------
/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/lcss/lcss.py:
--------------------------------------------------------------------------------
1 | import functools as ft
2 | import logging
3 |
4 | from mappymatch.maps.map_interface import MapInterface
5 | from mappymatch.matchers.lcss.constructs import TrajectorySegment
6 | from mappymatch.matchers.lcss.ops import (
7 | add_matches_for_stationary_points,
8 | drop_stationary_points,
9 | find_stationary_points,
10 | join_segment,
11 | new_path,
12 | same_trajectory_scheme,
13 | split_trajectory_segment,
14 | )
15 | from mappymatch.matchers.matcher_interface import (
16 | MatcherInterface,
17 | MatchResult,
18 | Trace,
19 | )
20 |
21 | log = logging.getLogger(__name__)
22 |
23 |
24 | class LCSSMatcher(MatcherInterface):
25 | """
26 | A map matcher based on the paper:
27 |
28 | Zhu, Lei, Jacob R. Holden, and Jeffrey D. Gonder.
29 | "Trajectory Segmentation Map-Matching Approach for Large-Scale,
30 | High-Resolution GPS Data."
31 | Transportation Research Record: Journal of the Transportation Research
32 | Board 2645 (2017): 67-75.
33 |
34 | Args:
35 | road_map: The road map to use for matching
36 | distance_epsilon: The distance epsilon to use for matching (default: 50 meters)
37 | similarity_cutoff: The similarity cutoff to use for stopping the algorithm (default: 0.9)
38 | cutting_threshold: The distance threshold to use for computing cutting points (default: 10 meters)
39 | random_cuts: The number of random cuts to add at each iteration (default: 0)
40 | distance_threshold: The distance threshold above which no match is made (default: 10000 meters)
41 | """
42 |
43 | def __init__(
44 | self,
45 | road_map: MapInterface,
46 | distance_epsilon: float = 50.0,
47 | similarity_cutoff: float = 0.9,
48 | cutting_threshold: float = 10.0,
49 | random_cuts: int = 0,
50 | distance_threshold: float = 10000,
51 | ):
52 | self.road_map = road_map
53 | self.distance_epsilon = distance_epsilon
54 | self.similarity_cutoff = similarity_cutoff
55 | self.cutting_threshold = cutting_threshold
56 | self.random_cuts = random_cuts
57 | self.distance_threshold = distance_threshold
58 |
59 | def match_trace(self, trace: Trace) -> MatchResult:
60 | stationary_index = find_stationary_points(trace)
61 |
62 | sub_trace = drop_stationary_points(trace, stationary_index)
63 |
64 | road_map = self.road_map
65 | de = self.distance_epsilon
66 | ct = self.cutting_threshold
67 | rc = self.random_cuts
68 | dt = self.distance_threshold
69 | initial_segment = (
70 | TrajectorySegment(trace=sub_trace, path=new_path(road_map, sub_trace))
71 | .score_and_match(de, dt)
72 | .compute_cutting_points(de, ct, rc)
73 | )
74 |
75 | initial_scheme = split_trajectory_segment(road_map, initial_segment)
76 | scheme = initial_scheme
77 |
78 | n = 0
79 | while n < 10:
80 | next_scheme = []
81 | for segment in scheme:
82 | scored_segment = segment.score_and_match(de, dt).compute_cutting_points(
83 | de, ct, rc
84 | )
85 | if scored_segment.score >= self.similarity_cutoff:
86 | next_scheme.append(scored_segment)
87 | else:
88 | # split and check the score
89 | new_split = split_trajectory_segment(road_map, scored_segment)
90 | joined_segment = ft.reduce(
91 | lambda a, b: join_segment(road_map, a, b), new_split
92 | ).score_and_match(de, dt)
93 | if joined_segment.score > scored_segment.score:
94 | # we found a better fit
95 | next_scheme.extend(new_split)
96 | else:
97 | next_scheme.append(scored_segment)
98 | n += 1
99 | if same_trajectory_scheme(scheme, next_scheme):
100 | break
101 |
102 | scheme = next_scheme
103 |
104 | joined_segment = ft.reduce(
105 | lambda a, b: join_segment(road_map, a, b), scheme
106 | ).score_and_match(de, dt)
107 |
108 | matches = joined_segment.matches
109 |
110 | matches_w_stationary_points = add_matches_for_stationary_points(
111 | matches, stationary_index
112 | )
113 |
114 | return MatchResult(matches_w_stationary_points, joined_segment.path)
115 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/mappymatch/utils/plot/trajectory_segment.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import folium
4 | import geopandas as gpd
5 | import pandas as pd
6 | from shapely.geometry import Point
7 |
8 | from mappymatch.matchers.lcss.constructs import TrajectorySegment
9 | from mappymatch.utils.crs import LATLON_CRS
10 |
11 |
12 | def plot_trajectory_segment(
13 | segment: TrajectorySegment,
14 | m: Optional[folium.Map] = None,
15 | trace_point_color: str = "black",
16 | path_line_color: str = "red",
17 | path_line_weight: int = 10,
18 | path_line_opacity: float = 0.8,
19 | show_matches: bool = True,
20 | match_point_color: str = "blue",
21 | show_cutting_points: bool = True,
22 | cutting_point_color: str = "orange",
23 | ):
24 | """
25 | Plot a TrajectorySegment showing the trace, path, matches, and cutting points.
26 |
27 | Args:
28 | segment: The TrajectorySegment to plot.
29 | m: The folium map to plot on. If None, a new map will be created.
30 | trace_point_color: The color for trace points.
31 | path_line_color: The color for the path line.
32 | path_line_weight: The weight of the path line.
33 | path_line_opacity: The opacity of the path line.
34 | show_matches: Whether to show matched points.
35 | match_point_color: The color for matched points.
36 | show_cutting_points: Whether to show cutting points.
37 | cutting_point_color: The color for cutting points.
38 |
39 | Returns:
40 | A folium map with the trajectory segment plotted.
41 | """
42 | trace = segment.trace
43 | path = segment.path
44 | matches = segment.matches
45 | cutting_points = segment.cutting_points
46 |
47 | original_crs = trace.crs
48 |
49 | if trace.crs != LATLON_CRS:
50 | trace = trace.to_crs(LATLON_CRS)
51 |
52 | # Create map if not provided
53 | if m is None:
54 | mid_coord = trace.coords[int(len(trace) / 2)]
55 | m = folium.Map(location=[mid_coord.y, mid_coord.x], zoom_start=13)
56 |
57 | # Plot trace points
58 | for i, c in enumerate(trace.coords):
59 | folium.Circle(
60 | location=(c.y, c.x),
61 | radius=5,
62 | color=trace_point_color,
63 | tooltip=f"Trace Point {i}",
64 | fill=True,
65 | fill_opacity=0.8,
66 | fill_color=trace_point_color,
67 | ).add_to(m)
68 |
69 | # Plot path (roads) if available
70 | if path:
71 | road_df = pd.DataFrame([{"road_id": r.road_id, "geom": r.geom} for r in path])
72 | road_gdf = gpd.GeoDataFrame(
73 | road_df, geometry=road_df.geom, crs=original_crs
74 | ).drop(columns=["geom"])
75 | road_gdf = road_gdf.to_crs(LATLON_CRS)
76 |
77 | for road in road_gdf.itertuples():
78 | folium.PolyLine(
79 | [(lat, lon) for lon, lat in road.geometry.coords],
80 | color=path_line_color,
81 | tooltip=f"Road ID: {road.road_id}",
82 | weight=path_line_weight,
83 | opacity=path_line_opacity,
84 | ).add_to(m)
85 |
86 | # Plot matches if requested
87 | if show_matches and matches:
88 | for i, match in enumerate(matches):
89 | if match.road:
90 | coord = match.coordinate
91 | if original_crs != LATLON_CRS:
92 | # Convert coordinate to lat/lon
93 | coord_gdf = gpd.GeoDataFrame(
94 | [{"geom": Point(coord.x, coord.y)}],
95 | geometry="geom",
96 | crs=original_crs,
97 | )
98 | coord_gdf = coord_gdf.to_crs(LATLON_CRS)
99 | coord_point = coord_gdf.iloc[0].geometry
100 | y, x = coord_point.y, coord_point.x
101 | else:
102 | y, x = coord.y, coord.x
103 |
104 | folium.CircleMarker(
105 | location=(y, x),
106 | radius=7,
107 | color=match_point_color,
108 | tooltip=f"Match {i}
Road ID: {match.road.road_id}
Distance: {match.distance:.2f}m",
109 | fill=True,
110 | fill_opacity=0.6,
111 | fill_color=match_point_color,
112 | ).add_to(m)
113 |
114 | # Plot cutting points if requested
115 | if show_cutting_points and cutting_points:
116 | for cp in cutting_points:
117 | coord = trace.coords[cp.trace_index]
118 | folium.CircleMarker(
119 | location=(coord.y, coord.x),
120 | radius=10,
121 | color=cutting_point_color,
122 | tooltip=f"Cutting Point at index {cp.trace_index}",
123 | fill=True,
124 | fill_opacity=0.9,
125 | fill_color=cutting_point_color,
126 | ).add_to(m)
127 |
128 | # Add segment score to map if available
129 | if segment.score > 0:
130 | folium.Marker(
131 | location=(trace.coords[0].y, trace.coords[0].x),
132 | popup=f"Segment Score: {segment.score:.4f}",
133 | icon=folium.Icon(color="lightgray", icon="info-sign"),
134 | ).add_to(m)
135 |
136 | return m
137 |
--------------------------------------------------------------------------------
/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 | from mappymatch.utils.keys import DEFAULT_METADATA_KEY
14 |
15 | log.basicConfig(level=log.INFO)
16 |
17 |
18 | METERS_TO_KM = 1 / 1000
19 | DEFAULT_MPH = 30
20 |
21 |
22 | class NetworkType(Enum):
23 | """
24 | Enumerator for Network Types supported by osmnx.
25 | """
26 |
27 | ALL_PRIVATE = "all_private"
28 | ALL = "all"
29 | BIKE = "bike"
30 | DRIVE = "drive"
31 | DRIVE_SERVICE = "drive_service"
32 | WALK = "walk"
33 |
34 |
35 | def nx_graph_from_osmnx(
36 | geofence: Geofence,
37 | network_type: NetworkType,
38 | xy: bool = True,
39 | custom_filter: Optional[str] = None,
40 | additional_metadata_keys: Optional[set] = None,
41 | filter_to_largest_component: bool = True,
42 | ) -> nx.MultiDiGraph:
43 | """
44 | Build a networkx graph from OSM data
45 |
46 | Args:
47 | geofence: the geofence to clip the graph to
48 | network_type: the network type to use for the graph
49 | xy: whether to use xy coordinates or lat/lon
50 | custom_filter: a custom filter to pass to osmnx
51 | additional_metadata_keys: additional keys to preserve in metadata
52 | filter_to_largest_component: if True, keep only the largest strongly connected component;
53 | if False, keep all components (may result in routing failures between disconnected components)
54 |
55 | Returns:
56 | a networkx graph of the OSM network
57 | """
58 | try:
59 | import osmnx as ox
60 | except ImportError:
61 | raise MapException("osmnx is not installed but is required for this map type")
62 | ox.settings.log_console = False
63 |
64 | raw_graph = ox.graph_from_polygon(
65 | geofence.geometry,
66 | network_type=network_type.value,
67 | custom_filter=custom_filter,
68 | )
69 | return parse_osmnx_graph(
70 | raw_graph,
71 | network_type,
72 | xy=xy,
73 | additional_metadata_keys=additional_metadata_keys,
74 | filter_to_largest_component=filter_to_largest_component,
75 | )
76 |
77 |
78 | def parse_osmnx_graph(
79 | graph: nx.MultiDiGraph,
80 | network_type: NetworkType,
81 | xy: bool = True,
82 | additional_metadata_keys: Optional[set] = None,
83 | filter_to_largest_component: bool = True,
84 | ) -> nx.MultiDiGraph:
85 | """
86 | Parse the raw osmnx graph into a graph that we can use with our NxMap
87 |
88 | Args:
89 | geofence: the geofence to clip the graph to
90 | xy: whether to use xy coordinates or lat/lon
91 | network_type: the network type to use for the graph
92 | additional_metadata_keys: additional keys to preserve in metadata
93 | filter_to_largest_component: if True, keep only the largest strongly connected component;
94 | if False, keep all components (may result in routing failures between disconnected components)
95 |
96 | Returns:
97 | a cleaned networkx graph of the OSM network
98 | """
99 | try:
100 | import osmnx as ox
101 | except ImportError:
102 | raise MapException("osmnx is not installed but is required for this map type")
103 | ox.settings.log_console = False
104 | g = graph
105 |
106 | if xy:
107 | g = ox.project_graph(g, to_crs=XY_CRS)
108 |
109 | g = ox.add_edge_speeds(g)
110 | g = ox.add_edge_travel_times(g)
111 |
112 | length_meters = nx.get_edge_attributes(g, "length")
113 | kilometers = {k: v * METERS_TO_KM for k, v in length_meters.items()}
114 | nx.set_edge_attributes(g, kilometers, "kilometers")
115 |
116 | # this makes sure there are no graph 'dead-ends'
117 | if filter_to_largest_component:
118 | sg_components = nx.strongly_connected_components(g)
119 |
120 | if not sg_components:
121 | raise MapException(
122 | "road network has no strongly connected components and is not routable; "
123 | "check polygon boundaries."
124 | )
125 |
126 | g = nx.MultiDiGraph(g.subgraph(max(sg_components, key=len)))
127 |
128 | for u, v, d in g.edges(data=True):
129 | if "geometry" not in d:
130 | # we'll build a pseudo-geometry using the x, y data from the nodes
131 | unode = g.nodes[u]
132 | vnode = g.nodes[v]
133 | line = LineString([(unode["x"], unode["y"]), (vnode["x"], vnode["y"])])
134 | d["geometry"] = line
135 |
136 | g = compress(g, additional_metadata_keys=additional_metadata_keys)
137 |
138 | # TODO: these should all be sourced from the same location
139 | g.graph["distance_weight"] = "kilometers"
140 | g.graph["time_weight"] = "travel_time"
141 | g.graph["geometry_key"] = "geometry"
142 | g.graph["network_type"] = network_type.value
143 |
144 | return g
145 |
146 |
147 | def compress(
148 | g: nx.MultiDiGraph, additional_metadata_keys: Optional[set] = None
149 | ) -> nx.MultiDiGraph:
150 | """
151 | Remove unnecessary data from the networkx graph while preserving essential attributes
152 |
153 | Args:
154 | g: the networkx graph to compress
155 | additional_metadata_keys: additional keys to preserve in metadata
156 |
157 | Returns:
158 | the compressed networkx graph
159 | """
160 | # Define attributes to keep for edges
161 | edge_keep_keys = {
162 | "geometry",
163 | "kilometers",
164 | "travel_time",
165 | DEFAULT_METADATA_KEY,
166 | }
167 |
168 | # Define attributes to move to metadata
169 | default_metadata_keys = {"osmid", "name"}
170 | if additional_metadata_keys:
171 | default_metadata_keys.update(additional_metadata_keys)
172 |
173 | # Define attributes to keep for nodes (only what we need)
174 | node_keep_keys: set[str] = set()
175 |
176 | # Process edges
177 | for _, _, d in g.edges(data=True):
178 | # Initialize metadata dict if needed
179 | if DEFAULT_METADATA_KEY not in d:
180 | d[DEFAULT_METADATA_KEY] = {}
181 |
182 | # Move specified keys to metadata
183 | for key in default_metadata_keys:
184 | if key in d:
185 | d[DEFAULT_METADATA_KEY][key] = d[key]
186 |
187 | # Delete all keys not in keep list
188 | keys_to_remove = [k for k in list(d.keys()) if k not in edge_keep_keys]
189 | for key in keys_to_remove:
190 | del d[key]
191 |
192 | # Process nodes
193 | for _, d in g.nodes(data=True):
194 | keys_to_remove = [k for k in list(d.keys()) if k not in node_keep_keys]
195 | for key in keys_to_remove:
196 | del d[key]
197 |
198 | return g
199 |
--------------------------------------------------------------------------------
/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_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_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 |
--------------------------------------------------------------------------------
/mappymatch/matchers/lcss/ops.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from copy import deepcopy
3 | from typing import Any, List, NamedTuple
4 |
5 | from shapely.geometry import Point
6 |
7 | from mappymatch.constructs.coordinate import Coordinate
8 | from mappymatch.constructs.match import Match
9 | from mappymatch.constructs.road import Road
10 | from mappymatch.constructs.trace import Trace
11 | from mappymatch.maps.map_interface import MapInterface
12 | from mappymatch.matchers.lcss.constructs import (
13 | TrajectoryScheme,
14 | TrajectorySegment,
15 | )
16 | from mappymatch.matchers.lcss.utils import merge
17 |
18 | log = logging.getLogger(__name__)
19 |
20 |
21 | def join_segment(
22 | road_map: MapInterface, a: TrajectorySegment, b: TrajectorySegment
23 | ) -> TrajectorySegment:
24 | """
25 | Join two trajectory segments together, attempting to route between them if needed.
26 |
27 | Args:
28 | road_map: The road map to use for routing
29 | a: The first trajectory segment
30 | b: The second trajectory segment
31 |
32 | Returns:
33 | A new trajectory segment combining both segments
34 | """
35 | new_traces = a.trace + b.trace
36 | new_path = a.path + b.path
37 |
38 | # test to see if there is a gap between the paths and if so,
39 | # try to connect it
40 | if len(a.path) > 0 and len(b.path) > 0:
41 | end_road = a.path[-1]
42 | start_road = b.path[0]
43 | if end_road.road_id.end != start_road.road_id.start:
44 | o = Coordinate(
45 | coordinate_id=None,
46 | geom=Point(end_road.geom.coords[-1]),
47 | crs=new_traces.crs,
48 | )
49 | d = Coordinate(
50 | coordinate_id=None,
51 | geom=Point(start_road.geom.coords[0]),
52 | crs=new_traces.crs,
53 | )
54 | path = road_map.shortest_path(o, d)
55 | # If no path exists (disconnected components), just concatenate the paths
56 | if path:
57 | new_path = a.path + path + b.path
58 | else:
59 | new_path = a.path + b.path
60 |
61 | return TrajectorySegment(new_traces, new_path)
62 |
63 |
64 | def new_path(
65 | road_map: MapInterface,
66 | trace: Trace,
67 | ) -> List[Road]:
68 | """
69 | Computes a shortest path and returns the path
70 |
71 | Args:
72 | road_map: the road map to match to
73 | trace: the trace to match
74 |
75 | Returns:
76 | the path that most closely matches the trace
77 | """
78 | if len(trace.coords) < 1:
79 | return []
80 |
81 | origin = trace.coords[0]
82 | destination = trace.coords[-1]
83 |
84 | new_path = road_map.shortest_path(origin, destination)
85 |
86 | return new_path
87 |
88 |
89 | def split_trajectory_segment(
90 | road_map: MapInterface,
91 | trajectory_segment: TrajectorySegment,
92 | ) -> List[TrajectorySegment]:
93 | """
94 | Splits a trajectory segment based on the provided cutting points.
95 |
96 | Merge back any segments that are too short
97 |
98 | Args:
99 | road_map: the road map to match to
100 | trajectory_segment: the trajectory segment to split
101 | distance_epsilon: the distance epsilon
102 |
103 | Returns:
104 | a list of split segments or the original segment if it can't be split
105 | """
106 | trace = trajectory_segment.trace
107 | cutting_points = trajectory_segment.cutting_points
108 |
109 | def _short_segment(ts: TrajectorySegment):
110 | if len(ts.trace) < 2 or len(ts.path) < 1:
111 | return True
112 | return False
113 |
114 | if len(trace.coords) < 2:
115 | # segment is too short to split
116 | return [trajectory_segment]
117 | elif len(cutting_points) < 1:
118 | # no points to cut
119 | return [trajectory_segment]
120 |
121 | new_paths = []
122 | new_traces = []
123 |
124 | # using type: ignore below because, trace_index can only be a signedinteger or integer
125 | # mypy wants it to only be an int, but this should never affect code functionality
126 | # start
127 | scp = cutting_points[0]
128 | new_trace = trace[: scp.trace_index] # type: ignore
129 | new_paths.append(new_path(road_map, new_trace))
130 | new_traces.append(new_trace)
131 |
132 | # mids
133 | for i in range(len(cutting_points) - 1):
134 | cp = cutting_points[i]
135 | ncp = cutting_points[i + 1]
136 | new_trace = trace[cp.trace_index : ncp.trace_index] # type: ignore
137 | new_paths.append(new_path(road_map, new_trace))
138 | new_traces.append(new_trace)
139 |
140 | # end
141 | ecp = cutting_points[-1]
142 | new_trace = trace[ecp.trace_index :] # type: ignore
143 | new_paths.append(new_path(road_map, new_trace))
144 | new_traces.append(new_trace)
145 |
146 | if not any(new_paths):
147 | # can't split
148 | return [trajectory_segment]
149 | elif not any(new_traces):
150 | # can't split
151 | return [trajectory_segment]
152 | else:
153 | segments = [TrajectorySegment(t, p) for t, p in zip(new_traces, new_paths)]
154 |
155 | merged_segments = merge(segments, _short_segment)
156 |
157 | return merged_segments
158 |
159 |
160 | def same_trajectory_scheme(
161 | scheme1: TrajectoryScheme, scheme2: TrajectoryScheme
162 | ) -> bool:
163 | """
164 | Compares two trajectory schemes for equality
165 |
166 | Args:
167 | scheme1: the first trajectory scheme
168 | scheme2: the second trajectory scheme
169 |
170 | Returns:
171 | True if the two schemes are equal, False otherwise
172 | """
173 | same_paths = all(map(lambda a, b: a.path == b.path, scheme1, scheme2))
174 | same_traces = all(
175 | map(lambda a, b: a.trace.coords == b.trace.coords, scheme1, scheme2)
176 | )
177 |
178 | return same_paths and same_traces
179 |
180 |
181 | class StationaryIndex(NamedTuple):
182 | """
183 | An index of a stationary point in a trajectory
184 |
185 | Attributes:
186 | trace_index: the index of the trace
187 | coord_index: the index of the coordinate
188 | """
189 |
190 | i_index: List[int] # i based index on the trace
191 | c_index: List[Any] # coordinate ids
192 |
193 |
194 | def find_stationary_points(trace: Trace) -> List[StationaryIndex]:
195 | """
196 | Find the positional index of all stationary points in a trace
197 |
198 | Args:
199 | trace: the trace to find the stationary points in
200 |
201 | Returns:
202 | a list of stationary indices
203 | """
204 | f = trace._frame
205 | coords = trace.coords
206 | dist = f.distance(f.shift())
207 | index_collections = []
208 | index = set()
209 | for i in range(1, len(dist)):
210 | d = dist.iloc[i] # distance to previous point
211 | if d < 0.001:
212 | index.add(i - 1)
213 | index.add(i)
214 | else:
215 | # there is distance between this point and the previous
216 | if index:
217 | l_index = sorted(list(index))
218 | cids = [coords[li].coordinate_id for li in l_index]
219 | si = StationaryIndex(l_index, cids)
220 | index_collections.append(si)
221 | index = set()
222 |
223 | # catch any group of points at the end
224 | if index:
225 | l_index = sorted(list(index))
226 | cids = [coords[li].coordinate_id for li in l_index]
227 | si = StationaryIndex(l_index, cids)
228 | index_collections.append(si)
229 |
230 | return index_collections
231 |
232 |
233 | def drop_stationary_points(
234 | trace: Trace, stationary_index: List[StationaryIndex]
235 | ) -> Trace:
236 | """
237 | Drops stationary points from the trace, keeping the first point
238 |
239 | Args:
240 | trace: the trace to drop the stationary points from
241 | stationary_index: the stationary indices to drop
242 |
243 | Returns:
244 | the trace with the stationary points dropped
245 | """
246 | for si in stationary_index:
247 | trace = trace.drop(si.c_index[1:])
248 |
249 | return trace
250 |
251 |
252 | def add_matches_for_stationary_points(
253 | matches: List[Match],
254 | stationary_index: List[StationaryIndex],
255 | ) -> List[Match]:
256 | """
257 | Takes a set of matches and adds duplicate match entries for stationary
258 |
259 | Args:
260 | matches: the matches to add the stationary points to
261 | stationary_index: the stationary indices to add
262 |
263 | Returns:
264 | the matches with the stationary points added
265 | """
266 | matches = deepcopy(matches)
267 |
268 | for si in stationary_index:
269 | mi = si.i_index[0]
270 | m = matches[mi]
271 | new_matches = [
272 | m.set_coordinate(
273 | Coordinate(ci, geom=m.coordinate.geom, crs=m.coordinate.crs)
274 | )
275 | for ci in si.c_index[1:]
276 | ]
277 | matches[si.i_index[1] : si.i_index[1]] = new_matches
278 |
279 | return matches
280 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 == 0:
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 |
--------------------------------------------------------------------------------
/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_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_disconnected_components.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import Mock
3 |
4 | import networkx as nx
5 | import osmnx as ox
6 | import geopandas as gpd
7 | from shapely.geometry import LineString, Point
8 |
9 | from mappymatch.constructs.coordinate import Coordinate
10 | from mappymatch.constructs.road import Road, RoadId
11 | from mappymatch.constructs.trace import Trace
12 | from mappymatch.maps.nx.nx_map import NxMap
13 | from mappymatch.maps.nx.readers.osm_readers import (
14 | NetworkType,
15 | parse_osmnx_graph,
16 | )
17 | from mappymatch.matchers.lcss.constructs import TrajectorySegment
18 | from mappymatch.matchers.lcss.ops import join_segment
19 | from mappymatch.utils.crs import XY_CRS
20 | from tests import get_test_dir
21 |
22 |
23 | class TestDisconnectedComponents(TestCase):
24 | """Test handling of disconnected graph components"""
25 |
26 | def setUp(self):
27 | """Create a simple disconnected graph for testing"""
28 | # Create a graph with two disconnected components
29 | self.g = nx.MultiDiGraph()
30 |
31 | # Component 1: Simple path from 0 -> 1 -> 2
32 | self.g.add_edge(
33 | 0,
34 | 1,
35 | 0,
36 | geometry=LineString([(0, 0), (1, 0)]),
37 | kilometers=1.0,
38 | travel_time=60.0,
39 | metadata={},
40 | )
41 | self.g.add_edge(
42 | 1,
43 | 2,
44 | 0,
45 | geometry=LineString([(1, 0), (2, 0)]),
46 | kilometers=1.0,
47 | travel_time=60.0,
48 | metadata={},
49 | )
50 |
51 | # Component 2: Separate path from 10 -> 11 -> 12
52 | self.g.add_edge(
53 | 10,
54 | 11,
55 | 0,
56 | geometry=LineString([(10, 10), (11, 10)]),
57 | kilometers=1.0,
58 | travel_time=60.0,
59 | metadata={},
60 | )
61 | self.g.add_edge(
62 | 11,
63 | 12,
64 | 0,
65 | geometry=LineString([(11, 10), (12, 10)]),
66 | kilometers=1.0,
67 | travel_time=60.0,
68 | metadata={},
69 | )
70 |
71 | # Add required graph attributes
72 | self.g.graph["crs"] = XY_CRS
73 | self.g.graph["distance_weight"] = "kilometers"
74 | self.g.graph["time_weight"] = "travel_time"
75 | self.g.graph["geometry_key"] = "geometry"
76 |
77 | def test_parse_osmnx_graph_keeps_all_components(self):
78 | """Test that parse_osmnx_graph can keep all components when filter_to_largest_component=False"""
79 | # Load test graph
80 | gfile = get_test_dir() / "test_assets" / "osmnx_drive_graph.graphml"
81 | osmnx_graph = ox.load_graphml(gfile)
82 |
83 | # Parse without filtering
84 | cleaned_graph = parse_osmnx_graph(
85 | osmnx_graph, NetworkType.DRIVE, filter_to_largest_component=False
86 | )
87 |
88 | # Graph should have edges and basic structure
89 | self.assertGreater(len(cleaned_graph.edges), 0)
90 | self.assertEqual(cleaned_graph.graph["network_type"], NetworkType.DRIVE.value)
91 |
92 | def test_parse_osmnx_graph_filters_to_largest(self):
93 | """Test that parse_osmnx_graph filters to largest component by default"""
94 | # Load test graph
95 | gfile = get_test_dir() / "test_assets" / "osmnx_drive_graph.graphml"
96 | osmnx_graph = ox.load_graphml(gfile)
97 |
98 | # Parse with filtering (default behavior)
99 | cleaned_graph = parse_osmnx_graph(
100 | osmnx_graph, NetworkType.DRIVE, filter_to_largest_component=True
101 | )
102 |
103 | # Graph should be strongly connected
104 | self.assertTrue(nx.is_strongly_connected(cleaned_graph))
105 |
106 | def test_shortest_path_returns_empty_for_disconnected_nodes(self):
107 | """Test that shortest_path returns empty list when no path exists"""
108 | # Create NxMap from disconnected graph
109 | nx_map = NxMap(self.g)
110 |
111 | # Create coordinates in different components
112 | origin = Coordinate(None, Point(0.5, 0), XY_CRS)
113 | destination = Coordinate(None, Point(10.5, 10), XY_CRS)
114 |
115 | # Should return empty list instead of raising exception
116 | path = nx_map.shortest_path(origin, destination)
117 |
118 | self.assertEqual(path, [])
119 |
120 | def test_shortest_path_works_within_component(self):
121 | """Test that shortest_path works normally within a connected component"""
122 | # Create NxMap from disconnected graph
123 | nx_map = NxMap(self.g)
124 |
125 | # Create coordinates in the same component
126 | origin = Coordinate(None, Point(0.5, 0), XY_CRS)
127 | destination = Coordinate(None, Point(1.5, 0), XY_CRS)
128 |
129 | # Should find a path
130 | path = nx_map.shortest_path(origin, destination)
131 |
132 | self.assertGreater(len(path), 0)
133 | self.assertIsInstance(path[0], Road)
134 |
135 | def test_lcss_merge_handles_empty_path(self):
136 | """Test that LCSS merge handles empty path between disconnected segments"""
137 | # Create mock road map
138 | mock_map = Mock(spec=NxMap)
139 | mock_map.crs = XY_CRS
140 |
141 | # Mock shortest_path to return empty list (disconnected components)
142 | mock_map.shortest_path.return_value = []
143 |
144 | # Create mock trajectory segments with paths
145 | coords1 = [
146 | Coordinate(None, Point(0, 0), XY_CRS),
147 | Coordinate(None, Point(1, 0), XY_CRS),
148 | ]
149 | coords2 = [
150 | Coordinate(None, Point(10, 10), XY_CRS),
151 | Coordinate(None, Point(11, 10), XY_CRS),
152 | ]
153 |
154 | gdf1 = gpd.GeoDataFrame(
155 | {"geometry": [c.geom for c in coords1]}, crs=XY_CRS, index=[0, 1]
156 | )
157 | gdf2 = gpd.GeoDataFrame(
158 | {"geometry": [c.geom for c in coords2]}, crs=XY_CRS, index=[2, 3]
159 | )
160 |
161 | trace1 = Trace(gdf1)
162 | trace2 = Trace(gdf2)
163 |
164 | road1 = Road(
165 | RoadId(0, 1, 0),
166 | LineString([(0, 0), (1, 0)]),
167 | metadata={},
168 | )
169 | road2 = Road(
170 | RoadId(10, 11, 0),
171 | LineString([(10, 10), (11, 10)]),
172 | metadata={},
173 | )
174 |
175 | segment_a = TrajectorySegment(trace=trace1, path=[road1])
176 | segment_b = TrajectorySegment(trace=trace2, path=[road2])
177 |
178 | # Merge segments using imported join_segment function
179 | result = join_segment(mock_map, segment_a, segment_b)
180 |
181 | # Should concatenate paths without intermediate routing
182 | self.assertEqual(len(result.path), 2)
183 | self.assertEqual(result.path[0].road_id, road1.road_id)
184 | self.assertEqual(result.path[1].road_id, road2.road_id)
185 |
186 | def test_lcss_merge_handles_connected_path(self):
187 | """Test that LCSS merge works normally when routing succeeds"""
188 | # Create mock road map
189 | mock_map = Mock(spec=NxMap)
190 | mock_map.crs = XY_CRS
191 |
192 | # Mock shortest_path to return a connecting road
193 | connecting_road = Road(
194 | RoadId(1, 2, 0),
195 | LineString([(1, 0), (2, 0)]),
196 | metadata={},
197 | )
198 | mock_map.shortest_path.return_value = [connecting_road]
199 |
200 | # Create mock trajectory segments
201 | coords1 = [
202 | Coordinate(None, Point(0, 0), XY_CRS),
203 | Coordinate(None, Point(1, 0), XY_CRS),
204 | ]
205 | coords2 = [
206 | Coordinate(None, Point(2, 0), XY_CRS),
207 | Coordinate(None, Point(3, 0), XY_CRS),
208 | ]
209 |
210 | gdf1 = gpd.GeoDataFrame(
211 | {"geometry": [c.geom for c in coords1]}, crs=XY_CRS, index=[0, 1]
212 | )
213 | gdf2 = gpd.GeoDataFrame(
214 | {"geometry": [c.geom for c in coords2]}, crs=XY_CRS, index=[2, 3]
215 | )
216 |
217 | trace1 = Trace(gdf1)
218 | trace2 = Trace(gdf2)
219 |
220 | road1 = Road(
221 | RoadId(0, 1, 0),
222 | LineString([(0, 0), (1, 0)]),
223 | metadata={},
224 | )
225 | road3 = Road(
226 | RoadId(2, 3, 0),
227 | LineString([(2, 0), (3, 0)]),
228 | metadata={},
229 | )
230 |
231 | segment_a = TrajectorySegment(trace=trace1, path=[road1])
232 | segment_b = TrajectorySegment(trace=trace2, path=[road3])
233 |
234 | # Merge segments using imported join_segment function
235 | result = join_segment(mock_map, segment_a, segment_b)
236 |
237 | # Should include connecting road
238 | self.assertEqual(len(result.path), 3)
239 | self.assertEqual(result.path[0].road_id, road1.road_id)
240 | self.assertEqual(result.path[1].road_id, connecting_road.road_id)
241 | self.assertEqual(result.path[2].road_id, road3.road_id)
242 |
243 | def test_networkx_no_path_exception_handling(self):
244 | """Test that NetworkXNoPath exception is caught and handled"""
245 | # Create NxMap from disconnected graph
246 | nx_map = NxMap(self.g)
247 |
248 | # Verify graph is disconnected
249 | self.assertFalse(nx.is_strongly_connected(self.g))
250 |
251 | # Try to find path between disconnected components
252 | origin = Coordinate(None, Point(0.1, 0), XY_CRS)
253 | destination = Coordinate(None, Point(10.1, 10), XY_CRS)
254 |
255 | # Should not raise exception, should return empty list
256 | try:
257 | path = nx_map.shortest_path(origin, destination)
258 | self.assertEqual(path, [])
259 | except nx.NetworkXNoPath:
260 | self.fail("NetworkXNoPath exception should be caught and handled")
261 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------