Skip to content
C.W.K.
Stream
Lesson 03 of 05 · published

Asking OpenStreetMap Well

~16 min · swift, openstreetmap, overpass, ranking, networking

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Near is not on

When neither Dad nor Pippa knows a building's door, Pathfinder asks OpenStreetMap, through the Overpass API: a public service with no key. The obvious question is "which entrances are near this point?", and Atocha shows why it is the wrong one. Measured there while the feature was built: 31 entrances within 350 m, most of them other buildings'. A main door tagged a hundred metres away is as likely the museum across the road as the station. The strong evidence is not distance. It is a door that sits on the outline of the building the destination stands in.

One query, three sections

OSMEntrances.query(around:) asks three things in one round trip:

  1. The venue. is_in finds the areas the point stands in, and the query keeps the buildings, railway stations and public-transport stations among them, plus any building within 40 m, so a point that falls just outside a footprint still finds its building. Each comes back with its tags and a centre.
  2. The doors. Entrance nodes, and railway=train_station_entrance nodes, that are members of those venue ways: points on the building's own outline.
  3. The near. Station entrances within 250 m and any entrance node within 150 m.

Overpass answers with one flat list of elements and no labels, so the reader could not tell a door from the second section from one in the third. The query solves that with Overpass's make statement: make split section="doors"; out; emits a derived element of type split carrying a tag, between sections. OSMEntrances.read walks the list, switches section whenever it meets a split, and marks each door onVenue if it appeared in the doors section. A node listed in both sections (a door on the outline is also near the point) is kept once, and stays on the venue. The Santa Justa fixture in the kit's tests, read live from Overpass, is exactly that case: one door, listed twice, on the station itself.

The venue section does a second job. If any venue is a station (building=train_station, railway=station or public_transport=station) or has building:levels of three or more, the reading says isLargeVenue, which is how a place named "Madrid Puerta de Atocha" earns its last stretch.

Scoring a door

OSMEntrances.rank gives each door a score and sorts best first:

TagsBase
railway=train_station_entrance45 at a station, else 30
entrance=main25
entrance=yes or home12
entrance=secondary8
any other entrance value4
railway=subway_entrance with no entrance tag2
access=no or privatedropped, checked before anything else
entrance=service, emergency, exit, garage, staircasedropped, unless the node is also a railway=train_station_entrance

The order of the checks is the table's fine print. Access is read first, so a private door never scores. The station-entrance tag is read next, before the entrance value, so a node tagged both railway=train_station_entrance and entrance=exit still scores 45 at a station: the code trusts the railway tag over the entrance value.

Then: +25 if the door is on the venue, minus distance to the centre / 20, and, when Dad's starting point is known, minus distance from him / 60, so between two equally good doors the one on his side of the building wins. The subway entrance scores 2 for a reason written in the code: a metro entrance near a railway station leads into a different station.

The sketch below runs that arithmetic on the kit test's five synthetic doors around Atocha's centre. A main door 100 m off the building scores 25 − 5 = 20. A plain entrance=yes on the outline, 67 m away, scores 12 + 25 − 3.3 = 33.7, and beats it. A station entrance named "Salidas" 133 m away scores 45 − 6.7 = 38.3 and leads. The service door and the private main door never enter the list. That is the order the kit test asserts.

Levels, honestly

OpenStreetMap's level tag is the only floor information on a door, and Pathfinder takes it narrowly. Only a single non-zero whole number counts. 0 is the street and says nothing about climbing; 0;-1 (a lift between two levels) is ambiguous; neither becomes a level. Santa Justa's one door is tagged level 0, so it arrives with no level at all. And OSM's level is the building's own numbering, while Entrance.level means floors above the street the walk arrives on. Those agree only when the walk arrives at the building's level 0, which is one more reason a door Dad confirmed outranks this one.

Being a polite client of a free service

  • Say who you are. The main server answers 406 to a request with no User-Agent. Pathfinder sends cwkPathfinder/1.0 (personal navigation; iOS). The project notes record it as a gotcha, because a 406 looks like a server problem when it is a client one.
  • Have a second door. overpass-api.de is often busy, so EntranceFinder falls through to the mirror overpass.kumi.systems. Anything but a 200 with readable JSON moves on to the next server.
  • Never make Dad wait. Each server gets a 6 s request timeout and 8 s in all. It is asked while the route preview shows, never during a walk, and only when the phone has no fresh answer for that building. The route is already planned from what the phone knows; a door learned in time re-plans the preview, a failed answer changes nothing, and a late one is only remembered for next time.
  • Ask rarely. An answer is kept for 30 days, up to 12 doors per building.
  • Credit the data. OpenStreetMap is the contributors', under the ODbL. When the approach card shows a door from it, the card says "입구 정보 © OpenStreetMap".

None of this runs in a simulation or under XCTest. The tests read the recorded Santa Justa fixture instead of the network.

Ask the question whose answer separates the cases. "What is near?" returns everything plausible. "What is on this building, and what is merely near it?" returns evidence you can rank, and one marker in the response is all it takes to keep the two apart.

Code

