From c3875d3c8623ddcce1da7d009b09bc3c9014fc07 Mon Sep 17 00:00:00 2001 From: Laurens <laurens@gfz-potsdam.de> Date: Wed, 10 Jul 2024 14:56:50 +0200 Subject: [PATCH] Convert stories to the GEM Taxonomy height attribute --- .gitlab-ci.yml | 16 +- .../height_and_floorspace.py | 160 ++++++++++++++++++ .../02_process/height_and_floorspace/rule.xml | 13 ++ .../stories_and_floorspace/rule.xml | 13 -- .../stories_and_floorspace.py | 73 -------- tests/__init__.py | 17 ++ tests/conftest.py | 48 ++++++ tests/test_height_and_floorspace_rule.py | 129 ++++++++++++++ 8 files changed, 381 insertions(+), 88 deletions(-) create mode 100644 building/02_process/height_and_floorspace/height_and_floorspace.py create mode 100644 building/02_process/height_and_floorspace/rule.xml delete mode 100644 building/02_process/stories_and_floorspace/rule.xml delete mode 100644 building/02_process/stories_and_floorspace/stories_and_floorspace.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_height_and_floorspace_rule.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 51cc9e0..cfb2be1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,11 +11,11 @@ cache: - venv/ stages: - - linters + - tests - release linters: - stage: linters + stage: tests before_script: - python3 -V - pip3 install virtualenv @@ -26,6 +26,18 @@ linters: - flake8 --max-line-length=$LINE_LENGTH $SOURCES - black --check --line-length $LINE_LENGTH $SOURCES +tests: + stage: tests + before_script: + - python3 -V + - pip3 install virtualenv + - virtualenv venv + - source venv/bin/activate + - pip3 install pytest + - pip3 install https://git.gfz-potsdam.de/globaldynamicexposure/libraries/rule-lib/-/archive/main/rule-lib-main.zip + script: + - pytest tests + release_job: stage: release image: registry.gitlab.com/gitlab-org/release-cli:latest diff --git a/building/02_process/height_and_floorspace/height_and_floorspace.py b/building/02_process/height_and_floorspace/height_and_floorspace.py new file mode 100644 index 0000000..d1f24bb --- /dev/null +++ b/building/02_process/height_and_floorspace/height_and_floorspace.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2023-2024: +# Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + + +class HeightAndFloorspaceRule: + def __call__(self, tags, area, *args, **kwargs): + """ + Find the `building:levels` tag in the attributes of the building or one of the building + relations and save this as the number of stories. Calculate the floorspace of the + building, based on the footprint size of the building and the number of stories. + + Args: + tags (dict): + Building tags, such as the building levels or building type. + area (float): + Footprint size of the building. + + Returns: + A dictionary with the number of stories and the floorspace of the building. + """ + + gem_taxonomy_height = [] + + # The number of stories ideally comes from the `building:levels` tags in OpenStreetMap. + # If they are present, the floorspace of a building can be calculated. OpenStreetMap + # buildings can also contain a `height` tag but this tag is only parsed without the + # floorspace being estimated. + try: + stories, floorspace = self.get_stories_and_floorspace_from_osm(tags, area) + # OSM can have unexpected data types and unexpected content in their tagging scheme, + # therefore we do not raise an exception in case of a ValueError, but instead ignore + # the values. + except ValueError: + stories, floorspace = None, None + if stories: + gem_taxonomy_height.append(stories) + + # If any of the number-of-stories and height tags are found in OpenStreetMap, they are + # returned. + if len(gem_taxonomy_height) > 0: + return { + "height": "+".join(gem_taxonomy_height), + "floorspace": floorspace, + } + + return {"height": None, "floorspace": None} + + def get_stories_and_floorspace_from_osm(self, tags: dict, area: float): + """ + Get the number of stories and the floorspace, based on the tags `building:levels`, + `roof:levels`, `building:levels:underground` and `building:min_level` and the footprint + area of the building. The information about parsing the tags comes from + https://wiki.openstreetmap.org/wiki/Key:building:levels. + + Args: + tags (dict): + Building tags, such as the building levels or building type. + area (float): + Footprint size of the building. + + Returns: + A string formatted according to the `height` tag in the GEM Taxonomy. + """ + + from math import ceil + + # Parse the story tags from OpenStreetMap. + main_stories = self.tag_to_float(tags, "building:levels") + roof_stories = self.tag_to_float(tags, "roof:levels") + underground_stories = self.tag_to_float(tags, "building:levels:underground") + min_stories = self.tag_to_float(tags, "building:min_level") + + # Parse the number of main stories and roof stories. + stories = 0 + floorspace = 0 + if main_stories or roof_stories: + if main_stories: + stories += main_stories + floorspace += main_stories * area + if roof_stories: + stories += roof_stories + # Take only 50% of the floorspace for roof stories. + floorspace += roof_stories * area * 0.5 + if min_stories: + stories -= min_stories + floorspace -= min_stories * area + + # Ceil the number of stories. + stories = ceil(stories) + + # Parse the underground story tag. + if underground_stories: + underground_stories = ceil(underground_stories) + floorspace += underground_stories * area + else: + underground_stories = 0 + + # Take a factor of 90% for the floorspace. + floorspace *= 0.9 + + # Check if anything is wrong with the tags. + if stories < 0 or underground_stories < 0: + return None, None + elif stories == 0 and underground_stories == 0: + return None, None + elif stories + underground_stories > 175: + return None, None + + # Create the number-of-stories tag of the height attribute. + story_tags = [] + if stories != 0: + story_tags.append(f"H:{stories}") + if underground_stories != 0: + story_tags.append(f"HBEX:{underground_stories}") + + return "+".join(story_tags), floorspace + + def tag_to_float(self, tags: dict, tag_key: str): + """ + Try to parse a tag as a floating point value. If the value cannot be parsed, return + None. + + Args: + tags (dict): + Building tags, such as the building levels or building type. + tag_key (str): + Name of the tag that should be converted. + + Returns: + Tag value converted to a float. + """ + + # Try to get the tag value. + tag_value = tags.get(tag_key, None) + + # If the tag does not exist, return None. + if tag_value is None or tag_value == "": + return None + + # If the tag does exist, try to convert it to float. If that raises a `ValueError`, + # return None. + try: + return float(tag_value) + except ValueError: + return None diff --git a/building/02_process/height_and_floorspace/rule.xml b/building/02_process/height_and_floorspace/rule.xml new file mode 100644 index 0000000..a3e34be --- /dev/null +++ b/building/02_process/height_and_floorspace/rule.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<rule name="HeightAndFloorspaceRule" category="building"> + <input> + <param type="dict">tags</param> + <param type="float">area</param> + <param type="int">ghsl_characteristics_type</param> + </input> + <function filepath="height_and_floorspace.py"/> + <output> + <param type="str">height</param> + <param type="float">floorspace</param> + </output> +</rule> diff --git a/building/02_process/stories_and_floorspace/rule.xml b/building/02_process/stories_and_floorspace/rule.xml deleted file mode 100644 index 24ce38c..0000000 --- a/building/02_process/stories_and_floorspace/rule.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" ?> -<rule name="StoriesAndFloorspaceRule" category="building"> - <input> - <param type="int">tags</param> - <param type="float">relations</param> - <param type="float">area</param> - </input> - <function filepath="stories_and_floorspace.py"/> - <output> - <param type="float">storeys</param> - <param type="float">floorspace</param> - </output> -</rule> diff --git a/building/02_process/stories_and_floorspace/stories_and_floorspace.py b/building/02_process/stories_and_floorspace/stories_and_floorspace.py deleted file mode 100644 index 847ed97..0000000 --- a/building/02_process/stories_and_floorspace/stories_and_floorspace.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (c) 2023-2024: -# Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ -# -# This program is free software: you can redistribute it and/or modify it -# under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or (at -# your option) any later version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero -# General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see http://www.gnu.org/licenses/. - - -class StoriesAndFloorspaceRule: - def __call__(self, tags, relations, area, *args, **kwargs): - """ - Find the `building:levels` tag in the attributes of the building or one of the building - relations and save this as the number of stories. Calculate the floorspace of the - building, based on the footprint size of the building and the number of stories. - - Args: - tags (dict): - Building tags, such as the building levels or building type. - relations (list): - List of the attributes of all relations that the building is member of. - area (float): - Footprint size of the building. - - Returns: - A dictionary with the number of stories and the floorspace of the building. - """ - - from math import ceil - - all_building_tags = [tags] + [building["tags"] for building in relations] - - for tags in all_building_tags: - story_string = self.get_story_tag(tags) - if story_string is None or story_string == "": - continue - try: - stories = ceil(float(story_string)) - if stories < 1: - raise ValueError("Number of stories cannot be below 1") - elif stories > 175: - raise ValueError("Number of stories cannot be above 175") - - floorspace = stories * area - return {"stories": stories, "floorspace": floorspace} - except ValueError: - continue - return {"stories": None, "floorspace": None} - - @staticmethod - def get_story_tag(tags): - """ - Get the number of stories, if the attribute `building:levels` exist - - Args: - tags: - Building tags, such as the building levels or building type. - - Returns: - Number of stories, if the attribute `building:levels` exist, otherwise `None`. - """ - - return tags.get("building:levels", None) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..561623c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2024: +# Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f88e873 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2024: +# Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +import pytest +import shutil +import tempfile +import os + +from rulelib import Rule + + +@pytest.fixture +def height_and_floorspace_rule(): + """ + Creates a temporary directory, where the `HeightsAndFloorspaceRule` rule is converted to a + ZIP file. This ZIP file is parsed using `rule-lib` and the parsed rule is provided as a + fixture. + """ + + # Get the filepath of the rule + project_dir = os.getenv("CI_PROJECT_DIR", "") + rule_dir = os.path.join(project_dir, "building/02_process/height_and_floorspace") + + # Create a temporary ZIP file from the `height_and_floorspace` rule. + tmp_dir = tempfile.mkdtemp() + file_path = os.path.join(tmp_dir + "height_and_floorspace") + shutil.make_archive(file_path, "zip", rule_dir) + + # Yield rule. + yield Rule.load_rule_from_zip(open(file_path + ".zip", "rb")) + + # Remove temporary ZIP file. + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/tests/test_height_and_floorspace_rule.py b/tests/test_height_and_floorspace_rule.py new file mode 100644 index 0000000..9aa34c7 --- /dev/null +++ b/tests/test_height_and_floorspace_rule.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2024: +# Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + + +import logging +import pytest + +logger = logging.getLogger() + + +def test_height_and_floorspace_rule(height_and_floorspace_rule): + """ + Test the retrieval of number of stories, height and the floorspace, based on the tags from + OSM and GHSL. + """ + + building_information = { + "tags": { + "building:levels": 5, + "roof:levels": 2, + "building:min_level": 1, + "building:levels:underground": 0, + }, + "area": 300.0, + } + result = height_and_floorspace_rule(**building_information) + message = ( + f"The `height` attribute is not correct. " + f"Expected `H:6` but estimated {result['height']}" + ) + assert result["height"] == "H:6", message + message = ( + f"The `floorspace` attribute is not correct. " + f"Expected `1350` but estimated {result['floorspace']}" + ) + assert result["floorspace"] == pytest.approx(1350.0), message + + +def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule): + """ + Test the retrieval of number of stories and the floorspace, based on the tags from OSM. + """ + + rule_function = height_and_floorspace_rule.function.get_stories_and_floorspace_from_osm + test_list = [ + # Check with only building levels. + [{"tags": {"building:levels": 5}, "area": 100.0}, "H:5", 450.0], + # Check with only roof levels. + [{"tags": {"roof:levels": 5}, "area": 100.0}, "H:5", 225.0], + # Check with only levels underground. + [{"tags": {"building:levels:underground": 5}, "area": 100.0}, "HBEX:5", 450.0], + # Check with a wrong type as input. + [{"tags": {"building:levels": "no"}, "area": 100.0}, None, None], + # Check with a negative value. + [{"tags": {"building:levels:underground": -1}, "area": 100.0}, None, None], + # Check with a `building:min_level` value higher than the `building_levels` value. + [{"tags": {"building:levels": 5, "building:min_level": 6}, "area": 100.0}, None, None], + # Check without any tags. + [{"tags": {}, "area": 100.0}, None, None], + # Check with all tags. + [ + { + "tags": { + "building:levels": 4, + "roof:levels": 2, + "building:min_level": 1, + "building:levels:underground": 1, + }, + "area": 100.0, + }, + "H:5+HBEX:1", + 450.0, + ], + ] + + for input_values, correct_height, correct_floorspace in test_list: + height, floorspace = rule_function(**input_values) + + # Check the height attribute + message = ( + f"The `height` attribute is not correct. Expected `{correct_height}` but estimated " + f"`{height}` using {input_values} as input." + ) + assert height == correct_height, message + + # Check the floorspace attribute + message = ( + f"The `floorspace` attribute is not correct. Expected `{correct_floorspace}` but " + f"estimated `{floorspace}` using {input_values} as input." + ) + assert floorspace == correct_floorspace, message + + +def test_tag_to_float(height_and_floorspace_rule): + """ + Test the function `tag_to_float` of the `HeightAndFloorspaceRule. + """ + + rule_function = height_and_floorspace_rule.function.tag_to_float + + tags = { + "correct_01": "1", + "correct_02": "1.5", + "correct_03": "-1", + "incorrect_01": "ground_floor", + "incorrect_02": "1,5", + } + + assert rule_function(tags, "correct_01") == pytest.approx(1.0) + assert rule_function(tags, "correct_02") == pytest.approx(1.5) + assert rule_function(tags, "correct_03") == pytest.approx(-1.0) + assert rule_function(tags, "incorrect_01") is None + assert rule_function(tags, "incorrect_02") is None + assert rule_function(tags, "incorrect_03") is None # Value is not there. -- GitLab