# (c) 2022 DTU Wind Energy
"""
WAsP Workspace module.
Module containing a class and related methods for importing WAsP workspace
XML and converting it to WindKit data structures.
"""
import tempfile
import warnings
import zipfile
from pathlib import Path
import lxml.etree as ET
import numpy as np
import pandas as pd
from packaging import version
from ._rvea_xml import _parse_rvea_generalised_mean_wind_climate
from .binned_wind_climate import _parse_owc
from .generalized_wind_climate import _weibull_to_dataset
from .metadata import _BWC_ATTRS, _WEIB_ATTRS, update_history, update_var_attrs
from .spatial import add_crs, create_dataset
from .vector_map import _crs_from_map_file, read_vector_map
_MAP_TYPE_MAPPING = {
"RoughnessChangeLines": "roughness",
"ElevationContours": "elevation",
"Boundaries": "boundaries",
}
[docs]
class Workspace:
"""
Workspace class for interacting with WAsP workspace XML files
Parameters
-------
tree : lxml.etree._ElementTree
WAsP workspace XML tree
path: str or Path
File path.
"""
[docs]
def __init__(self, tree, path):
self.tree = tree
self.path = path
self.root = tree.getroot()
self._id_map = {}
self._vector_map_id = []
self._met_mast_id = None
self.mast_coords = None
self._map_files = {}
self.catalogue = {}
self.crs = None
try:
self.format_version = self.root.attrib["FormatVersion"]
except (AttributeError, KeyError):
raise TypeError("Not a WWH file")
if version.parse(self.format_version) < version.parse("4.01.0004"):
raise NotImplementedError(
f"""The input WWH file has version {self.format_version} which is not supported. Only version 4.01.0004 and higher of WWH files can be loaded."""
)
self._create_catalogue()
self._add_parent()
self._add_children()
self._get_vector_map_id()
self._get_map_filenames()
self._update_map_files_dict()
self._update_met_mast_coords()
def __str__(self):
return self.__repr__()
def __repr__(self):
lst = []
allowed_objects = [
"Turbine site group",
"Generalised wind climate",
"Observed wind climate",
"Vector map",
]
for key in self.catalogue.keys():
if self.catalogue[key]["ObjectDescription"] in allowed_objects:
lst += [
[
key,
self.catalogue[key]["ObjectDescription"],
self.catalogue[key]["CRS"],
]
]
df = pd.DataFrame.from_records(lst)
df.columns = ["Object ID", "Object description", "Object CRS"]
return df.to_string(index=False)
def _get_desc(self): # pragma: no cover private_method
"""
Get a full list of XML objects and their relations
Returns
-------
str
Table with XML objects and their relations
"""
lst = []
for key in self.catalogue.keys():
lst += [
[
key,
self.catalogue[key]["ObjectDescription"],
self.catalogue[key]["ParentID"],
self.catalogue[key]["ChildrenID"],
]
]
df = pd.DataFrame.from_records(lst)
df.columns = [
"Object ID",
"Object description",
"Parent object ID",
"Children object ID",
]
return print(df.to_string(index=False))
[docs]
@classmethod
def read_wwh(cls, path):
"""
Instantiated Workspace class using WAsP Workspace Hierarchy (WWH) file.
Parameters
----------
path : str, path
Path to WAsP Workspace Hierarchy file to read.
Returns
-------
Workspace
Instantiated Workspace class.
"""
with zipfile.ZipFile(path) as myZip:
tree = ET.parse(myZip.open("Inventory.xml"))
return cls(tree, path)
def _create_catalogue(self):
"""
Creates catalogue of objects from WAsP workspace XML tree
Returns
-------
obj_cat : dict
Dictionary containing WAsP workspace objects
"""
search_template = '//*[@ID="{insert_id}"]'
obj_cat = {}
for i, elem in enumerate(self.root.iter("WaspHierarchyMember")):
ID = elem.attrib["ID"].replace("{", "").replace("}", "")
self._id_map.update({ID: i})
# there should be only one path!
path_binary = self.tree.xpath(search_template.replace("insert_id", ID))[0]
# converting binary path to string path
path_str = self.tree.getpath(path_binary).replace("/" + self.root.tag, ".")
obj_cat.update(
{
i: {
"ObjectDescription": elem.attrib["ClassDescription"],
"XPATH": path_str,
"ParentID": None,
"ChildrenID": [],
"CRS": {},
}
}
)
if elem.attrib["ClassDescription"] == "Met. station":
self._met_mast_id = i
self.catalogue = obj_cat
def _get_parent_id(self, ID):
"""
Gets object's parent ID
Parameters
----------
ID : int
ID of object for which parent ID is extracted
Returns
-------
int
ID of parent or 0 if it is root object
"""
elem = self._get_object_xml(ID)
parent_xml = elem.getparent().getparent()
if parent_xml != None:
ID = parent_xml.attrib["ID"].replace("{", "").replace("}", "")
return self._id_map[ID]
else:
return 0
def _add_parent(self):
"""
Adds IDs of parent objects to catalogue
"""
for key in self.catalogue.keys():
self.catalogue[key]["ParentID"] = self._get_parent_id(key)
def _add_children(self):
"""
Adds IDs of children objects to catalogue
"""
for key in self.catalogue.keys():
parent_id = self.catalogue[key]["ParentID"]
self.catalogue[parent_id]["ChildrenID"] += [key]
def _get_vector_map_id(self):
"""
Updates self._vector_map_id object ids corresponding to vector maps.
"""
for obj_id, obj in self.catalogue.items():
if obj["ObjectDescription"] == "Vector map":
self._vector_map_id += [obj_id]
def _get_map_filenames(self):
"""
Updates self._map_files with file names of vector maps in .wwh file
"""
if len(self._vector_map_id) > 0:
for ID in self._vector_map_id:
vector_map_xml = self._get_object_xml(ID).xpath(
"./ChildMembers/WaspHierarchyMember"
)
self._map_files.update({ID: {}})
for m in vector_map_xml:
_type = m.xpath("./MemberData/DataLayer")[0].attrib[
"DataPhenomenon"
]
_file_name = m.xpath("./MemberData/ExternalArchive")[0].attrib[
"ArchiveTagID"
]
self._map_files[ID].update(
{
_MAP_TYPE_MAPPING[_type]: {
"filename": _file_name,
"crs": None,
}
}
)
def _update_map_files_dict(self):
"""
Updates self._map_files with crs of vector maps in .wwh file
"""
crs_ls = []
if len(self._map_files) > 0:
for ID, metadata in self._map_files.items():
tempfolder = tempfile.TemporaryDirectory()
path_tmp = Path(tempfolder.name)
for map_type, info in metadata.items():
with zipfile.ZipFile(self.path) as myZip:
myZip.extract(info["filename"], path_tmp)
crs = _crs_from_map_file(path_tmp.joinpath(info["filename"]))
self._map_files[ID][map_type]["crs"] = crs
self.catalogue[ID]["CRS"].update({map_type: crs})
if crs is not None:
crs_ls += [crs]
tempfolder.cleanup()
crs_ls = np.unique(np.asarray(crs_ls))
if len(crs_ls) == 1:
self.crs = int(crs_ls[0])
def _update_met_mast_coords(self):
"""
Updates self.mast_coords which contains met mast position coordinates.
"""
if self._met_mast_id != None:
mast_xml = self._get_object_xml(self._met_mast_id)
height = float(
mast_xml.xpath("./MemberData/SiteInformation")[0].attrib[
"WorkingHeightAgl"
]
)
west_east = float(
mast_xml.xpath("./MemberData/SiteInformation/Location")[0].attrib[
"x-Location"
]
)
south_north = float(
mast_xml.xpath("./MemberData/SiteInformation/Location")[0].attrib[
"y-Location"
]
)
header = mast_xml.attrib["Description"]
self.mast_coords = {
"height": height,
"west_east": west_east,
"south_north": south_north,
"header": header,
"crs": self.crs,
}
def _get_object_xml(self, ID):
"""
Extracts objects XML from WAsP workspace XML
Parameters
----------
ID : int
ID of object to be extracted
Returns
-------
lxml.etree._Element
Object XML representation
"""
return self.root.find(self.catalogue[ID]["XPATH"])
def _write_object(self, ID, path): # pragma: no cover private_method
"""
Write XML object to XML file
Parameters
----------
ID : int
ID of object to be written to XML file
path : str, path
Path to XML file
Returns
-------
file
XML file on user machine
"""
return ET.ElementTree(self._get_object_xml(ID)).write(path)
def _print_object(self, ID): # pragma: no cover private_method
"""
Prints XML object
Parameters
----------
ID : int
XML object ide
Returns
-------
str
String of XML object
"""
return print(ET.tostring(self._get_object_xml(ID), encoding="unicode"))
[docs]
def get_owc(self, ID, crs=None):
"""
Converts XML representation of owc to WindKit bwc data structure.
Parameters
----------
ID : int
ID of bwc object in .wwh file.
crs : int, dict, str or pyproj.crs.CRS
Value to initialize `pyproj.crs.CRS`
Returns
-------
xarray.Dataset
xarray Dataset that is formatted to match the bwc description.
"""
if ID not in self.catalogue:
raise ValueError("ID does not exist in object catalogue")
if self.catalogue[ID]["ObjectDescription"] != "Observed wind climate":
raise ValueError("ID is not linked to binned wind climate")
if self.crs == None and crs == None:
raise ValueError(
"No CRS associated with observed wind climate, CRS must be provided!"
)
if crs == None and self.crs != None:
crs = self.crs
# subset XML
bwc_xml = self._get_object_xml(ID).xpath(
"./MemberData/RveaObservedWindClimate"
)[0]
bwc = _parse_owc(bwc_xml)
# adding mast location and crs
if self.mast_coords != None:
bwc.height.values = np.array([self.mast_coords["height"]])
bwc.south_north.values = np.array([self.mast_coords["south_north"]])
bwc.west_east.values = np.array([self.mast_coords["west_east"]])
return add_crs(bwc, self.crs)
bwc = add_crs(bwc, crs)
ds = update_var_attrs(bwc, _BWC_ATTRS)
return update_history(ds)
[docs]
def get_gwc(
self, ID, crs=None, west_east=None, south_north=None, height=None, header=None
):
"""
Converts XML representation of gwc to WindKit gwc data structure.
Parameters
----------
ID : int
ID of gwc object in .wwh file.
crs : int, dict, str or pyproj.crs.CRS
Value to initialize `pyproj.crs.CRS` Default, use from file.
west_east, south_north : float or None, optional
x- and y-coordinates of gwc location. Defaults to None, which means
the coordinates are taken from the met mast location or the file.
If the met mast location is not available, an error is raised.
height : float or None, optional
Height of gwc location. Defaults to None, which means the height
is taken from the met mast location or the file. If the met mast
location is not available, the height is set to 0 m.
header : str or None, optional
Header of gwc. Defaults to None, which means the header
is attempted to be taken from the met mast or the file.
Returns
-------
xarray.Dataset
xarray Dataset that is formatted to match the gwc description.
Raises
------
ValueError
If the ID does not exist in the catalogue.
ValueError
If the ID is not linked to a gwc.
ValueError
If the mast location is not available and the coordinates are not
provided.
ValueError
If no CRS is provided and the file does not contain a CRS.
Warns
-----
UserWarning
If no height is provided and the mast location is not available.
The height is set to 0 m.
"""
if ID not in self.catalogue:
raise ValueError("ID does not exist in object catalogue")
if self.catalogue[ID]["ObjectDescription"] != "Generalised wind climate":
raise ValueError("ID is not linked to generalised wind climate")
# subset XML
tree = self._get_object_xml(ID)
gwc_data = _parse_rvea_generalised_mean_wind_climate(
tree.find(".//RveaGeneralisedMeanWindClimate")
)
if west_east is not None and south_north is not None:
if crs is None:
warnings.warn("No CRS provided, defaulting to file CRS")
crs = self.crs
elif self.mast_coords is not None:
west_east = self.mast_coords["west_east"]
south_north = self.mast_coords["south_north"]
height = self.mast_coords["height"]
crs = self.mast_coords["crs"]
if header is None:
header = self.mast_coords["header"]
elif "longitude" in gwc_data and "latitude" in gwc_data:
west_east = gwc_data["longitude"]
south_north = gwc_data["latitude"]
crs = "EPSG:4326"
else:
raise ValueError(
"No location information provided, either provide coordinates or mast location"
)
if height is None:
height = 0.0
warnings.warn("No height provided, defaulting to 0 m")
if self.crs == None and crs == None:
raise ValueError(
"No CRS associated with generalised wind climate, CRS must be provided!"
)
if header is None:
header = ""
gwc = _weibull_to_dataset(
wdfreq=gwc_data["wdfreq"],
A=gwc_data["A"],
k=gwc_data["k"],
gen_roughness=gwc_data["gen_roughness"],
gen_height=gwc_data["gen_height"],
west_east=west_east,
south_north=south_north,
height=height,
crs=crs,
wasp_header=header,
)
gwc = add_crs(gwc, crs)
gwc = update_var_attrs(gwc, _WEIB_ATTRS)
return update_history(gwc)
[docs]
def get_vector_map(self, ID, map_type="elevation", crs=None):
"""
Extracts vector map from WWH and creates VectorMap object.
Parameters
----------
ID : int
ID of vector map object to be extracted.
map_type : str
Feature type to extract from mapfile
Possible values 'elevation', 'roughness'. Defaults to 'elevation'
crs : int, dict, str or pyproj.crs.CRS
Value to initialize `pyproj.crs.CRS` Default, use from file.
Returns
-------
geopandas.GeoDataFrame
Vector representation of the map.
"""
if map_type not in ["elevation", "roughness"]:
raise ValueError("""map_type must be either 'elevation' or 'roughness'""")
if ID not in self.catalogue:
raise ValueError("ID does not exist in object catalogue")
if self.catalogue[ID]["ObjectDescription"] != "Vector map":
raise ValueError("ID is not linked to vector map")
if crs == None and self._map_files[ID][map_type]["crs"] == None:
raise ValueError(
"No CRS associated with this vector map, CRS must be provided!"
)
if crs == None and self._map_files[ID][map_type]["crs"] != None:
crs = self._map_files[ID][map_type]["crs"]
filename = self._map_files[ID][map_type]["filename"]
tempfolder = tempfile.TemporaryDirectory()
path_tmp = Path(tempfolder.name)
with zipfile.ZipFile(self.path) as myZip:
myZip.extract(filename, path_tmp)
vector_map = read_vector_map(path_tmp.joinpath(filename), crs, map_type)
tempfolder.cleanup()
return vector_map
[docs]
def get_turbines(self, ID, crs=None):
"""
Extracts turbine positions from WWH and creates WindKit xarray DataArray.
Parameters
----------
ID : int
ID of turbine site group object to be converted.
crs : int, dict, str or pyproj.crs.CRS
Value to initialize `pyproj.crs.CRS` Default, use from file.
Returns
-------
xarray.DataArray
WindKit formated zero-filled data array with point dimension
"""
if ID not in self.catalogue:
raise ValueError("ID does not exist in object catalogue")
if self.catalogue[ID]["ObjectDescription"] != "Turbine site group":
raise ValueError("ID is not linked to turbine site group")
if crs == None and self.crs == None:
raise ValueError(
"No CRS associated with the turbine site group, CRS must be provided!"
)
if crs == None and self.crs != None:
crs = self.crs
height = []
x = []
y = []
for child in self.catalogue[ID]["ChildrenID"]:
if self.catalogue[child]["ObjectDescription"] == "Turbine site":
xml = self._get_object_xml(child).xpath("./MemberData/SiteInformation")[
0
]
height += [float(xml.attrib["WorkingHeightAgl"])]
x += [float(xml.xpath("./Location")[0].attrib["x-Location"])]
y += [float(xml.xpath("./Location")[0].attrib["y-Location"])]
return create_dataset(x, y, height, crs)
[docs]
def get_object(self, ID, map_type="elevation", crs=None):
"""
Extracts object from .wwh file.
Parameters
----------
ID : int
ID of object to be extracted.
map_type : str, optional
Feature type to extract from mapfile
Possible values 'elevation', 'roughness'. Defaults to 'elevation'.
crs : int, dict, str or pyproj.crs.CRS
Value to initialize `pyproj.crs.CRS` Default, use from file.
Returns
-------
gwc, bwc, vector map or xarray.Dataset of turbine positions
"""
if ID not in self.catalogue:
raise ValueError("ID does not exist in object catalogue")
if self.catalogue[ID]["ObjectDescription"] == "Vector map":
return self.get_vector_map(ID, map_type=map_type, crs=crs)
if self.catalogue[ID]["ObjectDescription"] == "Generalised wind climate":
ds = self.get_gwc(ID, crs=crs)
return update_history(ds)
if self.catalogue[ID]["ObjectDescription"] == "Observed wind climate":
ds = self.get_owc(ID, crs=crs)
return update_history(ds)
if self.catalogue[ID]["ObjectDescription"] == "Turbine site group":
return self.get_turbines(ID, crs=crs)
else:
raise TypeError(
"\nCan only convert: \n\t(1) Vector maps, \n\t(2) Generalised wind climate, \n\t(3) Observed wind climate and \n\t(4) Turbine site group"
)