Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.13.0"
hooks:
- id: mypy
additional_dependencies:
[matplotlib, pandas-stubs, pytest, types-requests]
- repo: local
hooks:
- id: pixi-check
name: Run pixi checks
entry: pixi run -e dev check
language: system
pass_filenames: false
always_run: true
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Project Context

When working with this codebase, prioritize readability over cleverness. Ask clarifying questions before making architectural changes.

## Project overview

Mappymatch is a python package used to match a series of GPS waypoints (Trace) to a road network.


## Common Commands

### running full check (test, types, lint, format)

```
pixi run -e dev check
```

23 changes: 16 additions & 7 deletions mappymatch/maps/nx/nx_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ def from_geofence(
network_type: NetworkType = NetworkType.DRIVE,
custom_filter: Optional[str] = None,
additional_metadata_keys: Optional[set | list] = None,
filter_to_largest_component: bool = True,
) -> NxMap:
"""
Read an OSM network graph into a NxMap
Expand All @@ -218,7 +219,9 @@ def from_geofence(
xy: whether to use xy coordinates or lat/lon
network_type: the network type to use for the graph
custom_filter: a custom filter to pass to osmnx like '["highway"~"motorway|primary"]'
additional_metadata_keys: additional keys to preserve in road metadata like '["maxspeed", "highway"]
additional_metadata_keys: additional keys to preserve in road metadata like '["maxspeed", "highway"]'
filter_to_largest_component: if True, keep only the largest strongly connected component;
if False, keep all components (may result in routing failures between disconnected components)

Returns:
a NxMap
Expand All @@ -237,6 +240,7 @@ def from_geofence(
xy=xy,
custom_filter=custom_filter,
additional_metadata_keys=additional_metadata_keys,
filter_to_largest_component=filter_to_largest_component,
)

return NxMap(nx_graph)
Expand Down Expand Up @@ -374,12 +378,17 @@ def shortest_path(
else:
dest_id = dest_road.road_id.end

nx_route = nx.shortest_path(
self.g,
origin_id,
dest_id,
weight=weight,
)
try:
nx_route = nx.shortest_path(
self.g,
origin_id,
dest_id,
weight=weight,
)
except nx.NetworkXNoPath:
# No path exists between origin and destination
# This can happen when the graph has multiple disconnected components
return []

path = []
for i in range(1, len(nx_route)):
Expand Down
22 changes: 15 additions & 7 deletions mappymatch/maps/nx/readers/osm_readers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def nx_graph_from_osmnx(
xy: bool = True,
custom_filter: Optional[str] = None,
additional_metadata_keys: Optional[set] = None,
filter_to_largest_component: bool = True,
) -> nx.MultiDiGraph:
"""
Build a networkx graph from OSM data
Expand All @@ -48,6 +49,8 @@ def nx_graph_from_osmnx(
xy: whether to use xy coordinates or lat/lon
custom_filter: a custom filter to pass to osmnx
additional_metadata_keys: additional keys to preserve in metadata
filter_to_largest_component: if True, keep only the largest strongly connected component;
if False, keep all components (may result in routing failures between disconnected components)

Returns:
a networkx graph of the OSM network
Expand All @@ -68,6 +71,7 @@ def nx_graph_from_osmnx(
network_type,
xy=xy,
additional_metadata_keys=additional_metadata_keys,
filter_to_largest_component=filter_to_largest_component,
)


Expand All @@ -76,6 +80,7 @@ def parse_osmnx_graph(
network_type: NetworkType,
xy: bool = True,
additional_metadata_keys: Optional[set] = None,
filter_to_largest_component: bool = True,
) -> nx.MultiDiGraph:
"""
Parse the raw osmnx graph into a graph that we can use with our NxMap
Expand All @@ -85,6 +90,8 @@ def parse_osmnx_graph(
xy: whether to use xy coordinates or lat/lon
network_type: the network type to use for the graph
additional_metadata_keys: additional keys to preserve in metadata
filter_to_largest_component: if True, keep only the largest strongly connected component;
if False, keep all components (may result in routing failures between disconnected components)

Returns:
a cleaned networkx graph of the OSM network
Expand All @@ -107,15 +114,16 @@ def parse_osmnx_graph(
nx.set_edge_attributes(g, kilometers, "kilometers")

# this makes sure there are no graph 'dead-ends'
sg_components = nx.strongly_connected_components(g)
if filter_to_largest_component:
sg_components = nx.strongly_connected_components(g)

if not sg_components:
raise MapException(
"road network has no strongly connected components and is not routable; "
"check polygon boundaries."
)
if not sg_components:
raise MapException(
"road network has no strongly connected components and is not routable; "
"check polygon boundaries."
)

g = nx.MultiDiGraph(g.subgraph(max(sg_components, key=len)))
g = nx.MultiDiGraph(g.subgraph(max(sg_components, key=len)))

for u, v, d in g.edges(data=True):
if "geometry" not in d:
Expand Down
2 changes: 1 addition & 1 deletion mappymatch/matchers/lcss/constructs.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def score_and_match(
if m < 1:
# todo: find a better way to handle this edge case
raise Exception("traces of 0 points can't be matched")
elif n < 2:
elif n == 0:
# a path was not found for this segment; might not be matchable;
# we set a score of zero and return a set of no-matches
matches = [
Expand Down
35 changes: 5 additions & 30 deletions mappymatch/matchers/lcss/lcss.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import functools as ft
import logging

from shapely.geometry import Point

from mappymatch.constructs.coordinate import Coordinate
from mappymatch.maps.map_interface import MapInterface
from mappymatch.matchers.lcss.constructs import TrajectorySegment
from mappymatch.matchers.lcss.ops import (
add_matches_for_stationary_points,
drop_stationary_points,
find_stationary_points,
join_segment,
new_path,
same_trajectory_scheme,
split_trajectory_segment,
Expand Down Expand Up @@ -59,31 +57,6 @@ def __init__(
self.distance_threshold = distance_threshold

def match_trace(self, trace: Trace) -> MatchResult:
def _join_segment(a: TrajectorySegment, b: TrajectorySegment):
new_traces = a.trace + b.trace
new_path = a.path + b.path

# test to see if there is a gap between the paths and if so,
# try to connect it
if len(a.path) > 1 and len(b.path) > 1:
end_road = a.path[-1]
start_road = b.path[0]
if end_road.road_id.end != start_road.road_id.start:
o = Coordinate(
coordinate_id=None,
geom=Point(end_road.geom.coords[-1]),
crs=new_traces.crs,
)
d = Coordinate(
coordinate_id=None,
geom=Point(start_road.geom.coords[0]),
crs=new_traces.crs,
)
path = self.road_map.shortest_path(o, d)
new_path = a.path + path + b.path

return TrajectorySegment(new_traces, new_path)

stationary_index = find_stationary_points(trace)

sub_trace = drop_stationary_points(trace, stationary_index)
Expand Down Expand Up @@ -115,7 +88,7 @@ def _join_segment(a: TrajectorySegment, b: TrajectorySegment):
# split and check the score
new_split = split_trajectory_segment(road_map, scored_segment)
joined_segment = ft.reduce(
_join_segment, new_split
lambda a, b: join_segment(road_map, a, b), new_split
).score_and_match(de, dt)
if joined_segment.score > scored_segment.score:
# we found a better fit
Expand All @@ -128,7 +101,9 @@ def _join_segment(a: TrajectorySegment, b: TrajectorySegment):

scheme = next_scheme

joined_segment = ft.reduce(_join_segment, scheme).score_and_match(de, dt)
joined_segment = ft.reduce(
lambda a, b: join_segment(road_map, a, b), scheme
).score_and_match(de, dt)

matches = joined_segment.matches

Expand Down
45 changes: 45 additions & 0 deletions mappymatch/matchers/lcss/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from copy import deepcopy
from typing import Any, List, NamedTuple

from shapely.geometry import Point

from mappymatch.constructs.coordinate import Coordinate
from mappymatch.constructs.match import Match
from mappymatch.constructs.road import Road
Expand All @@ -16,6 +18,49 @@
log = logging.getLogger(__name__)


def join_segment(
road_map: MapInterface, a: TrajectorySegment, b: TrajectorySegment
) -> TrajectorySegment:
"""
Join two trajectory segments together, attempting to route between them if needed.

Args:
road_map: The road map to use for routing
a: The first trajectory segment
b: The second trajectory segment

Returns:
A new trajectory segment combining both segments
"""
new_traces = a.trace + b.trace
new_path = a.path + b.path

# test to see if there is a gap between the paths and if so,
# try to connect it
if len(a.path) > 0 and len(b.path) > 0:
end_road = a.path[-1]
start_road = b.path[0]
if end_road.road_id.end != start_road.road_id.start:
o = Coordinate(
coordinate_id=None,
geom=Point(end_road.geom.coords[-1]),
crs=new_traces.crs,
)
d = Coordinate(
coordinate_id=None,
geom=Point(start_road.geom.coords[0]),
crs=new_traces.crs,
)
path = road_map.shortest_path(o, d)
# If no path exists (disconnected components), just concatenate the paths
if path:
new_path = a.path + path + b.path
else:
new_path = a.path + b.path

return TrajectorySegment(new_traces, new_path)


def new_path(
road_map: MapInterface,
trace: Trace,
Expand Down
Loading