diff --git a/CHANGELOG.md b/CHANGELOG.md index bde6fab..b31e827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Configuration validation to use a JSON schema. Partially **backward-incompatible** since configuration errors now raise exceptions rather than logging a message to the `error` level [#56](https://github.com/arup-group/osmox/pull/56). - Recommended installation instructions changed from using `pip` to creating a `mamba` environment [#38](https://github.com/arup-group/osmox/pull/38). - Supported and tested Python versions updated to py3.10 - py3.12 [#38](https://github.com/arup-group/osmox/pull/38). - Majority of documentation moved from README to dedicated documentation site: https://arup-group.github.io/osmox [#40](https://github.com/arup-group/osmox/pull/40). @@ -30,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Rendered JSON schema in documentation [#56](https://github.com/arup-group/osmox/pull/56). - Activity infilling can use a geospatial point data source to fill OSM `landuse` areas, e.g. postcode data points. - Activity infilling can take place in target areas that have existing facilities, using the `max_existing_acts_fraction` argument to set the area that existing facilities can already take up in the target geometry while still allowing infilling. diff --git a/configs/config_UK.json b/configs/config_UK.json index 7c63fee..5eb22e7 100644 --- a/configs/config_UK.json +++ b/configs/config_UK.json @@ -1,180 +1,181 @@ { - "filter": { - "building": [ - "apartments", - "bungalow", - "detached", - "dormitory", - "hotel", - "house", - "residential", - "semidetached_house", - "terrace", - "commercial", - "retail", - "supermarket", - "industrial", - "office", - "warehouse", - "bakehouse", - "firestation", - "government", - "cathedral", - "chapel", - "church", - "mosque", - "other", - "shrine", - "synagogue", - "temple", - "hospital", - "kindergarden", - "school", - "university", - "college", - "sports_hall", - "stadium", - "yes" - ], - "public_transport": ["*"], - "highway": ["bus_stop"] - }, + "$schema": "../src/osmox/schema.json", + "filter": { + "building": [ + "apartments", + "bungalow", + "detached", + "dormitory", + "hotel", + "house", + "residential", + "semidetached_house", + "terrace", + "commercial", + "retail", + "supermarket", + "industrial", + "office", + "warehouse", + "bakehouse", + "firestation", + "government", + "cathedral", + "chapel", + "church", + "mosque", + "other", + "shrine", + "synagogue", + "temple", + "hospital", + "kindergarden", + "school", + "university", + "college", + "sports_hall", + "stadium", + "yes" + ], + "public_transport": ["*"], + "highway": ["bus_stop"] + }, - "object_features": ["units", "levels", "area", "floor_area"], + "object_features": ["units", "levels", "area", "floor_area"], - "distance_to_nearest": ["transit", "education", "shop", "medical"], + "distance_to_nearest": ["transit", "education", "shop", "medical"], - "default_tags": [["building", "residential"]], + "default_tags": [["building", "residential"]], - "activity_mapping": { - "public_transport": { - "*": ["transit"] - }, - "highway": { - "bus_stop": ["transit"] - }, - "building": { - "apartments": ["home"], - "bungalow": ["home"], - "detached": ["home"], - "dormitory": ["home"], - "hotel": ["home"], - "house": ["home"], - "residential": ["home"], - "semidetached_house": ["home"], - "terrace": ["home"], - "commercial": ["shop", "work"], - "retail": ["shop", "work"], - "supermarket": ["shop", "work"], - "industrial": ["work"], - "office": ["work"], - "warehouse": ["work"], - "bakehouse": ["work"], - "firestation": ["work"], - "government": ["work"], - "cathedral": ["other"], - "chapel": ["other"], - "church": ["other"], - "mosque": ["other"], - "other": ["other"], - "shrine": ["other"], - "synagogue": ["other"], - "temple": ["other"], - "hospital": ["medical", "work"], - "kindergarden": ["education", "work"], - "school": ["education", "work"], - "university": ["education", "work"], - "college": ["education", "work"], - "sports_hall": ["other", "work"], - "stadium": ["other", "work"] - }, - "amenity": { - "bar": ["visit", "work"], - "pub": ["visit", "work"], - "cafe": ["visit", "work", "shop"], - "fast_food": ["work", "shop"], - "food_court": ["work", "shop"], - "ice_cream": ["work", "shop"], - "restaurant": ["work", "shop"], - "college": ["education", "work"], - "kindergarten": ["education", "work"], - "language_school": ["education", "work"], - "library": ["other", "work"], - "music_school": ["education", "work"], - "school": ["education", "work"], - "university": ["education", "work"], - "bank": ["other", "work"], - "clinic": ["medical", "work"], - "dentist": ["medical", "work"], - "doctors": ["medical", "work"], - "hospital": ["medical", "work"], - "pharmacy": ["shop", "work"], - "visit_facility": ["medical", "work"], - "vetinary": ["other", "work"], - "arts_centre": ["other", "work"], - "casino": ["other", "work"], - "cinema": ["other", "work"], - "community_centre": ["other"], - "gambling": ["other", "work"], - "studio": ["other", "work"], - "theatre": ["other", "work"], - "courthouse": ["other", "work"], - "crematorium": ["other", "work"], - "embassy": ["other", "work"], - "fire_station": ["work"], - "funeral_hall": ["other", "work"], - "internet_cafe": ["other", "work"], - "marketplace": ["shop", "work"], - "place_of_worship": ["other"], - "police": ["other", "work"], - "post_box": ["other", "work"], - "post_work": ["other", "work"], - "post_office": ["other", "work"], - "prison": ["other", "work"], - "townhall": ["other", "work"] - }, - "healthcare": { - "*": ["medical", "work"] - }, - "landuse": { - "commercial": ["shop", "work"], - "industrial": ["work"], - "residential": ["home"], - "retail": ["shop", "work"], - "work": ["work"], - "port": ["work"], - "quary": ["work"], - "other": ["other"] - }, - "other": { - "adult_gaming_centre": ["other", "work"], - "amusement_arcade": ["other", "work"], - "beach_resort": ["other"], - "dance": ["other", "work"], - "escape_game": ["other", "work"], - "fishing": ["other"], - "fitness_centre": ["other", "work"], - "fitness_station": ["other"], - "garden": ["other"], - "horse_riding": ["other", "work"], - "ice_rink": ["other", "work"], - "marina": ["other", "work"], - "miniature_golf": ["other"], - "nature_reserve": ["other"], - "park": ["other"], - "pitch": ["other"], - "playground": ["other"], - "sports_centre": ["other", "work"], - "stadium": ["other", "work"], - "swimming_pool": ["other", "work"], - "track": ["other"], - "water_park": ["other", "work"] - }, - "office": { - "*": ["work"] - }, - "shop": { - "*": ["shop", "work"] - } - } + "activity_mapping": { + "public_transport": { + "*": ["transit"] + }, + "highway": { + "bus_stop": ["transit"] + }, + "building": { + "apartments": ["home"], + "bungalow": ["home"], + "detached": ["home"], + "dormitory": ["home"], + "hotel": ["home"], + "house": ["home"], + "residential": ["home"], + "semidetached_house": ["home"], + "terrace": ["home"], + "commercial": ["shop", "work"], + "retail": ["shop", "work"], + "supermarket": ["shop", "work"], + "industrial": ["work"], + "office": ["work"], + "warehouse": ["work"], + "bakehouse": ["work"], + "firestation": ["work"], + "government": ["work"], + "cathedral": ["other"], + "chapel": ["other"], + "church": ["other"], + "mosque": ["other"], + "other": ["other"], + "shrine": ["other"], + "synagogue": ["other"], + "temple": ["other"], + "hospital": ["medical", "work"], + "kindergarden": ["education", "work"], + "school": ["education", "work"], + "university": ["education", "work"], + "college": ["education", "work"], + "sports_hall": ["other", "work"], + "stadium": ["other", "work"] + }, + "amenity": { + "bar": ["visit", "work"], + "pub": ["visit", "work"], + "cafe": ["visit", "work", "shop"], + "fast_food": ["work", "shop"], + "food_court": ["work", "shop"], + "ice_cream": ["work", "shop"], + "restaurant": ["work", "shop"], + "college": ["education", "work"], + "kindergarten": ["education", "work"], + "language_school": ["education", "work"], + "library": ["other", "work"], + "music_school": ["education", "work"], + "school": ["education", "work"], + "university": ["education", "work"], + "bank": ["other", "work"], + "clinic": ["medical", "work"], + "dentist": ["medical", "work"], + "doctors": ["medical", "work"], + "hospital": ["medical", "work"], + "pharmacy": ["shop", "work"], + "visit_facility": ["medical", "work"], + "vetinary": ["other", "work"], + "arts_centre": ["other", "work"], + "casino": ["other", "work"], + "cinema": ["other", "work"], + "community_centre": ["other"], + "gambling": ["other", "work"], + "studio": ["other", "work"], + "theatre": ["other", "work"], + "courthouse": ["other", "work"], + "crematorium": ["other", "work"], + "embassy": ["other", "work"], + "fire_station": ["work"], + "funeral_hall": ["other", "work"], + "internet_cafe": ["other", "work"], + "marketplace": ["shop", "work"], + "place_of_worship": ["other"], + "police": ["other", "work"], + "post_box": ["other", "work"], + "post_work": ["other", "work"], + "post_office": ["other", "work"], + "prison": ["other", "work"], + "townhall": ["other", "work"] + }, + "healthcare": { + "*": ["medical", "work"] + }, + "landuse": { + "commercial": ["shop", "work"], + "industrial": ["work"], + "residential": ["home"], + "retail": ["shop", "work"], + "work": ["work"], + "port": ["work"], + "quary": ["work"], + "other": ["other"] + }, + "other": { + "adult_gaming_centre": ["other", "work"], + "amusement_arcade": ["other", "work"], + "beach_resort": ["other"], + "dance": ["other", "work"], + "escape_game": ["other", "work"], + "fishing": ["other"], + "fitness_centre": ["other", "work"], + "fitness_station": ["other"], + "garden": ["other"], + "horse_riding": ["other", "work"], + "ice_rink": ["other", "work"], + "marina": ["other", "work"], + "miniature_golf": ["other"], + "nature_reserve": ["other"], + "park": ["other"], + "pitch": ["other"], + "playground": ["other"], + "sports_centre": ["other", "work"], + "stadium": ["other", "work"], + "swimming_pool": ["other", "work"], + "track": ["other"], + "water_park": ["other", "work"] + }, + "office": { + "*": ["work"] + }, + "shop": { + "*": ["shop", "work"] + } + } } \ No newline at end of file diff --git a/configs/config_UKfill.json b/configs/config_UKfill.json index d6fab70..56cfc0f 100644 --- a/configs/config_UKfill.json +++ b/configs/config_UKfill.json @@ -1,192 +1,193 @@ { - "filter": { - "building": [ - "apartments", - "bungalow", - "detached", - "dormitory", - "hotel", - "house", - "residential", - "semidetached_house", - "terrace", - "commercial", - "retail", - "supermarket", - "industrial", - "office", - "warehouse", - "bakehouse", - "firestation", - "government", - "cathedral", - "chapel", - "church", - "mosque", - "other", - "shrine", - "synagogue", - "temple", - "hospital", - "kindergarden", - "school", - "university", - "college", - "sports_hall", - "stadium", - "yes" - ], - "public_transport": ["*"], - "highway": ["bus_stop"] - }, + "$schema": "../src/osmox/schema.json", + "filter": { + "building": [ + "apartments", + "bungalow", + "detached", + "dormitory", + "hotel", + "house", + "residential", + "semidetached_house", + "terrace", + "commercial", + "retail", + "supermarket", + "industrial", + "office", + "warehouse", + "bakehouse", + "firestation", + "government", + "cathedral", + "chapel", + "church", + "mosque", + "other", + "shrine", + "synagogue", + "temple", + "hospital", + "kindergarden", + "school", + "university", + "college", + "sports_hall", + "stadium", + "yes" + ], + "public_transport": ["*"], + "highway": ["bus_stop"] + }, - "object_features": ["units", "levels", "area", "floor_area"], + "object_features": ["units", "levels", "area", "floor_area"], - "distance_to_nearest": ["transit"], + "distance_to_nearest": ["transit"], - "fill_missing_activities": - [ - { - "area_tags": [["landuse", "residential"]], - "required_acts": ["home"], - "new_tags": [["building", "house"]], - "size": [10, 10], - "spacing": [25, 50] - } + "fill_missing_activities": + [ + { + "area_tags": [["landuse", "residential"]], + "required_acts": ["home"], + "new_tags": [["building", "house"]], + "size": [10, 10], + "spacing": [25, 50] + } - ], + ], - "default_tags": [["building", "residential"]], + "default_tags": [["building", "residential"]], - "activity_mapping": { - "public_transport": { - "*": ["transit"] - }, - "highway": { - "bus_stop": ["transit"] - }, - "building": { - "apartments": ["home"], - "bungalow": ["home"], - "detached": ["home"], - "dormitory": ["home"], - "hotel": ["home"], - "house": ["home"], - "residential": ["home"], - "semidetached_house": ["home"], - "terrace": ["home"], - "commercial": ["shop", "work"], - "retail": ["shop", "work"], - "supermarket": ["shop", "work"], - "industrial": ["work"], - "office": ["work"], - "warehouse": ["work"], - "bakehouse": ["work"], - "firestation": ["work"], - "government": ["work"], - "cathedral": ["other"], - "chapel": ["other"], - "church": ["other"], - "mosque": ["other"], - "other": ["other"], - "shrine": ["other"], - "synagogue": ["other"], - "temple": ["other"], - "hospital": ["medical", "work"], - "kindergarden": ["education", "work"], - "school": ["education", "work"], - "university": ["education", "work"], - "college": ["education", "work"], - "sports_hall": ["other", "work"], - "stadium": ["other", "work"] - }, - "amenity": { - "bar": ["visit", "work"], - "pub": ["visit", "work"], - "cafe": ["visit", "work", "shop"], - "fast_food": ["work", "shop"], - "food_court": ["work", "shop"], - "ice_cream": ["work", "shop"], - "restaurant": ["work", "shop"], - "college": ["education", "work"], - "kindergarten": ["education", "work"], - "language_school": ["education", "work"], - "library": ["other", "work"], - "music_school": ["education", "work"], - "school": ["education", "work"], - "university": ["education", "work"], - "bank": ["other", "work"], - "clinic": ["medical", "work"], - "dentist": ["medical", "work"], - "doctors": ["medical", "work"], - "hospital": ["medical", "work"], - "pharmacy": ["shop", "work"], - "visit_facility": ["medical", "work"], - "vetinary": ["other", "work"], - "arts_centre": ["other", "work"], - "casino": ["other", "work"], - "cinema": ["other", "work"], - "community_centre": ["other"], - "gambling": ["other", "work"], - "studio": ["other", "work"], - "theatre": ["other", "work"], - "courthouse": ["other", "work"], - "crematorium": ["other", "work"], - "embassy": ["other", "work"], - "fire_station": ["work"], - "funeral_hall": ["other", "work"], - "internet_cafe": ["other", "work"], - "marketplace": ["shop", "work"], - "place_of_worship": ["other"], - "police": ["other", "work"], - "post_box": ["other", "work"], - "post_work": ["other", "work"], - "post_office": ["other", "work"], - "prison": ["other", "work"], - "townhall": ["other", "work"] - }, - "healthcare": { - "*": ["medical", "work"] - }, - "landuse": { - "commercial": ["shop", "work"], - "industrial": ["work"], - "residential": ["home"], - "retail": ["shop", "work"], - "work": ["work"], - "port": ["work"], - "quary": ["work"], - "other": ["other"] - }, - "other": { - "adult_gaming_centre": ["other", "work"], - "amusement_arcade": ["other", "work"], - "beach_resort": ["other"], - "dance": ["other", "work"], - "escape_game": ["other", "work"], - "fishing": ["other"], - "fitness_centre": ["other", "work"], - "fitness_station": ["other"], - "garden": ["other"], - "horse_riding": ["other", "work"], - "ice_rink": ["other", "work"], - "marina": ["other", "work"], - "miniature_golf": ["other"], - "nature_reserve": ["other"], - "park": ["other"], - "pitch": ["other"], - "playground": ["other"], - "sports_centre": ["other", "work"], - "stadium": ["other", "work"], - "swimming_pool": ["other", "work"], - "track": ["other"], - "water_park": ["other", "work"] - }, - "office": { - "*": ["work"] - }, - "shop": { - "*": ["shop", "work"] - } - } + "activity_mapping": { + "public_transport": { + "*": ["transit"] + }, + "highway": { + "bus_stop": ["transit"] + }, + "building": { + "apartments": ["home"], + "bungalow": ["home"], + "detached": ["home"], + "dormitory": ["home"], + "hotel": ["home"], + "house": ["home"], + "residential": ["home"], + "semidetached_house": ["home"], + "terrace": ["home"], + "commercial": ["shop", "work"], + "retail": ["shop", "work"], + "supermarket": ["shop", "work"], + "industrial": ["work"], + "office": ["work"], + "warehouse": ["work"], + "bakehouse": ["work"], + "firestation": ["work"], + "government": ["work"], + "cathedral": ["other"], + "chapel": ["other"], + "church": ["other"], + "mosque": ["other"], + "other": ["other"], + "shrine": ["other"], + "synagogue": ["other"], + "temple": ["other"], + "hospital": ["medical", "work"], + "kindergarden": ["education", "work"], + "school": ["education", "work"], + "university": ["education", "work"], + "college": ["education", "work"], + "sports_hall": ["other", "work"], + "stadium": ["other", "work"] + }, + "amenity": { + "bar": ["visit", "work"], + "pub": ["visit", "work"], + "cafe": ["visit", "work", "shop"], + "fast_food": ["work", "shop"], + "food_court": ["work", "shop"], + "ice_cream": ["work", "shop"], + "restaurant": ["work", "shop"], + "college": ["education", "work"], + "kindergarten": ["education", "work"], + "language_school": ["education", "work"], + "library": ["other", "work"], + "music_school": ["education", "work"], + "school": ["education", "work"], + "university": ["education", "work"], + "bank": ["other", "work"], + "clinic": ["medical", "work"], + "dentist": ["medical", "work"], + "doctors": ["medical", "work"], + "hospital": ["medical", "work"], + "pharmacy": ["shop", "work"], + "visit_facility": ["medical", "work"], + "vetinary": ["other", "work"], + "arts_centre": ["other", "work"], + "casino": ["other", "work"], + "cinema": ["other", "work"], + "community_centre": ["other"], + "gambling": ["other", "work"], + "studio": ["other", "work"], + "theatre": ["other", "work"], + "courthouse": ["other", "work"], + "crematorium": ["other", "work"], + "embassy": ["other", "work"], + "fire_station": ["work"], + "funeral_hall": ["other", "work"], + "internet_cafe": ["other", "work"], + "marketplace": ["shop", "work"], + "place_of_worship": ["other"], + "police": ["other", "work"], + "post_box": ["other", "work"], + "post_work": ["other", "work"], + "post_office": ["other", "work"], + "prison": ["other", "work"], + "townhall": ["other", "work"] + }, + "healthcare": { + "*": ["medical", "work"] + }, + "landuse": { + "commercial": ["shop", "work"], + "industrial": ["work"], + "residential": ["home"], + "retail": ["shop", "work"], + "work": ["work"], + "port": ["work"], + "quary": ["work"], + "other": ["other"] + }, + "other": { + "adult_gaming_centre": ["other", "work"], + "amusement_arcade": ["other", "work"], + "beach_resort": ["other"], + "dance": ["other", "work"], + "escape_game": ["other", "work"], + "fishing": ["other"], + "fitness_centre": ["other", "work"], + "fitness_station": ["other"], + "garden": ["other"], + "horse_riding": ["other", "work"], + "ice_rink": ["other", "work"], + "marina": ["other", "work"], + "miniature_golf": ["other"], + "nature_reserve": ["other"], + "park": ["other"], + "pitch": ["other"], + "playground": ["other"], + "sports_centre": ["other", "work"], + "stadium": ["other", "work"], + "swimming_pool": ["other", "work"], + "track": ["other"], + "water_park": ["other", "work"] + }, + "office": { + "*": ["work"] + }, + "shop": { + "*": ["shop", "work"] + } + } } diff --git a/configs/config_UKno_transit.json b/configs/config_UKno_transit.json index 1fabd1e..a654069 100644 --- a/configs/config_UKno_transit.json +++ b/configs/config_UKno_transit.json @@ -1,4 +1,5 @@ { + "$schema": "../src/osmox/schema.json", "filter": { "building": [ "apartments", @@ -34,7 +35,7 @@ "sports_hall", "stadium", "yes" - ] + ] }, "object_features": ["units", "levels", "area", "floor_area"], diff --git a/configs/example.json b/configs/example.json index bde7ba0..5eb22e7 100644 --- a/configs/example.json +++ b/configs/example.json @@ -1,4 +1,5 @@ { + "$schema": "../src/osmox/schema.json", "filter": { "building": [ "apartments", diff --git a/docs/config.md b/docs/config.md index da6c76f..f21beb9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -9,6 +9,10 @@ osmox validate OSMOX features and associated configurations are described in the sections below. +!!! info "See also" + We use a configuration schema to validate the configuration files you input - malformed files will cause OSMOX to fail fast and early. + You can read a detailed description of the configuration as described in the schema [here](schema.md). + !!! warning These configs get very long - see the full examples in the `configs` to get an idea. diff --git a/docs/static/hooks.py b/docs/static/hooks.py index f5d5d1a..a07a259 100644 --- a/docs/static/hooks.py +++ b/docs/static/hooks.py @@ -1,8 +1,10 @@ import tempfile from pathlib import Path +import jsonschema2md import mkdocs.plugins from mkdocs.structure.files import File +from osmox.config import SCHEMA TEMPDIR = tempfile.TemporaryDirectory() # Add to this list if you want to ignore any other source files from the documentation API reference @@ -16,7 +18,7 @@ def on_files(files: list, config: dict, **kwargs): for file in Path("./resources").glob("**/*.*"): files.append(_new_file(file, config)) files.append(_new_file(Path("./CHANGELOG.md"), config)) - + files.append(_schema_to_md(SCHEMA, config)) api_nav = _api_gen(files, config) _update_nav(api_nav, config) @@ -137,6 +139,34 @@ def _get_nav_list(nav: list[dict | str], ref: str) -> list: return nav_ref[ref] +def _schema_to_md(schema: dict, config: dict) -> File: + """Parse a JSON schema to Markdown, edit some lines, then dump to file + + Args: + schema (dict): Path to the YAML schema to be converted. + """ + output_file = "schema.md" + output_full_filepath = Path(TEMPDIR.name) / output_file + output_full_filepath.parent.mkdir(exist_ok=True, parents=True) + + parser = jsonschema2md.Parser() + parser.tab_size = 4 + + lines = parser.parse_schema(schema) + + assert lines[2] == "## Properties\n\n" + del lines[2] + + output_full_filepath.write_text("\n".join(lines)) + + return File( + path=output_file, + src_dir=TEMPDIR.name, + dest_dir=config["site_dir"], + use_directory_urls=config["use_directory_urls"], + ) + + @mkdocs.plugins.event_priority(-100) def on_post_build(**kwargs): """After mkdocs has finished building the docs, remove the temporary directory of markdown files. diff --git a/mkdocs.yml b/mkdocs.yml index 2d5d74c..56e4f9c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,7 @@ nav: - quick_start.md - osmox_run.md - config.md + - Configuration schema: schema.md - Contributing: contributing.md - Changelog: CHANGELOG.md - Reference: diff --git a/pyproject.toml b/pyproject.toml index 7851a63..e677e0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ where = ["src"] # Add file globs from the source code directory if they include non-py files that should be packaged # E.g. "fixtures/**/*" # "py.typed" is added by default. It allows `mypy` to register the package as having type hints. -osmox = ["py.typed"] +osmox = ["py.typed", "schema.json"] [build-system] diff --git a/requirements/base.txt b/requirements/base.txt index 7e45717..ea4a3a0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,5 @@ click < 9 +jsonschema >= 4, < 5 geopandas >= 0.13, < 0.15 osmium < 3.7 pandas >= 1.5, < 3 diff --git a/requirements/dev.txt b/requirements/dev.txt index 56fc3e3..2bd30f9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,4 +1,5 @@ cruft >= 2, < 3 +jsonschema2md >= 1.1, < 2 mike >= 2, < 3 mkdocs < 2 mkdocs-material >= 9.4, < 10 diff --git a/src/osmox/build.py b/src/osmox/build.py index f67f7cb..9ed404d 100644 --- a/src/osmox/build.py +++ b/src/osmox/build.py @@ -15,7 +15,6 @@ OSMTag = namedtuple("OSMtag", "key value") OSMObject = namedtuple("OSMobject", "idx, activity_tags, geom") -AVAILABLE_FEATURES = ["area", "levels", "floor_area", "units", "transit_distance"] class Object: @@ -62,7 +61,7 @@ def __init__(self, idx, osm_tags, activity_tags, geom) -> None: self.activity_tags = activity_tags self.geom = geom self.activities = None - self.features = {} + self.features: dict = {} def add_features(self, features): available = { diff --git a/src/osmox/config.py b/src/osmox/config.py index d243f43..c2bdf71 100644 --- a/src/osmox/config.py +++ b/src/osmox/config.py @@ -1,9 +1,13 @@ +import importlib import json import logging -from osmox import build +import jsonschema logger = logging.getLogger(__name__) +SCHEMA_FILE = importlib.resources.files("osmox") / "schema.json" +with importlib.resources.as_file(SCHEMA_FILE) as f: + SCHEMA = json.load(f.open()) def load(config_path): @@ -12,6 +16,13 @@ def load(config_path): return json.load(read_file) +def validate(config): + validator = jsonschema.validators.validator_for(SCHEMA) + validator.META_SCHEMA["unevaluatedProperties"] = False + validator.check_schema(SCHEMA) + jsonschema.validate(config, SCHEMA) + + def get_acts(config): activity_config = config.get("activity_mapping") if activity_config: @@ -40,48 +51,22 @@ def get_tags(config): def validate_activity_config(config): + validate(config) + keys, tags = get_tags(config) + logger.info(f"Configured OSM tag keys: {sorted(keys)}") - filter_config = config.get("filter") - if not filter_config: - logger.error("No 'filter' found in config.") - - else: - keys, tags = get_tags(config) - logger.warning(f"Configured OSM tag keys: {sorted(keys)}") - - activity_mapping = config.get("activity_mapping") - if activity_mapping: - acts = get_acts(config) - logger.warning(f"Configured activities: {sorted(acts)}") - - else: - logger.error("No 'activity_config' found in config.") - - if config.get("object_features"): - available = set(build.AVAILABLE_FEATURES) - unsupported = set(config.get("object_features")) - available - if unsupported: - logger.error( - f"Unsupported features in config: {unsupported}, please choose from: {available}." - ) + acts = get_acts(config) + logger.info(f"Configured activities: {sorted(acts)}") if "distance_to_nearest" in config: - acts = get_acts(config=config) - for act in config["distance_to_nearest"]: - if act not in acts: - logger.error(f"'Distance to nearest' has a non-configured activity '{act}'") + act_diff = set(config["distance_to_nearest"]).difference(acts) + if act_diff: + raise ValueError(f"'Distance to nearest' has non-configured activities: {act_diff}") if "fill_missing_activities" in config: - required_keys = {"area_tags", "required_acts", "new_tags", "size", "spacing"} - acts = get_acts(config=config) - for group in config["fill_missing_activities"]: - keys = list(group) - for k in required_keys: - if k not in keys: - logger.error(f"'Fill missing activities' group is missing required key: {k}") - for act in group.get("required_acts", []): - if act not in acts: - logger.error( - f"'Fill missing activities' group has a non-configured activity '{act}'" - ) + act_diff = set(group.get("required_acts", [])).difference(acts) + if act_diff: + raise ValueError( + f"'Fill missing activities' group has non-configured activities: {act_diff}" + ) diff --git a/src/osmox/schema.json b/src/osmox/schema.json new file mode 100644 index 0000000..a1ebe20 --- /dev/null +++ b/src/osmox/schema.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OSMOX config schema", + "description": "Schema for the OSMOX config JSON file.", + "type": "object", + "additionalProperties": false, + "required": [ + "filter", + "activity_mapping", + "object_features" + ], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to OSMOX schema (URL or filepath). Setting this value will enable your IDE to highlight issues with your configuration." + }, + "filter": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^\\w+$": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(\\*|\\w+)$" + }, + "description": "List of tag values to filter. If all values should be used, use the '*' wildcard value." + } + }, + "description": "Filter OSM data to consider only these tags." + }, + "object_features": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "area", + "levels", + "floor_area", + "units", + "transit_distance" + ] + }, + "description": "Features of filtered OSM objects to keep in final facility dataset." + }, + "distance_to_nearest": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\w+$" + }, + "description": "For every facility, add distance to nearest activity for every activity in this list. Each activity distance will be provided as a new data column." + }, + "default_tags": { + "type": "array", + "description": "For any filtered OSM object without any tags, use these tags as default.", + "items": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\w+$" + }, + "description": "Tag [key, value] pairs.", + "minItems": 2, + "maxItems": 2 + } + }, + "activity_mapping": { + "type": "object", + "description": "Map filtered OSM objects to OSMOX activities.", + "additionalProperties": false, + "patternProperties": { + "^\\w+$": { + "type": "object", + "additionalProperties": false, + "description": "OSM tag key name", + "patternProperties": { + "^(\\*|\\w+)$": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\w+$" + }, + "description": "Key: OSM object tag values, Value: list of activities to map to. If all tag values should be mapped to the same activities, use the `*` wildcard as the key." + } + } + } + } + }, + "fill_missing_activities": { + "type": "array", + "description": "Fill tagged areas found in OSM with points according to a given method.", + "items": { + "type": "object", + "additionalProperties": false, + "description": "Activity filling configuration. Filling will take place in the order they are placed in the list.", + "required": [ + "area_tags", + "required_acts", + "new_tags" + ], + "allOf": [ + { + "if": { + "properties": { + "fill_method": { + "const": "spacing" + } + } + }, + "then": { + "required": [ + "spacing" + ] + } + }, + { + "if": { + "properties": { + "fill_method": { + "const": "point_source" + } + }, + "required": [ + "fill_method" + ] + }, + "then": { + "required": [ + "point_source" + ] + }, + "else": { + "required": [ + "spacing" + ] + } + } + ], + "properties": { + "area_tags": { + "type": "array", + "description": "Key:value pairs to filter on for infilling, often land use area tags (e.g. ['landuse', 'residential']", + "default": [ + "landuse", + "residential" + ], + "minItems": 1, + "items": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\w+$" + }, + "description": "Tag [key, value] pairs.", + "minItems": 2, + "maxItems": 2 + } + }, + "required_acts": { + "description": "Single activity or list of activities to look for in the filtered area. If present, infilling will not be undertaken (see `max_existing_acts_fraction` to set a threshold for when the presence of required activities will stop infilling).", + "default": [ + "home" + ], + "oneOf": [ + { + "type": "string", + "pattern": "^\\w+$", + "description": "Activity name." + }, + { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\w+$" + }, + "description": "Activity names.", + "minItems": 1 + } + ] + }, + "new_tags": { + "type": "array", + "description": "New OSM tags to assign to infilled facilities. These will be used to map to OSMOX activities using `activity_mapping`.", + "default": [ + "building", + "house" + ], + "items": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\w+$" + }, + "description": "Tag [key, value] pairs.", + "minItems": 2, + "maxItems": 2 + } + }, + "size": { + "type": "array", + "description": "Footprint of infilled facilities as [length/x, width/y] values, extending from the bottom-left of each point. Will be used to define the `area` feature of the infilled facilities.", + "default": [ + 10, + 10 + ], + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "spacing": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Spacing between infilled facilities as [length/x, width/y] values from the bottom-left of each point. Will only be used if `fill_method` is `spacing`.", + "default": [ + 25, + 25 + ], + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "fill_method": { + "type": "string", + "description": "Choice of infilling method. `spacing` will space infilled points evenly across each filtered area. `point_source` will use a user-defined dataset of points (e.g. addresses) to infill.", + "default": "spacing", + "enum": [ + "spacing", + "point_source" + ] + }, + "point_source": { + "type": "string", + "description": "Path to geospatial dataset of points to use for infilling. Can be any format in [`geparquet`, `geojson`, `geopackage`]. Will only be used if `fill_method` is `point_source`" + }, + "max_existing_acts_fraction": { + "type": "number", + "description": "Fraction of filtered area that can be occupied by existing required activities (given in `required_acts`) before infilling of that area will be skipped. Uses the `area` feature of facilities or the area inferred by `size`, if the facility is given only be a point.", + "default": 0, + "minimum": 0 + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/test_config_infill.json b/tests/fixtures/test_config_infill.json index 0e51c61..691c938 100644 --- a/tests/fixtures/test_config_infill.json +++ b/tests/fixtures/test_config_infill.json @@ -159,15 +159,22 @@ }, "office": { "*": ["work"] + }, + "public_transport": { + "*": ["transit"] + }, + "highway": { + "bus_stop": ["transit"] } }, - "fill_missing_activities": + "fill_missing_activities": [ { "area_tags": [["landuse", "residential"]], "required_acts": ["home"], "new_tags": [["building", "house"]], + "fill_method": "spacing", "size": [10, 10], "spacing": [50, 50] } diff --git a/tests/test_config.py b/tests/test_config.py index 3538d3c..5c17af0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,7 @@ +import logging import os +import jsonschema import pytest from osmox import config @@ -41,6 +43,21 @@ def valid_config(): } +@pytest.fixture +def strict_schema_validator(): + strict = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/strict", + "$ref": "https://json-schema.org/draft/2020-12/schema", + "unevaluatedProperties": False, + } + return jsonschema.validators.validator_for(strict) + + +def test_schema(strict_schema_validator): + strict_schema_validator.check_schema(config.SCHEMA) + + def test_load_config(): cnfg = config.load(test_config_path) assert cnfg @@ -72,44 +89,57 @@ def test_get_tags(valid_config): def test_valid_config_logging(caplog, valid_config): + caplog.set_level(logging.INFO) config.validate_activity_config(valid_config) assert "['delivery', 'food_shop', 'home', 'shop', 'social', 'transit', 'work']" in caplog.text -def test_config_with_missing_filter_logging(caplog, valid_config): +def test_config_with_missing_filter_logging(valid_config): valid_config.pop("filter") - config.validate_activity_config(valid_config) - assert "No 'filter' found in config." in caplog.text + with pytest.raises( + jsonschema.exceptions.ValidationError, match="'filter' is a required property" + ): + config.validate_activity_config(valid_config) -def test_config_with_missing_activity_mapping_logging(caplog, valid_config): +def test_config_with_missing_activity_mapping_logging(valid_config): valid_config.pop("activity_mapping") - config.validate_activity_config(valid_config) - assert "No 'activity_config' found in config." in caplog.text + + with pytest.raises( + jsonschema.exceptions.ValidationError, match="'activity_mapping' is a required property" + ): + config.validate_activity_config(valid_config) -def test_config_with_unsupported_object_features_logging(caplog, valid_config): +def test_config_with_unsupported_object_features_logging(valid_config): valid_config["object_features"].append("invalid_feature") - config.validate_activity_config(valid_config) - assert "Unsupported features in config: {'invalid_feature'}," in caplog.text + with pytest.raises( + jsonschema.exceptions.ValidationError, match="'invalid_feature' is not one of" + ): + config.validate_activity_config(valid_config) -def test_config_with_unsupported_distance_to_nearest_activity_logging(caplog, valid_config): +def test_config_with_unsupported_distance_to_nearest_activity_logging(valid_config): valid_config["distance_to_nearest"].append("invalid_activity") - config.validate_activity_config(valid_config) - assert "'Distance to nearest' has a non-configured activity 'invalid_activity'" in caplog.text + with pytest.raises( + ValueError, + match="'Distance to nearest' has non-configured activities: {'invalid_activity'}", + ): + config.validate_activity_config(valid_config) -def test_config_with_missing_fill_missing_activities_key_logging(caplog, valid_config): +def test_config_with_missing_fill_missing_activities_key_logging(valid_config): valid_config["fill_missing_activities"][0].pop("required_acts") - config.validate_activity_config(valid_config) - assert "'Fill missing activities' group is missing required key: required_acts" in caplog.text + with pytest.raises( + jsonschema.exceptions.ValidationError, match="'required_acts' is a required property" + ): + config.validate_activity_config(valid_config) -def test_config_with_invalid_activity_for_fill_missing_activities_logging(caplog, valid_config): +def test_config_with_invalid_activity_for_fill_missing_activities_logging(valid_config): valid_config["fill_missing_activities"][0]["required_acts"].append("invalid_activity") - config.validate_activity_config(valid_config) - assert ( - "'Fill missing activities' group has a non-configured activity 'invalid_activity'" - in caplog.text - ) + with pytest.raises( + ValueError, + match="'Fill missing activities' group has non-configured activities: {'invalid_activity'}", + ): + config.validate_activity_config(valid_config)