diff --git a/README.md b/README.md index 70f741c..16003a9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ It is based on but updated to: ## TODO - currently if a point from GPS trace fails (too far etc.) it's excluded from match. Should add it to match just with start == end and appropriate error code. -- For reverse_tolerance, why not, if the movement is < reverse tolerance, just assume they're still at the same place (and it's GPS jitter)? - test the time apportioning. - If not found in UBODT, instead of bailing, do a normal djikstra lookup. - max_distance_between_candidates is not a hard limit in UBODT ... I think. Test this, and if needed, add an extra check. @@ -33,21 +32,25 @@ pip install fastmm The simplest way to use fastmm is with the high-level `FastMapMatch` class: ```python -from fastmm import FastMapMatch, MatchErrorCode, Network, Trajectory, TransitionMode, FastMapMatchConfig +from pathlib import Path +from fastmm import FastMapMatch, MatchErrorCode, Network, Trajectory, TransitionMode # Create and populate network network = Network() -network.add_edge(1, source=1, target=2, geom=[(0, 0), (100, 0)]) -network.add_edge(2, source=2, target=3, geom=[(100, 0), (200, 0)]) +# Edge IDs and Node IDs are long long (64-bit) for OSM compatibility +network.add_edge(1234567890123, source=10, target=20, geom=[[0, 0], [100, 0]]) network.finalize() # Create matcher with automatic UBODT caching (SHORTEST mode - distance-based) matcher = FastMapMatch( - network, TransitionMode.SHORTEST, max_distance_between_candidates=300.0, cache_dir="./cache" + network, + TransitionMode.SHORTEST, + max_distance_between_candidates=300.0, + cache_dir=Path("./ubodt_cache") ) -# Match a trajectory (automatic splitting) -trajectory = Trajectory.from_xy_tuples(1, [(10, 0), (50, 0), (150, 0)]) +# Match a trajectory of (x, y, t) +trajectory = Trajectory.from_xy_tuples([(10, 0, 1), (50, 0, 2), (150, 0, 3)]) result = matcher.match( trajectory, max_candidates=8, @@ -55,14 +58,14 @@ result = matcher.match( gps_error=50 ) -# Process successful sub-trajectories +# Construct the points: +matched_xyt = [] for sub in result.subtrajectories: if sub.error_code == MatchErrorCode.SUCCESS: - print(f"Matched points {sub.start_index} to {sub.end_index}") for segment in sub.segments: - print(f" Segment from {segment.p0} to {segment.p1}") for edge in segment.edges: - print(f" Edge {edge.edge_id} with {len(edge.points)} points") + for p in edge.points: + matched_xyt.append((p.x, p.y, p.t)) ``` For time-based routing you simply add a speed on all edges, and use `TransitionMode.FASTEST`. Otherwise it's the same. @@ -186,10 +189,16 @@ Similarly to above, this gives a higher priority when the travel time is the sam ## Developing -You can create stubs with +You can run the tests with: +```bash +pytest . ``` -python .\generate_stubs_for_wheel.py .\python\fastmm\ .\python\fastmm\ + +You can create stubs with: + +```bash +python ./generate_stubs_for_wheel.py ./python/fastmm/ ./python/fastmm/ ``` For now, this is better than doing it in a CI/CD pipeline as Windows is painful. \ No newline at end of file diff --git a/python/fastmm/fastmm.pyi b/python/fastmm/fastmm.pyi index 4f11cec..05f9179 100644 --- a/python/fastmm/fastmm.pyi +++ b/python/fastmm/fastmm.pyi @@ -6,92 +6,93 @@ import typing __all__: list[str] = ['CANDIDATES_NOT_FOUND', 'DISCONNECTED_LAYERS', 'FASTEST', 'FastMapMatch', 'INDEX_OUT_OF_BOUNDS', 'INDEX_OUT_OF_BOUNDS_END', 'MatchCandidate', 'MatchErrorCode', 'MatchPoint', 'MatchSegment', 'MatchSegmentEdge', 'Network', 'NetworkGraph', 'SHORTEST', 'SUCCESS', 'SplitMatchResult', 'SubTrajectory', 'Trajectory', 'TransitionMode', 'UNKNOWN_ERROR'] class FastMapMatch: """ - + Fast map matching algorithm using Hidden Markov Model with UBODT optimization. - + Matches GPS trajectories to a road network by finding the most probable sequence of road edges, considering both emission probabilities (GPS accuracy) and transition probabilities (path likelihood). Uses precomputed UBODT for fast path lookups. - + """ - def __init__(self, network: Network, mode: TransitionMode, max_distance_between_candidates: typing.SupportsFloat | None = None, max_time_between_candidates: typing.SupportsFloat | None = None, cache_dir: str = './ubodt_cache') -> None: + def __init__(self, network: Network, mode: TransitionMode, max_distance_between_candidates: typing.SupportsFloat | None = None, max_time_between_candidates: typing.SupportsFloat | None = None, cache_dir: str | typing.Any = './ubodt_cache') -> None: """ Create a FastMapMatch instance with automatic UBODT management. - + Args: network: Road network with spatial index built (call finalize() first) mode: Routing mode (TransitionMode.SHORTEST for distance, FASTEST for time) max_distance_between_candidates: Maximum distance in meters (for SHORTEST mode) max_time_between_candidates: Maximum time in seconds (for FASTEST mode) - cache_dir: Directory for caching UBODT files (default: "./ubodt_cache") - + cache_dir: Directory for caching UBODT files (str or Path, default: "./ubodt_cache") + Note: Only the relevant parameter is used depending on mode. This constructor automatically generates/loads UBODT from cache based on network hash, mode, and delta. UBODT is cached for reuse. + Accepts str or pathlib.Path for cache_dir. """ def match(self, trajectory: Trajectory, max_candidates: typing.SupportsInt = 8, candidate_search_radius: typing.SupportsFloat, gps_error: typing.SupportsFloat, reverse_tolerance: typing.SupportsFloat = 0.0, reference_speed: typing.SupportsFloat | None = None) -> SplitMatchResult: """ Match a GPS trajectory with automatic splitting on failures. - + This method performs candidate search once and reuses it when matching sub-trajectories. When the matching algorithm encounters failures (no candidates, disconnected layers), it automatically continues matching from the next viable point instead of stopping. - + Args: trajectory: Trajectory with GPS observations (with or without timestamps) - + max_candidates: Maximum number of candidate edges to consider per GPS point. Higher values improve matching quality in dense networks but slow performance. Typical: 4-16. Default: 8. - + candidate_search_radius: Maximum distance to search for candidate edges around each GPS point (in coordinate units). Should exceed typical GPS errors. Typical: 30-100 meters. Default: 50. - + gps_error: Expected GPS accuracy (standard deviation in coordinate units). Used in emission probability: P(obs|candidate) ~ exp(-dist²/(2*gps_error²)). Higher values are more tolerant of GPS noise. Typical: 10-100 meters. Default: 50. - + reverse_tolerance: Maximum distance allowed when routing backward along an edge (in coordinate units). Set to 0 to forbid reversing, which is recommended for directed road networks. Default: 0.0. - + transition_mode: Routing cost metric - SHORTEST (distance) or FASTEST (time). Must match the mode used to create NetworkGraph and UBODT. Default: SHORTEST. - + reference_speed: Expected travel speed for straight-line movement between GPS points (distance units per time unit). REQUIRED for FASTEST mode, unused for SHORTEST mode. This represents the typical speed at which vehicles travel directly between points. Lower values encourage sticking to routes, higher values allow more detours. Typical: average vehicle speed like 40-60 in urban areas. Default: None. - + Returns: SplitMatchResult: containing a list of sub-trajectories, each marked as either successfully matched (with segments) or failed (with error code) - + Example: Trajectory with points [0,1,2,3,4,5,6,7] where point 4 has no candidates: - Returns 2 sub-trajectories: [0-3] SUCCESS, [4-4] CANDIDATES_NOT_FOUND - If points 5-7 can be matched, adds [5-7] SUCCESS - Much faster than calling match_trajectory() multiple times since candidate lookup is done once - + Note: The config's transition_mode must match the mode used to create the NetworkGraph and UBODT. For FASTEST mode, ensure reference_speed is set. """ class MatchCandidate: """ - + A candidate match location for a GPS observation point. - + Represents a potential location on the road network where a GPS point might actually be located, accounting for GPS error. Multiple candidates per point are considered during matching. - + """ def __repr__(self) -> str: ... @@ -123,17 +124,17 @@ class MatchCandidate: class MatchErrorCode: """ Members: - + SUCCESS : Matching succeeded - + CANDIDATES_NOT_FOUND : No candidate edges found for trajectory - + DISCONNECTED_LAYERS : Trajectory has disconnected layers - + INDEX_OUT_OF_BOUNDS : Start edge index out of bounds - + INDEX_OUT_OF_BOUNDS_END : End edge index out of bounds - + UNKNOWN_ERROR : Unknown error occurred """ CANDIDATES_NOT_FOUND: typing.ClassVar[MatchErrorCode] # value = @@ -171,12 +172,12 @@ class MatchErrorCode: ... class MatchPoint: """ - + A matched point along a road edge in the map matching result. - + Represents a specific location on a matched edge, including its position relative to the edge start and the cumulative distance from the trajectory start. - + """ def __repr__(self) -> str: ... @@ -217,13 +218,13 @@ class MatchPoint: """ class MatchSegment: """ - + A continuous matched path segment between two GPS observation points. - + Represents the matched route from one GPS point to the next, potentially spanning multiple road edges. Each segment contains the start/end candidates and the sequence of edges traversed. - + """ def __repr__(self) -> str: ... @@ -244,12 +245,12 @@ class MatchSegment: """ class MatchSegmentEdge: """ - + A matched road edge with interpolated points along the matched path. - + Contains the edge ID and a sequence of matched points representing where the trajectory intersects or follows this edge. - + """ def __repr__(self) -> str: ... @@ -270,32 +271,32 @@ class MatchSegmentEdge: """ class Network: """ - + A road network consisting of nodes (junctions) and directed edges (road segments). - + The network must be fully constructed (all edges added) before building the spatial index. Once the index is built, the network is ready for map matching operations. - + Example: >>> network = fastmm.Network() >>> network.add_edge(1, source=10, target=20, geom=[(0, 0), (100, 0)], speed=50.0) >>> network.finalize() - + """ def __init__(self) -> None: """ Create an empty network. - + Use add_edge() to populate the network with road segments, then call finalize() to prepare it for map matching. """ def add_edge(self, edge_id: typing.SupportsInt, source: typing.SupportsInt, target: typing.SupportsInt, geom: list, speed: typing.SupportsFloat | None = None) -> None: """ Add a directed edge (road segment) to the network. - + Each edge must have a unique ID and connects two nodes. The geometry defines the spatial path of the edge. Speed is required for FASTEST routing mode. - + Args: edge_id: Unique integer identifier for this edge source: Node ID where the edge starts @@ -303,109 +304,104 @@ class Network: geom: List of (x, y) tuples defining the edge geometry (minimum 2 points) speed: Optional speed value (distance units per time unit). Required if using TransitionMode.FASTEST routing. - + Note: Call finalize() after adding all edges. - + Example: >>> network.add_edge(1, source=1, target=2, geom=[(0, 0), (100, 0)], speed=50.0) """ def compute_hash(self) -> str: """ Compute a hash of the network structure for cache validation. - + The hash is computed from edge count, sampled edge IDs, sources, targets, and speeds. It's used to detect network changes and invalidate cached UBODT files. - + Returns: str: 8-character hexadecimal hash string """ def finalize(self) -> None: """ Build the spatial R-tree index for efficient candidate edge lookup. - + This MUST be called after adding all edges and before creating a NetworkGraph or performing any map matching operations. The index enables fast spatial queries to find nearby road segments for GPS points. - + Raises: RuntimeError: If called on an empty network """ def get_edge_count(self) -> int: """ Get the total number of edges in the network. - + Returns: int: Number of edges """ def get_node_count(self) -> int: """ Get the total number of nodes in the network. - + Returns: int: Number of unique nodes """ class NetworkGraph: """ - + A routing graph built from a Network for path finding. - + The graph representation depends on the chosen routing mode: - SHORTEST: Uses edge distances for path costs - FASTEST: Uses edge travel times (distance/speed) for path costs - + A separate graph must be created for each routing mode, and FASTEST mode requires that all edges have speed values defined. - + """ def __init__(self, network: Network, mode: TransitionMode = ...) -> None: """ Create a NetworkGraph from a Network with specified routing mode. - + Args: network: Network with edges (must have finalize() called) mode: Routing mode - SHORTEST (distance-based) or FASTEST (time-based). Default is SHORTEST. - + Raises: RuntimeError: If mode is FASTEST and any edge lacks a speed value - + Note: Generate separate UBODT files for each routing mode, as the shortest paths differ between distance-based and time-based routing. """ class SplitMatchResult: """ - + Result of matching with automatic trajectory splitting. - + Contains a list of sub-trajectories representing all continuous matched portions and failed sections of the input trajectory. Each sub-trajectory indicates which points it covers and whether matching succeeded or failed. - + """ def __repr__(self) -> str: ... @property - def id(self) -> int: - """ - Trajectory ID (copied from input Trajectory) - """ - @property def subtrajectories(self) -> list[SubTrajectory]: """ List of SubTrajectory objects (both successful and failed portions) """ class SubTrajectory: """ - + A continuous portion of a trajectory that was matched or failed. - + Represents a successful match with segments. Failed portions are simply excluded from the results - only successfully matched sub-trajectories are returned in the SplitMatchResult. - + """ def __repr__(self) -> str: ... @@ -431,35 +427,33 @@ class SubTrajectory: """ class Trajectory: """ - + A GPS trajectory consisting of sequential observations. - + Trajectories can include timestamps (x, y, t) for time interpolation or be spatial-only (x, y). The trajectory's geometry and timestamps are used during map matching to find the best road path. - + """ @staticmethod - def from_xy_tuples(id: typing.SupportsInt, tuples: list) -> Trajectory: + def from_xy_tuples(tuples: list) -> Trajectory: """ Create a Trajectory from GPS points without timestamps (spatial-only). - + Args: - id: Unique integer identifier for this trajectory - tuples: List of (x, y) tuples representing GPS observation locations - + tuples: List of (x, y) tuples/lists representing GPS observation locations + Returns: Trajectory instance without time information """ @staticmethod - def from_xyt_tuples(id: typing.SupportsInt, tuples: list) -> Trajectory: + def from_xyt_tuples(tuples: list) -> Trajectory: """ Create a Trajectory from GPS points with timestamps. - + Args: - id: Unique integer identifier for this trajectory - tuples: List of (x, y, t) tuples representing GPS observations with time - + tuples: List of (x, y, t) tuples/lists representing GPS observations with time + Returns: Trajectory instance with timestamps for time interpolation """ @@ -472,38 +466,30 @@ class Trajectory: def has_timestamps(self) -> bool: """ Check if the trajectory has timestamps. - + Returns: bool: True if timestamps are present, False otherwise """ def to_xy_tuples(self) -> list: """ Export trajectory as a list of (x, y) tuples (spatial only). - + Returns: List of tuples, each containing (x_coord, y_coord) """ def to_xyt_tuples(self) -> list: """ Export trajectory as a list of (x, y, t) tuples - only if the original had timestamps. - + Returns: List of tuples, each containing (x_coord, y_coord, timestamp) """ - @property - def id(self) -> int: - """ - Unique integer identifier for this trajectory - """ - @id.setter - def id(self, arg0: typing.SupportsInt) -> None: - ... class TransitionMode: """ Members: - + SHORTEST : Distance-based routing - + FASTEST : Time-based routing """ FASTEST: typing.ClassVar[TransitionMode] # value = diff --git a/python/pybind11/fastmm_bindings.cpp b/python/pybind11/fastmm_bindings.cpp index 2ee9147..b0fb0c6 100644 --- a/python/pybind11/fastmm_bindings.cpp +++ b/python/pybind11/fastmm_bindings.cpp @@ -56,7 +56,7 @@ PYBIND11_MODULE(fastmm, m) Use add_edge() to populate the network with road segments, then call finalize() to prepare it for map matching. )pbdoc") - .def("add_edge", [](Network &self, int edge_id, int source, int target, py::list coords, std::optional speed) + .def("add_edge", [](Network &self, EdgeID edge_id, NodeID source, NodeID target, py::list coords, std::optional speed) { LineString geom; for (auto item : coords) { @@ -80,9 +80,9 @@ PYBIND11_MODULE(fastmm, m) the spatial path of the edge. Speed is required for FASTEST routing mode. Args: - edge_id: Unique integer identifier for this edge - source: Node ID where the edge starts - target: Node ID where the edge ends + edge_id: Unique identifier for this edge (long long) + source: Node ID where the edge starts (long long) + target: Node ID where the edge ends (long long) geom: List of (x, y) tuples defining the edge geometry (minimum 2 points) speed: Optional speed value (distance units per time unit). Required if using TransitionMode.FASTEST routing. @@ -237,7 +237,7 @@ PYBIND11_MODULE(fastmm, m) "List of PyMatchSegmentEdge objects forming the path from p0 to p1") .def("__repr__", [](const PyMatchSegment &s) { return ""; }); // SubTrajectory struct @@ -271,8 +271,6 @@ PYBIND11_MODULE(fastmm, m) portions and failed sections of the input trajectory. Each sub-trajectory indicates which points it covers and whether matching succeeded or failed. )pbdoc") - .def_readonly("id", &PySplitMatchResult::id, - "Trajectory ID (copied from input Trajectory)") .def_readonly("subtrajectories", &PySplitMatchResult::subtrajectories, "List of SubTrajectory objects (both successful and failed portions)") .def("__repr__", [](const PySplitMatchResult &r) @@ -286,8 +284,7 @@ PYBIND11_MODULE(fastmm, m) failed_count++; } } - return ""; }); @@ -299,13 +296,10 @@ PYBIND11_MODULE(fastmm, m) spatial-only (x, y). The trajectory's geometry and timestamps are used during map matching to find the best road path. )pbdoc") - .def_readwrite("id", &Trajectory::id, - "Unique integer identifier for this trajectory") .def("__len__", [](const Trajectory &self) { return self.geom.get_num_points(); }, "Number of GPS observation points in the trajectory") .def("__repr__", [](const Trajectory &self) - { return ""; }) .def("has_timestamps", &Trajectory::has_timestamps, R"pbdoc( @@ -340,49 +334,47 @@ PYBIND11_MODULE(fastmm, m) Returns: List of tuples, each containing (x_coord, y_coord) )pbdoc") - .def_static("from_xyt_tuples", [](int id, py::list tuples) + .def_static("from_xyt_tuples", [](py::list tuples) { std::vector> data; for (auto item : tuples) { - if (py::isinstance(item) && py::len(item) == 3) { - py::tuple tup = item.cast(); - double x = py::float_(tup[0]); - double y = py::float_(tup[1]); - double t = py::float_(tup[2]); + if ((py::isinstance(item) || py::isinstance(item)) && py::len(item) == 3) { + py::sequence seq = item.cast(); + double x = py::float_(seq[0]); + double y = py::float_(seq[1]); + double t = py::float_(seq[2]); data.emplace_back(x, y, t); } else { - throw std::runtime_error("Each item must be a tuple (x, y, t)"); + throw std::runtime_error("Each item must be a 3-element tuple or list (x, y, t)"); } } - return Trajectory::from_xyt_tuples(id, data); }, py::arg("id"), py::arg("tuples"), R"pbdoc( + return Trajectory::from_xyt_tuples(data); }, py::arg("tuples"), R"pbdoc( Create a Trajectory from GPS points with timestamps. Args: - id: Unique integer identifier for this trajectory - tuples: List of (x, y, t) tuples representing GPS observations with time + tuples: List of (x, y, t) tuples/lists representing GPS observations with time Returns: Trajectory instance with timestamps for time interpolation )pbdoc") - .def_static("from_xy_tuples", [](int id, py::list tuples) + .def_static("from_xy_tuples", [](py::list tuples) { std::vector> data; for (auto item : tuples) { - if (py::isinstance(item) && py::len(item) == 2) { - py::tuple tup = item.cast(); - double x = py::float_(tup[0]); - double y = py::float_(tup[1]); + if ((py::isinstance(item) || py::isinstance(item)) && py::len(item) == 2) { + py::sequence seq = item.cast(); + double x = py::float_(seq[0]); + double y = py::float_(seq[1]); data.emplace_back(x, y); } else { - throw std::runtime_error("Each item must be a tuple (x, y)"); + throw std::runtime_error("Each item must be a 2-element tuple or list (x, y)"); } } - return Trajectory::from_xy_tuples(id, data); }, py::arg("id"), py::arg("tuples"), R"pbdoc( + return Trajectory::from_xy_tuples(data); }, py::arg("tuples"), R"pbdoc( Create a Trajectory from GPS points without timestamps (spatial-only). Args: - id: Unique integer identifier for this trajectory - tuples: List of (x, y) tuples representing GPS observation locations + tuples: List of (x, y) tuples/lists representing GPS observation locations Returns: Trajectory instance without time information @@ -396,13 +388,26 @@ PYBIND11_MODULE(fastmm, m) of road edges, considering both emission probabilities (GPS accuracy) and transition probabilities (path likelihood). Uses precomputed UBODT for fast path lookups. )pbdoc") - .def(py::init, std::optional, const std::string &>(), - py::arg("network"), - py::arg("mode"), - py::arg("max_distance_between_candidates") = std::nullopt, - py::arg("max_time_between_candidates") = std::nullopt, - py::arg("cache_dir") = "./ubodt_cache", - R"pbdoc( + // Accept cache_dir as pathlib.Path (or any object with __fspath__) + .def( + py::init([](const Network &network, TransitionMode mode, std::optional max_distance_between_candidates, std::optional max_time_between_candidates, py::object cache_dir) + { + // Accept str or Path-like (with __fspath__) + std::string cache_dir_str; + if (py::isinstance(cache_dir)) { + cache_dir_str = cache_dir.cast(); + } else if (py::hasattr(cache_dir, "__fspath__")) { + cache_dir_str = py::str(cache_dir.attr("__fspath__")()); + } else { + throw std::invalid_argument("cache_dir must be a str or Path-like object"); + } + return new FastMapMatch(network, mode, max_distance_between_candidates, max_time_between_candidates, cache_dir_str); }), + py::arg("network"), + py::arg("mode"), + py::arg("max_distance_between_candidates") = std::nullopt, + py::arg("max_time_between_candidates") = std::nullopt, + py::arg("cache_dir") = "./ubodt_cache", + R"pbdoc( Create a FastMapMatch instance with automatic UBODT management. Args: @@ -410,11 +415,12 @@ PYBIND11_MODULE(fastmm, m) mode: Routing mode (TransitionMode.SHORTEST for distance, FASTEST for time) max_distance_between_candidates: Maximum distance in meters (for SHORTEST mode) max_time_between_candidates: Maximum time in seconds (for FASTEST mode) - cache_dir: Directory for caching UBODT files (default: "./ubodt_cache") + cache_dir: Directory for caching UBODT files (str or Path, default: "./ubodt_cache") Note: Only the relevant parameter is used depending on mode. This constructor automatically generates/loads UBODT from cache based on network hash, mode, and delta. UBODT is cached for reuse. + Accepts str or pathlib.Path for cache_dir. )pbdoc") .def("match", &FastMapMatch::pymatch_trajectory, py::arg("trajectory"), diff --git a/src/core/gps.hpp b/src/core/gps.hpp index 6c94c36..419a301 100644 --- a/src/core/gps.hpp +++ b/src/core/gps.hpp @@ -28,15 +28,15 @@ namespace FASTMM struct Trajectory { Trajectory() : has_timestamps_(false) {}; - Trajectory(int id_arg, const LineString &geom_arg) : id(id_arg), geom(geom_arg), has_timestamps_(false) + Trajectory(const LineString &geom_arg) : geom(geom_arg), has_timestamps_(false) { if (geom.get_num_points() == 0) { throw std::invalid_argument("Trajectory: must have at least one point!"); } }; - Trajectory(int id_arg, const LineString &geom_arg, const std::vector ×tamps_arg) - : id(id_arg), geom(geom_arg), timestamps(timestamps_arg), has_timestamps_(true) + Trajectory(const LineString &geom_arg, const std::vector ×tamps_arg) + : geom(geom_arg), timestamps(timestamps_arg), has_timestamps_(true) { if (geom.get_num_points() != static_cast(timestamps.size())) { @@ -63,7 +63,6 @@ namespace FASTMM prev_t = t; } } - int id; /**< Id of the trajectory */ LineString geom; /**< Geometry of the trajectory */ std::vector timestamps; /**< Timestamps of the trajectory */ @@ -79,18 +78,18 @@ namespace FASTMM } // Create a Trajectory from a vector of (x, y) tuples - static Trajectory from_xy_tuples(int id, const std::vector> &data) + static Trajectory from_xy_tuples(const std::vector> &data) { LineString geom; for (const auto &item : data) { geom.add_point(Point(std::get<0>(item), std::get<1>(item))); } - return Trajectory(id, geom); + return Trajectory(geom); } // Create a Trajectory from a vector of (x, y, t) tuples - static Trajectory from_xyt_tuples(int id, const std::vector> &data) + static Trajectory from_xyt_tuples(const std::vector> &data) { LineString geom; std::vector timestamps; @@ -99,7 +98,7 @@ namespace FASTMM geom.add_point(Point(std::get<0>(item), std::get<1>(item))); timestamps.push_back(std::get<2>(item)); } - return Trajectory(id, geom, timestamps); + return Trajectory(geom, timestamps); } // Return as a vector of (x, y, t) tuples diff --git a/src/mm/fmm/fmm_algorithm.cpp b/src/mm/fmm/fmm_algorithm.cpp index f123216..f026dca 100644 --- a/src/mm/fmm/fmm_algorithm.cpp +++ b/src/mm/fmm/fmm_algorithm.cpp @@ -182,7 +182,6 @@ MatchResult FastMapMatch::match_trajectory(const Trajectory &trajectory, const F TrajectoryCandidates tc = network_.search_tr_cs_knn(trajectory.geom, config.max_candidates, config.candidate_search_radius); SPDLOG_DEBUG("Trajectory candidate {}", tc); MatchResult result = MatchResult{}; - result.id = trajectory.id; result.error_code = MatchErrorCode::UNKNOWN_ERROR; std::vector unmatched_indices; for (int i = 0; i < tc.size(); ++i) @@ -195,7 +194,7 @@ MatchResult FastMapMatch::match_trajectory(const Trajectory &trajectory, const F if (!unmatched_indices.empty()) { - SPDLOG_DEBUG("No candidates found for trajectory {} at points {}", trajectory.id, unmatched_indices); + SPDLOG_DEBUG("No candidates found for trajectory at points {}", unmatched_indices); result.error_code = MatchErrorCode::CANDIDATES_NOT_FOUND; return result; } @@ -208,7 +207,7 @@ MatchResult FastMapMatch::match_trajectory(const Trajectory &trajectory, const F int last_connected = update_tg(&tg, trajectory, config, &all_connected); if (!all_connected) { - SPDLOG_DEBUG("Traj {} unmatched at trajectory point {}", trajectory.id, last_connected); + SPDLOG_DEBUG("Traj unmatched at trajectory point {}", last_connected); result.error_code = MatchErrorCode::DISCONNECTED_LAYERS; return result; } @@ -224,7 +223,7 @@ MatchResult FastMapMatch::match_trajectory(const Trajectory &trajectory, const F { return a->c->edge->id; }); std::vector indices; const std::vector &edges = network_.get_edges(); - CompletePath complete_path = ubodt_->construct_complete_path(trajectory.id, tg_opath, edges, &indices, config.reverse_tolerance); + CompletePath complete_path = ubodt_->construct_complete_path(tg_opath, edges, &indices, config.reverse_tolerance); SPDLOG_DEBUG("Opath is {}", optimal_path); SPDLOG_DEBUG("Indices is {}", indices); SPDLOG_DEBUG("Complete path is {}", complete_path); @@ -330,7 +329,7 @@ int FastMapMatch::update_tg(TransitionGraph *tg, const Trajectory &trajectory, c update_layer(i, &(layers[i]), &(layers[i + 1]), euclidean_distances[i], config, &layer_connected); if (!layer_connected) { - SPDLOG_DEBUG("Traj {} unmatched as point {} and {} not connected", trajectory.id, i, i + 1); + SPDLOG_DEBUG("Traj unmatched as point {} and {} not connected", i, i + 1); if (all_connected != nullptr) { *all_connected = false; @@ -419,10 +418,9 @@ PySplitMatchResult FastMapMatch::pymatch_trajectory(const CORE::Trajectory &traj mode_, reference_speed); PySplitMatchResult output; - output.id = trajectory.id; int N = trajectory.geom.get_num_points(); - SPDLOG_DEBUG("Split matching trajectory {} with {} points", trajectory.id, N); + SPDLOG_DEBUG("Split matching trajectory with {} points", N); // Do candidate search once for all points TrajectoryCandidates tc = network_.search_tr_cs_knn(trajectory.geom, config.max_candidates, config.candidate_search_radius); @@ -580,7 +578,7 @@ PySplitMatchResult FastMapMatch::pymatch_trajectory(const CORE::Trajectory &traj std::vector indices; const std::vector &edges = network_.get_edges(); - CompletePath complete_path = ubodt_->construct_complete_path(trajectory.id, tg_opath, edges, &indices, config.reverse_tolerance); + CompletePath complete_path = ubodt_->construct_complete_path(tg_opath, edges, &indices, config.reverse_tolerance); // Build PyMatchSegments PySubTrajectory success_sub; @@ -663,7 +661,7 @@ std::vector FastMapMatch::build_py_segments(const MatchedCandida for (int j = 0; j < line.get_num_points(); ++j) { - double d = distances[j - 1]; + double d = (j == 0) ? 0.0 : distances[j - 1]; if (j > 0) { cumulative_distance += d; @@ -684,7 +682,7 @@ std::vector FastMapMatch::build_py_segments(const MatchedCandida double d; for (int j = 0; j < line.get_num_points(); ++j) { - d = distances[j - 1]; + d = (j == 0) ? 0.0 : distances[j - 1]; if (j > 0) { cumulative_distance += d; @@ -705,7 +703,7 @@ std::vector FastMapMatch::build_py_segments(const MatchedCandida for (int k = 0; k < line.get_num_points(); ++k) { - d = distances[k - 1]; + d = (k == 0) ? 0.0 : distances[k - 1]; if (k > 0) { cumulative_distance += d; @@ -725,7 +723,7 @@ std::vector FastMapMatch::build_py_segments(const MatchedCandida for (int j = 0; j < line.get_num_points(); ++j) { - d = distances[j - 1]; + d = (j == 0) ? 0.0 : distances[j - 1]; if (j > 0) { cumulative_distance += d; diff --git a/src/mm/fmm/ubodt.cpp b/src/mm/fmm/ubodt.cpp index e3c75ef..9fd4026 100644 --- a/src/mm/fmm/ubodt.cpp +++ b/src/mm/fmm/ubodt.cpp @@ -64,7 +64,7 @@ std::vector UBODT::look_sp_path(NodeIndex source, return edges; } -CompletePath UBODT::construct_complete_path(int traj_id, const TGOpath &path, +CompletePath UBODT::construct_complete_path(const TGOpath &path, const std::vector &edges, std::vector *indices, double reverse_tolerance) const @@ -95,9 +95,9 @@ CompletePath UBODT::construct_complete_path(int traj_id, const TGOpath &path, SPDLOG_DEBUG("Edges not found connecting a b"); SPDLOG_DEBUG("reverse movement {} tolerance {}", a->offset - b->offset, reverse_tolerance); - SPDLOG_WARN("Traj {} unmatched as edge {} L {} offset {}" + SPDLOG_WARN("Traj unmatched as edge {} L {} offset {}" " and edge {} L {} offset {} disconnected", - traj_id, a->edge->id, a->edge->length, a->offset, + a->edge->id, a->edge->length, a->offset, b->edge->id, b->edge->length, b->offset); indices->clear(); diff --git a/src/mm/fmm/ubodt.hpp b/src/mm/fmm/ubodt.hpp index c3f02e9..1d14fe8 100644 --- a/src/mm/fmm/ubodt.hpp +++ b/src/mm/fmm/ubodt.hpp @@ -82,7 +82,7 @@ namespace FASTMM * path implying complete path cannot be found in UBDOT, * an empty path is returned */ - CompletePath construct_complete_path(int traj_id, const TGOpath &path, + CompletePath construct_complete_path(const TGOpath &path, const std::vector &edges, std::vector *indices, double reverse_tolerance) const; diff --git a/src/mm/mm_type.hpp b/src/mm/mm_type.hpp index b33f395..6da11a4 100644 --- a/src/mm/mm_type.hpp +++ b/src/mm/mm_type.hpp @@ -87,7 +87,6 @@ namespace FASTMM */ struct MatchResult { - int id; /**< id of the trajectory to be matched */ MatchedCandidatePath opt_candidate_path; /**< A vector of candidate matched to each point of a trajectory. It is stored in order to export more detailed map matching information. */ OptimalPath optimal_path; /**< the optimal path, containing id of edges matched to each point in a trajectory */ CompletePath complete_path; /**< the complete path, containing ids of a sequence of topologically connected edges traversed by the trajectory. */ @@ -109,7 +108,7 @@ namespace FASTMM struct PyMatchSegmentEdge { - long long edge_id; + NETWORK::EdgeID edge_id; std::vector points; bool reversed; // True if geometry is reversed (offset1 > offset2 on same edge) }; @@ -145,7 +144,6 @@ namespace FASTMM */ struct PySplitMatchResult { - int id; /**< id of the trajectory */ std::vector subtrajectories; /**< List of sub-trajectory matches (both successful and failed) */ }; }; diff --git a/test_fastmm.py b/test_fastmm.py index d02d38f..c3cb6cd 100644 --- a/test_fastmm.py +++ b/test_fastmm.py @@ -96,19 +96,17 @@ class TestTrajectoryCreation: def test_create_trajectory_from_xy(self): """Test creating trajectory from (x, y) tuples.""" - traj = Trajectory.from_xy_tuples(1, [(0, 0), (100, 0), (200, 0)]) - assert traj.id == 1 + traj = Trajectory.from_xy_tuples([(0, 0), (100, 0), (200, 0)]) assert len(traj) == 3 def test_create_trajectory_from_xyt(self): """Test creating trajectory from (x, y, t) tuples.""" - traj = Trajectory.from_xyt_tuples(2, [(0, 0, 0), (100, 0, 10), (200, 0, 20)]) - assert traj.id == 2 + traj = Trajectory.from_xyt_tuples([(0, 0, 0), (100, 0, 10), (200, 0, 20)]) assert len(traj) == 3 def test_trajectory_to_xy_tuples(self): """Test exporting trajectory to (x, y) tuples.""" - traj = Trajectory.from_xy_tuples(1, [(0, 0), (100, 0)]) + traj = Trajectory.from_xy_tuples([(0, 0), (100, 0)]) xy_tuples = traj.to_xy_tuples() assert len(xy_tuples) == 2 assert xy_tuples[0] == (0, 0) @@ -116,7 +114,7 @@ def test_trajectory_to_xy_tuples(self): def test_trajectory_to_xyt_tuples(self): """Test exporting trajectory to (x, y, t) tuples.""" - traj = Trajectory.from_xyt_tuples(1, [(0, 0, 5), (100, 0, 10)]) + traj = Trajectory.from_xyt_tuples([(0, 0, 5), (100, 0, 10)]) xyt_tuples = traj.to_xyt_tuples() assert len(xyt_tuples) == 2 assert xyt_tuples[0] == (0, 0, 5) @@ -125,13 +123,12 @@ def test_trajectory_to_xyt_tuples(self): def test_trajectory_with_decreasing_timestamps_fails(self): """Test that creating trajectory with non-increasing timestamps fails.""" with pytest.raises(ValueError, match="non-decreasing"): - Trajectory.from_xyt_tuples(1, [(0, 0, 10), (100, 0, 5), (200, 0, 15)]) + Trajectory.from_xyt_tuples([(0, 0, 10), (100, 0, 5), (200, 0, 15)]) def test_trajectory_with_equal_timestamps_succeeds(self): """Test that trajectory with equal consecutive timestamps is allowed.""" # Non-decreasing means t[i] <= t[i+1], so equal timestamps are OK - traj = Trajectory.from_xyt_tuples(1, [(0, 0, 10), (100, 0, 10), (200, 0, 15)]) - assert traj.id == 1 + traj = Trajectory.from_xyt_tuples([(0, 0, 10), (100, 0, 10), (200, 0, 15)]) assert len(traj) == 3 @@ -194,7 +191,7 @@ def test_shortest_routing_prefers_direct_route(self, network_with_detour): network, TransitionMode.SHORTEST, max_distance_between_candidates=5000, cache_dir=".cache" ) # Trajectory through A->B->?->C->D - t = Trajectory.from_xyt_tuples(1, [(25, 0, 0), (75, 0, 5), (150, 5, 10), (225, 0, 15), (275, 0, 20)]) + t = Trajectory.from_xyt_tuples([(25, 0, 0), (75, 0, 5), (150, 5, 10), (225, 0, 15), (275, 0, 20)]) result = matcher.match(t, max_candidates=8, candidate_search_radius=15, gps_error=5) assert len(result.subtrajectories) == 1 result = result.subtrajectories[0] @@ -222,7 +219,7 @@ def test_fastest_routing_prefers_detour_route(self, network_with_detour): network = network_with_detour matcher = FastMapMatch(network, TransitionMode.FASTEST, max_time_between_candidates=5000, cache_dir=".cache") # Trajectory through A->B->?->C->D - t = Trajectory.from_xyt_tuples(1, [(25, 0, 0), (75, 0, 5), (150, 5, 10), (225, 0, 15), (275, 0, 20)]) + t = Trajectory.from_xyt_tuples([(25, 0, 0), (75, 0, 5), (150, 5, 10), (225, 0, 15), (275, 0, 20)]) result = matcher.match(t, max_candidates=8, candidate_search_radius=15, gps_error=5, reference_speed=50) assert len(result.subtrajectories) == 1 result = result.subtrajectories[0] @@ -258,10 +255,8 @@ def test_match_result_structure(self): max_distance_between_candidates=1000, cache_dir=".cache/test_match_result_structure", ) - t = Trajectory.from_xy_tuples(1, [(10, 0), (90, 0)]) + t = Trajectory.from_xy_tuples([(10, 0), (90, 0)]) result = matcher.match(t, max_candidates=4, candidate_search_radius=50, gps_error=50) - assert result.id == 1 - assert hasattr(result, "id") assert hasattr(result, "subtrajectories") # Check result structure @@ -281,7 +276,7 @@ def test_match_result_segments(self): matcher = FastMapMatch( network, TransitionMode.SHORTEST, max_distance_between_candidates=1000, cache_dir=".cache" ) - t = Trajectory.from_xy_tuples(1, [(10, 0), (150, 0)]) + t = Trajectory.from_xy_tuples([(10, 0), (150, 0)]) result = matcher.match(t, max_candidates=4, candidate_search_radius=50, gps_error=50) assert len(result.subtrajectories) == 1 result = result.subtrajectories[0] @@ -314,10 +309,9 @@ def test_split_match_basic(self): cache_dir=".cache/test_split_match_result_structure", ) # Simple trajectory that should match completely - t = Trajectory.from_xy_tuples(1, [(10, 0), (50, 0), (150, 0)]) + t = Trajectory.from_xy_tuples([(10, 0), (50, 0), (150, 0)]) result = matcher.match(t, max_candidates=4, candidate_search_radius=50, gps_error=50) - assert result.id == 1 assert len(result.subtrajectories) >= 1 # Should have at least one successful sub-trajectory success_count = sum(1 for sub in result.subtrajectories if sub.error_code == MatchErrorCode.SUCCESS) @@ -335,7 +329,6 @@ def test_split_match_with_gap(self): ) # Trajectory with point far from network t = Trajectory.from_xy_tuples( - 1, [ (10, 0), # Point 0 - near network (50, 0), # Point 1 - near network @@ -345,7 +338,6 @@ def test_split_match_with_gap(self): ], ) result = matcher.match(t, max_candidates=4, candidate_search_radius=30, gps_error=20) - assert result.id == 1 # Should have successfully matched portions (points 0-1 and 3-4) # Point 2 is just skipped (no single-point sub-trajectories added) @@ -369,11 +361,10 @@ def test_split_match_result_structure(self): matcher = FastMapMatch( network, TransitionMode.SHORTEST, max_distance_between_candidates=1000, cache_dir=".cache" ) - t = Trajectory.from_xy_tuples(1, [(10, 0), (50, 0)]) + t = Trajectory.from_xy_tuples([(10, 0), (50, 0)]) result = matcher.match(t, max_candidates=4, candidate_search_radius=50, gps_error=50) # Check result structure - assert hasattr(result, "id") assert hasattr(result, "subtrajectories") assert isinstance(result.subtrajectories, list) @@ -412,7 +403,6 @@ def test_split_match_disconnected_by_distance(self): # Use max_candidates=1 and small radius to ensure we get the closest edge only # First, test with regular matching to see what happens t = Trajectory.from_xy_tuples( - 0, [ (150, 0), # Point on edge 2 (middle of 100-200) (750, 0), # Point on edge 8 (middle of 700-800) - very far away @@ -425,7 +415,6 @@ def test_split_match_disconnected_by_distance(self): # - All have candidates (near roads) # - But some consecutive points are beyond UBODT delta distance t = Trajectory.from_xy_tuples( - 1, [ (50, 0), # Point 0 - on edge 1 (middle of 0-100) (150, 0), # Point 1 - on edge 2 (middle of 100-200) @@ -434,7 +423,6 @@ def test_split_match_disconnected_by_distance(self): ], ) result = matcher.match(t, max_candidates=1, candidate_search_radius=30, gps_error=50) - assert result.id == 1 # Should split into at least 2 sub-trajectories due to disconnection # Point 1 (edge 2) to Point 2 (edge 7) requires path distance of ~400-500 units @@ -475,7 +463,7 @@ def test_fails_to_reverse_if_outside_tolerance(self): # Allow reverse tolerance for backward movement # GPS points that move backward: 80m -> 30m on same edge - t = Trajectory.from_xy_tuples(1, [(80, 0), (30, 0)]) + t = Trajectory.from_xy_tuples([(80, 0), (30, 0)]) result = matcher.match( t, @@ -501,7 +489,7 @@ def test_reversed_flag_on_backward_movement(self): # Allow reverse tolerance for backward movement # GPS points that move backward: 80m -> 30m on same edge - t = Trajectory.from_xy_tuples(1, [(80, 0), (30, 0)]) + t = Trajectory.from_xy_tuples([(80, 0), (30, 0)]) result = matcher.match( t, max_candidates=4, @@ -548,7 +536,7 @@ def test_not_reversed_on_forward_movement(self): cache_dir=".cache/test_not_reversed", ) # Normal forward movement: 30m -> 80m - t = Trajectory.from_xy_tuples(1, [(30, 0), (80, 0)]) + t = Trajectory.from_xy_tuples([(30, 0), (80, 0)]) result = matcher.match(t, max_candidates=4, candidate_search_radius=50, gps_error=50) assert len(result.subtrajectories) == 1 result = result.subtrajectories[0] @@ -577,7 +565,7 @@ def test_reversed_geometry_consistency(self): cache_dir=".cache/test_reversed_consistency", ) # Backward movement on multi-segment edge - t = Trajectory.from_xy_tuples(1, [(90, 0), (40, 0)]) + t = Trajectory.from_xy_tuples([(90, 0), (40, 0)]) result = matcher.match(t, max_candidates=4, candidate_search_radius=50, gps_error=50, reverse_tolerance=60) assert len(result.subtrajectories) == 1