Skip to content
Snippets Groups Projects
Commit 5a75f42c authored by Chengzhi Rao's avatar Chengzhi Rao :speech_balloon: Committed by Chengzhi Rao
Browse files

Change Rule attributes, add tests

parent b6fa21cd
No related branches found
No related tags found
1 merge request!18Resolve "Change the output of rules to `attributes` field"
Pipeline #82961 passed
......@@ -16,27 +16,44 @@
# 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, ghsl_characteristics_type=None, *args, **kwargs):
from rulelib import AbstractRule
class HeightAndFloorspaceRule(AbstractRule):
def __call__(
self,
tags: dict[str, any],
area: float,
ghsl_characteristics_type: int | None = None,
attributes: dict[str, any] | None = None,
*args,
**kwargs,
) -> dict[str, dict]:
"""
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):
tags (dict[str, any]):
Building tags, such as the building levels or building type.
area (float):
Footprint size of the building.
ghsl_characteristics_type (int):
ghsl_characteristics_type (int | None, optional, default: None):
The GHSL characteristics type with the largest overlap with the building.
attributes (dict[str, any], optional, default: None):
A dictionary of building attributes.
Returns:
A dictionary with the number of stories and the floorspace of the building.
dict[str, dict]:
A dictionary with a single key `attributes` that stores the number of stories
and the floorspace of the building. If these values are not found, the provided
`attributes` dictionary is returned, which may remain unchanged or be empty.
"""
if attributes is None:
attributes = {}
gem_taxonomy_height = []
# The number of stories ideally comes from the `building:levels` tags in OpenStreetMap.
......@@ -54,20 +71,24 @@ class HeightAndFloorspaceRule:
# 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,
}
attributes.update({"height": "+".join(gem_taxonomy_height)})
if floorspace and floorspace > 0:
attributes.update({"floorspace": floorspace})
return {"attributes": attributes}
# If no information can be retrieved from OSM, we use the height estimation from GHSL
# and do not estimate any floorspace.
ghsl_height = self.get_stories_from_ghsl(ghsl_characteristics_type)
if ghsl_height:
return {"height": ghsl_height, "floorspace": None}
attributes["height"] = ghsl_height
return {"attributes": attributes}
return {"height": None, "floorspace": None}
return {"attributes": attributes}
def get_stories_and_floorspace_from_osm(self, tags: dict, area: float):
def get_stories_and_floorspace_from_osm(
self, tags: dict[str, any], area: float
) -> tuple[str | None, float | None]:
"""
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
......@@ -81,7 +102,10 @@ class HeightAndFloorspaceRule:
Footprint size of the building.
Returns:
A string formatted according to the `height` tag in the GEM Taxonomy.
str (tuple[str | None, float | None]):
A string formatted according to the `height` tag in the GEM Taxonomy, and the
floorspace based on the stories and footprint size. If the input
arguments are not sufficient, return None.
"""
from math import ceil
......@@ -117,8 +141,8 @@ class HeightAndFloorspaceRule:
else:
underground_stories = 0
# Take a factor of 90% for the floorspace.
floorspace *= 0.9
# Take a factor of 70% for the floorspace.
floorspace *= 0.7
# Check if anything is wrong with the tags.
if stories < 0 or underground_stories < 0:
......@@ -137,18 +161,20 @@ class HeightAndFloorspaceRule:
return "+".join(story_tags), floorspace
def get_height_from_osm(self, tags: dict):
def get_height_from_osm(self, tags: dict[str, any]) -> str | None:
"""
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):
tags (dict[str, any]):
Building tags, such as the building levels or building type.
Returns:
A string formatted according to the `height` tag in the GEM Taxonomy.
str | None:
A string formatted according to the `height` tag in the GEM Taxonomy or
None if no valid height can be determined.
"""
# Parse the height tag from OpenStreetMap.
......@@ -167,7 +193,7 @@ class HeightAndFloorspaceRule:
return f"HHT:{height:.2f}"
def get_stories_from_ghsl(self, ghsl_characteristics_type: int):
def get_stories_from_ghsl(self, ghsl_characteristics_type: int) -> str | None:
"""
Get the GEM Taxonomy `height` attribute, based on the type in the GHSL
characteristics layer that has the largest overlap with the building.
......@@ -177,36 +203,39 @@ class HeightAndFloorspaceRule:
The GHSL characteristics type with the largest overlap with the building.
Returns:
A string formatted according to the `height` attribute in the GEM Taxonomy.
str | None:
A string formatted according to the `height` attribute in the GEM Taxonomy
or None if no valid height tag can be determined.
"""
ghsl_type_map = {
11: "HBET:1-2", # res_3
12: "HBET:1-2", # res_3_6
13: "HBET:1-5", # res_6_15
14: None, # res_15_30
15: None, # res_30
21: "HBET:1-2", # nonres_3
22: "HBET:1-2", # nonres_3_6
23: "HBET:1-5", # nonres_6_15
24: None, # nonres_15_30
25: None, # nonres_30
11: "HBET:1-2", # Residential 0m-3m
12: "HBET:1-2", # Residential 3m-6m
13: "HBET:1-5", # Residential 6m-15m
14: None, # Residential 15m-30m
15: None, # Residential 30m+
21: "HBET:1-2", # Non-residential 0m-3m
22: "HBET:1-2", # Non-residential 3m-6m
23: "HBET:1-5", # Non-residential 6m-15m
24: None, # Non-residential 15m-30m
25: None, # Non-residential 30m+
}
return ghsl_type_map.get(ghsl_characteristics_type, None)
def tag_to_float(self, tags: dict, tag_key: str):
def tag_to_float(self, tags: dict[str, any], tag_key: str) -> float | None:
"""
Try to parse a tag as a floating point value. If the value cannot be parsed, return
None.
Args:
tags (dict):
tags (dict[str, any]):
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.
float | None:
Tag value converted to a float or None if conversion fails.
"""
# Try to get the tag value.
......
......@@ -7,7 +7,6 @@
</input>
<function filepath="height_and_floorspace.py"/>
<output>
<param type="str">height</param>
<param type="float">floorspace</param>
<param type="dict">attributes</param>
</output>
</rule>
#!/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/.
from rulelib import AbstractRule
class OccupancyRule(AbstractRule):
def __call__(self, tags, osm_spots, osm_lands, *args, **kwargs):
def __call__(
self,
tags: dict[str, any],
osm_spots: list[dict],
osm_lands: list[dict],
attributes: dict[str, any] | None = None,
*args,
**kwargs,
) -> dict[str, dict]:
"""
Define the occupancy type of the building. There are 10 sequential rules that can
define an occupancy type:
1. No occupancies: return UNK
2. One occupancy: return that occupancy
3. Overriding occupancies: return the overriding occupancy
4. All tags have the same level 1: Return the `detail_1` of that occupancy
5. Residential with garage: Return residential occupancy
6. Shopping mall: With a combination of certain occupancy types, return COM1
7. All tags have the same level 0: Return the `detail_0` of that occupancy
8. Residential/commercial: With a mix of only these two, return MIX1
9. Commercial/industrial: With a mix of only these two, return MIX5
10. Residential/industrial: With a mix of only these two, return MIX4
1. No occupancies: return UNK.
2. One occupancy: return that occupancy.
3. Overriding occupancies: return the overriding occupancy.
4. All tags have the same level 1: Return the `detail_1` of that occupancy.
5. Residential with garage: Return residential occupancy.
6. Shopping mall: With a combination of certain occupancy types, return COM1.
7. All tags have the same level 0: Return the `detail_0` of that occupancy.
8. Residential/commercial: With a mix of only these two, return MIX1.
9. Commercial/industrial: With a mix of only these two, return MIX5.
10. Residential/industrial: With a mix of only these two, return MIX4.
For a more elaborate description, look at the comments before each rule.
Args:
tags:
Building tags, such as the building levels or building type
osm_spots:
tags (dict[str, any]):
Building tags, such as the building levels or building type.
osm_spots (list[dict]):
A list of the tags of all POIs inside the building.
osm_lands:
osm_lands (list[dict]):
A list of the tags of all land-use features intersecting the building.
attributes (dict[str, any], optional, default: None):
A dictionary of building attributes.
Returns:
A dictionary with the occupancy of the building.
dict[str, dict]:
A dictionary with a single key `attributes` that that maps to another dictionary
containing the `occupancy` of the building.
"""
if attributes is None:
attributes = {}
extended_tags = [tags] + osm_spots + osm_lands
occupancies = self.occupancies_from_tags(extended_tags)
......@@ -38,37 +71,42 @@ class OccupancyRule(AbstractRule):
[occupancy.detail_1 for occupancy in occupancies if occupancy.detail_1 != ""]
)
# Rule no occupancies
# If there are no occupancies, return `unknown`
# Rule no occupancies.
# If there are no occupancies, return `unknown`.
if len(occupancies) == 0:
return {"occupancy": "UNK"}
attributes["occupancy"] = "UNK"
return {"attributes": attributes}
# Rule one occupancy
# If there is only one occupancy, return that occupancy
# Rule one occupancy.
# If there is only one occupancy, return that occupancy.
if len(occupancies) == 1:
return {"occupancy": occupancies[0].occupancy_string}
attributes["occupancy"] = occupancies[0].occupancy_string
return {"attributes": attributes}
# Rule overriding occupancies
# Rule overriding occupancies.
# If any of the `overriding_occupancies` is in the list, return that one.
for occ in occupancies:
if occ in self.overriding_occupancies:
return {"occupancy": occ.occupancy_string}
for occupancy in occupancies:
if occupancy in self.overriding_occupancies:
attributes["occupancy"] = occupancy.occupancy_string
return {"attributes": attributes}
# Rule two tags; same level 1
# Rule two tags; same level 1.
# If there is one unique level 0 and one unique level 1 (which implies that the
# unique level 1 has the same level 0 as the unique level 0), returns level 1.
if len(unique_occupancies_level_0) == 1 and len(unique_occupancies_level_1) <= 1:
for occ in occupancies:
if occ.detail_1 != "" or len(unique_occupancies_level_1) == 0:
return {"occupancy": occ.level_1}
for occupancy in occupancies:
if occupancy.detail_1 != "" or len(unique_occupancies_level_1) == 0:
attributes["occupancy"] = occupancy.level_1
return {"attributes": attributes}
# Rule residential with garage
# Rule residential with garage.
# If there is a combination of residential occupancies and COM7 (covered parking
# garage), it returns "RES".
if all(occ.level_0 == "RES" or occ.level_1 == "COM7" for occ in occupancies):
return {"occupancy": "RES"}
attributes["occupancy"] = "RES"
return {"attributes": attributes}
# Rule shopping mall
# Rule shopping mall.
# A concatenation of "many" retail trade (COM1) and restaurants/bars/cafes (COM5), which
# could also have a cinema (ASS3), could likely be a shopping mall (COM1).
#
......@@ -79,22 +117,25 @@ class OccupancyRule(AbstractRule):
# - There are at least two cases of "COM1" and all other tags are "COM" and/or
# "COM2" and/or "COM3" and/or "COM5" and/or "COM7" and/or "COM11" and/or "ASS3".
if all(occ.level_1 in ["COM1", "COM5", "COM"] for occ in occupancies):
return {"occupancy": "COM1"}
attributes["occupancy"] = "COM1"
return {"attributes": attributes}
com_1_occurance = sum(map(lambda occ: 1 if occ.level_1 == "COM1" else 0, occupancies))
if com_1_occurance >= 2 and all(
occ.level_0 == "COM" or occ.level_1 == "ASS3" for occ in occupancies
):
return {"occupancy": "COM1"}
attributes["occupancy"] = "COM1"
return {"attributes": attributes}
# Rule many tags same level 0
# Rule many tags same level 0.
# Returns the unique value of level 0 occupancy, if only one value of level 0
# occupancy but more than one different values of level 1 occupancy are associated
# with the building.
if len(unique_occupancies_level_0) == 1:
return {"occupancy": occupancies[0].level_0}
attributes["occupancy"] = occupancies[0].level_0
return {"attributes": attributes}
# Rule residential and commercial
# Rule residential and commercial.
# When the tags are a mix of residential and commercial occupancies of the kind "COM1",
# "COM3" and/or "COM5" and/or "ASS3", the result is "MIX1" ("mostly residential and
# commercial").
......@@ -106,9 +147,10 @@ class OccupancyRule(AbstractRule):
for occ in occupancies
)
):
return {"occupancy": "MIX1"}
attributes["occupancy"] = "MIX1"
return {"attributes": attributes}
# Rule commercial and industrial
# Rule commercial and industrial.
# When the tags are a mix of industrial ("IND" or "IND1" or "IND2") and commercial
# occupancies of the kind "COM", "COM1", "COM2", "COM3", "COM5", "COM7" and/or "COM11",
# the result is "MIX5" ("mostly industrial and commercial").
......@@ -121,27 +163,31 @@ class OccupancyRule(AbstractRule):
for occ in occupancies
)
):
return {"occupancy": "MIX5"}
attributes["occupancy"] = "MIX5"
return {"attributes": attributes}
# Rule residential and industrial
# Rule residential and industrial.
# When the tags are a mix of residential and industrial occupancies, the result is
# "MIX4" ("mostly residential and industrial").
if all(occ.level_0 in ["RES", "IND"] for occ in occupancies):
return {"occupancy": "MIX4"}
attributes["occupancy"] = "MIX4"
return {"attributes": attributes}
return {"occupancy": "UNK"}
attributes["occupancy"] = "UNK"
return {"attributes": attributes}
def occupancies_from_tags(self, tag_list):
def occupancies_from_tags(self, tag_list: list[dict]) -> list["Occupancy"]:
"""
Given a list of tags, get all the possible occupancies according to the OSM tags to
occupancy mapping (attribute `occupancy_mapper`).
Args:
tag_list:
List of tags that could be mapped to occupancy types
tag_list[dict]:
List of tags that could be mapped to occupancy types.
Return:
A list of possible occupancy types
Returns:
list['Occupancy']:
A list of `Occupancy` objects that correspond to the possible occupancy types.
"""
occupancies = []
......@@ -158,121 +204,147 @@ class OccupancyRule(AbstractRule):
]
class Occupancy:
def __init__(self, occupancy_as_string):
def __init__(self, occupancy_as_string: str):
"""
Dissect a string formatted occupancy into three parts. For example, the RES2A
occupancy type has three parts: RES (residential), 2 (multi-dwelling) and A (2-unit
residential building / duplex). The first part is mapped to `detail_0`, the second
to `detail_1` and the third to `detail_2`.
to `detail_1` and the third to `detail_2`.
Args:
occupancy_as_string (str):
String formatted occupancy
String formatted occupancy.
"""
self.detail_0 = ""
self.detail_1 = ""
self.detail_2 = ""
for idx, char in enumerate(occupancy_as_string):
# The first three
# The first three characters describe the main occupancy class.
if idx < 3:
self.detail_0 = self.detail_0 + char
# If the character is a digit, it is part of detail_1. Detail_1 can be max 2
# characters long, therefore the index should be below 5.
elif idx < 5 and char.isdigit():
self.detail_1 = self.detail_1 + char
# Everything else is part of detail_2
# Everything else is part of detail_2.
else:
self.detail_2 = occupancy_as_string[idx:]
break
# Check if detail_0 exists
# Check if detail_0 exists.
assert (
self.detail_0
), f"could not extract occupancy level_0 from {occupancy_as_string}"
# Check if all characters in detail_0 are alphabetic
), f"Could not extract occupancy `level_0` from {occupancy_as_string}."
# Check if all characters in detail_0 are alphabetic.
assert self.detail_0.isalpha(), (
f"{occupancy_as_string} is not formatted correctly: "
+ f"detail_0 {self.detail_0} should only consist of letters, not digits"
+ f"`detail_0` {self.detail_0} should only consist of letters, not digits."
)
# Check if all characters in detail_0 are upper-case
# Check if all characters in detail_0 are upper-case.
assert self.detail_0.isupper(), (
f"{occupancy_as_string} is not formatted correctly: "
+ f"detail_0 {self.detail_0} should be upper-case"
+ f"`detail_0` {self.detail_0} should be upper-case."
)
# Check the formatting of detail_1
# Check the formatting of detail_1.
if self.detail_1:
# Check if all characters in detail_1 are digits
# Check if all characters in detail_1 are digits.
assert self.detail_1.isdigit(), (
f"{occupancy_as_string} is not formatted correctly: "
+ f"detail_1 {self.detail_0} should only consist of digits, not letters"
+ f"`detail_1` {self.detail_1} should only consist of digits, not letters."
)
# Check the formatting of detail_2
# Check the formatting of detail_2.
if self.detail_2:
# Check if all characters in detail_2 are alphabetic
# Check if all characters in detail_2 are alphabetic.
assert self.detail_2.isalpha(), (
f"{occupancy_as_string} is not formatted correctly: "
+ f"detail_2 {self.detail_2} should only consist of letters, not digits"
+ f"`detail_2` {self.detail_2} should only consist of letters, not digits."
)
# Check if all characters in detail_2 are upper-case
# Check if all characters in detail_2 are upper-case.
assert self.detail_2.isupper(), (
f"{occupancy_as_string} is not formatted correctly: "
+ f"detail_2 {self.detail_2} should be upper-case"
+ f"`detail_2` {self.detail_2} should be upper-case."
)
self.occupancy_string = occupancy_as_string
@property
def level_0(self):
def level_0(self) -> str:
"""
Return the detail 0 part of the Occupancy
Returns the first part of the occupancy string, representing the broad occupancy
category. This corresponds to `detail_0`. For example, in `RES2A`, `RES` is
`level_0` and represents generic residential occupancy.
Returns:
str:
The `detail_0` part of the occupancy string.
"""
return self.detail_0
@property
def level_1(self):
def level_1(self) -> str:
"""
Return the detail 0 and detail 1 parts of the Occupancy
Returns the first and second part of the occupancy string, representing a more
specific occupancy classification. This corresponds to the concatenated `detail_0`
and `detail_1`. In the occupancy type `RES2A`, `RES2` is the `level_1` string.
Returns:
str:
The detail 0 and detail 1 parts of the `Occupancy` class.
"""
return self.detail_0 + self.detail_1
@property
def level_2(self):
def level_2(self) -> str:
"""
Return the detail 0, detail 1 and detail 2 parts of the Occupancy
Returns the three parts of the occupancy string, representing a detailed
classification. This corresponds to the concatenated `detail_0`, `detail_1` and
`detail_2`, providing additional granularity to the occupancy classification.
Returns:
str:
The `detail_0`, `detail_1` and `detail_2` parts of the occupancy stirng.
"""
return self.detail_0 + self.detail_1 + self.detail_2
def __eq__(self, other):
def __eq__(self, other) -> bool:
"""
Check if two occupancies are the same.
Args:
other (Occupancy):
Occupancy to compare with
Occupancy to compare with.
"""
return self.occupancy_string == other
def __hash__(self):
def __hash__(self) -> int:
"""
Use the occupancy string to hash the Occupancy class
Creates the hash value of the occupancy string and returns it.
Returns:
int:
The hash value generated using the occupancy string.
"""
return hash(self.occupancy_string)
@property
def occupancy_mapper(self):
def occupancy_mapper(self) -> dict[tuple, str]:
"""
Dictionary of OSM tags (key/value) as keys and occupancy type as values
Maps OSM tags to occupancy classes.
Returns:
dict[tuple, str]:
Dictionary of OSM tags (key/value) as keys and occupancy type as values.
"""
# OSM occupancies to occupancies
# OSM occupancies to occupancies.
return {
("amenity", "university"): "EDU3",
("amenity", "school"): "EDU2",
......@@ -282,10 +354,10 @@ class OccupancyRule(AbstractRule):
("amenity", "cinema"): "ASS3",
("amenity", "theatre"): "ASS3",
("amenity", "place_of_worship"): "ASS1",
("amenity", "hospital"): "COM4", # Add MED1 extension in next version
("amenity", "hospital"): "COM4", # Add MED1 extension in next version.
("landuse", "allotments"): "RES3",
("landuse", "animal_keeping"): "AGR2",
# ("landuse", "cemetery"): "NOC", # Add extension in next version
# ("landuse", "cemetery"): "NOC", # Add extension in next version.
("landuse", "commercial"): "COM",
("landuse", "education"): "EDU",
("landuse", "farm"): "AGR",
......@@ -299,11 +371,11 @@ class OccupancyRule(AbstractRule):
("landuse", "leisure"): "COM11",
("landuse", "meadow"): "AGR",
("landuse", "mine"): "IND1",
("landuse", "military"): "GOV", # Add MIL in next version
("landuse", "military"): "GOV", # Add MIL in next version.
("landuse", "orchard"): "AGR",
("landuse", "plant_nursery"): "AGR3",
("landuse", "quarry"): "IND1",
# ("landuse", "railway"): "TRA", # Add extension in next version
# ("landuse", "railway"): "TRA", # Add extension in next version.
("landuse", "recreation_ground"): "COM11",
("landuse", "religious"): "ASS1",
("landuse", "residential"): "RES",
......@@ -327,9 +399,9 @@ class OccupancyRule(AbstractRule):
("leisure", "water_park"): "COM11",
("tourism", "theme_park"): "COM11",
("tourism", "zoo"): "AGR2|COM5|COM11",
("aerialway", "station"): "COM9", # Add TRA7A extension in next version
("aeroway", "hangar"): "COM10", # Add TRA7B extension in next version
("aeroway", "terminal"): "COM10", # Add TRA7A extension in next version
("aerialway", "station"): "COM9", # Add TRA7A extension in next version.
("aeroway", "hangar"): "COM10", # Add TRA7B extension in next version.
("aeroway", "terminal"): "COM10", # Add TRA7A extension in next version.
("amenity", "arts_centre"): "COM6",
("amenity", "bank"): "COM3",
("amenity", "bar"): "COM5",
......@@ -337,20 +409,20 @@ class OccupancyRule(AbstractRule):
("amenity", "bicycle_rental"): "COM1",
("amenity", "biergarten"): "COM5",
("amenity", "bureau_de_change"): "COM1",
("amenity", "bus_station"): "COM8", # Add TRA2A extension in next version
("amenity", "bus_station"): "COM8", # Add TRA2A extension in next version.
("amenity", "cafe"): "COM5",
("amenity", "car_rental"): "COM1",
("amenity", "childcare"): "EDU1",
("amenity", "clinic"): "COM4",
("amenity", "community_centre"): "ASS4",
("amenity", "courthouse"): "GOV1",
("amenity", "dentist"): "COM4", # Add MED2 extension in next version
("amenity", "doctors"): "COM4", # Add MED2 extension in next version
("amenity", "dentist"): "COM4", # Add MED2 extension in next version.
("amenity", "doctors"): "COM4", # Add MED2 extension in next version.
("amenity", "driving_school"): "COM1",
("amenity", "embassy"): "GOV1",
("amenity", "events_venue"): "ASS4",
("amenity", "fast_food"): "COM5",
("amenity", "ferry_terminal"): "COM9", # Add TRA extension in next version
("amenity", "ferry_terminal"): "COM9", # Add TRA extension in next version.
("amenity", "fire_station"): "GOV2",
("amenity", "food_court"): "COM5",
("amenity", "grave_yard"): "ASS1",
......@@ -361,7 +433,7 @@ class OccupancyRule(AbstractRule):
("amenity", "mobile_money_agent"): "COM1",
("amenity", "nightclub"): "COM11",
("amenity", "nursing_home"): "RES4",
("amenity", "pharmacy"): "COM4", # Add MED3 extension in next version
("amenity", "pharmacy"): "COM4", # Add MED3 extension in next version.
("amenity", "police"): "GOV2",
("amenity", "post_office"): "COM3",
("amenity", "prison"): "RES4",
......@@ -374,12 +446,12 @@ class OccupancyRule(AbstractRule):
("amenity", "studio"): "COM3",
("amenity", "swimming_pool"): "COM11",
("amenity", "townhall"): "GOV1",
("amenity", "veterinary"): "COM4", # Add MED4 extension in next version
("amenity", "veterinary"): "COM4", # Add MED4 extension in next version.
("building", "allotment_house"): "RES1",
("building", "apartments"): "RES2",
("building", "barn"): "AGR1",
("building", "bungalow"): "RES3",
# ("building", "bunker"): "NOC", # Add extension in next version
# ("building", "bunker"): "NOC", # Add extension in next version.
("building", "cabin"): "RES3",
("building", "chapel"): "ASS1",
("building", "church"): "ASS1",
......@@ -387,14 +459,14 @@ class OccupancyRule(AbstractRule):
("building", "college"): "EDU3",
("building", "commercial"): "COM",
("building", "commercial;residential"): "MIX2",
# ("building", "construction"): "NOC", # Add extension in next version
# ("building", "construction"): "NOC", # Add extension in next version.
("building", "cowshed"): "AGR2",
("building", "detached"): "RES1",
("building", "dormitory"): "RES4",
("building", "farm"): "AGR",
("building", "farm_auxiliary"): "AGR",
("building", "garage"): "COM7", # Add extension RES7|COM7 in next version
("building", "garages"): "COM7", # Add extension RES7|COM7 in next version
("building", "garage"): "COM7", # Add extension RES7|COM7 in next version.
("building", "garages"): "COM7", # Add extension RES7|COM7 in next version.
("building", "government"): "GOV1",
("building", "grandstand"): "ASS2",
("building", "greenhouse"): "AGR3",
......@@ -402,7 +474,7 @@ class OccupancyRule(AbstractRule):
("building", "hospital"): "COM4",
("building", "hotel"): "RES3",
("building", "house"): "RES1",
("building", "hut"): "RES1", # Add extension RES6 in next version
("building", "hut"): "RES1", # Add extension RES6 in next version.
("building", "industrial"): "IND",
("building", "kindergarten"): "EDU1",
("building", "kiosk"): "COM1",
......@@ -417,7 +489,7 @@ class OccupancyRule(AbstractRule):
("building", "semi"): "RES2A",
("building", "semidetached_house"): "RES2A",
("building", "service"): "IND",
# ("building", "shed"): "NOC", # Add extension in next version
# ("building", "shed"): "NOC", # Add extension in next version.
("building", "shop"): "COM",
("building", "silo"): "AGR1",
("building", "slurry_tank"): "AGR3",
......@@ -427,10 +499,10 @@ class OccupancyRule(AbstractRule):
("building", "supermarket"): "COM1",
("building", "temple"): "ASS1",
("building", "terrace"): "RES2",
# ("building", "toilets"): "NOC", # Add extension in next version
("building", "train_station"): "COM9", # Add TRA5 extension in next version
("building", "transformer_tower"): "IND", # Add LIF2BA in next version
("building", "transportation"): "COM9", # Add TRA extension in next version
# ("building", "toilets"): "NOC", # Add extension in next version.
("building", "train_station"): "COM9", # Add TRA5 extension in next version.
("building", "transformer_tower"): "IND", # Add LIF2BA in next version.
("building", "transportation"): "COM9", # Add TRA extension in next version.
("building", "trullo"): "RES1",
("building", "university"): "EDU3",
("building", "warehouse"): "COM2",
......@@ -438,7 +510,7 @@ class OccupancyRule(AbstractRule):
("building:type", "apartments"): "RES2",
("building:type", "barn"): "AGR1",
("building:type", "dwelling_house"): "RES1",
("building:type", "garage"): "COM7", # Add extension RES7|COM7 in next version
("building:type", "garage"): "COM7", # Add extension RES7|COM7 in next version.
("building:type", "greenhouse"): "AGR3",
("building:type", "house"): "RES1",
("building:type", "residential"): "RES",
......@@ -568,9 +640,14 @@ class OccupancyRule(AbstractRule):
}
@property
def overriding_occupancies(self):
def overriding_occupancies(self) -> set[str]:
"""
List of overriding occupancies
Get the set of overriding occupancies. If any of these exist in the total occupancy
list, they will override the current occupancy type.
Returns:
set[str]:
A set as the list of overriding occupancies.
"""
# If one of these occupancies exist in the total occupancy list, we should override the
......
......@@ -7,6 +7,6 @@
</input>
<function filepath="occupancy.py"/>
<output>
<param type="float">occupancy</param>
<param type="dict">attributes</param>
</output>
</rule>
#!/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/.
from rulelib import AbstractRule
class RelationIDRule(AbstractRule):
def __call__(self, relations, *args, **kwargs):
def __call__(
self, relations: list[dict], attributes: dict[str, dict] | None = None, *args, **kwargs
) -> dict[str, dict]:
"""
Determine the building relation OSM ID.
Given a list of relations that the building is a member of, this function will check
for the first relation that contains a valid OSM ID.
Args:
relations:
relations (list[dict]):
List of the attributes of all relations that the building is member of.
attributes (dict[str, dict], optional, default: None):
A dictionary of building attributes.
Returns:
OSM ID of the first relation that the building is member of.
dict[str, dict]:
A dictionary with a single key `attributes` that maps to another dictionary
containing the `relation_id`, which is the OSM ID of the first relation that
the building is member of as value. If no OSM ID is found, it returns the
provided `attributes` dictionary, which may remain unchanged, or an empty
dictionary.
"""
if attributes is None:
attributes = {}
for relation in relations:
osm_id = relation.get("osm_id", None)
attributes["relation_id"] = osm_id
if osm_id is not None:
return {"relation_id": osm_id}
return {"relation_id": None}
return {"attributes": attributes}
return {"attributes": attributes}
......@@ -5,6 +5,6 @@
</input>
<function filepath="relation_id.py"/>
<output>
<param type="int">relation_id</param>
<param type="dict">attributes</param>
</output>
</rule>
......@@ -29,7 +29,7 @@ from rulelib import Rule
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
ZIP file. This ZIP file is parsed using rule-lib and the parsed rule is provided as a
fixture.
"""
......@@ -37,7 +37,7 @@ def height_and_floorspace_rule():
project_dir = os.getenv("CI_PROJECT_DIR", "")
rule_dir = os.path.join(project_dir, "building/height_and_floorspace")
# Create a temporary ZIP file from the `height_and_floorspace` rule.
# Create a temporary ZIP file from the `height_and_floorspace` folder.
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir + "height_and_floorspace")
shutil.make_archive(filepath, "zip", rule_dir)
......@@ -63,7 +63,8 @@ def year_of_construction_from_valencian_cadaster_rule():
project_dir, "building/tabula/year_of_construction_from_valencian_cadaster"
)
# Create a temporary ZIP file from the `height_and_floorspace` rule.
# Create a temporary ZIP file from the `year_of_construction_from_valencian_cadaster`
# folder.
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir + "year_of_construction_from_valencian_cadaster")
shutil.make_archive(filepath, "zip", rule_dir)
......@@ -79,7 +80,7 @@ def year_of_construction_from_valencian_cadaster_rule():
def year_of_construction_rule():
"""
Creates a temporary directory, where the `YearOfConstructionRule` rule is converted to a
ZIP file. This ZIP file is parsed using `rule-lib` and the parsed rule is provided as a
ZIP file. This ZIP file is parsed using rule-lib and the parsed rule is provided as a
fixture.
"""
......@@ -87,7 +88,7 @@ def year_of_construction_rule():
project_dir = os.getenv("CI_PROJECT_DIR", "")
rule_dir = os.path.join(project_dir, "building/tabula/year_of_construction")
# Create a temporary ZIP file from the `height_and_floorspace` rule.
# Create a temporary ZIP file from the `year_of_construction` folder.
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir + "year_of_construction")
shutil.make_archive(filepath, "zip", rule_dir)
......@@ -152,3 +153,49 @@ def test_data_valencia():
)
yield test_data
@pytest.fixture
def relation_id_rule():
"""
Creates a temporary directory, where the `RelationIDRule` 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/relation_id")
# Create a temporary ZIP file from the `relation_id` folder.
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir + "relation_id")
shutil.make_archive(filepath, "zip", rule_dir)
# Yield rule.
yield Rule.load_rule_from_zip(open(filepath + ".zip", "rb"))
# Remove temporary ZIP file.
shutil.rmtree(tmp_dir, ignore_errors=True)
@pytest.fixture
def occupancy_rule():
"""
Creates a temporary directory, where the `OccupancyRule` 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/occupancy")
# Create a temporary ZIP file from the `occupancy` folder.
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir + "occupancy")
shutil.make_archive(filepath, "zip", rule_dir)
# Yield rule.
yield Rule.load_rule_from_zip(open(filepath + ".zip", "rb"))
# Remove temporary ZIP file.
shutil.rmtree(tmp_dir, ignore_errors=True)
......@@ -26,7 +26,7 @@ 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.
OSM and GHSL, the correct values are stored in the dictionary using the `attributes` as key.
"""
test_list = [
......@@ -42,7 +42,7 @@ def test_height_and_floorspace_rule(height_and_floorspace_rule):
"area": 300.0,
},
"H:6", # Expected return height.
1350.0, # Expected return floorspace.
1050.0, # Expected return floorspace.
],
# Test values related to building height.
[
......@@ -68,7 +68,7 @@ def test_height_and_floorspace_rule(height_and_floorspace_rule):
"area": 300.0,
},
"H:5+HBEX:1+HHT:3.00", # Expected return height.
1620.0, # Expected return floorspace.
1260.0, # Expected return floorspace.
],
# Check with building level tag and GHSL.
[
......@@ -78,7 +78,7 @@ def test_height_and_floorspace_rule(height_and_floorspace_rule):
"ghsl_characteristics_type": 11,
},
"H:5", # Expected return height.
450.0, # Expected return floorspace.
350.0, # Expected return floorspace.
],
# Check with building height tag and GHSL.
[
......@@ -96,16 +96,25 @@ def test_height_and_floorspace_rule(height_and_floorspace_rule):
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
if correct_height is not None:
message = (
f"The `height` attribute is not correct. "
f"Expected `{correct_height}` but estimated `{result['attributes']['height']}`."
)
assert result["attributes"]["height"] == correct_height, message
else:
assert "height" not in result["attributes"].keys()
if correct_floorspace is not None:
message = (
f"The `floorspace` attribute is not correct. Expected `{correct_floorspace}` "
f"but estimated `{result['attributes']['floorspace']}`."
)
assert result["attributes"]["floorspace"] == pytest.approx(
correct_floorspace
), message
else:
assert "floorspace" not in result["attributes"].keys()
def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
......@@ -116,14 +125,14 @@ 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", 350.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", 175.0],
# Check with only levels underground.
[
{"tags": {"building:levels:underground": "5"}, "area": 100.0},
"HBEX:5",
450.0,
350.0,
],
# Check with a wrong type as input.
[{"tags": {"building:levels": "no"}, "area": 100.0}, None, None],
......@@ -152,7 +161,7 @@ def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
"area": 100.0,
},
"H:5+HBEX:1",
450.0,
350.0,
],
]
......
#!/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_occupancy_rule(occupancy_rule):
"""
Test the definition of occupancy type of the building.
"""
test_list = [
# Test no occupancy.
[
{
"tags": {"building:levels": "5"},
"osm_spots": [],
"osm_lands": [],
},
"UNK", # Expected return occupancy `UNK`.
],
# Test one occupancy.
[
{
"tags": {
"building:levels": "5",
"amenity": "university", # "EDU3".
},
"osm_spots": [],
"osm_lands": [],
},
"EDU3", # Expected return only one occupancy `EDU3` from tags.
],
# Test overriding occupancies.
[
{
"tags": {"building": "public"}, # "GOV".
"osm_spots": [{"tourism": "museum"}], # "COM6".
"osm_lands": [{"landuse": "education"}], # "EDU".
},
"COM6", # Expected return occupancy since the overriding.
],
# Test level 1 output as occupancies.
[
{
"tags": {"building:type": "dwelling_house"}, # "RES1".
"osm_spots": [],
"osm_lands": [{"landuse": "residential"}], # "RES".
},
"RES1", # Expected return level 1 occupancy.
],
# Test the rule residential with garage.
[
{
"tags": {"building:type": "dwelling_house"}, # "RES1".
"osm_spots": [{"building": "garage"}], # "COM7".
"osm_lands": [{"landuse": "residential"}], # "RES".
},
"RES", # Expected return "RES" for a combination of residential and COM7.
],
# Test the rule shopping mall.
[
{
"tags": {"building": "supermarket"}, # "COM1".
"osm_spots": [{"amenity": "cafe"}], # "COM5".
"osm_lands": [{"landuse": "retail"}], # "COM1".
},
"COM1", # Expected return "COM1" as a shopping mall.
],
# Test the rule to address many tags with same level 0.
[
{
"tags": {"building": "supermarket"}, # "COM1".
"osm_spots": [{"office": "company"}], # "COM3".
"osm_lands": [{"landuse": "leisure"}], # "COM11".
},
"COM", # Expected return "COM" to solve the conflicts of multiple tags.
],
# Test the rule residential and commercial.
[
{
"tags": {"building": "supermarket"}, # "COM1".
"osm_spots": [{"office": "company"}], # "COM3".
"osm_lands": [{"landuse": "residential"}], # "RES".
},
"MIX1", # Expected return "MIX1".
],
# Test the rule commercial and industrial.
[
{
"tags": {"building": "supermarket"}, # "COM1".
"osm_spots": [{"office": "forestry"}], # "COM3".
"osm_lands": [{"landuse": "industrial"}], # "IND".
},
"MIX5", # Expected return "MIX5".
],
# Test the rule residential and industrial.
[
{
"tags": {"building:type": "dwelling_house"}, # "RES1".
"osm_spots": [{"building": "semi"}], # "RES2A".
"osm_lands": [{"landuse": "industrial"}], # "IND".
},
"MIX4", # Expected return "MIX4".
],
]
for building_information, correct_occupancy in test_list:
result = occupancy_rule(**building_information)
non_message = (
f"The `occupancy` attribute is not in the `attributes`. Expected "
f"`{correct_occupancy}` but estimated `None`."
)
assert "occupancy" in result["attributes"].keys(), non_message
message = (
f"The `occupancy` attribute is not correct. Expected `{correct_occupancy}` "
f"but estimated `{result['attributes']['occupancy']}`."
)
assert result["attributes"]["occupancy"] == correct_occupancy, message
def test_occupancies_from_tags(occupancy_rule):
"""
Test get all the possible occupancies according to the OSM tags to occupancy mapping.
"""
rule_function = occupancy_rule.function.occupancies_from_tags
test_tag_list = [
# Test the existing mapping value.
[[{"amenity": "university"}], "EDU3", ["EDU3"]],
[[{"tourism": "zoo"}], "AGR2|COM5|COM11", ["AGR2", "COM5", "COM11"]],
# Test the non-mapping value.
[[{"amenity": "airport"}], "XX", []],
]
for osm_tag_list, input_value, correct_occupancy_mapping in test_tag_list:
result = rule_function(osm_tag_list)
# Check the occupancies result.
message = (
f"The `occupancy` attribute is not correct. Expected `{correct_occupancy_mapping}` "
f"but estimated `{result}` using {input_value} as input."
)
assert result == correct_occupancy_mapping, message
#!/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_relation_id_rule(relation_id_rule):
"""
Test the `relation_id` rule with `relations` as a list, which is the attributes of all
relations that the building is member of, this rule is to determine the building relation
OSM ID.
"""
# Test output `attributes` has a `relation_id` value.
test_relations = [
[
[
{
"osm_id": -8177545,
"building": "apartments",
"member": 38987209,
"role": "outline",
"tags": {"type": "building"},
}
],
-8177545, # Expected return OSM ID.
],
[
[
{
"osm_id": -8177545,
"building": "apartments",
"member": 264780123,
"role": "part",
"tags": {"type": "building"},
}
],
-8177545, # Expected return OSM ID.
],
[
[
{
"osm_id": -8177458,
"building": "garages",
"member": 576084001,
"role": "outline",
"tags": {"type": "building"},
}
],
-8177458, # Expected return OSM ID.
],
]
for relation, expected_osm_id in test_relations:
result = relation_id_rule(**{"relations": relation})
message = (
f"The expected OSM ID is not correct, {expected_osm_id} was expected and "
f"`{result['attributes']['relation_id']}` was returned."
)
assert result["attributes"]["relation_id"] == expected_osm_id, message
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment