Source code for spinneret.eml

"""EML metadata related operations"""

import json
import warnings
from math import isnan
from typing import List, Union
from lxml import etree


[docs]def get_geographic_coverage(eml: str) -> List["GeographicCoverage"]: """Get GeographicCoverage objects from EML metadata :param eml: path to EML metadata :return: list of geographicCoverage objects """ xml = etree.parse(eml) gc = xml.xpath(".//geographicCoverage") if len(gc) == 0: return None res = [] for item in gc: res.append(GeographicCoverage(item)) return res
[docs]class GeographicCoverage: """GeographicCoverage class""" def __init__(self, gc): self.gc = gc
[docs] def description(self) -> Union[str | None]: """Get geographicDescription element value from geographicCoverage :return: geographicDescription """ try: return self.gc.findtext(".//geographicDescription") except TypeError: return None
[docs] def west(self) -> Union[float | None]: """ Get westBoundingCoordinate element value from geographicCoverage :return: westBoundingCoordinate """ try: return float(self.gc.findtext(".//westBoundingCoordinate")) except TypeError: return None
[docs] def east(self) -> Union[float | None]: """Get eastBoundingCoordinate element value from geographicCoverage :return: eastBoundingCoordinate """ try: return float(self.gc.findtext(".//eastBoundingCoordinate")) except TypeError: return None
[docs] def north(self) -> Union[float | None]: """Get northBoundingCoordinate element value from geographicCoverage :return: northBoundingCoordinate """ try: return float(self.gc.findtext(".//northBoundingCoordinate")) except TypeError: return None
[docs] def south(self) -> Union[float | None]: """Get southBoundingCoordinate element value from geographicCoverage :return: southBoundingCoordinate """ try: return float(self.gc.findtext(".//southBoundingCoordinate")) except TypeError: return None
[docs] def altitude_minimum(self, to_meters=False) -> Union[float | None]: """Get altitudeMinimum element value from geographicCoverage :param to_meters: Convert to meters? :return: altitudeMinimum :notes: A conversion to meters is based on the value retrieved from the altitudeUnits element of the geographic coverage, and a conversion table from the EML specification. If the altitudeUnits element is not present, and the to_meters parameter is True, then the altitude value is returned as-is and a warning issued. """ try: res = float(self.gc.findtext(".//altitudeMinimum")) except TypeError: res = None if to_meters is True: res = self._convert_to_meters(x=res, from_units=self.altitude_units()) return res
[docs] def altitude_maximum(self, to_meters=False) -> Union[float | None]: """Get altitudeMaximum element value from geographicCoverage :param to_meters: Convert to meters? :return: altitudeMaximum :notes: A conversion to meters is based on the value retrieved from the altitudeUnits element of the geographic coverage, and a conversion table from the EML specification. If the altitudeUnits element is not present, and the to_meters parameter is True, then the altitude value is returned as-is and a warning issued. """ try: res = float(self.gc.findtext(".//altitudeMaximum")) except TypeError: res = None if to_meters is True: res = self._convert_to_meters(x=res, from_units=self.altitude_units()) return res
[docs] def altitude_units(self) -> Union[str | None]: """Get altitudeUnits element value from geographicCoverage :return: altitudeUnits """ try: return self.gc.findtext(".//altitudeUnits") except TypeError: return None
[docs] def outer_gring(self) -> Union[str | None]: """Get datasetGPolygonOuterGRing/gRing element value from geographicCoverage :return: datasetGPolygonOuterGRing/gRing element """ try: return self.gc.findtext(".//datasetGPolygonOuterGRing/gRing") except TypeError: return None
[docs] def exclusion_gring(self) -> Union[str | None]: """Get datasetGPolygonExclusionGRing/gRing element value from geographicCoverage :return: datasetGPolygonExclusionGRing/gRing """ try: return self.gc.findtext(".//datasetGPolygonExclusionGRing/gRing") except TypeError: return None
[docs] def geom_type(self, schema="eml") -> Union[str | None]: """Get geometry type from geographicCoverage :param: Schema dialect to use when returning values, either "eml" or "esri" :return: geometry type as "polygon", "point", or "envelope" for `schema="eml"`, or "esriGeometryPolygon", "esriGeometryPoint", or "esriGeometryEnvelope" for `schema="esri"` """ if self.gc.find(".//datasetGPolygon") is not None: if schema == "eml": return "polygon" return "esriGeometryPolygon" if self.gc.find(".//boundingCoordinates") is not None: if self.west() == self.east() and self.north() == self.south(): if schema == "eml": res = "point" else: res = "esriGeometryPoint" return res if schema == "eml": res = "envelope" else: res = "esriGeometryEnvelope" return res return None
[docs] def to_esri_geometry(self) -> Union[str | None]: """Convert geographicCoverage to ESRI JSON geometry :return: ESRI JSON geometry type as "polygon", "point", or "envelope" :notes: The logic here presumes that if a polygon is listed, it is the true feature of interest, rather than the associated boundingCoordinates, which are required to be listed by the EML spec alongside all polygon listings. Geographic coverage latitude and longitude are assumed to be in the spatial reference system of WKID 4326 and are inserted into the ESRI geometry as x and y values. Geographic coverages with altitudes and associated units are converted to units of meters and added to the ESRI geometry as z values. Geographic coverages that are point locations, as indicated by their bounding box latitude min and max values and longitude min and max values being equivalent, are converted to ESRI envelopes rather than ESRI points, because the envelope geometry type is more expressive and handles more usecases than the point geometry alone. Furthermore, point locations represented as envelope geometries produce the same results as if the point of location was represented as a point geometry. """ if self.geom_type() == "polygon": return self._to_esri_polygon() if self.geom_type() == "point": return ( self._to_esri_envelope() ) # Envelopes are more expressive and behave the same as point # geometries, so us envelopes if self.geom_type() == "envelope": return self._to_esri_envelope() return None
def _to_esri_envelope(self) -> str: """Convert boundingCoordinates to ESRI JSON envelope geometry :return: ESRI JSON envelope geometry :notes: Defaulting to WGS84 because the EML spec does not specify a CRS and notes the coordinates are meant to convey general information. """ altitude_minimum = self.altitude_minimum(to_meters=True) altitude_maximum = self.altitude_maximum(to_meters=True) res = { "xmin": self.west(), "ymin": self.south(), "xmax": self.east(), "ymax": self.north(), "zmin": altitude_minimum, "zmax": altitude_maximum, "spatialReference": {"wkid": 4326}, } return json.dumps(res) def _to_esri_polygon(self) -> str: """Convert datasetGPolygon to ESRI JSON polygon geometry :return: ESRI JSON polygon geometry :notes: Defaulting to WGS84 because the EML spec does not specify a CRS and notes the coordinates are meant to convey general information. """ def _format_ring(gring): # Reformat the string of coordinates into a list of lists ring = [] for item in gring.split(): x = item.split(",") # Try to convert the coordinates to floats. The EML spec does # not enforce strictly numeric values. try: ring.append([float(x[0]), float(x[1])]) except TypeError: ring.append([x[0], x[1]]) # Ensure that the first and last points are the same if ring[0] != ring[-1]: ring.append(ring[0]) return ring if self.outer_gring() is not None: ring = _format_ring(self.outer_gring()) res = {"rings": [ring], "spatialReference": {"wkid": 4326}} if self.exclusion_gring() is not None: ring = _format_ring(self.exclusion_gring()) res["rings"].append(ring) return json.dumps(res) return None @staticmethod def _convert_to_meters(x, from_units) -> Union[float | None]: """Convert an elevation from a given unit of measurement to meters. :param x: value to convert :param from_units: Units to convert from. This must be one of: meter, decimeter, dekameter, hectometer, kilometer, megameter, Foot_US, foot, Foot_Gold_Coast, fathom, nauticalMile, yard, Yard_Indian, Link_Clarke, Yard_Sears, mile. :return: value in meters """ if x is None: x = float("NaN") conversion_factors = _load_conversion_factors() conversion_factor = conversion_factors.get(from_units, float("NaN")) if not isnan( conversion_factor ): # Apply the conversion factor if from_units is a valid unit of # measurement otherwise return the length value as is x = x * conversion_factors.get(from_units, float("NaN")) if isnan(x): # Convert back to None, which is the NULL type returned by # altitude_minimum and altitude_maximum x = None return x
[docs] def to_geojson_geometry(self) -> Union[str | None]: """Convert geographicCoverage to GeoJSON geometry :return: GeoJSON geometry type as "polygon" or "point" :notes: The logic here presumes that if a polygon is listed, it is the true feature of interest, rather than the associated boundingCoordinates, which are required to be listed by the EML spec alongside all polygon listings. Geographic coverage latitude and longitude are assumed to be in the spatial reference system of WKID 4326 and are inserted into the GeoJSON geometry as x and y values. Geographic coverages with altitudes and associated units are converted to units of meters and added to the GeoJSON geometry as z values. Geographic coverages that are point locations, as indicated by their bounding box latitude min and max values and longitude min and max values being equivalent, are converted to GeoJSON points. """ if self.geom_type() == "polygon" or self.geom_type() == "envelope": return self._to_geojson_polygon() if self.geom_type() == "point": return self._to_geojson_point() return None
def _to_geojson_polygon(self) -> str: """Convert EML polygon or envelope to GeoJSON polygon geometry""" if self.geom_type() == "envelope": z = self._average_altitudes() coordinates = [ [self.west(), self.south(), z], [self.east(), self.south(), z], [self.east(), self.north(), z], [self.west(), self.north(), z], [self.west(), self.south(), z], ] coordinates = [list(filter(None, item)) for item in coordinates] res = { "type": "Polygon", "coordinates": [coordinates], } return json.dumps(res) if self.geom_type() == "polygon": def _format_ring(gring): # Reformat the string of coordinates into a list of lists ring = [] z = self._average_altitudes() for item in gring.split(): x = item.split(",") # Try to convert the coordinates to floats. The EML spec does # not enforce strictly numeric values. try: ring.append([float(x[0]), float(x[1]), z]) except TypeError: ring.append([x[0], x[1], z]) # Ensure that the first and last points are the same if ring[0] != ring[-1]: ring.append(ring[0]) # Remove None values to comply with GeoJSON spec ring = [list(filter(None, item)) for item in ring] return ring if self.outer_gring() is not None: ring = _format_ring(self.outer_gring()) # counter-clockwise res = {"type": "Polygon", "coordinates": [ring]} # if self.exclusion_gring() is not None: # ring = _format_ring(self.exclusion_gring()) # clockwise # res["coordinates"].append(ring) return json.dumps(res) return None def _to_geojson_point(self) -> Union[str | None]: """Convert EML point to GeoJSON point geometry""" if self.geom_type() != "point": return None z = self._average_altitudes() coordinates = [self.west(), self.north(), z] # Remove z values that are None to comply with GeoJSON spec coordinates = list(filter(None, coordinates)) res = {"type": "Point", "coordinates": coordinates} return json.dumps(res) def _average_altitudes(self) -> Union[float | None]: """Average the minimum and maximum altitudes :return: average altitude :notes: GeoJSON doesn't support a range of z values, so we'll just use the average of the minimum and maximum values. """ try: altitude_minimum = self.altitude_minimum(to_meters=True) altitude_maximum = self.altitude_maximum(to_meters=True) z = (altitude_minimum + altitude_maximum) / 2 if altitude_minimum != altitude_maximum: warnings.warn( "Altitude minimum and maximum are different. Using " "average value." ) except TypeError: z = None return z
def _load_conversion_factors() -> dict: """Load conversion factors :return: Dictionary of conversion factors for converting from common units of length to meters. """ conversion_factors = { "meter": 1, "decimeter": 1e-1, "dekameter": 1e1, "hectometer": 1e2, "kilometer": 1e3, "megameter": 1e6, "Foot_US": 0.3048006, "foot": 0.3048, "Foot_Gold_Coast": 0.3047997, "fathom": 1.8288, "nauticalMile": 1852, "yard": 0.9144, "Yard_Indian": 0.914398530744440774, "Link_Clarke": 0.2011661949, "Yard_Sears": 0.91439841461602867, "mile": 1609.344, } return conversion_factors