Source code for bhf_smarthealth.validate.fitbit_schema

"""This module contains pydantic validation models for the raw Fitbit data ingested into the Smart Health pipeline"""

import logging

from datetime import datetime, date
from pydantic import BaseModel, ConfigDict, model_validator, Field, RootModel
from typing_extensions import Self
from typing import Optional


logger = logging.getLogger(__name__)


[docs] class FitbitSummaryDatapoint(BaseModel): model_config = ConfigDict(validate_by_alias=True, extra="forbid") # Maps JSON key 'd' (datetime) to Python field 'timestamp' timestamp: datetime = Field(alias="dateTime") # Checks if value is a non-negative integer value: int = Field(ge=0) units: str
[docs] class FitbitSummaryData(BaseModel): """ Validation model for the Fitbit summary data received from Daiser. Compulsory fields: sheffield_id, timestamp, and specific activity metrics. All other fields are optional. Each of these fields should contain a nested JSON object containing a dateTime, value, and units field. """ # Raise ValidationError if any unexpected additional data fields are present model_config = ConfigDict(extra="forbid") # Compulsary fields # Derived from filename metadata sheffield_id: str date: date # Raw data fields calories: FitbitSummaryDatapoint minutesSedentary: FitbitSummaryDatapoint # Optional fields steps: Optional[FitbitSummaryDatapoint] = None minutesLightlyActive: Optional[FitbitSummaryDatapoint] = None minutesFairlyActive: Optional[FitbitSummaryDatapoint] = None minutesVeryActive: Optional[FitbitSummaryDatapoint] = None HeartRatePeakMinutes: Optional[FitbitSummaryDatapoint] = None HeartRateCardioMinutes: Optional[FitbitSummaryDatapoint] = None HeartRateFatBurnMinutes: Optional[FitbitSummaryDatapoint] = None HeartRateResting: Optional[FitbitSummaryDatapoint] = None minutesAsleep: Optional[FitbitSummaryDatapoint] = None minutesAwake: Optional[FitbitSummaryDatapoint] = None minutesToFallAsleep: Optional[FitbitSummaryDatapoint] = None timeInBed: Optional[FitbitSummaryDatapoint] = None efficiency: Optional[FitbitSummaryDatapoint] = None
[docs] @model_validator(mode="before") @classmethod def log_optional_fields_present(cls, data: dict) -> dict: required_fields = { key for key, value in cls.model_fields.items() if value.is_required() } # Any key in the dataset that is not a required key must be optional optional_fields = set( key for key in data.keys() if key in cls.model_fields.keys() and key not in required_fields ) if optional_fields: logger.info( f"Additional optional fields present: {sorted(list(optional_fields))}" ) return data
# Checks if the values of activity fields sum to 1440 minutes, which is the total minutes in a day
[docs] @model_validator(mode="after") def check_sum_of_minutes(self) -> Self: activity_fields = [ self.minutesSedentary, self.minutesLightlyActive, self.minutesFairlyActive, self.minutesVeryActive, ] available_activity_fields = [field.value for field in activity_fields if field] total_mins = sum(available_activity_fields) if total_mins != 24 * 60: raise ValueError( f"{total_mins} total minutes in activity fields is different than total minutes in a day" ) return self
# 1. Model for a single data point (inside "dataset")
[docs] class IntradayDataPoint(BaseModel): """ Represents a measurement for a single fitbit intraday timepoint. """ model_config = ConfigDict(validate_by_alias=True) # Maps JSON key 'd' (datetime) to Python field 'timestamp' timestamp: datetime = Field(alias="d") # Maps JSON key 'v' (value) to Python field 'value' value: int = Field(alias="v") # Extracted from file metadata sheffield_id: str
# 2. Model for the Intraday data container
[docs] class IntradayDataList(RootModel): """ Represents the full list of intraday data points captured for a single Intraday metric. It manages the Intraday time-series data, e.g. Steps, Heart Rate, Calories, etc collected by Fitbit. Please note, that while it should be compatible with other intraday measures from fitbit, it has only been tested with Steps and Heart Rate """ root: list[IntradayDataPoint]