From 9fd1822013c350c46003240571901e854f1b5003 Mon Sep 17 00:00:00 2001
From: Pablo de la Mora Lobaton <pdlmora@gfz-potsdam.de>
Date: Tue, 5 Nov 2024 11:26:08 +0100
Subject: [PATCH] Add general year of construction rule for buildings

---
 .gitlab-ci.yml                                |   2 +-
 .../tabula/year_of_construction/rule.xml      |  15 +++
 .../year_of_construction.py                   | 113 +++++++++++++++++
 .../boundary.txt                              |   0
 .../rule.xml                                  |   5 +-
 ...f_construction_from_valencian_cadaster.py} |  22 ++--
 tests/conftest.py                             | 114 +++++++++++++++++-
 .../tests_valencia.txt                        |   7 ++
 tests/test_height_and_floorspace_rule.py      |  17 ++-
 ...nstruction_from_valencian_cadaster_rule.py |  42 +++++++
 tests/test_year_of_construction_rule.py       |  79 ++++++++++++
 11 files changed, 397 insertions(+), 19 deletions(-)
 create mode 100644 building/02_process/tabula/year_of_construction/rule.xml
 create mode 100644 building/02_process/tabula/year_of_construction/year_of_construction.py
 rename building/02_process/tabula/{date_from_valencian_cadaster => year_of_construction_from_valencian_cadaster}/boundary.txt (100%)
 rename building/02_process/tabula/{date_from_valencian_cadaster => year_of_construction_from_valencian_cadaster}/rule.xml (70%)
 rename building/02_process/tabula/{date_from_valencian_cadaster/date_from_valencian_cadaster.py => year_of_construction_from_valencian_cadaster/year_of_construction_from_valencian_cadaster.py} (69%)
 create mode 100644 tests/data/year_of_construction_from_valencian_cadaster/tests_valencia.txt
 create mode 100644 tests/test_year_of_construction_from_valencian_cadaster_rule.py
 create mode 100644 tests/test_year_of_construction_rule.py

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4277654..c5f9ca4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,4 @@
-image: python:3.9-slim
+image: python:3.11.10
 
 # Make pip cache the installed dependencies
 variables:
