diff --git a/CITATION.cff b/CITATION.cff index f2d17b15..f5f12e4a 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,5 +12,5 @@ authors: repository-code: 'https://github.com/Grufoony/DynamicalSystemFramework' url: 'https://grufoony.github.io/DynamicalSystemFramework/' license: AGPL-3.0-only -version: 6.4.0 +version: 6.5.0 date-released: '2026-07-06' diff --git a/src/dsf/cartography/cartography.py b/src/dsf/cartography/cartography.py index 6d2cfdb5..2ff41257 100644 --- a/src/dsf/cartography/cartography.py +++ b/src/dsf/cartography/cartography.py @@ -8,6 +8,7 @@ """ import ast +import math import re import folium import geopandas as gpd @@ -16,6 +17,9 @@ import osmnx as ox from shapely.geometry import LineString, Point, Polygon +if "turn:lanes" not in ox.settings.useful_tags_way: + ox.settings.useful_tags_way.append("turn:lanes") + def fetch_cartography( place_name: str | None = None, @@ -75,6 +79,7 @@ def process_cartography( consolidate_intersections: bool | float = 10, dead_ends: bool = False, infer_speeds: bool = False, + infer_forbidden_turns: bool = True, scc: bool | str = False, ) -> tuple[nx.DiGraph, gpd.GeoDataFrame, gpd.GeoDataFrame]: """ @@ -91,6 +96,7 @@ def process_cartography( dead_ends (bool, optional): Whether to preserve dead-end nodes during consolidation. Defaults to False. infer_speeds (bool, optional): If True, infers edge speeds via np.nanmedian and computes travel times. Defaults to False. + infer_forbidden_turns (bool, optional): If True, infers forbidden turns based on lane mapping. Defaults to True. scc (bool | str, optional): If True, keeps only the largest strongly connected component of the graph. Defaults to False. If "mark", adds a boolean "in_scc" attribute to nodes and edges instead of filtering. @@ -229,11 +235,13 @@ def _extract_numeric(item): lanes = data["lanes"] if isinstance(lanes, str): lanes = ast.literal_eval(lanes) if lanes.startswith("[") else lanes - n = ( - min([int(x) for x in lanes], default=1) - if isinstance(lanes, list) - else max(int(lanes), 1) - ) + if isinstance(lanes, list): + n = min([int(x) for x in lanes], default=1) + if "turn:lanes" in data: + # Empty it + data["turn:lanes"] = "" + else: + n = max(int(lanes), 1) n = max(n, 1) oneway = data.get("oneway", False) if not (oneway is True or oneway in ("yes", "True")): @@ -243,6 +251,16 @@ def _extract_numeric(item): else: updates["nlanes"] = 1 + if "turn:lanes" in data and len(data["turn:lanes"]) > 0: + updates["lane_mapping"] = [ + lane.strip() if len(lane.strip()) > 0 else "any" + for lane in data["turn:lanes"] + .replace("through", "straight") + .replace(";", "-") + .split("|") + ] + updates["_remove_turn:lanes"] = True + if "highway" in data: hw = data["highway"] if isinstance(hw, list): @@ -274,6 +292,7 @@ def _extract_numeric(item): "reversed", "junction", "osmid", + "turn:lanes", ): if attr in data: updates[f"_remove_{attr}"] = True @@ -295,6 +314,81 @@ def _extract_numeric(item): for i, (u, v) in enumerate(sorted(G.edges())): G[u][v].update({"id": i, "source": u, "target": v}) + def _get_bearing(p1, p2): + """Calculate initial bearing between two lat/lon points.""" + lon1, lat1 = p1 + lon2, lat2 = p2 + dLon = math.radians(lon2 - lon1) + lat1 = math.radians(lat1) + lat2 = math.radians(lat2) + y = math.sin(dLon) * math.cos(lat2) + x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos( + lat2 + ) * math.cos(dLon) + return (math.degrees(math.atan2(y, x)) + 360) % 360 + + def _extract_heading(G, u, v, data, is_incoming): + """Get directional heading accounting for linestring geometry if present.""" + if "geometry" in data: + coords = list(data["geometry"].coords) + # Use the whole segment for incoming, first segment for outgoing + p1, p2 = (coords[0], coords[-1]) if is_incoming else (coords[0], coords[1]) + else: + p1, p2 = ( + (G.nodes[u]["x"], G.nodes[u]["y"]), + (G.nodes[v]["x"], G.nodes[v]["y"]), + ) + return _get_bearing(p1, p2) + + if infer_forbidden_turns: + for u, v, data in G.edges(data=True): + data["forbidden_turns"] = None + + if "lane_mapping" not in data: + continue + + mapping_str = " ".join(data["lane_mapping"]).lower() + if "any" in mapping_str or "none" in mapping_str: + continue # Allows all directions, skip processing + + # Compile a set of structurally permitted directions + permitted = set() + if "left" in mapping_str: + permitted.add("left") + if "right" in mapping_str: + permitted.add("right") + if "straight" in mapping_str or "through" in mapping_str: + permitted.add("straight") + if "reverse" in mapping_str or "u_turn" in mapping_str: + permitted.add("u_turn") + + if not permitted: + continue + + # Calculate angle and classify the outgoing edges + in_bearing = _extract_heading(G, u, v, data, is_incoming=True) + forbidden_ids = [] + + for _, w, out_data in G.out_edges(v, data=True): + out_bearing = _extract_heading(G, v, w, out_data, is_incoming=False) + turn_angle = (out_bearing - in_bearing) % 360 + + # Map physical degrees to semantic turn directions + if turn_angle <= 35 or turn_angle >= 325: + turn_type = "straight" + elif 35 < turn_angle < 145: + turn_type = "right" + elif 145 <= turn_angle <= 215: + turn_type = "u_turn" + else: # 215 to 325 + turn_type = "left" + + if turn_type not in permitted: + forbidden_ids.append(out_data["id"]) + + if forbidden_ids: + data["forbidden_turns"] = forbidden_ids + # --- Standardize node attributes --- nodes_to_update = [] for node, data in G.nodes(data=True): @@ -360,6 +454,7 @@ def get_cartography( consolidate_intersections: bool | float = 10, dead_ends: bool = False, infer_speeds: bool = False, + infer_forbidden_turns: bool = True, custom_filter: str | list[str] | None = None, scc: bool | str = False, ) -> tuple[nx.DiGraph, gpd.GeoDataFrame, gpd.GeoDataFrame]: @@ -386,6 +481,7 @@ def get_cartography( infer_speeds (bool, optional): Whether to infer edge speeds based on road types. Defaults to False. If True, calls ox.routing.add_edge_speeds using np.nanmedian as aggregation function. Finally, the "maxspeed" attribute is replaced with the inferred "speed_kph", and the "travel_time" attribute is computed. + infer_forbidden_turns (bool, optional): Whether to infer forbidden turns based on lane mapping. Defaults to True. custom_filter (str | list[str], optional): A custom OSM filter string or list of strings to apply when retrieving the graph. Defaults to None. scc (bool | str, optional): Whether to keep only the largest strongly connected component of the graph. Defaults to False. If True, filters the graph to keep only the largest strongly connected component. If "mark", adds a boolean "in_scc" attribute to nodes and edges instead of filtering. @@ -411,6 +507,7 @@ def get_cartography( consolidate_intersections=consolidate_intersections, dead_ends=dead_ends, infer_speeds=infer_speeds, + infer_forbidden_turns=infer_forbidden_turns, scc=scc, ) diff --git a/src/dsf/dsf.hpp b/src/dsf/dsf.hpp index 2c0ca5fc..6ed34aeb 100644 --- a/src/dsf/dsf.hpp +++ b/src/dsf/dsf.hpp @@ -8,7 +8,7 @@ #include static constexpr uint8_t DSF_VERSION_MAJOR = 6; -static constexpr uint8_t DSF_VERSION_MINOR = 4; +static constexpr uint8_t DSF_VERSION_MINOR = 5; static constexpr uint8_t DSF_VERSION_PATCH = 0; static auto const DSF_VERSION = diff --git a/src/dsf/mobility/FirstOrderDynamics.cpp b/src/dsf/mobility/FirstOrderDynamics.cpp index d0c401a7..f55c9d09 100644 --- a/src/dsf/mobility/FirstOrderDynamics.cpp +++ b/src/dsf/mobility/FirstOrderDynamics.cpp @@ -536,37 +536,65 @@ namespace dsf::mobility { continue; } auto const direction{pNextStreet->turnDirection(pStreet->angle())}; - switch (direction) { - case Direction::UTURN: - case Direction::LEFT: - pStreet->enqueue(nLanes - 1); - break; - case Direction::RIGHT: - pStreet->enqueue(0); - break; - default: - std::vector weights; - for (auto const& queue : pStreet->exitQueues()) { - weights.push_back(1. / (queue.size() + 1)); - } - // If all weights are the same, make the last 0 - if (std::all_of(weights.begin(), weights.end(), [&](double w) { - return std::abs(w - weights.front()) < - std::numeric_limits::epsilon(); - })) { - weights.back() = 0.; - if (nLanes > 2) { - weights.front() = 0.; + std::vector validLanes; + auto const& mapping = pStreet->laneMapping(); + for (size_t laneIndex = 0; laneIndex < mapping.size(); ++laneIndex) { + switch (direction) { + case Direction::ANY: + validLanes.push_back(laneIndex); + break; + case Direction::UTURN: + case Direction::LEFT: + if (mapping[laneIndex] == Direction::LEFT || + mapping[laneIndex] == Direction::LEFTANDSTRAIGHT) { + validLanes.push_back(laneIndex); } - } - // Normalize the weights - auto const sum = std::accumulate(weights.begin(), weights.end(), 0.); - for (auto& w : weights) { - w /= sum; - } - std::discrete_distribution laneDist{weights.begin(), weights.end()}; - pStreet->enqueue(laneDist(this->m_generator)); + break; + case Direction::STRAIGHT: + if (mapping[laneIndex] == Direction::STRAIGHT || + mapping[laneIndex] == Direction::LEFTANDSTRAIGHT || + mapping[laneIndex] == Direction::RIGHTANDSTRAIGHT) { + validLanes.push_back(laneIndex); + } + break; + case Direction::RIGHT: + if (mapping[laneIndex] == Direction::RIGHT || + mapping[laneIndex] == Direction::RIGHTANDSTRAIGHT) { + validLanes.push_back(laneIndex); + } + break; + default: + validLanes.push_back(laneIndex); + } + } + std::vector weights; + auto itValidLane = validLanes.cbegin(); + for (std::size_t laneIndex = 0; + laneIndex < static_cast(pStreet->nLanes()); + ++laneIndex) { + if (itValidLane != validLanes.cend() && laneIndex == *itValidLane) { + weights.push_back(1. / (pStreet->queue(*itValidLane).size() + 1)); + ++itValidLane; + } else { + weights.push_back(0.); + } + } + // If all weights are the same, make the last 0 + if (std::all_of(weights.begin(), weights.end(), [&](double w) { + return std::abs(w - weights.front()) < std::numeric_limits::epsilon(); + })) { + weights.back() = 0.; + if (nLanes > 2) { + weights.front() = 0.; + } + } + // Normalize the weights + auto const sum = std::accumulate(weights.begin(), weights.end(), 0.); + for (auto& w : weights) { + w /= sum; } + std::discrete_distribution laneDist{weights.begin(), weights.end()}; + pStreet->enqueue(laneDist(this->m_generator)); } auto const& transportCapacity{pStreet->transportCapacity()}; std::uniform_real_distribution uniformDist{0., 1.}; @@ -919,6 +947,7 @@ namespace dsf::mobility { spdlog::debug( "No next street found for agent {} at node {}", *pAgent, pSourceNode->id()); itAgent = m_agents.erase(itAgent); + auto pAgentRemoved{this->m_removeAgent(std::move(*itAgent), false)}; continue; } pAgent->setNextStreetId(nextStreetId.value()); diff --git a/src/dsf/mobility/RoadNetwork.cpp b/src/dsf/mobility/RoadNetwork.cpp index a9b26c2b..194babe8 100644 --- a/src/dsf/mobility/RoadNetwork.cpp +++ b/src/dsf/mobility/RoadNetwork.cpp @@ -26,6 +26,8 @@ static constexpr auto EDGE_DEFAULT_ATTRIBUTES = "coilcode", "priority", "mobility_class", + "forbidden_turns", + "lane_mapping", "geometry"}); namespace dsf::mobility { @@ -74,6 +76,8 @@ namespace dsf::mobility { colNames.end()); bool const bHasMobilityClass = (std::find(colNames.begin(), colNames.end(), "mobility_class") != colNames.end()); + bool const bHasLaneMapping = + (std::find(colNames.begin(), colNames.end(), "lane_mapping") != colNames.end()); for (auto& row : reader) { auto const sourceId = row["source"].get(); @@ -233,6 +237,48 @@ namespace dsf::mobility { row["forbidden_turns"].get()); } } + if (bHasLaneMapping) { + // Expect a string of the form [straight, left, right, ...] + try { + auto laneMappingStr = row["lane_mapping"].get(); + std::replace(laneMappingStr.begin(), laneMappingStr.end(), '[', ' '); + std::replace(laneMappingStr.begin(), laneMappingStr.end(), ']', ' '); + std::replace(laneMappingStr.begin(), laneMappingStr.end(), ',', ' '); + + std::vector laneMappingVec; + std::stringstream ss(laneMappingStr); + std::string lane; + while (ss >> lane) { + std::transform(lane.begin(), lane.end(), lane.begin(), [](unsigned char c) { + return std::tolower(c); + }); + // Check if "straight" is substring of lane + if (lane.find("straight") != std::string::npos) { + if (lane.find("left") != std::string::npos) { + laneMappingVec.push_back(Direction::LEFTANDSTRAIGHT); + } else if (lane.find("right") != std::string::npos) { + laneMappingVec.push_back(Direction::RIGHTANDSTRAIGHT); + } else { + laneMappingVec.push_back(Direction::STRAIGHT); + } + } else if (lane.find("left") != std::string::npos) { + laneMappingVec.push_back(Direction::LEFT); + } else if (lane.find("right") != std::string::npos) { + laneMappingVec.push_back(Direction::RIGHT); + } else { + laneMappingVec.push_back(Direction::ANY); + } + } + if (!laneMappingVec.empty()) { + std::sort(laneMappingVec.begin(), laneMappingVec.end()); + edge(streetId).setLaneMapping(laneMappingVec); + } + } catch (...) { + spdlog::error("Invalid lane_mapping for edge {} ({}).", + streetId, + row["lane_mapping"].get()); + } + } // Handle additional attributes for (auto const& attrName : additionalAttributes) { @@ -480,6 +526,79 @@ namespace dsf::mobility { spdlog::warn("Invalid priority for edge {}, keeping default", edge_id); } } + // Check if there is a forbidden_turns property + auto const forbidden_turns_result = edge_properties.at_key("forbidden_turns"); + if (!forbidden_turns_result.error() && !forbidden_turns_result.is_null()) { + if (forbidden_turns_result.is_array()) { + auto const turnsArray = forbidden_turns_result.get_array().value(); + if (turnsArray.size() > 0) { + std::set forbiddenTurnsSet; + for (auto const& turn : turnsArray) { + if (turn.is_uint64()) { + forbiddenTurnsSet.insert(static_cast(turn.get_uint64())); + } else if (turn.is_int64()) { + forbiddenTurnsSet.insert(static_cast(turn.get_int64())); + } else { + spdlog::warn( + "Invalid forbidden turn value for edge {}, skipping this turn", + edge_id); + } + } + if (!forbiddenTurnsSet.empty()) { + edge(edge_id).setForbiddenTurns(forbiddenTurnsSet); + } + } + } else { + spdlog::warn( + "Invalid forbidden_turns property for edge {}, expected an array, skipping", + edge_id); + } + } + // Handle lane_mapping property + auto const lane_mapping_result = edge_properties.at_key("lane_mapping"); + if (!lane_mapping_result.error() && !lane_mapping_result.is_null()) { + if (lane_mapping_result.is_array()) { + std::vector laneMappingVec; + auto const laneMappingArray = lane_mapping_result.get_array().value(); + for (auto const lane : laneMappingArray) { + if (lane.is_string()) { + std::string laneStr{lane.get_string().value()}; + std::transform( + laneStr.begin(), laneStr.end(), laneStr.begin(), [](unsigned char c) { + return std::tolower(c); + }); + if (laneStr.find("straight") != std::string::npos) { + if (laneStr.find("left") != std::string::npos) { + laneMappingVec.push_back(Direction::LEFTANDSTRAIGHT); + } else if (laneStr.find("right") != std::string::npos) { + laneMappingVec.push_back(Direction::RIGHTANDSTRAIGHT); + } else { + laneMappingVec.push_back(Direction::STRAIGHT); + } + } else if (laneStr.find("left") != std::string::npos) { + laneMappingVec.push_back(Direction::LEFT); + } else if (laneStr.find("right") != std::string::npos) { + laneMappingVec.push_back(Direction::RIGHT); + } else { + laneMappingVec.push_back(Direction::ANY); + } + } else { + spdlog::warn( + "Invalid lane mapping value for edge {}, expected a string, skipping " + "this lane", + edge_id); + } + } + if (!laneMappingVec.empty()) { + std::sort(laneMappingVec.begin(), laneMappingVec.end()); + edge(edge_id).setLaneMapping(laneMappingVec); + } + } else { + spdlog::warn( + "Invalid lane_mapping property for edge {}, expected an array, skipping", + edge_id); + } + } // Handle additional attributes for (auto const& [attrName, attrValue] : edge_properties.get_object()) { if (std::find(EDGE_DEFAULT_ATTRIBUTES.begin(), diff --git a/src/dsf/mobility/Street.cpp b/src/dsf/mobility/Street.cpp index 51003c3b..adb414ec 100644 --- a/src/dsf/mobility/Street.cpp +++ b/src/dsf/mobility/Street.cpp @@ -67,7 +67,12 @@ namespace dsf::mobility { } void Street::setLaneMapping(std::vector const& laneMapping) { - assert(laneMapping.size() == static_cast(m_nLanes)); + if (laneMapping.size() != static_cast(m_nLanes)) { + throw std::invalid_argument( + std::format("Lane mapping size ({}) does not match number of lanes ({})", + laneMapping.size(), + m_nLanes)); + } m_laneMapping = laneMapping; std::string strLaneMapping; std::for_each( diff --git a/src/dsf/utility/Typedef.hpp b/src/dsf/utility/Typedef.hpp index e03983a3..5679aab1 100644 --- a/src/dsf/utility/Typedef.hpp +++ b/src/dsf/utility/Typedef.hpp @@ -21,16 +21,16 @@ namespace dsf { enum class SpeedFunction : uint8_t { CUSTOM = 0, LINEAR = 1 }; enum Direction : uint8_t { - RIGHT = 0, // delta < 0 - STRAIGHT = 1, // delta == 0 - LEFT = 2, // delta > 0 - UTURN = 3, // std::abs(delta) > std::numbers::pi - RIGHTANDSTRAIGHT = 4, - LEFTANDSTRAIGHT = 5, - ANY = 6 + RIGHT = 0, // delta < 0 + RIGHTANDSTRAIGHT = 1, + STRAIGHT = 2, // delta == 0 + ANY = 3, + LEFTANDSTRAIGHT = 4, + LEFT = 5, // delta > 0 + UTURN = 6 // std::abs(delta) > std::numbers::pi }; constexpr std::array directionToString{ - "RIGHT", "STRAIGHT", "LEFT", "UTURN", "RIGHT&STRAIGHT", "LEFT&STRAIGHT", "ANY"}; + "RIGHT", "RIGHT&STRAIGHT", "STRAIGHT", "ANY", "LEFT&STRAIGHT", "LEFT", "UTURN"}; enum class TrafficLightOptimization : uint8_t { SINGLE_TAIL = 0, DOUBLE_TAIL = 1 }; enum train_t : uint8_t { BUS = 0, // Autobus diff --git a/test/Test_cartography.py b/test/Test_cartography.py index 528e0293..000a994f 100644 --- a/test/Test_cartography.py +++ b/test/Test_cartography.py @@ -279,5 +279,64 @@ def test_default_which_parameter(self, sample_graph): assert len(default_result._children) == len(edges_result._children) +class TestForbiddenTurns: + """Tests for lane_mapping and forbidden_turns inference (uses Bologna, which has turn:lanes data).""" + + @pytest.fixture(scope="class") + def bologna_cartography(self): + """Fetch and process a Bologna cartography known to include turn:lanes tags.""" + return get_cartography("Bologna, Emilia-Romagna, Italy") + + def test_lane_mapping_column_has_values(self, bologna_cartography): + """Test that at least some edges have a non-null lane_mapping.""" + _, edges, _ = bologna_cartography + assert "lane_mapping" in edges.columns + assert edges["lane_mapping"].notna().any() + + def test_lane_mapping_entries_are_lists_of_strings(self, bologna_cartography): + """Test that lane_mapping entries are well-formed lists of non-empty strings.""" + _, edges, _ = bologna_cartography + lane_mappings = edges["lane_mapping"].dropna() + assert len(lane_mappings) > 0 + for mapping in lane_mappings: + assert isinstance(mapping, list) + assert len(mapping) > 0 + for lane in mapping: + assert isinstance(lane, str) + assert len(lane) > 0 + # "through" should have been normalized to "straight" + assert "through" not in lane + + def test_forbidden_turns_column_has_values(self, bologna_cartography): + """Test that at least some edges have inferred forbidden_turns.""" + _, edges, _ = bologna_cartography + assert "forbidden_turns" in edges.columns + assert edges["forbidden_turns"].notna().any() + + def test_forbidden_turns_reference_valid_edge_ids(self, bologna_cartography): + """Test that forbidden_turns entries are lists of valid, existing edge ids.""" + _, edges, _ = bologna_cartography + valid_ids = set(edges["id"]) + forbidden = edges["forbidden_turns"].dropna() + for entry in forbidden: + assert isinstance(entry, list) + assert len(entry) > 0 + for edge_id in entry: + assert edge_id in valid_ids + + def test_no_forbidden_turns_without_lane_mapping(self, bologna_cartography): + """Edges without a lane_mapping should never have forbidden_turns set.""" + _, edges, _ = bologna_cartography + no_mapping = edges[edges["lane_mapping"].isna()] + assert no_mapping["forbidden_turns"].isna().all() + + def test_infer_forbidden_turns_disabled(self): + """Test that disabling infer_forbidden_turns skips forbidden_turns computation entirely.""" + _, edges, _ = get_cartography( + "Bologna, Emilia-Romagna, Italy", infer_forbidden_turns=False + ) + assert "forbidden_turns" not in edges.columns + + if __name__ == "__main__": pytest.main([__file__, "-v"])