Source code for bhf_smarthealth.process.intraday_process

"""This module utilises functions from the parse.intraday_parser module to batch process raw Fitbit intraday activity data"""

import pandas as pd
import logging

from pathlib import Path

from bhf_smarthealth.log import metadata_logs
from bhf_smarthealth.parse import intraday_parser as bhf_intraday
from bhf_smarthealth.utils import df_utils, data_io
from bhf_smarthealth.exceptions import (
    RawDataProcessingError,
    EmptyFileError,
)
from bhf_smarthealth.models import DataStreamNames

from bhf_smarthealth.config import (
    DEFAULT_FB_INTRADAY_AGG_DICT,
    DEFAULT_FB_INTRADAY_AGG_SETTINGS,
    DEFAULT_FB_INTRADAY_COLUMN_OVERRIDES,
)

logger = logging.getLogger(__name__)


[docs] def process_file( part_path: Path, data_type: str, rename_columns: list[str] ) -> dict[str, pd.DataFrame]: # returns None if no file matches data_path = next(part_path.glob(f"Fitbit/*_{data_type}.json"), None) if not data_path: raise FileNotFoundError( f"No file matching the glob pattern 'Fitbit/*_{data_type}.json' could be found in the directory {part_path}" ) logger.info(f"Processing {data_path}") data_dict = bhf_intraday.load_json(data_path, data_type) parsed_data = bhf_intraday.validate_data(data_dict) data_df = bhf_intraday.create_dataframe(parsed_data) data_df = data_df.astype({"value": "int16"}) out_dict = {} # Perform both a daily and intraday aggregation of the Fitbit data for name, settings in DEFAULT_FB_INTRADAY_AGG_SETTINGS.items(): # get optional kwargs kwargs = settings.get("kwargs", {}) agg_df = df_utils.apply_agg_over_interval( df=data_df, agg_dict=DEFAULT_FB_INTRADAY_AGG_DICT[data_type], time_period=DEFAULT_FB_INTRADAY_AGG_SETTINGS[name]["time_period"], ID_col="sheffield_id", **kwargs, ) agg_df = df_utils.rename_columns( df=agg_df, col_names=rename_columns, ) # convert timestamp to date and set index for daily aggregate resolution if name == "summary": agg_df = ( agg_df.reset_index() .assign(date=lambda df_: df_["timestamp"].dt.date) .drop(columns=["timestamp"]) .set_index(DEFAULT_FB_INTRADAY_AGG_SETTINGS[name]["index"]) ) else: # ensure correct indices agg_df = agg_df.reset_index().set_index( [idx for idx in DEFAULT_FB_INTRADAY_AGG_SETTINGS[name]["index"]] ) out_dict[name] = agg_df logger.info( f"Processed {part_path}. Final {name} data length: {len(out_dict[name].values)}" ) return out_dict
[docs] def process_day( in_dir: Path, out_paths: dict[str, Path], data_type: str, date_str: str ): """Process a daily batch of raw Fitbit intraday activity .json files Parameters ---------- in_dir : Path Path of the directory containing raw data to batch process out_paths : dict[str, Path] Dictionary of Paths (values) to save each of the aggregation periods (keys) to data_type : str Intraday activity data type, e.g. heart, steps """ # Defines aggregation names we want to override overrides = DEFAULT_FB_INTRADAY_COLUMN_OVERRIDES rename_columns = [ # get string of column name from functools.partial filtered Series objects df_utils.get_agg_column_name( agg_type=agg_type, data_type=data_type, overrides=overrides ) for agg_type in DEFAULT_FB_INTRADAY_AGG_DICT[data_type]["value"] ] 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_file(part_path, data_type, rename_columns) 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: df_list.append( {key: pd.DataFrame() for key in DEFAULT_FB_INTRADAY_AGG_SETTINGS.keys()} ) valid_IDs_set = set() # Combine the DataFrames for both the Daily summary and intraday data for agg_resolution, settings in DEFAULT_FB_INTRADAY_AGG_SETTINGS.items(): unwrapped_list = [processed_file[agg_resolution] for processed_file in df_list] full_df = df_utils.merge_dataframes(unwrapped_list) if full_df.empty: logger.warning("Writing empty file..") data_io.save_dataframe_ouput(full_df, out_paths[agg_resolution]) continue data_io.save_dataframe_ouput(full_df, out_paths[agg_resolution]) logger.info( f"Merged DataFrame saved to {out_paths[agg_resolution]} with columns: {full_df.columns}" ) # add the valid IDs seen to the set of valid IDs. valid_IDs_set.update(full_df.reset_index()["sheffield_id"].unique().tolist()) # output metadata log of all IDs seen 'today' all_IDs = [str(part_path.stem) for part_path in part_paths] valid_IDs = list(valid_IDs_set) # Both summary and intraday stream receipts will have "fitbit" as the source stream, but will be saved out to different path names to be consolidated at a later stage. metadata_logs.generate_daily_IDs_receipt( all_IDs=all_IDs, valid_IDs=valid_IDs, data_stream=DataStreamNames.FITBIT, date_str=date_str, out_path=out_paths["metadata"], )