diff --git a/building/02_process/tabula/year_of_construction/rule.xml b/building/02_process/tabula/year_of_construction/rule.xml
new file mode 100644
index 0000000..df91dd1
--- /dev/null
+++ b/building/02_process/tabula/year_of_construction/rule.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<rule name="YearOfConstructionRule">
+    <input>
+        <param type="int">tags</param>
+        <param type="float">relations</param>
+        <param type="int">year_of_construction_cadaster</param>
+    </input>
+    <function filepath="year_of_construction.py"/>
+    <dependencies>
+        <dependency name="YearOfConstructionFromValencianCadasterRule"/>
+    </dependencies>
+    <output>
+        <param type="int">year_of_construction</param>
+    </output>
+</rule>
diff --git a/building/02_process/tabula/year_of_construction/year_of_construction.py b/building/02_process/tabula/year_of_construction/year_of_construction.py
new file mode 100644
index 0000000..37efebe
--- /dev/null
+++ b/building/02_process/tabula/year_of_construction/year_of_construction.py
@@ -0,0 +1,113 @@
+#!/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/.
+
+from rulelib import AbstractRule
+
+
+class YearOfConstructionRule(AbstractRule):
+    def __call__(
+        self,
+        tags: dict,
+        relations: list,
+        year_of_construction_cadaster: int | None = None,
+        *args,
+        **kwargs
+    ) -> dict[str, int | None]:
+        """
+        Obtain the year of construction of a building from any available source, presently only
+        OSM and cadaster data. In OSM, the time of construction is stored in the `start_date`
+        tag of which only an explicit four-digit year with or without a leading `~` (signifying
+        an approximation) are considered. Priority is given to exact years of construction from
+        OSM, followed by those from the cadaster data and finally approximate dates from OSM.
+
+        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.
+            year_of_construction_cadaster (int, optional, default: None):
+                Year of construction taken from cadaster data.
+
+        Returns:
+            dict[str, int | None]:
+                A dictionary with the construction year of the building as an integer or None.
+        """
+
+        all_building_tags = [tags] + [building["tags"] for building in relations]
+
+        year_of_construction_string_approximate = None
+
+        for tags in all_building_tags:
+            year_of_construction_string = self.get_start_date(tags)
+            if year_of_construction_string is None:
+                continue
+            else:
+                # Years with leading `~` are approximate dates, these values are set
+                # last in the priority list if other sources are available.
+                if (
+                    year_of_construction_string[0] == "~"
+                    and year_of_construction_string_approximate is None
+                ):
+                    year_of_construction_string_approximate = year_of_construction_string[1:5]
+                    continue
+                # Years from OSM without a leading `~` are at the top of the priority list,
+                # and returned as soon as one is found.
+                else:
+                    year_of_construction_string = year_of_construction_string[:4]
+                try:
+                    year_of_construction = int(year_of_construction_string)
+                    return {"year_of_construction": year_of_construction}
+                except ValueError:
+                    continue
+
+        # If no unambiguous year is found from the OSM tags, the cadaster year of construction
+        # is returned, second in the priority list.
+        if year_of_construction_cadaster is not None:
+            return {"year_of_construction": year_of_construction_cadaster}
+        # If the there is no construction year from the cadaster data, the approximate year is
+        # returned.
+        elif year_of_construction_string_approximate is not None:
+            try:
+                year_of_construction = int(year_of_construction_string_approximate)
+                return {"year_of_construction": year_of_construction}
+            except ValueError:
+                return {"year_of_construction": None}
+        # If no source has a valid year of construction, None is returned.
+        else:
+            return {"year_of_construction": None}
+
+    @staticmethod
+    def get_start_date(tags: dict) -> str | None:
+        """
+        Get the building year of construction based on the year in the `start_date` tag from
+        the OSM tags dictionary.
+
+        Args:
+            tags (dict):
+                Dictionary with building tags from OSM, which may include `start_date`, `name`,
+                `type`, etc.
+
+        Returns:
+            str | None:
+                String value of the tag `start_date` which represents the date in which a
+                building was completed, otherwise None. The date format may vary, as there are
+                many variations within the OSM `start_date` tag, for this rule, only the year is
+                relevant.
+        """
+
+        return tags.get("start_date", None)
diff --git a/building/02_process/tabula/date_from_valencian_cadaster/boundary.txt b/building/02_process/tabula/year_of_construction_from_valencian_cadaster/boundary.txt
similarity index 100%
rename from building/02_process/tabula/date_from_valencian_cadaster/boundary.txt
rename to building/02_process/tabula/year_of_construction_from_valencian_cadaster/boundary.txt
diff --git a/building/02_process/tabula/date_from_valencian_cadaster/rule.xml b/building/02_process/tabula/year_of_construction_from_valencian_cadaster/rule.xml
similarity index 70%
rename from building/02_process/tabula/date_from_valencian_cadaster/rule.xml
rename to building/02_process/tabula/year_of_construction_from_valencian_cadaster/rule.xml
index 732737f..a9e9ceb 100644
--- a/building/02_process/tabula/date_from_valencian_cadaster/rule.xml
+++ b/building/02_process/tabula/year_of_construction_from_valencian_cadaster/rule.xml
@@ -1,18 +1,19 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<rule name="DateFromValencianCadasterRule">
+<rule name="YearOfConstructionFromValencianCadasterRule">
     <input>
         <param type="str">geometry</param>
         <param type="PostGISDatabase">database</param>
         <param type="float">longitude</param>
         <param type="float">latitude</param>
     </input>
-    <function filepath="date_from_valencian_cadaster.py"/>
+    <function filepath="year_of_construction_from_valencian_cadaster.py"/>
     <database name="source"/>
     <dependencies>
         <dependency name="GeometryRule"/>
         <dependency name="SelectRule"/>
     </dependencies>
     <output>
+        <param type="int">year_of_construction_cadaster</param>
     </output>
     <filter filepath="boundary.txt"/>
 </rule>
\ No newline at end of file
diff --git a/building/02_process/tabula/date_from_valencian_cadaster/date_from_valencian_cadaster.py b/building/02_process/tabula/year_of_construction_from_valencian_cadaster/year_of_construction_from_valencian_cadaster.py
similarity index 69%
rename from building/02_process/tabula/date_from_valencian_cadaster/date_from_valencian_cadaster.py
rename to building/02_process/tabula/year_of_construction_from_valencian_cadaster/year_of_construction_from_valencian_cadaster.py
index f604a5e..4e363b7 100644
--- a/building/02_process/tabula/date_from_valencian_cadaster/date_from_valencian_cadaster.py
+++ b/building/02_process/tabula/year_of_construction_from_valencian_cadaster/year_of_construction_from_valencian_cadaster.py
@@ -17,15 +17,18 @@
 from rulelib import AbstractRule
 
 
-class DateFromValencianCadasterRule(AbstractRule):
+class YearOfConstructionFromValencianCadasterRule(AbstractRule):
     from databaselib import PostGISDatabase
 
