diff --git a/router/README.md b/router/README.md index bd4952d..6acd976 100644 --- a/router/README.md +++ b/router/README.md @@ -92,7 +92,7 @@ CMake config files — boost release tarballs ship no CMake support) and osmium-tool; then it builds OSRM 26.4.1, merges the 4 state PBFs (osrm-extract takes one input), extracts (~8 GB peak RAM, ~4 min), partitions, customizes, and serves on `:5000`. Artifacts live in -`/tmp/osm-build` by default (set `W=` for a durable location). +`$HOME/osm-build` by default (override with `W=`). ```sh ROUTER_BACKEND=osrm ROUTER_URL=http://localhost:5000 go test -tags integration ./... @@ -108,17 +108,33 @@ corridor polygon in the v1 design) sound. ### Profile deltas matter (measured, same OD pair) -| backend | Lincoln NH → Queens NY | +| backend | Lincoln NH → 39-84 46th St, Sunnyside | |---|---| -| Valhalla `auto` | 332 min, 531 km | -| OSRM `car` | 410 min, 514 km | +| Valhalla `auto` | 327 min, 529 km (97 km/h avg) | +| OSRM stock `car.lua` | 406 min, 511 km (76 km/h avg) | +| OSRM US-tuned speed table | 377 min, 526 km (84 km/h avg) | -Both avoid Boston (22 km vs 38 km clearance) and both take the -I-93/90/495/290 corridor — same route *shape*, different *clocks*. -Consequences: (a) goldens in `bench/tasks.json` are per -backend+profile — re-record (`--record`) after switching; (b) any -user-facing arrival-time math must use one backend consistently per -trip and say which. +All three avoid Boston and take the I-93/90/495/290 corridor — same +route *shape*, different *clocks*. + +Why the clocks differ: **the times are the profile's speed assumptions, +not measurements.** OSRM's stock `car.lua` lists `motorway = 90` km/h +and applies a global `speed_reduction = 0.8` → untagged motorway miles +run at ~72 km/h (45 mph). That's a generic/European-flavored default +(90 km/h mirrors the European "unsigned road = 90" convention), and US +interstates are `maxspeed`-untagged in large stretches, so the default +rules. Re-extracting the same data with a US-tuned table +(`profiles/car-us.lua`: motorway 135, trunk 130, primary 110, ... +through the same 0.8 factor) closes two-thirds of the gap (406 → 377 +min) and even changes the *route choice* (526 km now — faster +motorways make longer interstates win). Valhalla's `auto` is simply +calibrated more aggressively (97 km/h average). + +Consequences: (a) goldens in the bench files are per backend+profile — +re-record (`--record`) after switching; (b) any user-facing +arrival-time math must use one backend consistently per trip and say +which; (c) "computed" provenance should carry the profile name, +because two computed numbers for the same leg can differ by 20–25%. ## Design notes diff --git a/router/internal/plan/integration_test.go b/router/internal/plan/integration_test.go index ab6bd9e..04c9ec1 100644 --- a/router/internal/plan/integration_test.go +++ b/router/internal/plan/integration_test.go @@ -29,7 +29,7 @@ func liveRouter(t *testing.T) route.Router { var ( lincoln = geo.Point{Lat: 44.054, Lon: -71.650} - queens = geo.Point{Lat: 40.729, Lon: -73.966} + sunnyside = geo.Point{Lat: 40.74707, Lon: -73.9181} // 39-84 46th St, Sunnyside 11104 ) func TestLiveDirectRoute(t *testing.T) { @@ -37,11 +37,11 @@ func TestLiveDirectRoute(t *testing.T) { r := liveRouter(t) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - rt, err := r.Route(ctx, route.ProfileDrive, []geo.Point{lincoln, queens}) + rt, err := r.Route(ctx, route.ProfileDrive, []geo.Point{lincoln, sunnyside}) if err != nil { t.Fatalf("route: %v", err) } - // Lincoln NH -> Queens NY is a 5.25-5.75h drive (I-93/90/495/290). + // Lincoln NH -> Sunnyside is a 5.25-5.75h drive (I-93/90/495/290). // Anything outside this band means the backend or the dataset is wrong. mins := rt.Duration / 60 if mins < 290 || mins > 430 { @@ -62,7 +62,7 @@ func TestLiveRouteAvoidsBoston(t *testing.T) { r := liveRouter(t) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - rt, err := r.Route(ctx, route.ProfileDrive, []geo.Point{lincoln, queens}) + rt, err := r.Route(ctx, route.ProfileDrive, []geo.Point{lincoln, sunnyside}) if err != nil { t.Fatalf("route: %v", err) } @@ -93,7 +93,7 @@ func TestLiveStopCost(t *testing.T) { cascade := geo.Point{Lat: 42.2767, Lon: -71.806} mtmajor := geo.Point{Lat: 43.642, Lon: -71.156} - pts := []geo.Point{cascade, mtmajor, lincoln, queens} + pts := []geo.Point{cascade, mtmajor, lincoln, sunnyside} m, err := route.BuildMatrix(ctx, r, route.ProfileDrive, pts, 200*time.Millisecond) if err != nil { t.Fatalf("matrix: %v", err) diff --git a/router/profiles/car-us.lua b/router/profiles/car-us.lua new file mode 100644 index 0000000..ad1eec2 --- /dev/null +++ b/router/profiles/car-us.lua @@ -0,0 +1,572 @@ +-- Car profile + +api_version = 4 + +Set = require('lib/set') +Sequence = require('lib/sequence') +Handlers = require("lib/way_handlers") +Relations = require("lib/relations") +Obstacles = require("lib/obstacles") +find_access_tag = require("lib/access").find_access_tag +limit = require("lib/maxspeed").limit +Utils = require("lib/utils") +Measure = require("lib/measure") + +function setup() + return { + properties = { + max_speed_for_map_matching = 180/3.6, -- 180kmph -> m/s + -- For routing based on duration, but weighted for preferring certain roads + weight_name = 'routability', + -- For shortest duration without penalties for accessibility + -- weight_name = 'duration', + -- For shortest distance without penalties for accessibility + -- weight_name = 'distance', + process_call_tagless_node = false, + u_turn_penalty = 20, + continue_straight_at_waypoint = true, + use_turn_restrictions = true, + left_hand_driving = false, + }, + + default_mode = mode.driving, + default_speed = 10, + oneway_handling = true, + side_road_multiplier = 0.8, + turn_penalty = 7.5, + speed_reduction = 0.8, + turn_bias = 1.075, + cardinal_directions = false, + + -- Penalty multiplier for roads with no lane markings (lane_markings=no) + -- Applied to bidirectional roads to prefer roads with clear lane markings + lane_markings_penalty = 0.75, + + -- Size of the vehicle, to be limited by physical restriction of the way + vehicle_height = 2.0, -- in meters, 2.0m is the height slightly above biggest SUVs + vehicle_width = 1.9, -- in meters, ways with narrow tag are considered narrower than 2.2m + + -- Size of the vehicle, to be limited mostly by legal restriction of the way + vehicle_length = 4.8, -- in meters, 4.8m is the length of large or family car + vehicle_weight = 2000, -- in kilograms + + -- Optional: upper limit for all speeds (e.g., 87 for trucks) + -- When set, no derived speed will exceed this value + -- When nil (default), no additional capping is applied + vehicle_max_speed = nil, -- in km/h + + -- a list of suffixes to suppress in name change instructions. The suffixes also include common substrings of each other + suffix_list = { + 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'North', 'South', 'West', 'East', 'Nor', 'Sou', 'We', 'Ea' + }, + + barrier_whitelist = Set { + 'cattle_grid', + 'border_control', + 'toll_booth', + 'sally_port', + 'no', + 'entrance', + 'height_restrictor', + 'arch' + }, + + access_tag_whitelist = Set { + 'yes', + 'motorcar', + 'motor_vehicle', + 'vehicle', + 'permissive', + 'designated', + 'hov' + }, + + access_tag_blacklist = Set { + 'no', + 'agricultural', + 'forestry', + 'emergency', + 'psv', + 'taxi', -- sub class of psv + 'share_taxi', -- sub class of psv + 'minibus', -- sub class of psv + 'bus', -- sub class of psv + 'customers', + 'private', + 'delivery', + 'destination' + }, + + -- tags disallow access to in combination with highway=service + service_access_tag_blacklist = Set { + 'private' + }, + + restricted_access_tag_list = Set { + 'private', + 'delivery', + 'destination', + 'customers', + }, + + access_tags_hierarchy = Sequence { + 'motorcar', + 'motor_vehicle', + 'vehicle', + 'access' + }, + + service_tag_forbidden = Set { + 'emergency_access' + }, + + restrictions = Sequence { + 'motorcar', + 'motor_vehicle', + 'vehicle' + }, + + classes = Sequence { + 'toll', 'motorway', 'ferry', 'restricted', 'tunnel' + }, + + -- classes to support for exclude flags + excludable = Sequence { + Set {'toll'}, + Set {'motorway'}, + Set {'ferry'} + }, + + avoid = Set { + 'area', + -- 'toll', -- uncomment this to avoid tolls + 'reversible', + 'impassable', + 'hov_lanes', + 'steps', + 'construction', + 'proposed' + }, + + speeds = Sequence { + highway = { + motorway = 135, -- US-tuned (effective x speed_reduction 0.8) + motorway_link = 75, + trunk = 130, + trunk_link = 70, + primary = 110, + primary_link = 60, + secondary = 90, + secondary_link = 55, + tertiary = 70, + tertiary_link = 45, + unclassified = 55, + residential = 45, + living_street = 15, + service = 20, + -- winter highway types (OSM highway=winter_road / highway=ice_road) + winter_road = 20, + ice_road = 15 + } + }, + + service_penalties = { + alley = 0.5, + parking = 0.5, + parking_aisle = 0.5, + driveway = 0.5, + ["drive-through"] = 0.5, + ["drive-thru"] = 0.5 + }, + + barrier_penalties = { + gate = 60, + lift_gate = 60, + }, + + restricted_highway_whitelist = Set { + 'motorway', + 'motorway_link', + 'trunk', + 'trunk_link', + 'primary', + 'primary_link', + 'secondary', + 'secondary_link', + 'tertiary', + 'tertiary_link', + 'residential', + 'living_street', + 'unclassified', + 'service' + }, + + construction_whitelist = Set { + 'no', + 'widening', + 'minor', + }, + + route_speeds = { + ferry = 5, + shuttle_train = 10 + }, + + bridge_speeds = { + movable = 5 + }, + + -- surface/trackype/smoothness + -- values were estimated from looking at the photos at the relevant wiki pages + + -- max speed for surfaces + surface_speeds = { + asphalt = nil, -- nil mean no limit. removing the line has the same effect + concrete = nil, + ["concrete:plates"] = nil, + ["concrete:lanes"] = nil, + paved = nil, + + cement = 80, + compacted = 80, + fine_gravel = 80, + + paving_stones = 60, + metal = 60, + bricks = 60, + + grass = 40, + wood = 40, + sett = 40, + grass_paver = 40, + gravel = 40, + unpaved = 40, + ground = 40, + dirt = 40, + pebblestone = 40, + tartan = 40, + + cobblestone = 30, + clay = 30, + + earth = 20, + stone = 20, + rocky = 20, + sand = 20, + + mud = 10, + + -- winter surfaces (OSM surface=ice / surface=snow) + ice = 20, + snow = 30 + }, + + -- max speed for tracktypes + tracktype_speeds = { + grade1 = 60, + grade2 = 40, + grade3 = 30, + grade4 = 25, + grade5 = 20 + }, + + -- max speed for smoothnesses + smoothness_speeds = { + intermediate = 80, + bad = 40, + very_bad = 20, + horrible = 10, + very_horrible = 5, + impassable = 0 + }, + + -- http://wiki.openstreetmap.org/wiki/Speed_limits + maxspeed_table_default = { + urban = 50, + rural = 90, + trunk = 110, + motorway = 130 + }, + + -- List only exceptions + maxspeed_table = { + ["at:rural"] = 100, + ["at:trunk"] = 100, + ["ar:urban"] = 40, + ["ar:rural"] = 110, + ["be:motorway"] = 120, + ["be-bru:rural"] = 70, + ["be-bru:urban"] = 30, + ["be-vlg:rural"] = 70, + ["bg:motorway"] = 140, + ["by:urban"] = 60, + ["by:motorway"] = 110, + ["ca-on:rural"] = 80, + ["ch:rural"] = 80, + ["ch:trunk"] = 100, + ["ch:motorway"] = 120, + ["de:living_street"] = 7, + ["de:rural"] = 100, + ["de:motorway"] = 0, + ["dk:rural"] = 80, + ["es:trunk"] = 90, + ["fr:rural"] = 80, + ["gb:nsl_single"] = (60*1609)/1000, + ["gb:nsl_dual"] = (70*1609)/1000, + ["gb:motorway"] = (70*1609)/1000, + ["lv:living_street"] = 20, + ["nl:rural"] = 80, + ["nl:trunk"] = 100, + ['no:rural'] = 80, + ['no:motorway'] = 110, + ['ph:urban'] = 40, + ['ph:rural'] = 80, + ['ph:motorway'] = 100, + ['pl:rural'] = 100, + ['pl:expressway'] = 120, + ['pl:motorway'] = 140, + ["ro:trunk"] = 100, + ["ru:living_street"] = 20, + ["ru:urban"] = 60, + ["ru:motorway"] = 110, + ["uk:nsl_single"] = (60*1609)/1000, + ["uk:nsl_dual"] = (70*1609)/1000, + ["uk:motorway"] = (70*1609)/1000, + ['za:urban'] = 60, + ['za:rural'] = 100, + ["none"] = 140 + }, + + relation_types = Sequence { + "route" + }, + + -- classify highway tags when necessary for turn weights + highway_turn_classification = { + }, + + -- classify access tags when necessary for turn weights + access_turn_classification = { + } + } +end + +function process_node(profile, node, result, relations) + -- parse access and barrier tags + local access = find_access_tag(node, profile.access_tags_hierarchy) + if access then + if profile.access_tag_blacklist[access] and not profile.restricted_access_tag_list[access] then + obstacle_map:add(node, Obstacle.new(obstacle_type.barrier)) + end + else + local barrier = node:get_value_by_key("barrier") + if barrier then + -- check height restriction barriers + local restricted_by_height = false + if barrier == 'height_restrictor' then + local maxheight = Measure.get_max_height(node:get_value_by_key("maxheight"), node) + restricted_by_height = maxheight and maxheight < profile.vehicle_height + end + + -- make an exception for rising bollard barriers + local bollard = node:get_value_by_key("bollard") + local rising_bollard = bollard and "rising" == bollard + + -- make an exception for lowered/flat barrier=kerb + -- and incorrect tagging of highway crossing kerb as highway barrier + local kerb = node:get_value_by_key("kerb") + local highway = node:get_value_by_key("highway") + local flat_kerb = kerb and ("lowered" == kerb or "flush" == kerb) + local highway_crossing_kerb = barrier == "kerb" and highway and highway == "crossing" + + -- make an exception for fence with sensory=audible/audio (virtual livestock fences) + local sensory = node:get_value_by_key("sensory") + local audible_fence = barrier == "fence" and sensory and (sensory == "audible" or sensory == "audio") + + -- check if barrier has a configurable penalty (e.g., gates) + local barrier_penalty = profile.barrier_penalties[barrier] + + if not profile.barrier_whitelist[barrier] + and not rising_bollard + and not flat_kerb + and not highway_crossing_kerb + and not audible_fence + and not barrier_penalty + or restricted_by_height then + obstacle_map:add(node, Obstacle.new(obstacle_type.barrier)) + end + + -- apply configurable penalty to gates/lift_gates + if barrier_penalty then + obstacle_map:add(node, Obstacle.new(obstacle_type.gate, + obstacle_direction.both, barrier_penalty, 0)) + end + end + end + + Obstacles.process_node(profile, node) +end + +function process_way(profile, way, result, relations) + -- the intial filtering of ways based on presence of tags + -- affects processing times significantly, because all ways + -- have to be checked. + -- to increase performance, prefetching and intial tag check + -- is done in directly instead of via a handler. + + -- in general we should try to abort as soon as + -- possible if the way is not routable, to avoid doing + -- unnecessary work. this implies we should check things that + -- commonly forbids access early, and handle edge cases later. + + -- data table for storing intermediate values during processing + local data = { + -- prefetch tags + highway = way:get_value_by_key('highway'), + bridge = way:get_value_by_key('bridge'), + route = way:get_value_by_key('route') + } + + -- perform an quick initial check and abort if the way is + -- obviously not routable. + -- highway or route tags must be in data table, bridge is optional + if (not data.highway or data.highway == '') and + (not data.route or data.route == '') + then + return + end + + handlers = Sequence { + -- set the default mode for this profile. if can be changed later + -- in case it turns we're e.g. on a ferry + WayHandlers.default_mode, + + -- check various tags that could indicate that the way is not + -- routable. this includes things like status=impassable, + -- toll=yes and oneway=reversible + WayHandlers.blocked_ways, + WayHandlers.avoid_ways, + WayHandlers.handle_height, + WayHandlers.handle_width, + WayHandlers.handle_length, + WayHandlers.handle_weight, + + -- determine access status by checking our hierarchy of + -- access tags, e.g: motorcar, motor_vehicle, vehicle + WayHandlers.access, + + -- check whether forward/backward directions are routable + WayHandlers.oneway, + + -- check a road's destination + WayHandlers.destinations, + + -- check whether we're using a special transport mode + WayHandlers.ferries, + WayHandlers.movables, + + -- handle service road restrictions + WayHandlers.service, + + -- handle hov + WayHandlers.hov, + + -- compute speed taking into account way type, maxspeed tags, etc. + WayHandlers.speed, + WayHandlers.maxspeed, + WayHandlers.surface, + + -- apply vehicle-specific maximum speed cap before calculating rates + WayHandlers.vehicle_speed_cap, + + WayHandlers.penalties, + + -- compute class labels + WayHandlers.classes, + + -- handle turn lanes and road classification, used for guidance + WayHandlers.turn_lanes, + WayHandlers.classification, + + -- handle various other flags + WayHandlers.roundabouts, + WayHandlers.startpoint, + WayHandlers.driving_side, + + -- set name, ref and pronunciation + WayHandlers.names, + + -- set weight properties of the way + WayHandlers.weights, + + -- set classification of ways relevant for turns + WayHandlers.way_classification_for_turn + } + + WayHandlers.run(profile, way, result, data, handlers, relations) + + if profile.cardinal_directions then + Relations.process_way_refs(way, relations, result) + end +end + +function process_turn(profile, turn) + -- Use a sigmoid function to return a penalty that maxes out at turn_penalty + -- over the space of 0-180 degrees. Values here were chosen by fitting + -- the function to some turn penalty samples from real driving. + local turn_penalty = profile.turn_penalty + local turn_bias = turn.is_left_hand_driving and 1. / profile.turn_bias or profile.turn_bias + + for _, obs in pairs(obstacle_map:get(turn.from, turn.via)) do + -- disregard a minor stop if entering by the major road + -- rationale: if a stop sign is tagged at the center of the intersection with stop=minor + -- it should only penalize the minor roads entering the intersection + if obs.type == obstacle_type.stop_minor and not Obstacles.entering_by_minor_road(turn) then + goto skip + end + -- heuristic to infer the direction of a stop without an explicit direction tag + -- rationale: a stop sign should not be placed farther than 20m from the intersection + if turn.number_of_roads == 2 + and obs.type == obstacle_type.stop + and obs.direction == obstacle_direction.none + and turn.source_road.distance < 20 + and turn.target_road.distance > 20 then + goto skip + end + turn.duration = turn.duration + obs.duration + ::skip:: + end + + if turn.number_of_roads > 2 or turn.source_mode ~= turn.target_mode or turn.is_u_turn then + if turn.angle >= 0 then + turn.duration = turn.duration + turn_penalty / (1 + math.exp( -((13 / turn_bias) * turn.angle/180 - 6.5*turn_bias))) + else + turn.duration = turn.duration + turn_penalty / (1 + math.exp( -((13 * turn_bias) * -turn.angle/180 - 6.5/turn_bias))) + end + + if turn.is_u_turn then + turn.duration = turn.duration + profile.properties.u_turn_penalty + end + end + + -- for distance based routing we don't want to have penalties based on turn angle + if profile.properties.weight_name == 'distance' then + turn.weight = 0 + else + turn.weight = turn.duration + end + + if profile.properties.weight_name == 'routability' then + -- penalize turns from non-local access only segments onto local access only tags + if not turn.source_restricted and turn.target_restricted then + turn.weight = constants.max_turn_weight + end + end +end + +return { + setup = setup, + process_way = process_way, + process_node = process_node, + process_turn = process_turn +} diff --git a/router/scripts/deps.sh b/router/scripts/deps.sh index 9b2b960..daec14a 100755 --- a/router/scripts/deps.sh +++ b/router/scripts/deps.sh @@ -3,10 +3,10 @@ # conan: bzip2, lua 5.2 (static), oneTBB, boost (b2, with hand-rolled # CMake config files — boost release tarballs ship no CMake support). # -# Artifacts: $W (default /tmp/osm-build), installed into $W/prefix. +# Artifacts: $W (default $HOME/osm-build), installed into $W/prefix. # Idempotent: each dep is skipped when its .done- marker exists. set -uo pipefail -W="${W:-/tmp/osm-build}" +W="${W:-$HOME/osm-build}" P="$W/prefix" mkdir -p "$P/include" "$P/lib" "$W" log() { echo "[$(date +%T)] $*"; } diff --git a/router/scripts/setup-osrm.sh b/router/scripts/setup-osrm.sh index 3f70931..8144a86 100755 --- a/router/scripts/setup-osrm.sh +++ b/router/scripts/setup-osrm.sh @@ -7,14 +7,12 @@ # Usage: scripts/setup-osrm.sh # everything, then serve # scripts/setup-osrm.sh --no-serve # build + extract, don't serve # -# NOTE: /tmp is wiped on reboot. For a persistent install set W to a -# durable dir (and the extract's ~8 GB lives under $W/data). -# -# PBFs are pre-downloaded in ../../osm/ (see router/README.md); ~1.1 GB. +# Artifacts live in $W (default: $HOME/osm-build — durable; the extract +# is ~8 GB under $W/data). PBFs are in ../../osm/ (~1.1 GB, gitignored). set -euo pipefail cd "$(dirname "$0")" -W="${W:-/tmp/osm-build}" +W="${W:-$HOME/osm-build}" P="$W/prefix" OSM_DIR="$(cd ../.. && pwd)/osm" DATA_DIR="${DATA_DIR:-$W/data}" @@ -29,6 +27,10 @@ step() { echo; echo "=== $1"; } step "1/6 dependencies (bzip2, lua, tbb, boost, osmium)" bash deps.sh +# The built OSRM binaries carry an RPATH baked in at configure time; +# if $W ever moves, this keeps them finding their TBB/boost libs. +export LD_LIBRARY_PATH="$P/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + step "2/6 OSRM 26.4.1" if [ ! -d "$OSRM_SRC" ]; then curl -sL -o "$W/osrm.tar.gz" https://github.com/Project-OSRM/osrm-backend/archive/refs/tags/v26.4.1.tar.gz