Source code for bhf_smarthealth.process.gps_process
"""This module utilises functions from the parse.GPS_parser module to batch process raw GPS data"""
import logging
from pathlib import Path
import pandas as pd
from geopandas import GeoDataFrame
from pandas import DataFrame
from bhf_smarthealth.config import (
DEFAULT_GPS_AGG_DICT,
DEFAULT_GPS_OUTPUT_COLUMNS,
DEFAULT_REMOVE_GPS_COLUMNS,
DEFAULT_RENAME_GPS_COLUMNS,
DEFAULT_TIME_PERIOD,
DEFAULT_REPLACE_NON_MATCHED_LSOA_VALUES,
)
from bhf_smarthealth.exceptions import (
EmptyFileError,
RawDataProcessingError,
)
from bhf_smarthealth.log import metadata_logs
from bhf_smarthealth.models import DataStreamNames
from bhf_smarthealth.parse import gps_parser as bhf_gps
from bhf_smarthealth.utils import data_io, df_utils
logger = logging.getLogger(__name__)
[docs]
def process_gps_file(
part_path: Path, lsoa_data: GeoDataFrame
) -> DataFrame | GeoDataFrame:
"""Process an individual raw GPS .json file
Parameters
----------
part_path : Path
Path to subdirectory containing raw GPS .json file to be processed
lsoa_data : GeoDataFrame
DataFrame containing reference LSOA information
Returns
-------
DataFrame | GeoDataFrame
DataFrame containing the aggregated, pseudonymised LSOA data and mobility measures
Raises
------
FileNotFoundError
Raised if a *_gps.json file cannot be located in this subdirectory
"""
# returns None if no file matches
data_path = next(part_path.glob("*_gps.json"), None)
if not data_path:
raise FileNotFoundError(
f"No file matching the glob pattern '*_gps.json' could be found in the directory {part_path}"
)
logger.info(f"Processing {data_path}")
gps_json = bhf_gps.load_gps_json(data_path)
gps_val = bhf_gps.validate_gps_data(gps_json)
gps_gdf = bhf_gps.gps_to_dataframe(gps_val)
gps_gdf = bhf_gps.add_gps_as_geometry(gps_gdf)
gps_gdf["distance"] = bhf_gps.calculate_distances(gps_gdf)
gps_gdf["speed"] = bhf_gps.calculate_speeds(gps_gdf)
gps_gdf_clean = df_utils.apply_func_list_to_df(
df=gps_gdf,
func_list=[
bhf_gps.filter_speeds,
bhf_gps.filter_time_jumps,
],
)
gps_lsoa_gdf = bhf_gps.join_spatial_data(
gps_gdf_clean,
lsoa_data,
nan_replace_values=DEFAULT_REPLACE_NON_MATCHED_LSOA_VALUES,
)
lsoa_gdf = df_utils.remove_columns(
gps_lsoa_gdf, col_names=DEFAULT_REMOVE_GPS_COLUMNS
)
lsoa_gdf_clean = bhf_gps.nullify_lsoa_at_invalid_timepoints(
lsoa_gdf, drop_null_rows=True
)
lsoa_agg = df_utils.apply_agg_over_interval(
lsoa_gdf_clean,
agg_dict=DEFAULT_GPS_AGG_DICT,
time_period=DEFAULT_TIME_PERIOD,
ID_col="sheffield_id",
)
lsoa_agg = df_utils.rename_columns(
df=lsoa_agg,
col_names=DEFAULT_RENAME_GPS_COLUMNS,
)
logger.info(
f"Processed {part_path}. Final dataframe shape: {lsoa_agg.shape}. Dataframe columns: {lsoa_agg.columns} with index: {lsoa_agg.index.names}"
)
return lsoa_agg
[docs]
def process_day(
in_dir: Path, out_paths: dict[str, Path], lsoa_data: GeoDataFrame, date_str: str
):
"""Process a daily batch of raw GPS .json files
Parameters
----------
in_dir : Path
Path of the subdirectory of raw GPS data to batch process
out_paths : dict[str, Path]
Dictionary of Paths (values) for the required output tables (keys)
lsoa_data : GeoDataFrame
DataFrame containing reference LSOA information
"""
df_list = []
quality_counter = metadata_logs.DataQualityCounter()
part_paths = [
part_path for part_path in sorted(in_dir.iterdir()) if part_path.is_dir()
]
for part_path in part_paths:
try:
processed_df = process_gps_file(part_path, lsoa_data)
except EmptyFileError:
logger.error(f"File {part_path} is empty. Skipping file..")
quality_counter["empty"] += 1
continue
except RawDataProcessingError:
logger.exception(
f"Error with raw Health App data file at {part_path}. Skipping file.."
)
quality_counter["processing_error"] += 1
continue
except FileNotFoundError:
logger.error(f"File missing in {part_path}. Skipping file..")
quality_counter["missing"] += 1
continue
else:
df_list.append(processed_df)
quality_counter["valid"] += 1
# check whether enough valid data was processed
metadata_logs.evaluate_daily_processing_success(counter=quality_counter)
# back stop for DEFAULT_VALID_DATA_THRESHOLD being set to 0
if quality_counter["valid"] == 0:
full_df = pd.DataFrame()
valid_IDs = []
else:
full_df = df_utils.merge_dataframes(df_list)
valid_IDs = full_df.reset_index()["sheffield_id"].unique().tolist()
# Subset the dataframe and save the result to corresponding parquet file
for file, cols in DEFAULT_GPS_OUTPUT_COLUMNS.items():
if full_df.empty:
logger.warning("Writing empty file..")
data_io.save_dataframe_ouput(
full_df, out_paths[file]
) # save empty .parquet if insufficient data
else:
full_df_subset = full_df[cols]
full_df_subset = full_df_subset.rename(
# prepend the "n_samples" fields with the table name
columns={"n_samples": f"{file.split('_')[0]}_n_samples"}
)
data_io.save_dataframe_ouput(full_df_subset, out_paths[file])
logger.info(
f"Merged DataFrame saved to {out_paths[file]} with columns: {cols} and index: {full_df_subset.index.names}"
)
# output metadata log of all IDs seen 'today'
all_IDs = [str(part_path.stem) for part_path in part_paths]
metadata_logs.generate_daily_IDs_receipt(
all_IDs=all_IDs,
valid_IDs=valid_IDs,
data_stream=DataStreamNames.GPS,
date_str=date_str,
out_path=out_paths["metadata"],
)