From 4ebb5bef77d7ac14e98a04048a69322215801bf9 Mon Sep 17 00:00:00 2001
From: Laurens Oostwegel <laurens@gfz-potsdam.de>
Date: Fri, 12 Jul 2024 14:13:18 +0200
Subject: [PATCH] Add height from the OpenStreetMap height-related tags

---
 .../height_and_floorspace.py                  |  47 +++++--
 tests/test_height_and_floorspace_rule.py      | 130 ++++++++++++++----
 2 files changed, 141 insertions(+), 36 deletions(-)

diff --git a/building/02_process/height_and_floorspace/height_and_floorspace.py b/building/02_process/height_and_floorspace/height_and_floorspace.py
index d1f24bb..c29239c 100644
--- a/building/02_process/height_and_floorspace/height_and_floorspace.py
+++ b/building/02_process/height_and_floorspace/height_and_floorspace.py
@@ -40,16 +40,14 @@ class HeightAndFloorspaceRule:
         # 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
+        stories, floorspace = self.get_stories_and_floorspace_from_osm(tags, area)
         if stories:
             gem_taxonomy_height.append(stories)
 
+        height = self.get_height_from_osm(tags)
+        if height:
+            gem_taxonomy_height.append(height)
+
         # If any of the number-of-stories and height tags are found in OpenStreetMap, they are
         # returned.
         if len(gem_taxonomy_height) > 0:
@@ -130,6 +128,36 @@ class HeightAndFloorspaceRule:
 
         return "+".join(story_tags), floorspace
 
+    def get_height_from_osm(self, tags: dict):
+        """
+        Get the height of a building, based on the `height` and `min_height` tags. 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.
+
+        Returns:
+            A string formatted according to the `height` tag in the GEM Taxonomy.
+        """
+
+        # Parse the height tag from OpenStreetMap.
+        height = self.tag_to_float(tags, "height")
+        if not height:
+            return None
+
+        # Check if there is a `min_height` tag.
+        min_height = self.tag_to_float(tags, "min_height")
+        if min_height:
+            height -= min_height
+
+        # Check if the height is higher than 0.
+        if height <= 0:
+            return None
+
+        return f"HHT:{height:.2f}"
+
     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
@@ -152,8 +180,9 @@ class HeightAndFloorspaceRule:
         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.
+        # 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.
         try:
             return float(tag_value)
         except ValueError:
diff --git a/tests/test_height_and_floorspace_rule.py b/tests/test_height_and_floorspace_rule.py
index 9aa34c7..d20303f 100644
--- a/tests/test_height_and_floorspace_rule.py
+++ b/tests/test_height_and_floorspace_rule.py
@@ -29,26 +29,61 @@ def test_height_and_floorspace_rule(height_and_floorspace_rule):
     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
+    test_list = [
+        # Test values related to building stories.
+        [
+            {
+                "tags": {
+                    "building:levels": "5",
+                    "roof:levels": "2",
+                    "building:min_level": "1",
+                    "building:levels:underground": "0",
+                },
+                "area": 300.0,
+            },
+            "H:6",  # Expected return height.
+            1350.0,  # Expected return floorspace.
+        ],
+        # Test values related to building height.
+        [
+            {
+                "tags": {
+                    "height": "5",
+                    "min_height": "2",
+                },
+                "area": 300.0,
+            },
+            "HHT:3.00",  # Expected return height.
+            None,  # Expected return floorspace.
+        ],
+        # Test values related to building levels and height.
+        [
+            {
+                "tags": {
+                    "building:levels": "5",
+                    "building:levels:underground": "1",
+                    "height": "5",
+                    "min_height": "2",
+                },
+                "area": 300.0,
+            },
+            "H:5+HBEX:1+HHT:3.00",  # Expected return height.
+            1620.0,  # Expected return floorspace.
+        ],
+    ]
+
+    for building_information, correct_height, correct_floorspace in test_list:
+        result = height_and_floorspace_rule(**building_information)
+        message = (
+            f"The `height` attribute is not correct. "
+            f"Expected `{correct_height}` but estimated `{result['height']}`."
+        )
+        assert result["height"] == correct_height, message
+        message = (
+            f"The `floorspace` attribute is not correct. "
+            f"Expected `{correct_floorspace}` but estimated `{result['floorspace']}`"
+        )
+        assert result["floorspace"] == pytest.approx(correct_floorspace), message
 
 
 def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
@@ -59,17 +94,21 @@ def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
     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],
+        [{"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],
+        [{"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],
+        [{"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],
+        [{"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],
+        [
+            {"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.
@@ -91,14 +130,14 @@ def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
     for input_values, correct_height, correct_floorspace in test_list:
         height, floorspace = rule_function(**input_values)
 
-        # Check the height attribute
+        # 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
+        # Check the floorspace attribute.
         message = (
             f"The `floorspace` attribute is not correct. Expected `{correct_floorspace}` but "
             f"estimated `{floorspace}` using {input_values} as input."
@@ -106,6 +145,43 @@ def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
         assert floorspace == correct_floorspace, message
 
 
+def test_get_height_from_osm(height_and_floorspace_rule):
+    """
+    Test the function `tag_to_float` of the `HeightAndFloorspaceRule`.
+    """
+
+    rule_function = height_and_floorspace_rule.function.get_height_from_osm
+
+    test_list = [
+        # Check a valid height.
+        [{"tags": {"height": "8"}}, "HHT:8.00"],
+        # Check a building with a floating point height value.
+        [{"tags": {"height": "8.2"}}, "HHT:8.20"],
+        # Check a building that is above ground.
+        [{"tags": {"height": "8", "min_height": "4"}}, "HHT:4.00"],
+        # Check a building with an invalid `min_height` attribute.
+        [{"tags": {"height": "8", "min_height": "no"}}, "HHT:8.00"],
+        # Check a building with a `min_height` higher than the `height` attribute.
+        [{"tags": {"height": "4", "min_height": "5"}}, None],
+        # Check a building with a negative height.
+        [{"tags": {"height": "-4"}}, None],
+        # Check a building with an invalid `height` attribute.
+        [{"tags": {"height": "no", "min_height": "5"}}, None],
+        # Check a building without tags.
+        [{"tags": {}}, None],
+    ]
+
+    for input_values, correct_height in test_list:
+        height = 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
+
+
 def test_tag_to_float(height_and_floorspace_rule):
     """
     Test the function `tag_to_float` of the `HeightAndFloorspaceRule.
-- 
GitLab