diff --git a/.docker/entrypoint_pbf-patch.sh b/.docker/entrypoint_pbf-patch.sh index e53d494..8bef30f 100755 --- a/.docker/entrypoint_pbf-patch.sh +++ b/.docker/entrypoint_pbf-patch.sh @@ -9,6 +9,8 @@ OUTPUT="$OUTPUT_PATH/$OUTPUT_FILENAME" BOUNDING_BOX=${BOUNDING_BOX:-9.357298,54.751799,9.506812,54.837072} BOUNDED_FILENAME=${BOUNDED_FILENAME:-${FILENAME/schleswig-holstein/flensburg}} SKIP_CONSTRUCTION=${SKIP_CONSTRUCTION:-false} +SKIP_PATHS=${SKIP_PATHS:-true} +POINTS_FILE=${POINTS_FILE:-} CHANGESETS=() @@ -25,6 +27,8 @@ print_config() { echo "BOUNDING_BOX: $BOUNDING_BOX" echo "BOUNDED_FILENAME: $BOUNDED_FILENAME" echo "SKIP_CONSTRUCTION: $SKIP_CONSTRUCTION" + echo "SKIP_PATHS: $SKIP_PATHS" + echo "POINTS_FILE: $POINTS_FILE" } setup() { @@ -65,6 +69,26 @@ create_construction_changeset() { CHANGESETS+=("construction.osc") } +validate_config() { + [[ $SKIP_PATHS == "false" ]] || return 0 + # Checked before the download so a missing file fails in a second, not + # after a few hundred megabytes. The working directory is DATA_DIR, so + # POINTS_FILE has to be absolute. + [[ -n $POINTS_FILE ]] || fatal "POINTS_FILE must be set when SKIP_PATHS=false" + [[ -r $POINTS_FILE ]] || fatal "POINTS_FILE is not readable: $POINTS_FILE" +} + +create_allowed_paths_changeset() { + if [[ $SKIP_PATHS != "false" ]]; then + echo "Allowed-paths changeset skipped" + return + fi + echo "Creating allowed-paths changeset..." + /pbf-patch allowed-paths --input "$BOUNDED_FILENAME" --output city_access.osc \ + || fatal "failed to create allowed-paths changeset" + CHANGESETS+=("city_access.osc") +} + apply_changesets() { echo "Applying ${#CHANGESETS[@]} changeset(s)..." osmium apply-changes --overwrite -o "$OUTPUT" "$FILENAME" "${CHANGESETS[@]}" \ @@ -79,10 +103,12 @@ main() { fi setup + validate_config download_pbf extract_bounding_box empty_changeset "empty.osc" create_construction_changeset + create_allowed_paths_changeset apply_changesets echo "Output file: $OUTPUT" diff --git a/README.md b/README.md index 63c3f08..7fd36fe 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,17 @@ Streamlet is configured entirely via environment variables: | `STREAMLET_ENGINE_TIMEOUT_MS` | `10000` | Timeout for requests to the routing engine | | `STREAMLET_SOLVER_TIME_LIMIT_MS` | `2000` | Maximum solver time budget per solve request | +## Tile pipeline + +Alongside the service, the workspace ships `pbf-patch`, which patches an OSM +extract before tiles are built (blocked roads, footpaths opened for service +vehicles). Its input is plain GeoJSON, so any workflow can use it. See +[`crates/pbf-patch/README.md`](crates/pbf-patch/README.md). + ## Development -The project is a Cargo workspace with two crates: `streamlet-core` (domain -model and solver) and `server` (HTTP API). +The project is a Cargo workspace with three crates: `streamlet-core` (domain +model and solver), `server` (HTTP API) and `pbf-patch` (tile-pipeline CLI). ```sh # Build and test the whole workspace diff --git a/crates/pbf-patch/Cargo.toml b/crates/pbf-patch/Cargo.toml index d461a57..b8e5113 100644 --- a/crates/pbf-patch/Cargo.toml +++ b/crates/pbf-patch/Cargo.toml @@ -8,7 +8,7 @@ license.workspace = true repository.workspace = true homepage.workspace = true description = "Generates OSC changesets and patched OSM PBF files for the tile pipeline" -readme = "../../README.md" +readme = "README.md" publish.workspace = true [lib] @@ -20,7 +20,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } osmpbf = "0.3.8" reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] } rstar = "0.13.0" diff --git a/crates/pbf-patch/README.md b/crates/pbf-patch/README.md new file mode 100644 index 0000000..1850aba --- /dev/null +++ b/crates/pbf-patch/README.md @@ -0,0 +1,88 @@ +# pbf-patch + +Generates OSC changesets for an OSM PBF extract and applies them, so a routing +engine can be built from map data that reflects local rules. Two changesets +exist today, both consumer-agnostic in their input. + +## `construction` + +Closes roads that the Flensburg TBZ Verkehrsticker reports as blocked +(`access=no`), and drops `oneway` on directly connected ways so detours stay +possible. + +```sh +pbf-patch construction --input flensburg.osm.pbf --output construction.osc +``` + +## `allowed-paths` + +Opens footpaths for motor vehicles around points that no car-accessible road +reaches. Use it when a vehicle has to get to a place that is only served by a +path, for example a tree, a bin or a hydrant. + +```sh +pbf-patch allowed-paths \ + --input flensburg.osm.pbf \ + --output city_access.osc \ + --points targets.geojson + +# or from a pipeline +your-export-tool | pbf-patch allowed-paths --input flensburg.osm.pbf --output city_access.osc --points - +``` + +Input is a GeoJSON FeatureCollection of Point features. `properties.id` is +optional and used for logging only; a third coordinate is ignored. + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "id": 42 }, + "geometry": { "type": "Point", "coordinates": [9.4705, 54.81005] } + } + ] +} +``` + +| Option | Env | Default | Description | +| --- | --- | --- | --- | +| `--points` | `POINTS_FILE` | required | GeoJSON point file, `-` for stdin | +| `--road-max-distance` | `ROAD_MAX_DISTANCE` | `10` | A target with a car-accessible way within this many metres is considered reachable | +| `--footpath-search-radius` | `FOOTPATH_SEARCH_RADIUS` | `20` | Footpaths within this radius of an unreachable target are opened | + +A target counts as unreachable only if no car-accessible way lies within +`--road-max-distance`. Its nearby footpaths then get `motor_vehicle=yes`, plus +`maxspeed=10` and `maxweight=9` where the way does not already carry those keys +— an existing limit is kept rather than raised. Sidewalks, steps, +`access=private`, `access=no`, `motor_vehicle=no` and `vehicle=no` are never +opened; the last one because mappers use it for paths that are physically +impassable for a vehicle. + +Targets that end up with neither a road nor a footpath in range are reported +with their ids, so a point source can be checked against the map data. + +The command performs no network access: whoever knows where the targets are +exports them, and the patcher stays independent of that system. + +## Pipeline image + +`.docker/entrypoint_pbf-patch.sh` downloads a PBF, cuts a bounding box, builds +the enabled changesets and applies them with `osmium apply-changes`. + +| Variable | Default | Description | +| --- | --- | --- | +| `PBF_URL` | Geofabrik Schleswig-Holstein | Source extract | +| `BOUNDING_BOX` | Flensburg | `minlon,minlat,maxlon,maxlat` for `osmium extract` | +| `BOUNDED_FILENAME` | derived from `PBF_URL` | Name of the cut extract; must differ from the downloaded file | +| `DATA_DIR` | `.` | Working directory inside the container, mount a volume here | +| `OUTPUT_PATH` | `./output` | Where the patched PBF is written | +| `OUTPUT_FILENAME` | derived from `PBF_URL` | Name of the patched PBF | +| `SKIP_CONSTRUCTION` | `false` | Set to anything else to skip the construction changeset | +| `SKIP_PATHS` | `true` | Set to `false` to build the allowed-paths changeset | +| `POINTS_FILE` | unset | Path to the GeoJSON point file, required when `SKIP_PATHS=false` | + +The two radii are not declared by the entrypoint; `ROAD_MAX_DISTANCE` and +`FOOTPATH_SEARCH_RADIUS` reach the subcommand straight from the container +environment, so their defaults live in one place only. diff --git a/crates/pbf-patch/src/allowed_paths.rs b/crates/pbf-patch/src/allowed_paths.rs new file mode 100644 index 0000000..d08e0c6 --- /dev/null +++ b/crates/pbf-patch/src/allowed_paths.rs @@ -0,0 +1,347 @@ +use crate::model::{FootpathExclusion, Way, WayId, footpath_exclusion}; +use crate::pbf::Network; +use crate::points::TargetPoint; +use crate::spatial::WayIndex; +use std::collections::{BTreeMap, HashSet}; + +// Keep the logged sample of unreachable target ids small. +const MAX_LOGGED_UNREACHABLE_TARGETS: usize = 10; + +const MOTOR_VEHICLE_TAG: (&str, &str) = ("motor_vehicle", "yes"); + +// A vehicle may use an opened footpath at walking pace only; applied only +// when the way doesn't already carry the tag (see open_for_motor_vehicles). +const DEFAULT_LIMIT_TAGS: [(&str, &str); 2] = [("maxspeed", "10"), ("maxweight", "9")]; + +#[derive(Debug, Clone, Copy)] +pub struct Params { + pub road_max_distance_m: f64, + pub footpath_search_radius_m: f64, +} + +impl Default for Params { + fn default() -> Self { + Self { + road_max_distance_m: 10.0, + footpath_search_radius_m: 20.0, + } + } +} + +#[derive(Debug, Default, PartialEq)] +pub struct Stats { + pub targets: usize, + pub targets_outside_extent: usize, + pub targets_without_road: usize, + pub targets_without_footpath: usize, + pub footpath_candidates: usize, + pub excluded: BTreeMap, + pub opened_ways: usize, + /// Ids of targets with neither a road nor a footpath in range, capped at + /// `MAX_LOGGED_UNREACHABLE_TARGETS` so a bad export can't flood the log. + pub unreachable_target_ids: Vec, +} + +/// Opens footpaths around targets that no car-accessible way reaches. +pub fn changed_ways( + targets: &[TargetPoint], + network: &Network, + params: Params, +) -> (Vec, Stats) { + let mut stats = Stats { + targets: targets.len(), + footpath_candidates: network.footpaths.len(), + ..Default::default() + }; + + let mut eligible: Vec = Vec::new(); + for way in &network.footpaths { + match footpath_exclusion(way) { + Some(reason) => *stats.excluded.entry(reason).or_default() += 1, + None => eligible.push(way.clone()), + } + } + + let road_index = WayIndex::build(&network.car_ways, &network.nodes); + let footpath_index = WayIndex::build(&eligible, &network.nodes); + let extent = network.extent(); + + let mut to_open: HashSet = HashSet::new(); + for target in targets { + if extent.is_some_and(|bbox| !bbox.contains(target.coord)) { + stats.targets_outside_extent += 1; + } + if road_index.has_way_within(target.coord, params.road_max_distance_m) { + continue; + } + stats.targets_without_road += 1; + let nearby_footpaths = + footpath_index.ways_within(target.coord, params.footpath_search_radius_m); + if nearby_footpaths.is_empty() { + stats.targets_without_footpath += 1; + if stats.unreachable_target_ids.len() < MAX_LOGGED_UNREACHABLE_TARGETS { + stats.unreachable_target_ids.push(target.id.clone()); + } + } + to_open.extend(nearby_footpaths); + } + + let mut result: Vec = eligible + .into_iter() + .filter(|way| to_open.contains(&way.id)) + .map(open_for_motor_vehicles) + .collect(); + result.sort_by_key(|way| way.id); + stats.opened_ways = result.len(); + + (result, stats) +} + +fn open_for_motor_vehicles(mut way: Way) -> Way { + let (key, value) = MOTOR_VEHICLE_TAG; + match way.tags.iter_mut().find(|(k, _)| k == key) { + Some((_, existing)) => *existing = value.to_string(), + None => way.tags.push((key.to_string(), value.to_string())), + } + + // Never loosen a real maxspeed/maxweight restriction a mapper already set: + // OSM values like "3.5 t" or "walk" aren't reliably parseable, so keep + // whatever is already there and only fill in the gap. + for (key, value) in DEFAULT_LIMIT_TAGS { + if way.tag(key).is_none() { + way.tags.push((key.to_string(), value.to_string())); + } + } + way +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Coordinate, NodeId}; + use std::collections::HashMap; + + fn coord(lat: f64, lon: f64) -> Coordinate { + Coordinate { lat, lon } + } + + fn target(id: &str, lat: f64, lon: f64) -> TargetPoint { + TargetPoint { + id: id.to_string(), + coord: coord(lat, lon), + } + } + + fn way(id: WayId, refs: &[NodeId], tags: &[(&str, &str)]) -> Way { + Way { + id, + version: 1, + node_refs: refs.to_vec(), + tags: tags + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + } + } + + /// Road 100 along 54.7950 between lon 9.4300 and 9.4320, footway 300 + /// along 54.8100 between lon 9.4700 and 9.4710, three kilometres apart. + fn network(footpath_tags: &[(&str, &str)]) -> Network { + let nodes: HashMap = [ + (1, coord(54.7950, 9.4300)), + (2, coord(54.7950, 9.4320)), + (5, coord(54.8100, 9.4700)), + (6, coord(54.8100, 9.4710)), + ] + .into(); + Network { + nodes, + car_ways: vec![way(100, &[1, 2], &[("highway", "residential")])], + footpaths: vec![way(300, &[5, 6], footpath_tags)], + } + } + + #[test] + fn target_at_a_road_changes_nothing() { + let (changed, stats) = changed_ways( + &[target("a", 54.79505, 9.4310)], + &network(&[("highway", "footway")]), + Params::default(), + ); + assert!(changed.is_empty()); + assert_eq!(stats.targets, 1); + assert_eq!(stats.targets_without_road, 0); + assert_eq!(stats.opened_ways, 0); + } + + #[test] + fn target_without_road_opens_the_nearby_footpath() { + let (changed, stats) = changed_ways( + &[target("a", 54.81005, 9.4705)], + &network(&[("highway", "footway")]), + Params::default(), + ); + + assert_eq!(changed.len(), 1); + assert_eq!(changed[0].id, 300); + assert_eq!(changed[0].tag("motor_vehicle"), Some("yes")); + assert_eq!(changed[0].tag("maxspeed"), Some("10")); + assert_eq!(changed[0].tag("maxweight"), Some("9")); + assert_eq!(changed[0].tag("highway"), Some("footway")); + assert_eq!(stats.targets_without_road, 1); + assert_eq!(stats.opened_ways, 1); + } + + #[test] + fn motor_vehicle_is_overwritten_but_existing_maxspeed_is_kept() { + let (changed, _) = changed_ways( + &[target("a", 54.81005, 9.4705)], + &network(&[ + ("highway", "footway"), + ("motor_vehicle", "destination"), + ("maxspeed", "30"), + ]), + Params::default(), + ); + + let keys: Vec<&str> = changed[0].tags.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + keys.iter().filter(|k| **k == "motor_vehicle").count(), + 1, + "no duplicate key" + ); + assert_eq!( + keys.iter().filter(|k| **k == "maxspeed").count(), + 1, + "no duplicate key" + ); + assert_eq!( + changed[0].tag("motor_vehicle"), + Some("yes"), + "motor_vehicle is always overwritten to grant access" + ); + assert_eq!( + changed[0].tag("maxspeed"), + Some("30"), + "an existing maxspeed must never be loosened" + ); + assert_eq!( + changed[0].tag("maxweight"), + Some("9"), + "maxweight was absent, so it gets the default" + ); + } + + #[test] + fn excluded_footpaths_stay_closed_and_are_counted() { + let (changed, stats) = changed_ways( + &[target("a", 54.81005, 9.4705)], + &network(&[("highway", "footway"), ("access", "private")]), + Params::default(), + ); + + assert!(changed.is_empty()); + assert_eq!(stats.footpath_candidates, 1); + assert_eq!( + stats.excluded.get(&FootpathExclusion::AccessRestricted), + Some(&1) + ); + assert_eq!(stats.targets_without_road, 1); + } + + #[test] + fn target_out_of_reach_of_any_footpath_changes_nothing() { + let (changed, stats) = changed_ways( + &[target("a", 54.8200, 9.4705)], + &network(&[("highway", "footway")]), + Params::default(), + ); + assert!(changed.is_empty()); + assert_eq!(stats.targets_without_road, 1); + assert_eq!(stats.opened_ways, 0); + } + + #[test] + fn tracks_targets_with_no_footpath_either() { + let (_, stats) = changed_ways( + &[target("orphan", 54.8200, 9.4705)], + &network(&[("highway", "footway")]), + Params::default(), + ); + assert_eq!(stats.targets_without_road, 1); + assert_eq!(stats.targets_without_footpath, 1); + assert_eq!(stats.unreachable_target_ids, vec!["orphan".to_string()]); + } + + #[test] + fn unreachable_target_ids_are_capped_at_ten() { + let targets: Vec = (0..12) + .map(|i| target(&i.to_string(), 54.8200, 9.4705)) + .collect(); + + let (_, stats) = changed_ways( + &targets, + &network(&[("highway", "footway")]), + Params::default(), + ); + + assert_eq!(stats.targets_without_footpath, 12); + assert_eq!(stats.unreachable_target_ids.len(), 10); + assert_eq!( + stats.unreachable_target_ids, + (0..10).map(|i| i.to_string()).collect::>() + ); + } + + #[test] + fn road_and_footpath_radii_are_not_interchangeable() { + // Target sits ~5 m from the road: farther than road_max_distance_m (2 m) + // but well inside footpath_search_radius_m (40 m). If the two radii were + // swapped inside `changed_ways`, the road check would use the 40 m value + // and wrongly call the target reachable. + let nodes: HashMap = [ + (1, coord(54.7950, 9.4300)), + (2, coord(54.7950, 9.4320)), + (5, coord(54.795045, 9.4308)), + (6, coord(54.795045, 9.4312)), + ] + .into(); + let net = Network { + nodes, + car_ways: vec![way(100, &[1, 2], &[("highway", "residential")])], + footpaths: vec![way(300, &[5, 6], &[("highway", "footway")])], + }; + let params = Params { + road_max_distance_m: 2.0, + footpath_search_radius_m: 40.0, + }; + + let (changed, stats) = changed_ways(&[target("a", 54.795045, 9.4310)], &net, params); + + assert_eq!( + stats.targets_without_road, 1, + "target is ~5 m from the road, farther than road_max_distance_m (2 m), \ + so it must count as unreachable; using footpath_search_radius_m (40 m) \ + for the road check would wrongly call it reachable" + ); + assert_eq!( + changed.len(), + 1, + "the footpath within footpath_search_radius_m (40 m) should have been opened" + ); + } + + #[test] + fn targets_outside_the_pbf_extent_are_counted() { + let (_, stats) = changed_ways( + &[ + target("inside", 54.8100, 9.4705), + target("outside", 55.5, 10.5), + ], + &network(&[("highway", "footway")]), + Params::default(), + ); + assert_eq!(stats.targets, 2); + assert_eq!(stats.targets_outside_extent, 1); + } +} diff --git a/crates/pbf-patch/src/lib.rs b/crates/pbf-patch/src/lib.rs index 7cdd0a3..42ae104 100644 --- a/crates/pbf-patch/src/lib.rs +++ b/crates/pbf-patch/src/lib.rs @@ -1,7 +1,9 @@ +pub mod allowed_paths; pub mod construction; pub mod geometry; pub mod model; pub mod osc; pub mod pbf; +pub mod points; pub mod spatial; pub mod verkehrsticker; diff --git a/crates/pbf-patch/src/main.rs b/crates/pbf-patch/src/main.rs index 23f58fb..c48d543 100644 --- a/crates/pbf-patch/src/main.rs +++ b/crates/pbf-patch/src/main.rs @@ -21,6 +21,19 @@ enum Command { #[arg(long)] output: PathBuf, }, + AllowedPaths { + #[arg(long)] + input: PathBuf, + #[arg(long)] + output: PathBuf, + /// GeoJSON FeatureCollection of points a vehicle must reach; `-` reads stdin. + #[arg(long, env = "POINTS_FILE")] + points: PathBuf, + #[arg(long, env = "ROAD_MAX_DISTANCE", default_value_t = 10.0)] + road_max_distance: f64, + #[arg(long, env = "FOOTPATH_SEARCH_RADIUS", default_value_t = 20.0)] + footpath_search_radius: f64, + }, } fn main() -> Result<()> { @@ -29,20 +42,84 @@ fn main() -> Result<()> { let sites = pbf_patch::verkehrsticker::fetch()?; println!("Fetched {} construction sites", sites.len()); - let (nodes, ways) = pbf_patch::pbf::load_car_network(&input)?; + let network = pbf_patch::pbf::load_network(&input)?; println!( "Loaded {} nodes and {} car-accessible ways", - nodes.len(), - ways.len() + network.nodes.len(), + network.car_ways.len() ); - let changed = pbf_patch::construction::changed_ways(&sites, &ways, &nodes); + let changed = + pbf_patch::construction::changed_ways(&sites, &network.car_ways, &network.nodes); println!( "Writing {} changed ways to {}", changed.len(), output.display() ); + fs::write(&output, pbf_patch::osc::write_osc(&changed))?; + Ok(()) + } + Command::AllowedPaths { + input, + output, + points, + road_max_distance, + footpath_search_radius, + } => { + let targets = pbf_patch::points::read_points(&points)?; + println!("Read {} target points", targets.len()); + + let network = pbf_patch::pbf::load_network(&input)?; + println!( + "Loaded {} nodes, {} car-accessible ways and {} footpaths", + network.nodes.len(), + network.car_ways.len(), + network.footpaths.len() + ); + + let (changed, stats) = pbf_patch::allowed_paths::changed_ways( + &targets, + &network, + pbf_patch::allowed_paths::Params { + road_max_distance_m: road_max_distance, + footpath_search_radius_m: footpath_search_radius, + }, + ); + + println!( + "{} of {} targets lie outside the PBF extent", + stats.targets_outside_extent, stats.targets + ); + println!( + "{} targets have no road within {road_max_distance} m", + stats.targets_without_road + ); + println!( + "{} targets have no footpath within {footpath_search_radius} m either", + stats.targets_without_footpath + ); + if !stats.unreachable_target_ids.is_empty() { + let sample = stats.unreachable_target_ids.join(", "); + if stats.targets_without_footpath > stats.unreachable_target_ids.len() { + println!( + "Unreachable target ids (first {} of {}): {sample}", + stats.unreachable_target_ids.len(), + stats.targets_without_footpath + ); + } else { + println!("Unreachable target ids: {sample}"); + } + } + for (reason, count) in &stats.excluded { + println!("Excluded {count} footpaths: {reason:?}"); + } + println!( + "Writing {} opened ways to {}", + stats.opened_ways, + output.display() + ); + fs::write(&output, pbf_patch::osc::write_osc(&changed))?; Ok(()) } @@ -64,8 +141,71 @@ mod tests { "out.osc", ]) .unwrap(); - let Command::Construction { input, output } = cli.command; + let Command::Construction { input, output } = cli.command else { + panic!("expected the construction subcommand") + }; assert_eq!(input, PathBuf::from("in.pbf")); assert_eq!(output, PathBuf::from("out.osc")); } + + #[test] + fn parses_allowed_paths_subcommand_with_defaults() { + let cli = Cli::try_parse_from([ + "pbf-patch", + "allowed-paths", + "--input", + "in.pbf", + "--output", + "out.osc", + "--points", + "targets.geojson", + ]) + .unwrap(); + let Command::AllowedPaths { + input, + output, + points, + road_max_distance, + footpath_search_radius, + } = cli.command + else { + panic!("expected the allowed-paths subcommand") + }; + assert_eq!(input, PathBuf::from("in.pbf")); + assert_eq!(output, PathBuf::from("out.osc")); + assert_eq!(points, PathBuf::from("targets.geojson")); + assert_eq!(road_max_distance, 10.0); + assert_eq!(footpath_search_radius, 20.0); + } + + #[test] + fn parses_allowed_paths_radii() { + let cli = Cli::try_parse_from([ + "pbf-patch", + "allowed-paths", + "--input", + "in.pbf", + "--output", + "out.osc", + "--points", + "-", + "--road-max-distance", + "7.5", + "--footpath-search-radius", + "35", + ]) + .unwrap(); + let Command::AllowedPaths { + points, + road_max_distance, + footpath_search_radius, + .. + } = cli.command + else { + panic!("expected the allowed-paths subcommand") + }; + assert_eq!(points, PathBuf::from("-")); + assert_eq!(road_max_distance, 7.5); + assert_eq!(footpath_search_radius, 35.0); + } } diff --git a/crates/pbf-patch/src/model.rs b/crates/pbf-patch/src/model.rs index ab219fb..8d4a9e7 100644 --- a/crates/pbf-patch/src/model.rs +++ b/crates/pbf-patch/src/model.rs @@ -46,6 +46,64 @@ pub fn is_car_accessible(highway: &str) -> bool { CAR_ACCESSIBLE_HIGHWAYS.contains(&highway) } +const FOOTPATH_HIGHWAYS: &[&str] = &[ + "footway", + "bridleway", + "cycleway", + "path", + "pedestrian", + "track", +]; + +pub fn is_footpath(highway: &str) -> bool { + FOOTPATH_HIGHWAYS.contains(&highway) +} + +/// Why a footpath must stay closed for motor vehicles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FootpathExclusion { + Sidewalk, + AccessRestricted, + MotorVehicleNo, + VehicleNo, +} + +pub fn footpath_exclusion(way: &Way) -> Option { + // Sidewalks run parallel to a road that is already routable. + if way.tag("footway") == Some("sidewalk") { + return Some(FootpathExclusion::Sidewalk); + } + if matches!(way.tag("access"), Some("private") | Some("no")) { + return Some(FootpathExclusion::AccessRestricted); + } + if way.tag("motor_vehicle") == Some("no") { + return Some(FootpathExclusion::MotorVehicleNo); + } + // vehicle=no marks paths physically impassable (steps, bollards, too narrow); + // motor_vehicle=yes would override that. + if way.tag("vehicle") == Some("no") { + return Some(FootpathExclusion::VehicleNo); + } + None +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BoundingBox { + pub min_lat: f64, + pub min_lon: f64, + pub max_lat: f64, + pub max_lon: f64, +} + +impl BoundingBox { + pub fn contains(&self, c: Coordinate) -> bool { + c.lat >= self.min_lat + && c.lat <= self.max_lat + && c.lon >= self.min_lon + && c.lon <= self.max_lon + } +} + #[cfg(test)] mod tests { use super::*; @@ -74,4 +132,97 @@ mod tests { assert!(!is_car_accessible("cycleway")); assert!(!is_car_accessible("")); } + + #[test] + fn classifies_footpaths() { + assert!(is_footpath("footway")); + assert!(is_footpath("path")); + assert!(is_footpath("pedestrian")); + assert!(is_footpath("cycleway")); + assert!(is_footpath("bridleway")); + assert!(is_footpath("track")); + assert!(!is_footpath("residential")); + assert!(!is_footpath("steps")); + assert!(!is_footpath("")); + } + + #[test] + fn reports_footpath_exclusion_reasons() { + let path = |tags: &[(&str, &str)]| Way { + id: 1, + version: 1, + node_refs: vec![1, 2], + tags: tags + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + }; + + assert_eq!( + footpath_exclusion(&path(&[("highway", "footway"), ("footway", "sidewalk")])), + Some(FootpathExclusion::Sidewalk) + ); + assert_eq!( + footpath_exclusion(&path(&[("highway", "path"), ("access", "private")])), + Some(FootpathExclusion::AccessRestricted) + ); + assert_eq!( + footpath_exclusion(&path(&[("highway", "path"), ("access", "no")])), + Some(FootpathExclusion::AccessRestricted) + ); + assert_eq!( + footpath_exclusion(&path(&[("highway", "track"), ("motor_vehicle", "no")])), + Some(FootpathExclusion::MotorVehicleNo) + ); + assert_eq!( + footpath_exclusion(&path(&[("highway", "path"), ("vehicle", "no")])), + Some(FootpathExclusion::VehicleNo) + ); + assert_eq!(footpath_exclusion(&path(&[("highway", "footway")])), None); + assert_eq!( + footpath_exclusion(&path(&[ + ("highway", "footway"), + ("motor_vehicle", "destination") + ])), + None + ); + assert_eq!( + footpath_exclusion(&path(&[("highway", "footway"), ("vehicle", "designated")])), + None + ); + assert_eq!( + footpath_exclusion(&path(&[("highway", "footway"), ("vehicle", "yes")])), + None + ); + } + + #[test] + fn bounding_box_contains_points_on_the_edge() { + let bbox = BoundingBox { + min_lat: 54.79, + min_lon: 9.43, + max_lat: 54.81, + max_lon: 9.47, + }; + assert!(bbox.contains(Coordinate { + lat: 54.80, + lon: 9.45 + })); + assert!(bbox.contains(Coordinate { + lat: 54.79, + lon: 9.43 + })); + assert!(bbox.contains(Coordinate { + lat: 54.81, + lon: 9.47 + })); + assert!(!bbox.contains(Coordinate { + lat: 54.82, + lon: 9.45 + })); + assert!(!bbox.contains(Coordinate { + lat: 54.80, + lon: 9.42 + })); + } } diff --git a/crates/pbf-patch/src/pbf.rs b/crates/pbf-patch/src/pbf.rs index 9dc6b0a..772df3d 100644 --- a/crates/pbf-patch/src/pbf.rs +++ b/crates/pbf-patch/src/pbf.rs @@ -1,17 +1,48 @@ -use crate::model::{Coordinate, NodeId, Way, is_car_accessible}; +use crate::model::{BoundingBox, Coordinate, NodeId, Way, is_car_accessible, is_footpath}; use anyhow::Result; use osmpbf::{Element, ElementReader}; use std::collections::HashMap; use std::path::Path; -pub fn load_car_network(path: &Path) -> Result<(HashMap, Vec)> { +/// Everything one PBF scan yields: node positions plus the two way classes +/// the changesets work on. +pub struct Network { + pub nodes: HashMap, + pub car_ways: Vec, + pub footpaths: Vec, +} + +impl Network { + pub fn extent(&self) -> Option { + let mut positions = self.nodes.values(); + let first = positions.next()?; + let mut bbox = BoundingBox { + min_lat: first.lat, + min_lon: first.lon, + max_lat: first.lat, + max_lon: first.lon, + }; + for c in positions { + bbox.min_lat = bbox.min_lat.min(c.lat); + bbox.min_lon = bbox.min_lon.min(c.lon); + bbox.max_lat = bbox.max_lat.max(c.lat); + bbox.max_lon = bbox.max_lon.max(c.lon); + } + Some(bbox) + } +} + +pub fn load_network(path: &Path) -> Result { let reader = ElementReader::from_path(path)?; - let mut nodes = HashMap::new(); - let mut ways = Vec::new(); + let mut network = Network { + nodes: HashMap::new(), + car_ways: Vec::new(), + footpaths: Vec::new(), + }; reader.for_each(|element| match element { Element::Node(n) => { - nodes.insert( + network.nodes.insert( n.id(), Coordinate { lat: n.lat(), @@ -20,7 +51,7 @@ pub fn load_car_network(path: &Path) -> Result<(HashMap, Vec ); } Element::DenseNode(n) => { - nodes.insert( + network.nodes.insert( n.id(), Coordinate { lat: n.lat(), @@ -29,23 +60,32 @@ pub fn load_car_network(path: &Path) -> Result<(HashMap, Vec ); } Element::Way(w) => { - let highway = w.tags().find(|(k, _)| *k == "highway").map(|(_, v)| v); - if highway.is_some_and(is_car_accessible) { - ways.push(Way { - id: w.id(), - version: w.info().version().unwrap_or(0), - node_refs: w.refs().collect(), - tags: w - .tags() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(), - }); + let Some(highway) = w.tags().find(|(k, _)| *k == "highway").map(|(_, v)| v) else { + return; + }; + let is_car = is_car_accessible(highway); + if !is_car && !is_footpath(highway) { + return; + } + let way = Way { + id: w.id(), + version: w.info().version().unwrap_or(0), + node_refs: w.refs().collect(), + tags: w + .tags() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + }; + if is_car { + network.car_ways.push(way); + } else { + network.footpaths.push(way); } } Element::Relation(_) => {} })?; - Ok((nodes, ways)) + Ok(network) } #[cfg(test)] @@ -53,20 +93,43 @@ mod tests { use super::*; #[test] - fn loads_nodes_and_car_accessible_ways() { - let (nodes, ways) = - load_car_network(Path::new("tests/fixtures/flensburg.osm.pbf")).unwrap(); + fn loads_nodes_car_ways_and_footpaths() { + let network = load_network(Path::new("tests/fixtures/flensburg.osm.pbf")).unwrap(); + + assert_eq!(network.nodes.len(), 6); + assert!((network.nodes[&1].lat - 54.7950).abs() < 1e-7); + assert!((network.nodes[&1].lon - 9.4300).abs() < 1e-7); - assert_eq!(nodes.len(), 6); - assert!((nodes[&1].lat - 54.7950).abs() < 1e-7); - assert!((nodes[&1].lon - 9.4300).abs() < 1e-7); + let car_ids: Vec<_> = network.car_ways.iter().map(|w| w.id).collect(); + assert_eq!(car_ids, vec![100, 200]); - let ids: Vec<_> = ways.iter().map(|w| w.id).collect(); - assert_eq!(ids, vec![100, 200], "footway 300 must be filtered out"); + let path_ids: Vec<_> = network.footpaths.iter().map(|w| w.id).collect(); + assert_eq!(path_ids, vec![300], "footway 300 belongs to the footpaths"); - let way100 = &ways[0]; + let way100 = &network.car_ways[0]; assert_eq!(way100.version, 2); assert_eq!(way100.node_refs, vec![1, 2, 3]); assert_eq!(way100.tag("name"), Some("Teststraße")); } + + #[test] + fn extent_covers_all_nodes() { + let network = load_network(Path::new("tests/fixtures/flensburg.osm.pbf")).unwrap(); + let bbox = network.extent().unwrap(); + + assert!((bbox.min_lat - 54.7950).abs() < 1e-7); + assert!((bbox.min_lon - 9.4300).abs() < 1e-7); + assert!((bbox.max_lat - 54.8100).abs() < 1e-7); + assert!((bbox.max_lon - 9.4710).abs() < 1e-7); + } + + #[test] + fn extent_is_none_without_nodes() { + let network = Network { + nodes: HashMap::new(), + car_ways: Vec::new(), + footpaths: Vec::new(), + }; + assert_eq!(network.extent(), None); + } } diff --git a/crates/pbf-patch/src/points.rs b/crates/pbf-patch/src/points.rs new file mode 100644 index 0000000..b657459 --- /dev/null +++ b/crates/pbf-patch/src/points.rs @@ -0,0 +1,173 @@ +use crate::model::Coordinate; +use anyhow::{Context, Result, bail}; +use serde::Deserialize; +use std::io::Read; +use std::path::Path; + +/// A place a vehicle has to reach. `id` is carried for logging only. +#[derive(Debug, Clone, PartialEq)] +pub struct TargetPoint { + pub id: String, + pub coord: Coordinate, +} + +#[derive(Deserialize)] +struct FeatureCollection { + features: Vec, +} + +#[derive(Deserialize)] +struct Feature { + geometry: Geometry, + #[serde(default)] + properties: Option, +} + +#[derive(Deserialize)] +struct Geometry { + #[serde(rename = "type")] + kind: String, + coordinates: serde_json::Value, +} + +#[derive(Deserialize)] +struct Properties { + #[serde(default)] + id: Option, +} + +/// Reads a GeoJSON FeatureCollection; the path `-` means stdin. +pub fn read_points(path: &Path) -> Result> { + let body = if path == Path::new("-") { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("reading points from stdin")?; + buf + } else { + std::fs::read_to_string(path) + .with_context(|| format!("reading points from {}", path.display()))? + }; + parse_points(&body) +} + +pub fn parse_points(body: &str) -> Result> { + let parsed: FeatureCollection = serde_json::from_str(body).context("invalid GeoJSON")?; + + let mut points = Vec::with_capacity(parsed.features.len()); + for (index, feature) in parsed.features.iter().enumerate() { + if feature.geometry.kind != "Point" { + bail!( + "feature {index}: geometry type {} is not supported, expected Point", + feature.geometry.kind + ); + } + let Some(values) = feature.geometry.coordinates.as_array() else { + bail!("feature {index}: coordinates must be an array"); + }; + // Every element has to be numeric: skipping the odd one out would turn + // a malformed coordinate list into a plausible-looking position. + let coords = values + .iter() + .map(|value| { + value + .as_f64() + .with_context(|| format!("feature {index}: coordinates must be numbers")) + }) + .collect::>>()?; + let [lon, lat, ..] = coords.as_slice() else { + bail!("feature {index}: expected coordinates [lon, lat]"); + }; + points.push(TargetPoint { + id: feature_id(feature, index), + coord: Coordinate { + lat: *lat, + lon: *lon, + }, + }); + } + Ok(points) +} + +fn feature_id(feature: &Feature, index: usize) -> String { + match feature.properties.as_ref().and_then(|p| p.id.as_ref()) { + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + None => index.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_feature_collection_from_fixture() { + let points = parse_points(include_str!("../tests/fixtures/targets.geojson")).unwrap(); + + assert_eq!(points.len(), 3); + assert_eq!(points[0].id, "42", "numeric ids become strings"); + assert!((points[0].coord.lat - 54.81005).abs() < 1e-9); + assert!((points[0].coord.lon - 9.4705).abs() < 1e-9); + assert_eq!(points[1].id, "0190a8e9-7c4f-7000-8000-000000000000"); + assert!( + (points[1].coord.lat - 54.79505).abs() < 1e-9, + "a third coordinate (elevation) is ignored" + ); + assert_eq!( + points[2].id, "2", + "missing id falls back to the array index" + ); + } + + #[test] + fn accepts_an_empty_collection() { + let points = parse_points(r#"{"type":"FeatureCollection","features":[]}"#).unwrap(); + assert!(points.is_empty()); + } + + #[test] + fn rejects_non_point_geometry() { + let body = r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","geometry":{"type":"LineString","coordinates":[[9.4,54.8],[9.5,54.9]]}} + ]}"#; + let err = parse_points(body).unwrap_err().to_string(); + assert!(err.contains("feature 0"), "got {err}"); + assert!(err.contains("LineString"), "got {err}"); + } + + #[test] + fn rejects_polygon_geometry() { + let body = r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.4,54.8],[9.5,54.8],[9.5,54.9],[9.4,54.8]]]}} + ]}"#; + let err = parse_points(body).unwrap_err().to_string(); + assert!(err.contains("feature 0"), "got {err}"); + assert!(err.contains("Polygon"), "got {err}"); + } + + #[test] + fn rejects_truncated_coordinates() { + let body = r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","geometry":{"type":"Point","coordinates":[9.4]}} + ]}"#; + let err = parse_points(body).unwrap_err().to_string(); + assert!(err.contains("feature 0"), "got {err}"); + } + + #[test] + fn rejects_a_non_numeric_coordinate_instead_of_skipping_it() { + let body = r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","geometry":{"type":"Point","coordinates":[9.4,"north",54.8]}} + ]}"#; + let err = parse_points(body).unwrap_err().to_string(); + assert!(err.contains("feature 0"), "got {err}"); + assert!(err.contains("numbers"), "got {err}"); + } + + #[test] + fn rejects_invalid_json() { + let err = parse_points("not json").unwrap_err().to_string(); + assert!(err.contains("invalid GeoJSON"), "got {err}"); + } +} diff --git a/crates/pbf-patch/src/spatial.rs b/crates/pbf-patch/src/spatial.rs index 55e9946..ab151e4 100644 --- a/crates/pbf-patch/src/spatial.rs +++ b/crates/pbf-patch/src/spatial.rs @@ -53,20 +53,56 @@ impl WayIndex { } } - pub fn match_point(&self, point: Coordinate) -> Option<(WayId, f64)> { - let mut best: Option<(WayId, f64)> = None; - for candidate in self - .tree + /// The `limit` nearest indexed ways with their true distance to `point`. + /// The R-tree ranks by bounding box, so the distance has to be recomputed. + fn candidates( + &self, + point: Coordinate, + limit: usize, + ) -> impl Iterator + '_ { + self.tree .nearest_neighbor_iter([point.lon, point.lat]) - .take(NEAREST_CANDIDATES) - { - let way_id = candidate.data; - let dist = point_to_line_distance(point, &self.geometries[&way_id]); - if dist < MATCH_THRESHOLD_M && best.is_none_or(|(_, d)| dist < d) { - best = Some((way_id, dist)); - } - } - best + .take(limit) + .map(move |candidate| { + let way_id = candidate.data; + ( + way_id, + point_to_line_distance(point, &self.geometries[&way_id]), + ) + }) + } + + /// All indexed ways with their true distance to `point`, uncapped. + /// The R-tree's bounding-box ranking is only a lower bound on true + /// distance, so a capped candidate list can miss a way that is actually + /// closer than one ranked ahead of it by bbox alone. + fn all_candidates(&self, point: Coordinate) -> impl Iterator + '_ { + self.tree + .nearest_neighbor_iter([point.lon, point.lat]) + .map(move |candidate| { + let way_id = candidate.data; + ( + way_id, + point_to_line_distance(point, &self.geometries[&way_id]), + ) + }) + } + + pub fn match_point(&self, point: Coordinate) -> Option<(WayId, f64)> { + self.candidates(point, NEAREST_CANDIDATES) + .filter(|(_, dist)| *dist < MATCH_THRESHOLD_M) + .min_by(|(_, a), (_, b)| a.total_cmp(b)) + } + + pub fn has_way_within(&self, point: Coordinate, radius_m: f64) -> bool { + self.all_candidates(point).any(|(_, dist)| dist <= radius_m) + } + + pub fn ways_within(&self, point: Coordinate, radius_m: f64) -> Vec { + self.all_candidates(point) + .filter(|(_, dist)| *dist <= radius_m) + .map(|(way_id, _)| way_id) + .collect() } } @@ -184,4 +220,49 @@ mod tests { None ); } + + #[test] + fn detects_a_way_within_the_radius() { + let (ways, nodes) = test_network(); + let index = WayIndex::build(&ways, &nodes); + let near = Coordinate { + lat: 54.79505, + lon: 9.4305, + }; + assert!(index.has_way_within(near, 10.0)); + assert!(!index.has_way_within(near, 1.0)); + assert!(!index.has_way_within( + Coordinate { + lat: 54.90, + lon: 9.60 + }, + 50.0 + )); + } + + #[test] + fn collects_all_ways_within_the_radius() { + let (ways, nodes) = test_network(); + let index = WayIndex::build(&ways, &nodes); + + let at_junction = Coordinate { + lat: 54.7950, + lon: 9.4320, + }; + let mut found = index.ways_within(at_junction, 10.0); + found.sort(); + assert_eq!(found, vec![100, 200], "both ways share node 3"); + + assert!( + index + .ways_within( + Coordinate { + lat: 54.90, + lon: 9.60 + }, + 50.0 + ) + .is_empty() + ); + } } diff --git a/crates/pbf-patch/tests/construction.rs b/crates/pbf-patch/tests/construction.rs index bb4e168..7043ec9 100644 --- a/crates/pbf-patch/tests/construction.rs +++ b/crates/pbf-patch/tests/construction.rs @@ -1,12 +1,12 @@ use pbf_patch::construction::changed_ways; use pbf_patch::osc::write_osc; -use pbf_patch::pbf::load_car_network; +use pbf_patch::pbf::load_network; use pbf_patch::verkehrsticker::ConstructionSite; use std::path::Path; #[test] fn patches_fixture_network() { - let (nodes, ways) = load_car_network(Path::new("tests/fixtures/flensburg.osm.pbf")).unwrap(); + let network = load_network(Path::new("tests/fixtures/flensburg.osm.pbf")).unwrap(); let sites = vec![ConstructionSite { id: 1, @@ -15,7 +15,7 @@ fn patches_fixture_network() { lat: 54.79505, }]; - let changed = changed_ways(&sites, &ways, &nodes); + let changed = changed_ways(&sites, &network.car_ways, &network.nodes); assert_eq!( changed.iter().map(|w| w.id).collect::>(), vec![100, 200] diff --git a/crates/pbf-patch/tests/fixtures/targets.geojson b/crates/pbf-patch/tests/fixtures/targets.geojson new file mode 100644 index 0000000..3b6c9b6 --- /dev/null +++ b/crates/pbf-patch/tests/fixtures/targets.geojson @@ -0,0 +1,19 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "id": 42, "name": "Am Fußweg" }, + "geometry": { "type": "Point", "coordinates": [9.4705, 54.81005] } + }, + { + "type": "Feature", + "properties": { "id": "0190a8e9-7c4f-7000-8000-000000000000" }, + "geometry": { "type": "Point", "coordinates": [9.4305, 54.79505, 12.5] } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [9.6, 54.9] } + } + ] +}