"""OpenStreetMap routing graph: ways, per-class state and edges (docs/offroad/PLAN.md R10.3a, Appendix E.7). PC only. python3 osm_graph.py [--terrain | --no-elevation] Reads the ways and ford nodes GDAL's OSM driver wrote from the extract (build-osm, osmconf.ini: `highway`, `piste_type` and `other_tags`), keeps the ways a vehicle can use, nodes them with noding.py (the platform's, route-graph/lib) (OSM's shared vertices are exact, so the T-snap is only 1 m: it joins ways mapped a hair apart, never across a real gap) and writes edges-osm.geojsonseq and report-osm.json, and the fuel and charging points as pois-osm.geojsonseq (R10.3b: layer `p`, kind fuel | charging, name, brand, fuel = the fuel:* kinds tagged yes, el + oid = the OSM element; stations mapped as areas too; R11.6 plans fuel stops from them). ODbL: the files, the tiles and every table built from them stay apart from the official graph. Edge properties after a, b, src ('osm'), sid (way id), len: os per-class state, 7 characters in class order (passenger, high_clearance, ohv_wide, atv, motorcycle, e_bike, snowmobile): y (yes, designated, permissive, official), n (no, private, agricultural, forestry, delivery…) or u (not tagged, or anything else). OSM's access hierarchy: access → vehicle → motor_vehicle → the mode tag (motorcar for both car classes, ohv for wide OHVs, ohv then atv for ATVs, motorcycle, snowmobile); 4wd_only=yes closes passenger cars; e-bikes follow access → electric_bicycle only. In verdicts every OSM edge stays unknown (OSM is never an official rule); in routing n is never traversable (R10.5a). pub true on unclassified … trunk roads with no access restriction (the street-legal option, R10.6) surf paved | gravel | dirt | unknown; smooth (smoothness as tagged); tt tracktype 1-5 w width (else maxwidth) in m, 0.01: "2.5", "2.5 m", "8'", "8 ft", "50\\"" ow 1 or -1 (oneway; absent = both ways); ford true from the way's ford tag or a ford node on it cond true when a *:conditional tag of a relevant mode is set (not parsed in v1: unknown on every date, with a warning) name, ref, oid (way id), then up, dn, gmax (elevation.py) """ import argparse import json import os import re import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path[:0] = [HERE, os.path.join(HERE, '..', 'route-graph', 'lib'), os.path.join(HERE, '..', 'lib')] import noding # noqa: E402 OSM_SNAP_M = 1.0 CLASSES = ('passenger', 'high_clearance', 'ohv_wide', 'atv', 'motorcycle', 'e_bike', 'snowmobile') # The tags that rule each class, most general first; the last one tagged wins. HIERARCHY = { 'passenger': ('access', 'vehicle', 'motor_vehicle', 'motorcar'), 'high_clearance': ('access', 'vehicle', 'motor_vehicle', 'motorcar'), 'ohv_wide': ('access', 'vehicle', 'motor_vehicle', 'ohv'), 'atv': ('access', 'vehicle', 'motor_vehicle', 'ohv', 'atv'), 'motorcycle': ('access', 'vehicle', 'motor_vehicle', 'motorcycle'), 'e_bike': ('access', 'electric_bicycle'), 'snowmobile': ('access', 'vehicle', 'motor_vehicle', 'snowmobile'), } YES = {'yes', 'designated', 'permissive', 'official'} NO = {'no', 'private', 'agricultural', 'forestry', 'delivery', 'military', 'use_sidepath', 'emergency'} MODES = {k for chain in HIERARCHY.values() for k in chain} | {'4wd_only'} VEHICLE_TAGS = ('vehicle', 'motor_vehicle', 'motorcar', 'motorcycle', 'atv', 'ohv', 'snowmobile', '4wd_only') KEPT = {'track', 'path', 'unclassified', 'service', 'residential', 'tertiary', 'secondary', 'primary', 'trunk'} PUBLIC = {'unclassified', 'tertiary', 'secondary', 'primary', 'trunk'} PATH_TAGS = ('motor_vehicle', 'motorcar', 'motorcycle', 'atv', 'ohv', 'snowmobile', 'width', 'tracktype') DROPPED_SERVICE = {'driveway', 'parking_aisle'} FORDS = {'yes', 'stepping_stones', 'boat', 'intermittent'} SURFACES = { 'paved': ('paved', 'asphalt', 'concrete', 'concrete:plates', 'concrete:lanes', 'paving_stones', 'sett', 'chipseal', 'cobblestone', 'metal', 'wood'), 'gravel': ('gravel', 'fine_gravel', 'compacted', 'pebblestone', 'shells'), 'dirt': ('dirt', 'earth', 'ground', 'mud', 'sand', 'soil', 'grass', 'clay'), } SURFACE = {v: k for k, vs in SURFACES.items() for v in vs} TAG = re.compile(r'"((?:[^"\\]|\\.)*)"=>"((?:[^"\\]|\\.)*)"') WIDTH = re.compile(r'^\s*(\d+(?:[.,]\d+)?)\s*(m|metres?|meters?|ft|feet|foot|\'|′|"|″|in|inch|inches)?\s*$', re.I) WIDTH_FT_IN = re.compile(r'^\s*(\d+)\s*[\'′]\s*(\d+(?:\.\d+)?)\s*["″]\s*$') UNIT_M = {'m': 1.0, 'metre': 1.0, 'metres': 1.0, 'meter': 1.0, 'meters': 1.0, 'ft': 0.3048, 'feet': 0.3048, 'foot': 0.3048, "'": 0.3048, '′': 0.3048, '"': 0.0254, '″': 0.0254, 'in': 0.0254, 'inch': 0.0254, 'inches': 0.0254} def tags_of(props): """One tag dict from GDAL's columns and its other_tags string ("k"=>"v","k2"=>"v2").""" tags = {k: v for k, v in TAG.findall(props.get('other_tags') or '')} tags = {k.replace('\\"', '"'): v.replace('\\"', '"') for k, v in tags.items()} if props.get('highway'): tags['highway'] = props['highway'] if props.get('piste_type'): tags['piste:type'] = props['piste_type'] return tags def value(v): """y, n or None (untagged or another value). A list ("agricultural;forestry") is y when any value is a yes, n when every value is a no.""" parts = [x.strip() for x in (v or '').lower().split(';') if x.strip()] if any(x in YES for x in parts): return 'y' return 'n' if parts and all(x in NO for x in parts) else None def class_states(tags): out = [] for cls in CLASSES: state = 'u' for key in HIERARCHY[cls]: s = value(tags.get(key)) if s: state = s if cls == 'passenger' and value(tags.get('4wd_only')) == 'y': state = 'n' if cls == 'snowmobile' and state == 'u' and tags.get('piste:type') == 'snowmobile': state = 'y' out.append(state) return ''.join(out) def keep(tags): """Whether a way enters the graph.""" hw, piste = tags.get('highway'), tags.get('piste:type') if hw not in KEPT and piste != 'snowmobile': return False if hw == 'service' and tags.get('service') in DROPPED_SERVICE: return False if hw == 'path' and piste != 'snowmobile' and not any(tags.get(k) for k in PATH_TAGS): return False if tags.get('access') in ('private', 'no') and not any(value(tags.get(k)) == 'y' for k in VEHICLE_TAGS): return False return True def public_road(tags): return tags.get('highway') in PUBLIC and all( tags.get(k) is None or value(tags.get(k)) == 'y' for k in ('access', 'vehicle', 'motor_vehicle', 'motorcar')) def parse_width(v): """Metres (0.01) from OSM's width forms; None when it can't be read.""" if not v: return None m = WIDTH_FT_IN.match(v) if m: return round(int(m.group(1)) * 0.3048 + float(m.group(2)) * 0.0254, 2) m = WIDTH.match(v) if not m: return None n = float(m.group(1).replace(',', '.')) w = round(n * UNIT_M[(m.group(2) or 'm').lower()], 2) return w if 0 < w < 100 else None def conditional(tags): return any(k.endswith(':conditional') and k[:-len(':conditional')] in MODES for k in tags) def way_attrs(tags, way_id): out = {'os': class_states(tags)} if public_road(tags): out['pub'] = True out['surf'] = SURFACE.get((tags.get('surface') or '').strip().lower(), 'unknown') if tags.get('smoothness'): out['smooth'] = tags['smoothness'] m = re.fullmatch(r'grade([1-5])', tags.get('tracktype') or '') if m: out['tt'] = int(m.group(1)) w = parse_width(tags.get('width')) or parse_width(tags.get('maxwidth')) if w is not None: out['w'] = w ow = (tags.get('oneway') or '').lower() if ow in ('yes', 'true', '1'): out['ow'] = 1 elif ow in ('-1', 'reverse'): out['ow'] = -1 if (tags.get('ford') or '').lower() in FORDS: out['ford'] = True if conditional(tags): out['cond'] = True for key in ('name', 'ref'): if tags.get(key): out[key] = tags[key] out['oid'] = way_id return out def read_ways(lines): """GDAL's lines → rows for noding.read_parts (source 'osm', source_id = way id) and each way's tags.""" rows, tags_by_id, dropped = [], {}, 0 for line in lines: if not line.strip(): continue f = json.loads(line) p = f.get('properties') or {} way = int(p['osm_id']) tags = tags_of(p) if not keep(tags): dropped += 1 continue tags_by_id[way] = tags rows.append(json.dumps({'type': 'Feature', 'geometry': f.get('geometry'), 'properties': {'id': way, 'source': 'osm', 'source_id': str(way)}})) return rows, tags_by_id, dropped def ford_points(lines): """The coordinates (7 decimals, as noding keeps them) of ford nodes.""" out = set() for line in lines: if line.strip(): f = json.loads(line) if f['geometry']['type'] == 'Point' and ((f.get('properties') or {}).get('ford') or '').lower() in FORDS: x, y = f['geometry']['coordinates'][:2] out.add((round(x, 7), round(y, 7))) return out POI_KINDS = {'fuel': 'fuel', 'charging_station': 'charging'} def ring_centre(ring): """The area centroid of a ring (shoelace), or its first vertex when it has no area.""" a = cx = cy = 0.0 for (x1, y1), (x2, y2) in zip(ring, ring[1:]): c = x1 * y2 - x2 * y1 a, cx, cy = a + c, cx + (x1 + x2) * c, cy + (y1 + y2) * c if abs(a) < 1e-18: return ring[0][:2] return [cx / (3 * a), cy / (3 * a)] def ring_area(ring): return abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(ring, ring[1:]))) def pois(lines): """Fuel stations and chargers among GDAL's points and areas (a station mapped as an area is its outline's centre; `el` says which OSM element: n node, w way, r relation), as layer `p` features sorted by element and id.""" out = [] for line in lines: if not line.strip(): continue f = json.loads(line) p = f.get('properties') or {} kind = POI_KINDS.get(p.get('amenity') or '') if not kind: continue tags = tags_of(p) props = {'kind': kind} for key in ('name', 'brand'): if tags.get(key): props[key] = tags[key] fuels = sorted(k[len('fuel:'):] for k, v in tags.items() if k.startswith('fuel:') and value(v) == 'y') if fuels: props['fuel'] = ','.join(fuels) g = f['geometry'] if g['type'] == 'Point': props['el'], props['oid'] = 'n', int(p['osm_id']) x, y = g['coordinates'][:2] else: polygons = [g['coordinates']] if g['type'] == 'Polygon' else g['coordinates'] x, y = ring_centre(max((poly[0] for poly in polygons), key=ring_area)) way = p.get('osm_way_id') props['el'], props['oid'] = ('w', int(way)) if way else ('r', int(p['osm_id'])) out.append({'type': 'Feature', 'properties': props, 'geometry': {'type': 'Point', 'coordinates': [round(x, 7), round(y, 7)]}}) return sorted(out, key=lambda f: (f['properties']['el'], f['properties']['oid'])) def build(lines, ford_lines=(), terrain=None, snap=OSM_SNAP_M): rows, tags_by_id, dropped = read_ways(lines) parts, meta, counts = noding.read_parts(rows, require_clean=False) edges, _gaps, _crossings, graph = noding.build(parts, meta, snap) fords = ford_points(ford_lines) no_climb = 0 for e in edges: p = e['properties'] attrs = way_attrs(tags_by_id[p['rid']], p['rid']) if fords and any((round(x, 7), round(y, 7)) in fords for x, y in e['geometry']['coordinates']): attrs['ford'] = True del p['rid'] # the way id is oid p.update(attrs) if terrain is not None: import elevation c = elevation.edge_climb(e['geometry']['coordinates'], terrain) no_climb += not c p.update(c) report = {**counts, 'ways_dropped': dropped, 'snap_m': snap, **graph, 'edges_ford': sum(1 for e in edges if e['properties'].get('ford')), 'edges_conditional': sum(1 for e in edges if e['properties'].get('cond')), 'edges_public_road': sum(1 for e in edges if e['properties'].get('pub')), 'edges_without_elevation': no_climb if terrain is not None else None} return edges, report def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) ap.add_argument('lines', help="GDAL's lines layer as GeoJSONSeq") ap.add_argument('points', help="GDAL's ford, fuel and charging points as GeoJSONSeq") ap.add_argument('out', help='output directory') ap.add_argument('--terrain', default='/data/tiles/terrain.pmtiles') ap.add_argument('--no-elevation', action='store_true') a = ap.parse_args(argv) t0 = time.time() terrain = None if not a.no_elevation: import elevation if not os.path.isfile(a.terrain): print(f'error: no terrain archive at {a.terrain} (or pass --no-elevation)', file=sys.stderr) return 2 terrain = elevation.pmtiles_terrain(a.terrain) with open(a.points, encoding='utf-8') as g: points = g.readlines() with open(a.lines, encoding='utf-8') as f: edges, report = build(f, points, terrain) poi = pois(points) report['pois'] = {k: sum(1 for f in poi if f['properties']['kind'] == k) for k in ('fuel', 'charging')} os.makedirs(a.out, exist_ok=True) noding.write_seq(os.path.join(a.out, 'edges-osm.geojsonseq'), edges) noding.write_seq(os.path.join(a.out, 'pois-osm.geojsonseq'), poi) with open(os.path.join(a.out, 'report-osm.json'), 'w', encoding='utf-8', newline='\n') as out: json.dump(report, out, indent=1, sort_keys=True) out.write('\n') print(json.dumps(report, sort_keys=True), file=sys.stderr) print(f'OSM graph build took {time.time() - t0:.1f} s', file=sys.stderr) return 0 if __name__ == '__main__': sys.exit(main())