-    def __call__(self, database: PostGISDatabase, geometry: str, *args, **kwargs):
+    def __call__(
+        self, database: PostGISDatabase, geometry: str, *args, **kwargs
+    ) -> dict[str, int | None]:
         """
         This rule provides an additional source for a building's construction year for the
-        Valencia province in Spain if it is not available in OSM. A building's geometry is
-        tested for intersection with a cadastral parcel in the source database. If such a parcel
-        exists, its construction date is taken from the related cadastral buildings table.
+        Valencia province in Spain in case the year of construction is not available in OSM. A
+        building's geometry is tested for intersection with a cadastral parcel in the source
+        database. If such a parcel exists, its construction year of construction is taken from
+        the related cadastral buildings table.
 
          Args:
              database (PostGISDatabase):
@@ -36,12 +39,13 @@ class DateFromValencianCadasterRule(AbstractRule):
                  formatted string wrapped in the `ST_GeomFromText()` function.
 
          Returns:
-             Integer that represents the building's construction year.
+             dict[str, int | None]:
+                A dictionary with the construction year as an int or None.
 
         """
 
         sql_statement = f"""
-            WITH b (geom) AS (VALUES ({geometry})
+            WITH b (geom) AS (VALUES ({geometry}))
             SELECT construction_year
             FROM Parcels
             INNER JOIN cadaster_buildings USING(parcel_id)
@@ -53,6 +57,6 @@ class DateFromValencianCadasterRule(AbstractRule):
         construction_year = database.cursor.fetchone()
 
         if construction_year is None or construction_year == "":
-            return {"date_cadaster": None}
+            return {"year_of_construction_cadaster": None}
         else:
-            return {"date_cadaster": int(construction_year[0])}
+            return {"year_of_construction_cadaster": int(construction_year[0])}
diff --git a/tests/conftest.py b/tests/conftest.py
index f88e873..1739075 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -21,6 +21,7 @@ import shutil
 import tempfile
 import os
 
+from databaselib import PostGISDatabase
 from rulelib import Rule
 
 
@@ -32,17 +33,122 @@ def height_and_floorspace_rule():
     fixture.
     """
 
-    # Get the filepath of the rule
+    # 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)
+    filepath = os.path.join(tmp_dir + "height_and_floorspace")
+    shutil.make_archive(filepath, "zip", rule_dir)
 
     # Yield rule.
-    yield Rule.load_rule_from_zip(open(file_path + ".zip", "rb"))
+    yield Rule.load_rule_from_zip(open(filepath + ".zip", "rb"))
 
     # Remove temporary ZIP file.
     shutil.rmtree(tmp_dir, ignore_errors=True)
