Skip to content

Commit

Permalink
Expand capability of geojson class (#694)
Browse files Browse the repository at this point in the history
* put uo-des geojson methods into main gmt geojson class

* break out urbanopt_load class to its own file

* add types and returns for mypy

* remove what mypy said was unnecessary type hinting

* add error catching in geojson class

* WIP tests for methods moved into geojson class from uo-des repo

* add logging, errors, and a docstring comment to geojson class

* finish adding tests for geojson class

* add empty files to force testfolder creation on windows

* test getting site lat/lon from geojson

* improve docstring for geojson lat-lon method

* write to log, do not print

* comment out exception raising on getting meters

---------

Co-authored-by: Nicholas Long <[email protected]>
  • Loading branch information
vtnate and nllong authored Feb 4, 2025
1 parent c0cbdd9 commit 690231e
Show file tree
Hide file tree
Showing 9 changed files with 308 additions and 28 deletions.
188 changes: 163 additions & 25 deletions geojson_modelica_translator/geojson/urbanopt_geojson.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,19 @@
# :copyright (c) URBANopt, Alliance for Sustainable Energy, LLC, and other contributors.
# See also https://github.com/urbanopt/geojson-modelica-translator/blob/develop/LICENSE.md

import json
import logging
from pathlib import Path

import geojson
from jsonpath_ng.ext import parse

from geojson_modelica_translator.geojson.schemas import Schemas
from geojson_modelica_translator.geojson.urbanopt_load import GeoJsonValidationError, UrbanOptLoad

_log = logging.getLogger(__name__)


class GeoJsonValidationError(Exception):
pass


# TODO: Inherit from GeoJSON Feature class, move to its own file
class UrbanOptLoad:
"""An UrbanOptLoad is a container for holding Building-related data in a dictionary. This object
does not do much work on the GeoJSON definition of the data at the moment, rather it creates
an isolation layer between the GeoJSON data and the GMT.
"""

def __init__(self, feature):
self.feature = feature
self.id = feature.get("properties", {}).get("id", None)

# do some validation
if self.id is None:
raise GeoJsonValidationError("GeoJSON feature requires an ID property but value was null")

def __str__(self):
return f"ID: {self.id}"


class UrbanOptGeoJson:
"""Root class for parsing an URBANopt GeoJSON file. This class simply reads and parses
URBANopt GeoJSON files.
Expand All @@ -47,6 +26,8 @@ def __init__(self, filename, building_ids=None, skip_validation=False):
:param building_ids: list[str | int] | None, optional, list of GeoJSON building
IDs to parse from the file. If None or an empty list, parse all buildings.
"""

self._filename = Path(filename).resolve()
if not Path(filename).exists():
raise GeoJsonValidationError(f"URBANopt GeoJSON file does not exist: {filename}")

Expand All @@ -62,7 +43,7 @@ def __init__(self, filename, building_ids=None, skip_validation=False):
if feature["properties"]["type"] == "Building":
building = UrbanOptLoad(feature)
if not building_ids or building.id in building_ids:
# Ignore validation failures for features with 'detailed_model_filename' in the properties
# Do not attempt validation for features with 'detailed_model_filename' in the properties
# Buildings defined by an osm don't have all keys in geojson, therefore will always fail validation
if "detailed_model_filename" not in feature["properties"]:
errors = self.schemas.validate("building", building.feature.properties)
Expand Down Expand Up @@ -97,6 +78,8 @@ def get_feature_by_id(self, feature_id=None):
for feature in self.data.features:
if feature["properties"]["id"] == str(feature_id):
return feature
if feature_id not in self.data.features:
raise KeyError(f"No matches found for id {feature_id}")

def get_feature(self, jsonpath):
"""Return the parameter(s) from a jsonpath.
Expand All @@ -118,7 +101,162 @@ def get_feature(self, jsonpath):
# If only one value, then return that value and not a list of values
results = results[0]
elif len(results) == 0:
return print(f"No matches found for jsonpath {jsonpath}")
raise KeyError(f"No matches found for jsonpath {jsonpath}")

# otherwise return the list of values
return results

# TODO: test the following methods
def get_building_paths(self, scenario_name: str) -> list[Path]:
"""Return a list of Path objects for the building GeoJSON files"""
result = []
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building":
building_path = self._filename.parent / "run" / scenario_name / feature["properties"]["id"]
result.append(building_path)
# result.append(Path(feature["properties"]["file"]))

# verify that the paths exist
for path in result:
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")

return result

def get_building_ids(self) -> list:
"""Return a list of building names"""
result = []
for feature in self.data["features"]:
if "type" in feature["properties"] and feature["properties"]["type"] == "Building":
result.append(feature["properties"]["id"])
elif "name" in feature["properties"] and feature["properties"]["name"] == "Site Origin":
pass
else:
# need to implement a reasonable logger.
pass
# print(f"Feature does not have a type Building: {feature}")
# print("Did you forget to call the `update_geojson_from_seed_data` method?")

return result

def get_building_names(self) -> list:
"""Return a list of building names. Typically this field is only used for visual display name only."""
result = []
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building":
result.append(feature["properties"]["name"])

return result

def get_buildings(self, ids: list[str] | None = None) -> list:
"""Return a list of all the properties of type Building"""
result = []
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building" and (ids is None or feature["properties"]["id"] in ids):
# TODO: eventually add a list of building ids to keep, for now it
# will be all buildings.
result.append(feature)

return result

def get_building_properties_by_id(self, building_id: str) -> dict:
"""Get the list of building ids in the GeoJSON file. The Building id is what
is used in URBANopt as the identifier. It is common that this is used to name
the building, more than the GeoJSON's building name field.
Args:
building_id (str): building id, this is the property.id values in the geojson's feature
Returns:
dict: building properties
"""
result = {}
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building" and feature["properties"]["id"] == building_id:
result = feature["properties"]

return result

def get_meters_for_building(self, building_id: str) -> list:
"""Return a list of meters for the building_id"""
result = []
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building" and feature["properties"]["id"] == building_id:
for meter in feature["properties"].get("meters", []):
result.append(meter["type"])

if not result:
_log.debug(f"No meters found for building {building_id}")

return result

def get_meter_readings_for_building(self, building_id: str, meter_type: str) -> list:
"""Return a list of meter readings for the building_id"""
result = []
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building" and feature["properties"]["id"] == building_id:
for meter in feature["properties"].get("meters", []):
if meter["type"] == meter_type:
result = meter["readings"]
if not result:
_log.debug(f"No meter readings found for building {building_id}")

return result

def get_monthly_readings(self, building_id: str, meter_type: str = "Electricity") -> list:
"""Return a list of monthly electricity consumption for the building_id"""
result = []
for feature in self.data["features"]:
if (
feature["properties"]["type"] == "Building"
and feature["properties"]["id"] == building_id
and meter_type == "Electricity"
):
result = feature["properties"].get("monthly_electricity")

if not result:
_log.debug(f"No monthly readings found for building {building_id}")

return result

def set_property_on_building_id(
self, building_id: str, property_name: str, property_value: str, overwrite=True
) -> None:
"""Set a property on a building_id.
Note this method does not change the GeoJSON file, it only changes the in-memory data."""
for feature in self.data["features"]:
if (
feature["properties"]["type"] == "Building"
and feature["properties"]["id"] == building_id
and (overwrite or property_name not in feature["properties"])
):
feature["properties"][property_name] = property_value

def get_property_by_building_id(self, building_id: str, property_name: str) -> str | None:
"""Get a property on a building_id"""
for feature in self.data["features"]:
if feature["properties"]["type"] == "Building" and feature["properties"]["id"] == building_id:
return feature["properties"].get(property_name, None)
return None

def get_site_lat_lon(self) -> list | None:
"""Return the site's latitude and longitude
Rounds to 6 decimal places, if the geojson file has more than 6 decimal places.
Returns None if the site origin is not found."""
for feature in self.data["features"]:
if feature["properties"]["name"] == "Site Origin":
# reverse the order of the coordinates
return feature["geometry"]["coordinates"][::-1]
_log.warning("Site Origin not found in GeoJSON file")
return None

def save(self) -> None:
"""Save the GeoJSON file"""
self.save_as(self._filename)

def save_as(self, filename: Path) -> None:
"""Save the GeoJSON file"""
with open(filename, "w") as f:
json.dump(self.data, f, indent=2)
20 changes: 20 additions & 0 deletions geojson_modelica_translator/geojson/urbanopt_load.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class GeoJsonValidationError(Exception):
pass


class UrbanOptLoad:
"""An UrbanOptLoad is a container for holding Building-related data in a dictionary. This object
does not do much work on the GeoJSON definition of the data at the moment, rather it creates
an isolation layer between the GeoJSON data and the GMT.
"""

def __init__(self, feature):
self.feature = feature
self.id = feature.get("properties", {}).get("id", None)

# do some validation
if self.id is None:
raise GeoJsonValidationError("GeoJSON feature requires an ID property but value was null")

def __str__(self):
return f"ID: {self.id}"
2 changes: 1 addition & 1 deletion geojson_modelica_translator/model_connectors/model_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def __init__(self, system_parameters, template_dir):
}
# Get access to loop order output from ThermalNetwork package.
if "fifth_generation" in district_params and "ghe_parameters" in district_params["fifth_generation"]:
self.loop_order: list = load_loop_order(self.system_parameters.filename)
self.loop_order = load_loop_order(self.system_parameters.filename)

def ft2_to_m2(self, area_in_ft2: float) -> float:
"""Converts square feet to square meters
Expand Down
Empty file.
Empty file.
Empty file.
122 changes: 122 additions & 0 deletions tests/geojson/test_geojson.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,125 @@ def test_validate(self):
filename = self.data_dir / "geojson_1_invalid.json"
with pytest.raises(GeoJsonValidationError, match="is not valid under any of the given schemas"):
UrbanOptGeoJson(filename)

def test_get_all_features(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
feature_properties = json.get_feature("$.features.[*].properties")
assert len(feature_properties) == 4
# Check that the first feature has the expected properties
assert feature_properties[0]["floor_height"] == 9

def test_get_feature(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
feature = json.get_feature("$.features[1]")
assert feature["properties"]["floor_height"] == 3

def test_get_feature_invalid(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
with pytest.raises(KeyError, match="No matches found"):
json.get_feature("$.features[4]")

def test_get_feature_by_id(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
feature = json.get_feature_by_id("5a7229e737f4de77124f946d")
assert feature["properties"]["footprint_area"] == 8612

def test_get_feature_by_id_invalid(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
with pytest.raises(KeyError, match="No matches found"):
json.get_feature_by_id("non-existent-id")

def test_get_feature_by_id_missing(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
with pytest.raises(SystemExit):
json.get_feature_by_id()

def test_get_building_paths(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
building_paths = json.get_building_paths(scenario_name="baseline_test")
assert len(building_paths) == 3
# Check that the building paths end with the dir of the building_id
assert building_paths[0].stem == "5a6b99ec37f4de7f94020090"
assert building_paths[1].stem == "5a72287837f4de77124f946a"
assert building_paths[2].stem == "5a7229e737f4de77124f946d"
# Check that the correct error is raised if the path doesn't exist
with pytest.raises(FileNotFoundError, match="File not found"):
json.get_building_paths(scenario_name="baseline")

def test_get_building_ids(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
building_names = json.get_building_names()
assert len(building_names) == 3
assert building_names[0] == "Medium Office"

def test_get_buildings(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
buildings = json.get_buildings(ids=None)
assert len(buildings) == 3
assert buildings[2]["properties"]["floor_area"] == 34448

def test_get_building_properties_by_id(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
building_properties = json.get_building_properties_by_id("5a72287837f4de77124f946a")
assert building_properties["floor_area"] == 24567

# These no longer throw exceptions, they just write to the log file.
# def test_get_meters_for_building(self):
# filename = self.data_dir / "geojson_1.json"
# json = UrbanOptGeoJson(filename)
# with pytest.raises(KeyError, match="No meters found"):
# json.get_meters_for_building("5a72287837f4de77124f946a")

# def test_get_meter_readings_for_building(self):
# filename = self.data_dir / "geojson_1.json"
# json = UrbanOptGeoJson(filename)
# with pytest.raises(KeyError, match="No meter readings found"):
# json.get_meter_readings_for_building(building_id="5a72287837f4de77124f946a", meter_type="Electricity")

# def test_get_monthly_readings(self):
# filename = self.data_dir / "geojson_1.json"
# json = UrbanOptGeoJson(filename)
# with pytest.raises(KeyError, match="No monthly readings found"):
# json.get_monthly_readings(building_id="5a72287837f4de77124f946a")

def test_set_property_on_building_id(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
building_id = "5a72287837f4de77124f946a"
property_name = "floor_area"
property_value = 12345
json.set_property_on_building_id(building_id, property_name, property_value)
assert json.get_building_properties_by_id(building_id)[property_name] == property_value

def test_get_property_by_building_id(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
building_id = "5a72287837f4de77124f946a"
property_name = "building_type"
assert json.get_property_by_building_id(building_id, property_name) == "Retail other than mall"

def test_get_site_lat_lon_none(self):
filename = self.data_dir / "geojson_1.json"
json = UrbanOptGeoJson(filename)
assert json.get_site_lat_lon() is None

def test_get_site_lat_lon(self):
filename = (
self.data_dir.parent.parent
/ "model_connectors"
/ "data"
/ "sdk_output_skeleton_13_buildings"
/ "exportGeo.json"
)
json = UrbanOptGeoJson(filename)
assert json.get_site_lat_lon() == [42.816772, -78.849485]
2 changes: 1 addition & 1 deletion tests/model_connectors/test_district_multi_ghe.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def setUp(self):
sys_params = SystemParameters(sys_param_filename)

# read the loop order and create building groups
loop_order: list = load_loop_order(sys_param_filename)
loop_order = load_loop_order(sys_param_filename)

# create ambient water stub
ambient_water_stub = NetworkDistributionPump(sys_params)
Expand Down
Loading

0 comments on commit 690231e

Please sign in to comment.