PathfinderKit, Arrival/OSMEntrances.swift: the Overpass query with its section markers (verbatim excerpt)·swift
  /// The query for the doors of whatever stands at `center`.
  public static func query(around center: Coordinate, timeout: Int = 15) -> String {
    let at = String(format: "%.6f,%.6f", center.lat, center.lon)
    return """
      [out:json][timeout:\(timeout)];
      is_in(\(at))->.inside;
      (way(pivot.inside)[building];rel(pivot.inside)[building];way(pivot.inside)[railway=station];\
      way(pivot.inside)[public_transport=station];way(around:40,\(at))[building];)->.venue;
      .venue out tags center;
      make split section="doors"; out;
      (node(w.venue)[entrance];node(w.venue)[railway=train_station_entrance];)->.doors;
      .doors out body;
      make split section="near"; out;
      (node(around:250,\(at))[railway=train_station_entrance];node(around:150,\(at))[entrance];)->.near;
      .near out body;
      """
  }
Pathfinder app, Navigation/EntranceFinder.swift: User-Agent and the fall-through to the mirror (verbatim excerpt)·swift
  private func fetch(around center: Coordinate) async -> OSMEntrances.Reading? {
    let body = "data=" + (OSMEntrances.query(around: center).addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "")
    for endpoint in OSMEntrances.endpoints {
      var request = URLRequest(url: endpoint)
      request.httpMethod = "POST"
      request.httpBody = Data(body.utf8)
      request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
      // The main server answers 406 to a request that does not say who it is.
      request.setValue(OSMEntrances.userAgent, forHTTPHeaderField: "User-Agent")
      guard let (data, response) = try? await session.data(for: request),
        (response as? HTTPURLResponse)?.statusCode == 200,
        let reading = try? OSMEntrances.read(data)
      else { continue }
      return reading
    }
    return nil
  }
A minimal sketch: the kit's base scores and bonuses on the kit test's five synthetic doors, plus the level rule (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of OSMEntrances.rank: the kit's base scores, +25 for a
// door on the building's own outline, minus distance to the centre / 20.
// The five doors are the kit test's synthetic ones, north of Atocha's centre.
struct Door { var name: String; var north: Double; var tags: [String: String]; var onVenue: Bool }

func baseScore(_ tags: [String: String], station: Bool) -> Double? {
  if let access = tags["access"], ["no", "private"].contains(access) { return nil }
  if tags["railway"] == "train_station_entrance" { return station ? 45 : 30 }
  switch tags["entrance"] {
  case "main": return 25
  case "yes", "home": return 12
  case "secondary": return 8
  case "service", "emergency", "exit", "garage", "staircase": return nil
  case nil: break
  default: return 4
  }
  if tags["railway"] == "subway_entrance" { return 2 }
  return nil
}

func level(_ tags: [String: String]) -> Int? {
  guard let raw = tags["level"]?.trimmingCharacters(in: .whitespaces), let value = Int(raw), value != 0 else {
    return nil
  }
  return value
}

let metresPerDegree = 111_195.0
let doors = [
  Door(name: "main door, across the road", north: 0.0009, tags: ["entrance": "main"], onVenue: false),
  Door(name: "door on the outline", north: 0.0006, tags: ["entrance": "yes", "level": "2"], onVenue: true),
  Door(name: "service door", north: 0.0001, tags: ["entrance": "service"], onVenue: true),
  Door(name: "private main door", north: 0.0002, tags: ["entrance": "main", "access": "private"], onVenue: true),
  Door(name: "Salidas", north: 0.0012, tags: ["railway": "train_station_entrance"], onVenue: false),
]

var ranked: [(score: Double, door: Door)] = []
for door in doors {
  guard let base = baseScore(door.tags, station: true) else {
    print("dropped:", door.name)
    continue
  }
  let distance = door.north * metresPerDegree
  ranked.append((base + (door.onVenue ? 25 : 0) - distance / 20, door))
}
for (score, door) in ranked.sorted(by: { $0.score > $1.score }) {
  print(String(format: "%5.1f", score), door.name, "level:", level(door.tags).map(String.init) ?? "-")
}
for raw in ["2", "0", "0;-1", "-1"] {
  print("level \"\(raw)\" ->", level(["level": raw]).map(String.init) ?? "nil")
}

External links

Exercise

Pick a large station near you and run Pathfinder's query against it yourself: turn the first code block into plain Overpass QL, because it is a Swift string, not a query you can paste as is. Keep only the lines between the triple quotes, replace every \(at) with the station's centre as lat,lon and \(timeout) with 15, and join the line that ends in a backslash to the next one (in Swift that backslash only continues the line). Paste the result into overpass-turbo and run it. Count the doors in the "doors" section and in the "near" section. Then score them by hand with the table, using distance from the centre. Does the top door match the one you would actually use? If not, which tag or which missing tag led the ranking astray?
Hint
Set your own User-Agent if you script it against the API rather than using overpass-turbo, and keep it to a handful of requests: it is a shared, free service. If the doors section is empty, the station's building may be mapped as a relation or as a station area without entrance members, which is exactly the sparseness the known-limits table describes.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.