+
+
+@pytest.fixture
+def year_of_construction_from_valencian_cadaster_rule():
+    """
+    Creates a temporary directory, where the `YearOfConstructionFromValencianCadasterRule`
+    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/tabula/year_of_construction_from_valencian_cadaster"
+    )
+
+    # Create a temporary ZIP file from the `height_and_floorspace` rule.
+    tmp_dir = tempfile.mkdtemp()
+    filepath = os.path.join(tmp_dir + "year_of_construction_from_valencian_cadaster")
+    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 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
+    fixture.
+    """
+
+    # Get the file path of the rule.
+    project_dir = os.getenv("CI_PROJECT_DIR", "")
+    rule_dir = os.path.join(project_dir, "building/02_process/tabula/year_of_construction")
+
+    # Create a temporary ZIP file from the `height_and_floorspace` rule.
+    tmp_dir = tempfile.mkdtemp()
+    filepath = os.path.join(tmp_dir + "year_of_construction")
+    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 database():
+    """
+    Returns a database configuration to connect to a PostGIS database based on environment
+    variables provided either in the CI/CD pipeline variables or stored locally in the package
+    directory. The following environment variables are used:
+        `DB_NAME`: Name of the database the rule is connecting to.
+        `DB_HOST`: Host name (or IP address) of the database.
+        `DB_PORT`: Port (at the host computer) to connect to the database.
+        `DB_USERNAME`: Username of the account to access the database.
+        `DB_PASSWORD`: Password of the user account.
+    """
+
+    db_config = {
+        "dbname": os.getenv("DB_NAME", None),
+        "host": os.getenv("DB_HOST", None),
+        "port": os.getenv("DB_PORT", None),
+        "username": os.getenv("DB_USERNAME", None),
+        "password": os.getenv("DB_PASSWORD", None),
+    }
+    rule_database = PostGISDatabase(**db_config)
+    yield rule_database
+
+
+@pytest.fixture
+def test_data_valencia():
+    """
+    Returns building geometries for buildings in Montesa, Spain, part of the Valencian province,
+    in order to test the year_of_construction from the Valencian Cadaster Rule.
+    """
+
+    # Get the file path of the buildings.
+    project_dir = os.getenv("CI_PROJECT_DIR", "")
+    tests_filepath = os.path.join(
+        project_dir,
+        "tests/data/year_of_construction_from_valencian_cadaster/tests_valencia.txt",
+    )
+    # Create a list of the building geometries and export.
+    test_data = []
+    with open(tests_filepath, "r") as tests:
+        for test_set in tests:
+            test_set = test_set.strip()
+            lon, lat, year, geom = test_set.split(";")
+            test_data.append(
+                [
+                    float(lon),
+                    float(lat),
+                    int(year) if year != "None" else None,
+                    f"ST_GeomFromText('{geom}', 4326)",
+                ]
+            )
+
+    yield test_data
diff --git a/tests/data/year_of_construction_from_valencian_cadaster/tests_valencia.txt b/tests/data/year_of_construction_from_valencian_cadaster/tests_valencia.txt
new file mode 100644
index 0000000..c39c071
--- /dev/null
+++ b/tests/data/year_of_construction_from_valencian_cadaster/tests_valencia.txt
@@ -0,0 +1,7 @@
+-0.41747;39.43838;1900;POLYGON((-0.653067226172624 38.95003797988812,-0.652991118491826 38.95006781946344,-0.652971504838405 38.95002456884305,-0.653009726316867 38.95001207980732,-0.652968235896168 38.94994359965841,-0.652957339422045 38.94992499183334,-0.652943006367593 38.94990227687575,-0.652937139035373 38.949890877487434,-0.652959853992996 38.94988299849845,-0.653067226172624 38.95003797988812))
+-0.41747;39.43838;1922;POLYGON((-0.652986508445082 38.95041927266351,-0.652947281138211 38.950421954872525,-0.652857762412339 38.95042798984281,-0.652773775742531 38.950433689536965,-0.652772769914151 38.95034978668619,-0.65291551372519 38.95030343476165,-0.65293504355958 38.95034199151624,-0.652871257276445 38.95035942587484,-0.652870838181286 38.95039186384011,-0.652967565343914 38.950387672888525,-0.652978042722879 38.95038691851724,-0.652986508445082 38.95041927266351))
+-0.41747;39.438388;1985;POLYGON((-0.65309010876831 38.94938159305062,-0.653016264201341 38.949438589992184,-0.652938312501817 38.949403972732085,-0.652898749918847 38.94937438461389,-0.652917525381952 38.94935837517883,-0.652886931435376 38.94933188836478,-0.652906461269765 38.94931722003423,-0.652956249774633 38.949280255841245,-0.65309010876831 38.94938159305062))
+-0.41747;39.43838;1900;POLYGON((-0.652926326380282 38.9494135281017,-0.652895816252737 38.94943808707799,-0.652885003597646 38.94942869934644,-0.652873017476111 38.9494359916022,-0.652846363024025 38.94940799604561,-0.652865809039383 38.94939492027666,-0.65288081264606 38.94938159305062,-0.652926326380282 38.9494135281017))
+-0.41747;39.43838;1900;POLYGON((-0.653117182315555 38.94960362966569,-0.653059766278801 38.94963707345934,-0.653055407689152 38.94963338542195,-0.653029004694162 38.94960865880759,-0.653021209524212 38.949602540018276,-0.652998578385649 38.949582423450664,-0.652947616414338 38.949534478964495,-0.652999248937903 38.94949768240957,-0.65306261612588 38.94955392497985,-0.653074434609351 38.949564318539785,-0.653056916431723 38.94957487973778,-0.653078625560966 38.94959457721026,-0.653093126253452 38.94958284254582,-0.653117182315555 38.94960362966569))
+-0.41747;39.438388;None;POLYGON((-0.655097323121254 38.94979834127639,-0.655012833537285 38.949965224968594,-0.654796915711529 38.949901690142525,-0.654876040877468 38.949731621327146,-0.655097323121254 38.94979834127639))
+-0.41747;39.43838;None;POLYGON((-0.640721604807396 38.93797273318404,-0.640656980333944 38.93801715727088,-0.640622363073817 38.937986647143305,-0.640540639517894 38.93804263825652,-0.640470902083479 38.937981282725275,-0.640649939535251 38.93785832020572,-0.640713222904225 38.93791414368087,-0.640680533481856 38.93793652336234,-0.640721604807396 38.93797273318404))
diff --git a/tests/test_height_and_floorspace_rule.py b/tests/test_height_and_floorspace_rule.py
index c5ad79e..331207d 100644
--- a/tests/test_height_and_floorspace_rule.py
+++ b/tests/test_height_and_floorspace_rule.py
@@ -72,7 +72,11 @@ def test_height_and_floorspace_rule(height_and_floorspace_rule):
         ],
         # Check with building level tag and GHSL.
         [
-            {"tags": {"building:levels": "5"}, "area": 100.0, "ghsl_characteristics_type": 11},
+            {
+                "tags": {"building:levels": "5"},
+                "area": 100.0,
+                "ghsl_characteristics_type": 11,
+            },
             "H:5",  # Expected return height.
             450.0,  # Expected return floorspace.
         ],
