diff --git a/README.md b/README.md index eb5d606..9b93011 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ ATIP can display extra contextual layers: - Points of interest, like schools, hospitals, sports centres, etc - Roads with bus lanes and bus routes - Cycle parking + - Existing cycle paths - the [Major Road Network](https://www.data.gov.uk/dataset/95f58bfa-13d6-4657-9d6f-020589498cfd/major-road-network) - Boundaries - Parliament constituency boundaries, from [OS Boundary-Line](https://www.ordnancesurvey.co.uk/products/boundary-line) @@ -107,7 +108,7 @@ is a single GeoJSON file if it's small enough, or To run this: 1. Get `england-latest.osm.pbf` from Geofabrik. The `split_uk_osm.sh` script above does this. -2. Run `cd layers; ./generate_layers.py --osm_input=../england-latest.osm.pbf --schools --hospitals --mrn --parliamentary_constituencies --combined_authorities --local_authority_districts --local_planning_authorities --sports_spaces --railway_stations --bus_routes --cycle_parking` +2. Run `cd layers; ./generate_layers.py --osm_input=../england-latest.osm.pbf --schools --hospitals --mrn --parliamentary_constituencies --combined_authorities --local_authority_districts --local_planning_authorities --sports_spaces --railway_stations --bus_routes --cycle_parking --cycle_paths` 3. Pick an arbitrary version number, and upload the files: `for x in output/*; do aws s3 cp --dry $x s3://atip.uk/layers/v1/; done` If you're rerunning the script for the same output, you may need to manually delete the output files from the previous run. @@ -116,6 +117,8 @@ You can debug a PMTiles file using . There's a manual step required to generate `--wards`, `--census_output_areas`, and `--imd`. See the comment in the code. +For `--cycle_paths`, you'll need about 20GB of RAM, until we switch to a streaming JSON parser. + ### One-time cloud setup for PMTiles Currently we're using S3 and Cloudfront to host files generated by this repo. diff --git a/layers/cycle_paths.py b/layers/cycle_paths.py new file mode 100644 index 0000000..e4d7870 --- /dev/null +++ b/layers/cycle_paths.py @@ -0,0 +1,149 @@ +from utils import * + + +def makeCyclePaths(osm_input): + if not osm_input: + raise Exception("You must specify --osm_input") + + filename = "cycle_paths" + tmp = f"tmp_{filename}" + ensureEmptyTempDirectoryExists(tmp) + + run( + [ + "osmium", + "tags-filter", + osm_input, + "w/highway", + "-o", + f"{tmp}/cycle_paths.osm.pbf", + ] + ) + gjPath = f"{tmp}/cycle_paths.geojson" + convertPbfToGeoJson( + f"{tmp}/cycle_paths.osm.pbf", gjPath, "linestring", includeOsmID=True + ) + + cleanUpGeojson( + gjPath, getProps, filterFeatures=lambda f: getProps(f["properties"]) != None + ) + + convertGeoJsonToPmtiles(f"{tmp}/cycle_paths.geojson", "output/cycle_paths.pmtiles") + + +# If this has some kind of cycle-path, returns a dictionary with: +# +# - kind = track | lane | shared_use_segregated | shared_use_unsegregated +# - osm_id +# - direction = one-way | two-way | unknown +# (For cyclist traffic; unrelated to whether the path is a contraflow on a one-way street) +# - width = number in meters | unknown +# +# If there's no cycle path, returns None +def getProps(props): + if props.get("highway") == "cycleway": + kind = "track" + # Is it shared-use? Note "foot" may be missing (https://www.openstreetmap.org/way/849651126) + if props.get("foot") in ["yes", "designated"] or props.get("segregated"): + kind = getSharedUseKind(props) + + return { + "kind": kind, + "osm_id": props["@id"], + "width": getSeparateWayWidth(props), + "direction": getSeparateWayDirection(props), + } + elif hasLane(props): + return { + "kind": "lane", + "osm_id": props["@id"], + "width": getLaneWidth(props), + "direction": getLaneDirection(props), + } + elif props["highway"] in ["footway", "pedestrian", "path", "track"]: + if props.get("bicycle") not in ["yes", "designated"]: + return None + + return { + "kind": getSharedUseKind(props), + "osm_id": props["@id"], + "width": getSeparateWayWidth(props), + "direction": getSeparateWayDirection(props), + } + else: + return None + + +def getSeparateWayWidth(props): + # TODO Handle suffixes / units + if props.get("width"): + return props["width"] + if props.get("est_width"): + return props["est_width"] + return "unknown" + + +def getSeparateWayDirection(props): + oneway = props.get("oneway") + if oneway == "yes": + return "one-way" + elif oneway == "no": + return "two-way" + else: + return "unknown" + + +def valueIndicatesLane(value): + return value and value not in ["no", "separate", "share_busway"] + + +def hasLane(props): + # TODO Handle bicycle:lanes + for suffix in ["", ":left", ":right", ":both"]: + if valueIndicatesLane(props.get("cycleway" + suffix)): + return True + return False + + +# If there are two lanes, the result could capture one or both of the lanes +def getLaneWidth(props): + for suffix in ["", ":left", ":right", ":both"]: + for width in [":width", ":est_width"]: + value = props.get("cycleway" + suffix + width) + if value: + return value + return "unknown" + + +def getLaneDirection(props): + if valueIndicatesLane(props.get("cycleway:both")): + return "two-way" + if valueIndicatesLane(props.get("cycleway:left")) and valueIndicatesLane( + props.get("cycleway:right") + ): + return "two-way" + if props.get("oneway:bicycle") == "no": + # TODO On one-way roads, this might just mean cyclists can travel both + # directions, but there's only a dedicated lane in one + return "two-way" + if ( + props.get("cycleway:left:oneway") == "no" + or props.get("cycleway:right:oneway") == "no" + ): + return "two-way" + + # TODO opposite_track + # TODO cycleway=* + oneway=yes + # TODO bicycle:lanes + return "unknown" + + +def getSharedUseKind(props): + segregated = props.get("segregated") + if segregated == "yes": + return "shared_use_segregated" + elif segregated == "no": + return "shared_use_unsegregated" + else: + # Pessimistically assume unsegregated if unknown + return "shared_use_unsegregated" diff --git a/layers/generate_layers.py b/layers/generate_layers.py index 1c1f6a5..b171881 100755 --- a/layers/generate_layers.py +++ b/layers/generate_layers.py @@ -6,6 +6,7 @@ from utils import * import census import boundaries +import cycle_paths import osm @@ -38,6 +39,7 @@ def main(): help="Path to the manually downloaded Indices_of_Multiple_Deprivation_(IMD)_2019.geojson", type=str, ) + parser.add_argument("--cycle_paths", action="store_true") # Inputs required for some outputs parser.add_argument( "-i", "--osm_input", help="Path to england-latest.osm.pbf file", type=str @@ -109,6 +111,10 @@ def main(): made_any = True census.makeIMD(args.imd) + if args.cycle_paths: + made_any = True + cycle_paths.makeCyclePaths(args.osm_input) + if not made_any: print( "Didn't create anything. Call with --help to see possible layers that can be created" diff --git a/layers/osmium_with_ids.cfg b/layers/osmium_with_ids.cfg new file mode 100644 index 0000000..a3fbeeb --- /dev/null +++ b/layers/osmium_with_ids.cfg @@ -0,0 +1,17 @@ +{ + "attributes": { + "type": false, + "id": true, + "version": false, + "changeset": false, + "timestamp": false, + "uid": false, + "user": false, + "way_nodes": false, + }, + "format_options": {}, + "linear_tags": true, + "area_tags": true, + "exclude_tags": [], + "include_tags": [], +} diff --git a/layers/utils.py b/layers/utils.py index 9384285..5964df3 100644 --- a/layers/utils.py +++ b/layers/utils.py @@ -14,7 +14,14 @@ def ensureEmptyTempDirectoryExists(directoryName): os.makedirs(directoryName, exist_ok=True) -def convertPbfToGeoJson(pbfPath, geojsonPath, geometryType): +def convertPbfToGeoJson(pbfPath, geojsonPath, geometryType, includeOsmID=False): + config = [] + if includeOsmID: + # Created by `osmium export --print-default-config` and changing `id` + # TODO We can do this with a CLI flag with newer osmium, but it's not + # easy to install on Ubuntu 20 + config = ["--config", "osmium_with_ids.cfg"] + run( [ "osmium", @@ -24,6 +31,7 @@ def convertPbfToGeoJson(pbfPath, geojsonPath, geometryType): "-o", geojsonPath, ] + + config )