Source code for bhf_smarthealth.parse.gps_parser

"""This module contains functions to process raw GPS data ingressed to the Smart Health pipeline"""

import geopandas as gpd
import pandas as pd
import logging
import numpy as np

from typing import Any, List, Dict
from pydantic import ValidationError
from pathlib import Path
from bhf_smarthealth.exceptions import (
    DataValidationError,
    JSONStructureError,
    EmptyFileError,
)

from bhf_smarthealth.utils import data_io, df_utils
from bhf_smarthealth.validate import metadata_validation
from bhf_smarthealth.validate.gps_schema import GPSDataList
from bhf_smarthealth.config import (
    DEFAULT_GPS_CRS,
    DEFAULT_PLANAR_CRS,
    DEFAULT_DECIMAL_PLACES,
    DEFAULT_MAX_SPEED_MS,
    DEFAULT_MAX_TIME_JUMP_MINS,
)

logger = logging.getLogger(__name__)


[docs] def load_gps_json( file_path: str | Path, ) -> List[Dict[str, Any]]: """Load GPS from JSON file provided by DAISER Parameters ---------- file_path : str | Path Path where JSON file is located Returns ------- List[Dict[str, Any]] List of dictionaries containing unvalidated, raw GPS datapoints with sheffield_id and date appended Raises ------ KeyError Raised if the JSON file has an unexpected structure FileNotFoundError Raised if file not found ValueError Raised if file metadata cannot be validated """ data = data_io.load_data_from_file(file_path) try: time_series = data["gps-coordinates"]["dataset"] except KeyError as e: raise JSONStructureError( "GPS JSON file has an invalid structure. Missing key." ) from e if not time_series: raise EmptyFileError(f"No data to process in file {file_path}") # Extract a list of dates from the datapoints datetimes = [datapoint["d"] for datapoint in time_series if datapoint.get("d")] # Extract and validate the metadate in the filename metadata = metadata_validation.process_metadata(file_path, dates=datetimes) # Append the validated metadata to the raw data time_series_with_metadata = [ { **datapoint, "sheffield_id": metadata.sheffield_id, } for datapoint in time_series ] logger.info( f"Loaded {len(time_series_with_metadata)} GPS data points from {file_path}" ) return time_series_with_metadata
[docs] def validate_gps_data( gps_dict_list: List[Dict[str, Any]], ) -> GPSDataList: """Validate the dictionaries containing the raw GPS data using Pydantic Parameters ---------- gps_dict_list : List[GPSDataPoint] List of dictionaries containing raw GPS datapoints Returns ------- GPSDataList Pydantic model class containing the validated GPS data Raises ------ ValidationError Raised when input data is invalid """ try: return GPSDataList.model_validate(gps_dict_list) except ValidationError as e: # raised ValidationError is re-raised as a custom error that inherits from the RawDataProcessingError exception class raise DataValidationError(f"Schema validation failed: {e}") from e
[docs] def gps_to_dataframe( time_series: GPSDataList, ) -> pd.DataFrame: """Convert raw GPS pydantic model to pandas DataFrame Parameters ---------- time_series : GPSDataList Pydantic model class containing raw GPS data Returns ------- pd.DataFrame DataFrame containing raw GPS data """ gps_df = pd.DataFrame(time_series.model_dump()) gps_df = gps_df.rename(columns={"d": "timestamp"}) gps_df["timestamp"] = pd.to_datetime( gps_df["timestamp"], format="%Y-%m-%dT%H:%M:%S", errors="coerce" ) # Invalid datetimes are coerced to NaTs invalid_dates = gps_df["timestamp"].isna() if invalid_dates.any(): logger.warning(f"Found {invalid_dates.sum()} invalid datetime values") gps_df = gps_df.set_index("timestamp") return gps_df
[docs] def add_gps_as_geometry( gps_df: pd.DataFrame, crs: str = DEFAULT_GPS_CRS, ) -> gpd.GeoDataFrame: """Converts the raw GPS latitude and longitude to a geometry column Parameters ---------- gps_gdf : pd.DataFrame DataFrame containing raw GPS data crs : str, optional Coordinate Reference System to use for GPS data, by default "EPSG:4326" Returns ------- gpd.GeoDataFrame DataFrame containing raw GPS data, with geometry column Raises ------ KeyError Raised if DataFrame does not contain necessary latitude and/or longitude columns """ df_utils.validate_dataframe_columns(gps_df, ["lat", "long"]) gps_geom_series = gpd.GeoSeries.from_xy( gps_df["long"], gps_df["lat"], crs=crs, ) gps_gdf = gpd.GeoDataFrame(gps_df) gps_gdf = gps_gdf.set_geometry(gps_geom_series) return gps_gdf
[docs] def calculate_distances( gps_gdf: gpd.GeoDataFrame, crs: str = DEFAULT_PLANAR_CRS, decimal_places: int = DEFAULT_DECIMAL_PLACES, ) -> gpd.pd.Series: """Calculate distance between adjacent GeoDataFrame timepoints Parameters ---------- gps_gdf : gpd.GeoDataFrame DataFrame containing raw GPS data, with geometry column crs : str, optional Planar Coordinate Reference System to use for GPS data. Defaults to value set in config.py. decimal_places : int, optional Number of decimal places to round calculated distance to. Defaults to value set in config.py. If invalid value in config.py, will be set to 3. Returns ------- gpd.pd.Series Series containing calculated distances. Units are determined by the CRS (metres if using "EPSG:27700", British National Grid) Raises ------ KeyError Raised if DataFrame does not contain necessary geometry column ValueError Raised if decimal_places in not a non-negative integer Notes ----- - The data is reprojected to a planar coordiante reference system for accurate distance calculations. - The first value will be NaN as there is no previous point for comparison """ if decimal_places < 0: decimal_places = 3 logger.warning( "decimal_places must be a non-negative integer. Defaulting to 3." ) df_utils.validate_dataframe_columns(gps_gdf, ["geometry"]) gps_gdf_projected = gps_gdf.to_crs(crs) distances = gps_gdf_projected["geometry"].distance( gps_gdf_projected["geometry"].shift() ) # distance between adjacent timepoints return distances.round(decimal_places)
[docs] def calculate_speeds( gps_gdf: gpd.GeoDataFrame, decimal_places: int = DEFAULT_DECIMAL_PLACES, ) -> gpd.pd.Series: """Calculate speed of travel between adjacent GeoDataframe timepoints Parameters ---------- gps_gdf : gpd.GeoDataFrame Dataframe containing raw GPS data, with a datetime index and a distance column decimal_places : int, optional Number of decimal places to round calculated distance to. Defaults to value set in config.py. If invalid value in config.py, will be set to 3. Returns ------- gpd.pd.Series Series containing calculated speeds. Units are XX per second, where XX is the unit of distance in the distance column Raises ------ KeyError Raised if distance column is missing. AttributeError Raised if DataFrame does not have a datetime index ValueError Raised if decimal_places in not a non-negative integer Notes ----- - The first value will be NaN as there is no previous point for comparison """ if decimal_places < 0: decimal_places = 3 logger.warning( "decimal_places must be a non-negative integer. Defaulting to 3." ) df_utils.validate_dataframe_columns(gps_gdf, ["distance"]) df_utils.validate_datetime_index(gps_gdf) time_diffs = gps_gdf.index.to_series().diff().dt.total_seconds() # Avoid ZeroDivisionError by replacing zeroes (where time difference is duplicated) with NaN time_diffs = time_diffs.replace(0.0, np.nan) speed = gps_gdf["distance"] / time_diffs return speed.round(decimal_places)
[docs] def filter_speeds( gps_gdf: gpd.GeoDataFrame, max_speed_ms: float = DEFAULT_MAX_SPEED_MS, ) -> gpd.GeoDataFrame: """Filter rows from a GeoDataframe where the speed of travel is unrealistic, i.e. less than zero or greater than the max_speed_ms Parameters ---------- gps_gdf : gpd.GeoDataFrame Dataframe containing raw GPS data, with a speed column max_speed_ms : float, optional Maximum accepted speed in metres per second. Defaults to value set in config.py. Returns ------- gpd.GeoDataFrame Cleaned Dataframe Raises ------ KeyError Raised if speed column not in GeoDataframe Notes ----- - NaN values are not filtered out """ df_utils.validate_dataframe_columns(gps_gdf, ["speed"]) speed_mask = (gps_gdf["speed"] <= max_speed_ms) & (gps_gdf["speed"] >= 0) | gps_gdf[ "speed" ].isna() logger.debug(f"Removed {gps_gdf.shape[0] - sum(speed_mask)} invalid speed values") return gps_gdf[speed_mask]
[docs] def filter_time_jumps( gps_gdf: gpd.GeoDataFrame, max_jump_mins: int = DEFAULT_MAX_TIME_JUMP_MINS, ) -> gpd.GeoDataFrame: """Nullify distance and speed for rows following a large time jump. Parameters ---------- gps_gdf : gpd.GeoDataFrame Dataframe containing raw GPS data, with datetime index max_jump_mins : int, optional Maximum acceptable time jump in whole minutes. Defaults to value set in config.py. Returns ------- gpd.GeoDataFrame Cleaned Dataframe Raises ------ AttributeError Raised if index is not a datetime index Notes ----- This function sets distance and speed to NaN rather than removing rows as the location (LSOA) at that timepoint is still valid data """ df_utils.validate_datetime_index(gps_gdf) max_jump_seconds = max_jump_mins * 60 # to convert mins to seconds time_diffs = gps_gdf.index.to_series().diff().dt.total_seconds() jump_mask = time_diffs >= max_jump_seconds gps_gdf = gps_gdf.copy() # avoid inplace mutation gps_gdf.loc[jump_mask, ["distance", "speed"]] = np.nan logger.debug(f"Nullified {sum(jump_mask)} distance and speed values") return gps_gdf
[docs] def join_spatial_data( gps_gdf: gpd.GeoDataFrame | pd.DataFrame, ref_data: Any, nan_replace_values: dict[str, str] | None = None, **kwargs: Any, ) -> gpd.GeoDataFrame: """Spatial join for GPS and reference data, by default a left join with a within predicate Parameters ---------- gps_gdf : gpd.GeoDataFrame GPS GeoDataFrame ref_data : Any Reference data to spatial join to GPS data (most likely LSOA data) Returns ------- gpd.GeoDataFrame GPS GeoDataFrame with reference data joined Raises ------- KeyError Raised if the DataFrame doesn't have a geometry column """ if isinstance(gps_gdf, pd.DataFrame): df_utils.validate_dataframe_columns(gps_gdf, ["geometry"]) gps_gdf = gpd.GeoDataFrame(gps_gdf, geometry="geometry") defaults = { "how": "left", "predicate": "within", } kwargs = { **defaults, **kwargs, } gps_gdf = gps_gdf.sjoin( ref_data, **kwargs, ) logger.debug( f"Merged spatial data with DataFrame using the following conditions:\nHow: {kwargs['how']}, Predicate: {kwargs['predicate']}" ) if nan_replace_values: gps_gdf = gps_gdf.fillna(value=nan_replace_values) return gps_gdf
[docs] def nullify_lsoa_at_invalid_timepoints( df: gpd.GeoDataFrame | pd.DataFrame, lsoa_cols: list[str] = ["LSOA21CD", "LSOA21NM"], drop_null_rows: bool = True, ) -> gpd.GeoDataFrame | pd.DataFrame: """Set LSOA values to NULL for bad timepoints (i.e. distance is NULL) Parameters ---------- df : gpd.GeoDataFrame | pd.DataFrame GPS GeoDataFrame or DataFrame lsoa_cols: List[str], optional List of column names containing LSOA data drop_null_rows: bool , optional Optionally drop the rows where the distance and LSOA values are NULL Returns ------- gpd.GeoDataFrame | pd.DataFrame GPS GeoDataFrame or DataFrame Raises ------ KeyError Raised if distance or LSOA columns are not present """ df_utils.validate_dataframe_columns(df, ["distance", *lsoa_cols]) # Set LSOA to be NULL at invalid timepoints where distance is NULL (e.g. after a time jump) nan_mask = df["distance"].isna() df_copy = df.copy() # avoid inplace mutation df_copy.loc[nan_mask, lsoa_cols] = np.nan logger.debug(f"Nullified {sum(nan_mask)} LSOA values") if drop_null_rows: df_copy = df_copy.dropna(how="all", axis=0, subset=["distance", *lsoa_cols]) return df_copy