@@ -116,14 +120,21 @@ def test_get_stories_and_floorspace_from_osm(height_and_floorspace_rule):
         # 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],
+        [
+            {"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},
+            {
+                "tags": {"building:levels": "5", "building:min_level": "6"},
+                "area": 100.0,
+            },
             None,
             None,
         ],
diff --git a/tests/test_year_of_construction_from_valencian_cadaster_rule.py b/tests/test_year_of_construction_from_valencian_cadaster_rule.py
new file mode 100644
index 0000000..a85d5b1
--- /dev/null
+++ b/tests/test_year_of_construction_from_valencian_cadaster_rule.py
@@ -0,0 +1,42 @@
+#!/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/.
+
+
+def test_year_of_construction_from_valencian_cadaster_rule(
+    year_of_construction_from_valencian_cadaster_rule, database, test_data_valencia
+):
+    """
+    Test the `year_of_construction_from_the_valencian_cadaster` rule with inputs from the test
+    database and test geometries.
+    """
+
+    database.connect()
+
+    for lon, lat, expected_year, geometry in test_data_valencia:
+        result = year_of_construction_from_valencian_cadaster_rule(
+            longitude=lon, latitude=lat, **{"database": database, "geometry": geometry}
+        )
+
+        message = (
+            f"The expected construction year is not correct, {expected_year} was "
+            f"expected and '{result['year_of_construction_cadaster']}' was returned."
+        )
+
+        assert result["year_of_construction_cadaster"] == expected_year, message
+
+    database.close()
diff --git a/tests/test_year_of_construction_rule.py b/tests/test_year_of_construction_rule.py
new file mode 100644
index 0000000..de4f695
--- /dev/null
+++ b/tests/test_year_of_construction_rule.py
@@ -0,0 +1,79 @@
+#!/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
+
+logger = logging.getLogger()
+
+
+def test_year_of_construction_rule(year_of_construction_rule):
+    """
+    Test the `year_of_construction` rule with valid construction years from each source, as well
+    as no dates and invalid ones.
+    """
+
+    # Test set with valid dates as an output.
+    test_data = [
+        # Test a valid year of construction from the building tags.
+        [{"start_date": "1955"}, [], None, 1955],
+        # Test with an approximate year of construction from OSM and an exact
+        # year of construction from cadaster.
+        [{"start_date": "~1950"}, [], 1951, 1951],
+        # Test with no year of construction from the building tags, but one from its relations.
+        [{}, [{"osm_id": -8907330, "tags": {"start_date": "1960"}}], None, 1960],
+        # Test with an invalid year of construction from the building tags and a valid
+        # year of construction from its relations.
+        [{}, [{"osm_id": -8907330, "tags": {"start_date": "1960"}}], None, 1960],
+        # Test with only a cadaster year of construction.
+        [{"start_date": "before 1922"}, [], 2013, 2013],
+        # Test with an approximate year of construction from OSM.
+        [{"start_date": "~1992"}, [], None, 1992],
+        # Test an approximate year of construction from the building tags and an exact one
+        # from its relations.
+        [
+            {"start_date": "~1992"},
+            [{"osm_id": -8907330, "tags": {"start_date": "1960"}}],
+            None,
+            1960,
+        ],
+        # Test with no year of construction from any source
+        [{}, [], None, None],
+        # Test with no usable input year of construction from sources.
+        [
+            {"start_date": "before 1922"},
+            [{"osm_id": -8907330, "tags": {"start_date": "early 1920s"}}],
+            None,
+            None,
+        ],
+    ]
+
+    for tags, relations, year_of_construction_cadaster, expected_year in test_data:
+        result = year_of_construction_rule(
+            **{
+                "tags": tags,
+                "relations": relations,
+                "year_of_construction_cadaster": year_of_construction_cadaster,
+            }
+        )
+        message = (
+            f"The expected construction year is not correct, {expected_year} was "
+            f"expected and '{result['year_of_construction']}' was returned."
+        )
+
+        assert result["year_of_construction"] == expected_year, message
-- 
GitLab