Source code for bhf_smarthealth.validate.healthapp_schema
"""This module contains pydantic validation models for the raw Health App data ingested into the Smart Health pipeline"""
import logging
from datetime import datetime, date
from pydantic import (
BaseModel,
ConfigDict,
model_validator,
Field,
AfterValidator,
)
from typing import Optional, Union, List, Literal, Annotated
logger = logging.getLogger(__name__)
[docs]
def validate_offset(v: int) -> int:
"""Validate that the timezone minutes offset (from UTC+00:00) is a valid value
Parameters
----------
v : int
Value (in minutes) of timezone offset to validate
Returns
-------
int
Validated offset value (in minutes)
Raises
------
ValueError
Raised if the offset value is out of bounds or not in 15-minute increments
"""
# check range between westernmost (-12:00) and easternmost (+14:00) UTC offset
if not (-720 <= v <= 840):
raise ValueError(f"Offset {v} is out of bounds (-720 to 840)")
# check divisible by 15
if v % 15 != 0:
raise ValueError(f"Offset {v} is not aligned with 15-minute intervals")
return v
[docs]
class StandardField(BaseModel):
model_config = ConfigDict(extra="forbid")
source: str
unit: str
# 'whitelist' of expected Health App fields
type: Literal[
"HealthDataType.ACTIVE_ENERGY_BURNED",
"HealthDataType.BASAL_ENERGY_BURNED",
"HealthDataType.HEART_RATE",
"HealthDataType.RESTING_HEART_RATE",
"HealthDataType.STEPS",
"HealthDataType.FLIGHTS_CLIMBED",
"HealthDataType.SLEEP_ASLEEP",
"HealthDataType.SLEEP_AWAKE",
"HealthDataType.SLEEP_DEEP",
"HealthDataType.SLEEP_LIGHT",
"HealthDataType.SLEEP_REM",
"HealthDataType.WALKING_SPEED",
"HealthDataType.DISTANCE_DELTA",
]
value: Optional[float] = None
min_value: Optional[float] = None
max_value: Optional[float] = None
[docs]
@model_validator(mode="after")
def check_value_or_range(self) -> "StandardField":
has_val = self.value is not None
has_min = self.min_value is not None
has_max = self.max_value is not None
# must have min AND max, or neither
if has_min != has_max:
raise ValueError(
"Incomplete range of values. Both 'min_value' and 'max_value' must be present."
)
# check for either "value" or "min_value" AND "max_value", not both cases
if has_val == has_min:
raise ValueError(
"Provide either 'value' OR both 'min_value' and 'max_value'"
)
return self
[docs]
class IndividualWorkout(BaseModel):
# Raise ValidationError if any unexpected additional data fields are present
model_config = ConfigDict(extra="forbid")
energy: int
startTime: datetime
type: str
distance: Optional[float] # some workouts do not track a distance
endTime: datetime
# inherits all fields from the BaseHealtAppField model
[docs]
class WorkoutField(BaseModel):
type: Literal["WORKOUT"]
count: int
source: str
workouts: List[IndividualWorkout]
unit: str
totalDistance: Optional[float]
totalEnergy: int
[docs]
class HealthAppSummaryData(BaseModel):
"""
Validation model for the Health App data received from Daiser.
Compulsory fields: sheffield_id, date, date of synchronisation, the UTC timezone offset in mins, and specific activity metrics.
"""
# 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
sync_date: datetime
timezone_offset_mins: Annotated[int, AfterValidator(validate_offset)]
# the discriminator distinguishes between standard Health App fields and the nested "WORKOUT" fields
health_data: List[
Annotated[Union[WorkoutField, StandardField], Field(discriminator="type")]
]