diff --git a/geostructures/collections.py b/geostructures/collections.py index 992fb83..9b7f462 100644 --- a/geostructures/collections.py +++ b/geostructures/collections.py @@ -136,6 +136,184 @@ def from_fastkml_folder(cls, folder): return FeatureCollection(parse_fastkml(folder)) + @classmethod + def from_arcgis_featureclass( + cls, + feature_class_path: str, + time_start_property: Optional[str] = None, + time_end_property: Optional[str] = None, + time_fmt: Optional[Union[str, List[str]]] = None, + ): + """ + Creates an instance of the class from an ArcGIS feature class. + + Args: + feature_class_path (str): + The path to the feature class. + time_start_property (Optional[str]): + The name of the field containing the start time data. Defaults to None. + time_end_property (Optional[str]): + The name of the field containing the end time data. Defaults to None. + time_fmt (Optional[str] or [List[str]]): + The string format(s) of the time properties if they are strs. Defaults to None. + + Returns: + An instance of the class populated with geoshapes parsed from the feature class. + """ + from arcgis.features import GeoAcessor # noqa: F401 + import pandas as pd + from geostructures.parsers import parse_arcgis_featureclass + + _shapes = [] + # Convert the feature class into a Spatially Enabled DataFrame (SEDF) for further processing + sedf = pd.DataFrame.spatial.from_featureclass(feature_class_path) + property_columns = [col for col in sedf.columns if col != 'SHAPE'] + + # Get time values to determine the fmt needed if they are not strings and fmt was not provided + time_start_value, time_end_value = None, None + if time_start_property is not None: + time_start_value = getattr(sedf.iloc[0], time_start_property, None) + + if time_end_property is not None: + time_end_value = getattr(sedf.iloc[0], time_start_property, None) + + # Pull time format for time_start_property, if it is a string. + if time_start_value and isinstance(time_start_value, str): + if time_fmt is None: + time_fmt = [TimeInterval._get_timeformat(time_start_value)] + + if time_end_value and not isinstance(time_end_value, str): + raise TypeError( + f'Time formats cannot be mixed, ' + f'Start Time: {type(time_start_value)}, ' + f'End Time: {type(time_end_value)}' + ) + + # Pull time format for time_end_property, if it is a string. + if time_end_value and isinstance(time_end_value, str): + end_time_fmt = [TimeInterval._get_timeformat(time_end_value)] + if time_fmt != end_time_fmt: + if end_time_fmt not in time_fmt: + time_fmt.append(fmt for fmt in end_time_fmt) + + if time_start_value and not isinstance(time_start_value, str): + raise TypeError( + f'Time formats cannot be mixed, ' + f'Start Time: {type(time_start_value)}, ' + f'End Time: {type(time_end_value)}' + ) + + # Parse each row in SEDF to extract geoshapes and convert them into GeoShapes + for row in sedf.itertuples(): + _shapes.append( + parse_arcgis_featureclass( + row, + property_columns, + time_start_property, + time_end_property, + time_fmt + ) + ) + + return cls(_shapes) + + @classmethod + def from_arcpy_featureclass( + cls, + feature_class_path: str, + time_start_property: Optional[str] = None, + time_end_property: Optional[str] = None, + time_fmt: Optional[Union[str, List[str]]] = None, + ): + """ + Creates an instance of the class from an ArcGIS feature class. + + Args: + feature_class_path (str): + The path to the feature class. + time_start_property (Optional[str]): + The name of the field containing the start time data. Defaults to None. + time_end_property (Optional[str]): + The name of the field containing the end time data. Defaults to None. + time_fmt (Optional[str] or [List[str]]): + The string format(s) of the time properties if they are strs. Defaults to None. + + Returns: + An instance of the class populated with geoshapes parsed from the feature class. + """ + import arcpy # noqa: F401 + from geostructures.parsers import parse_arcpy_featureclass + + _shapes = [] + # Convert the feature class into a Spatially Enabled DataFrame (SEDF) for further processing + fields = ['SHAPE@'] + fields.extend([f.name for f in arcpy.ListFields(feature_class_path) if f.name not in fields]) + cursor = arcpy.da.SearchCursor(feature_class_path, fields) + if time_start_property and time_start_property not in fields: + raise ValueError(f'Invalid start time provided: {time_start_property}.') + + if time_end_property and time_end_property not in fields: + raise ValueError(f'Invalid end time provided: {time_end_property}.') + + # Get time values to determine the fmt needed if they are not strings and fmt was not provided + for row in cursor: + time_start_index, time_end_index = None, None + if time_start_property is not None: + time_start_index = fields.index(time_start_property) + + if time_end_property is not None: + time_end_index = fields.index(time_end_property) + + time_start_value, time_end_value = None, None + if time_start_index is not None: + time_start_value = row[time_start_index] + + if time_end_index is not None: + time_end_value = row[time_end_value] + + # Pull time format for time_start_property, if it is a string. + if time_start_value and isinstance(time_start_value, str): + if time_fmt is None: + time_fmt = [TimeInterval._get_timeformat(time_start_value)] + + if time_end_value and not isinstance(time_end_value, str): + raise TypeError( + f'Time formats cannot be mixed, ' + f'Start Time: {type(time_start_value)}, ' + f'End Time: {type(time_end_value)}' + ) + + # Pull time format for time_end_property, if it is a string. + if time_end_value and isinstance(time_end_value, str): + end_time_fmt = [TimeInterval._get_timeformat(time_end_value)] + if time_fmt != end_time_fmt: + if end_time_fmt not in time_fmt: + time_fmt.append(fmt for fmt in end_time_fmt) + + if time_start_value and not isinstance(time_start_value, str): + raise TypeError( + f'Time formats cannot be mixed, ' + f'Start Time: {type(time_start_value)}, ' + f'End Time: {type(time_end_value)}' + ) + + break + + cursor.reset() + # Parse each row in cursor to extract geoshapes + for row in cursor: + _shapes.append( + parse_arcpy_featureclass( + row, + fields, + time_start_property, + time_end_property, + time_fmt + ) + ) + + return cls(_shapes) + @classmethod def from_geojson( cls, @@ -405,6 +583,13 @@ def to_fastkml_folder(self, folder_name: str): features=[x.to_fastkml_placemark() for x in self.geoshapes] ) + def to_featureclass(self, geodatabase, filename): + from arcgis.features import GeoAccessor + + gdf = self.to_geopandas() + sedf = GeoAccessor.from_geodataframe(gdf) + sedf.spatial.to_featureclass(f'{geodatabase}\\{filename}') + def to_geojson(self, properties: Optional[Dict] = None, **kwargs): return { 'type': 'FeatureCollection', diff --git a/geostructures/parsers.py b/geostructures/parsers.py index fd079ba..c90c753 100644 --- a/geostructures/parsers.py +++ b/geostructures/parsers.py @@ -6,16 +6,19 @@ import json import re +from datetime import datetime from typing import cast, Any, Dict, List, Optional, Union from geostructures.collections import FeatureCollection from geostructures.structures import GeoPolygon, GeoPoint, GeoLineString from geostructures.multistructures import MultiGeoPoint, MultiGeoPolygon, MultiGeoLineString +from geostructures.time import TimeInterval from geostructures.typing import GeoShape, SimpleShape _PARSER_MAP: Dict[str, SimpleShape] = { 'POINT': GeoPoint, + 'POINTGEOMETRY': GeoPoint, 'LINESTRING': GeoLineString, 'POLYGON': GeoPolygon, 'MULTIPOINT': MultiGeoPoint, @@ -24,6 +27,153 @@ } +def _get_datetime_pandas(start_time, end_time): + """ + Converts pandas Timestamps to Python datetime objects and returns a TimeInterval. + + Args: + start_time (pd.Timestamp or None): The start time. + end_time (pd.Timestamp or None): The end time. + + Returns: + TimeInterval: The time interval representing the start and end time. + """ + import pandas as pd + from geostructures.time import TimeInterval + + if pd.notnull(start_time) or pd.notnull(end_time): + if isinstance(start_time, pd.Timestamp): + start_time = start_time.to_pydatetime() + + if isinstance(end_time, pd.Timestamp): + end_time = end_time.to_pydatetime() + + return TimeInterval(start_time, end_time) + + +def parse_arcgis_featureclass( + row, + columns, + time_start_property: Optional[Union[str, 'datetime']] = None, + time_end_property: Optional[Union[str, 'datetime']] = None, + time_fmt: Optional[Union[str, List[str]]] = None, +) -> GeoShape: + """ + Parses an ArcGIS feature class row into a geospatial structure. + + Args: + row: + The row from an ArcGIS feature class, typically obtained using an arcgis GeoAccessor. + + columns: + List of column names to extract attribute values from the row. + + time_start_property: + Optional; the column name or datetime representing the start time. + + time_end_property: + Optional; the column name or datetime representing the end time. + + time_fmt: + Optional; the format or list of formats for parsing time strings. + + Returns: + A geospatial object parsed from the feature class row, with attributes and time interval. + + Raises: + ValueError: If the geometry type is not supported. + """ + geometry = row.SHAPE + geometry_type_str = type(geometry).__name__.upper() + + if geometry_type_str not in _PARSER_MAP: + raise ValueError(f'Unsupported geometry type: {geometry_type_str}.') + + parser = _PARSER_MAP[geometry_type_str] + properties = {col: getattr(row, col) for col in columns} + time_start_value, time_end_value = None, None + if time_start_property is not None: + time_start_value = getattr(row, time_start_property, None) + + if time_end_property is not None: + time_end_value = getattr(row, time_end_property, None) + + dt = None + if time_start_value and isinstance(time_start_value, str): + dt = TimeInterval.from_str(time_start_value, time_end_value, time_fmt) + + elif time_start_value: + dt = TimeInterval(time_start_value, time_end_value) + + return parser.from_featureclass( + geometry, + dt=dt, + properties=properties + ) + + +def parse_arcpy_featureclass( + row, + fields, + time_start_property: Optional[Union[str, 'datetime']] = None, + time_end_property: Optional[Union[str, 'datetime']] = None, + time_fmt: Optional[Union[str, List[str]]] = None, +) -> GeoShape: + """ + Parses an ArcGIS feature class row into a geospatial structure. + + Args: + row: The row from an ArcGIS feature class, typically obtained using an arcpy cursor. + fields: List of column names to extract attribute values from the row. + time_start_property: Optional; the column name or datetime representing the start time. + time_end_property: Optional; the column name or datetime representing the end time. + time_fmt: Optional; the format or list of formats for parsing time strings. + + Returns: + A geospatial object parsed from the feature class row, with attributes and time interval. + + Raises: + ValueError: If the geometry type is not supported. + """ + geometry_index = fields.index('SHAPE@') + time_start_index, time_end_index = None, None + + if time_start_property in fields: + time_start_index = fields.index(time_start_property) + + if time_end_property in fields: + time_end_index = fields.index(time_end_property) + + geometry = row[geometry_index] + geometry_type_str = type(geometry).__name__.upper() + + if geometry_type_str not in _PARSER_MAP: + raise ValueError(f'Unsupported geometry type: {geometry_type_str}.') + + parser = _PARSER_MAP[geometry_type_str] + properties = dict(zip(fields, row)) + del properties['SHAPE@'] + time_start_value, time_end_value = None, None + if time_start_value is not None: + time_start_value = row[time_start_index] + + if time_end_value is not None: + time_end_value = row[time_end_index] + + dt = None + if time_start_value and isinstance(time_start_value, str): + dt = TimeInterval.from_str(time_start_value, time_end_value, time_fmt) + + elif time_start_value: + dt = TimeInterval(time_start_value, time_end_value) + + return parser.from_featureclass( + geometry, + dt=dt, + properties=properties + ) + + def parse_fastkml( kml, _shapes: Optional[List[GeoShape]] = None, diff --git a/geostructures/structures.py b/geostructures/structures.py index d52a42d..db188da 100644 --- a/geostructures/structures.py +++ b/geostructures/structures.py @@ -439,6 +439,103 @@ def copy(self): properties=copy.deepcopy(self._properties) ) + @classmethod + def from_featureclass( + cls, + geometry, + dt: Optional[GEOTIME_TYPE] = None, + properties: Optional[dict] = None, + ) -> 'GeoShape': + """ + Creates a GeoPolygon or MultiGeoPolygon from ESRI formatted geometry + + Args: + geometry: + The geometry of the feature + + dt: (Optional) + TimeInterval from the parser + + properties: (Optional) + the columns and values of the attributes of the feature + """ + from geostructures._geometry import is_counter_clockwise + from geostructures.multistructures import MultiGeoPolygon + + def _get_rings_from_part(part): + idx, rings = 0, [] + while idx < len(part): + ring = [] + while idx < len(part) and part[idx] is not None: + ring.append(part[idx]) + idx += 1 + + rings.append(ring) + idx += 1 + + return rings + + shapes = [] + if isinstance(geometry, dict) and 'rings' in geometry: + holes, idx, outline = [], 0, None + while idx < len(geometry['rings']): + ring = [Coordinate(*x) for x in geometry['rings'][idx]] + + if is_counter_clockwise(ring): + holes.append(GeoPolygon(ring)) + idx += 1 + continue + + if outline is not None: + shapes.append(GeoPolygon(outline, holes=holes or None)) + holes = [] + + outline = ring + idx += 1 + + if (not shapes) or outline != shapes[-1].bounds: + shapes.append(GeoPolygon(outline, holes=holes or None)) + + elif hasattr(geometry[0][0], 'X') and hasattr(geometry[0][0], 'Y'): + for part in geometry: + rings = _get_rings_from_part(part) + outline = [Coordinate(point.X, point.Y) for point in rings[0]] + holes = None + if len(rings) > 1: + holes = [ + GeoPolygon([ + Coordinate(point.X, point.Y) for point in ring + ]) for ring in rings[1:] + ] + + shapes.append(GeoPolygon(outline, holes=holes)) + + elif hasattr(geometry[0][0], 'centroid'): + for part in geometry: + rings = _get_rings_from_part(part) + outline = [Coordinate(point.centroid.X, point.centroid.Y) for point in rings[0]] + holes = None + if len(rings) > 1: + holes = [ + GeoPolygon([ + Coordinate(point.centroid.X, point.centroid.Y) for point in ring + ]) for ring in rings[1:] + ] + + shapes.append(GeoPolygon(outline, holes=holes)) + + else: + raise ValueError('Unable to extract shape from provided format.') + + if len(shapes) > 1: + return MultiGeoPolygon(shapes, dt=dt, properties=properties) + + shape = shapes[0] + shape._properties = properties + shape.dt = dt + + return shape + @classmethod def from_geojson( cls, @@ -1352,6 +1449,56 @@ def copy(self) -> 'GeoLineString': properties=copy.deepcopy(self._properties) ) + @classmethod + def from_featureclass( + cls, + geometry, + dt: Optional[GEOTIME_TYPE] = None, + properties: Optional[dict] = None, + ) -> 'GeoShape': + """ + Creates a GeoLineString or MultiGeoLineString from ESRI formatted geometry + + Args: + geometry: + The geometry of the feature + + dt: (Optional) + TimeInterval from the parser + + properties: (Optional) + the columns and values of the attributes of the feature + """ + from geostructures.multistructures import MultiGeoLineString + + lines = [] + if isinstance(geometry, dict) and 'paths' in geometry: + for part in geometry['paths']: + line = [Coordinate(point[0], point[1]) for point in part] + lines.append(GeoLineString(line)) + + elif hasattr(geometry[0][0], 'X') and hasattr(geometry[0][0], 'Y'): + for part in geometry: + line = [Coordinate(point.X, point.Y) for point in part] + lines.append(GeoLineString(line)) + + elif hasattr(geometry[0][0], 'centroid'): + for part in geometry: + line = [Coordinate(point.centroid.X, point.centroid.Y) for point in part] + lines.append(GeoLineString(line)) + + else: + raise ValueError('Unable to extract shape from provided format.') + + if len(lines) > 1: + return MultiGeoLineString(lines, dt=dt, properties=properties) + + line = lines[0] + line._properties = properties + line.dt = dt + + return lines + @classmethod def from_geojson( cls, @@ -1596,6 +1743,40 @@ def intersects_shape(self, shape: 'GeoShape', **kwargs) -> bool: return self == shape return self in shape + @classmethod + def from_featureclass( + cls, + geometry, + dt: Optional[GEOTIME_TYPE] = None, + properties: Optional[dict] = None, + ) -> 'GeoPoint': + """ + Creates a GeoPoint from ESRI formatted geometry + + Args: + geometry: + The geometry of the feature + + dt: (Optional) + TimeInterval from the parser + + properties: (Optional) + the columns and values of the attributes of the feature + """ + if isinstance(geometry, dict) and 'x' in geometry and 'y' in geometry: + coord = Coordinate(geometry['x'], geometry['y']) + + elif hasattr(geometry, 'X') and hasattr(geometry, 'Y'): + coord = Coordinate(geometry.X, geometry.Y) + + elif hasattr(geometry, 'centroid'): + coord = Coordinate(geometry.centroid.X, geometry.centroid.Y) + + else: + raise ValueError('Unable to extract shape from provided format.') + + return GeoPoint(coord, dt=dt, properties=properties) + @classmethod def from_geojson( cls,