diff --git a/README.md b/README.md index f6744e7..16c2a42 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,14 @@ from pygt3x.reader import FileReader with FileReader("FILENAME") as reader: df = reader.temperature_to_pandas() print(df.head(5)) -``` \ No newline at end of file +``` + +If your GT3X file contains Bluetooth heart rate data, you can read it using: + +```python +from pygt3x.reader import FileReader + +with FileReader("FILENAME") as reader: + df = reader.hr_to_pandas() + print(df.head(5)) +``` diff --git a/pygt3x/activity_payload.py b/pygt3x/activity_payload.py index def8785..802ed9e 100644 --- a/pygt3x/activity_payload.py +++ b/pygt3x/activity_payload.py @@ -171,3 +171,44 @@ def read_temperature_payload(payload_bytes, timestamp): (np.array(timestamp).repeat(len(data)).reshape(-1, 1), data), axis=1 ) return data + + +def read_heartrate_ble_payload(payload: bytes, timestamp: int): + """ + Parse Bluetooth LE Heart Rate Measurement (Type 14 / 0x0E). + + Format per Bluetooth spec (flags, HR, optional EE, optional RR-intervals) + + Returns two lists of tuples: + - hr_records: [(timestamp, hr_bpm), ...] + - rr_records: [(timestamp, hr_bpm, rr_seconds), ...] + """ + flags = payload[0] + offset = 1 + + # Heart Rate Value Format + hr_uint16 = bool(flags & 0x01) + if hr_uint16: + # ActiGraph devices only use 8-bit HR; ignore 16-bit case + return [], [] + hr = payload[offset] + offset += 1 + + # Energy Expended (skip if present) + if flags & (1 << 3): + offset += 2 + + hr_records = [(timestamp, hr)] + rr_records = [] + + # RR-Interval(s) present? + if flags & (1 << 4): + # each RR is a little-endian uint16, units 1/1024 sec + count = (len(payload) - offset) // 2 + for i in range(count): + raw = int.from_bytes(payload[offset : offset + 2], "little") + rr_s = raw / 1024.0 + rr_records.append((timestamp, hr, rr_s)) + offset += 2 + + return hr_records, rr_records diff --git a/pygt3x/reader.py b/pygt3x/reader.py index f4674ac..7648446 100644 --- a/pygt3x/reader.py +++ b/pygt3x/reader.py @@ -3,7 +3,7 @@ import json import logging from collections import Counter -from typing import Optional +from typing import Optional, List, Tuple from zipfile import ZipFile import numpy as np @@ -16,6 +16,7 @@ read_activity3_payload, read_nhanes_payload, read_temperature_payload, + read_heartrate_ble_payload, ) from pygt3x.calibration import CalibrationV2Service from pygt3x.components import Header, Info, RawEvent @@ -37,6 +38,8 @@ def __init__(self, file_name: str, num_rows: Optional[int] = None): self.file_name = file_name self.acceleration = np.empty((0, 5)) self.temperature = np.empty((0, 3)) + self.heart_rate_ble: List[Tuple[int, int]] = [] + self.heart_rate_ble_rr: List[Tuple[int, int, float]] = [] self.idle_sleep_mode_activated = None self.num_rows = num_rows self.nhanes = None @@ -255,6 +258,13 @@ def _get_data_default(self, num_rows=None): read_temperature_payload(evt.payload, evt.header.timestamp) ) continue + elif type == Types.HeartRateBle: + hr_recs, rr_recs = read_heartrate_ble_payload( + evt.payload, evt.header.timestamp + ) + self.heart_rate_ble.extend(hr_recs) + self.heart_rate_ble_rr.extend(rr_recs) + continue else: continue if payload.shape[0] > 0: @@ -325,6 +335,10 @@ def _get_data(self, num_rows=None): self.acceleration = np.concatenate(acceleration) if len(temperature) > 0: self.temperature = np.concatenate(temperature) + if self.heart_rate_ble: + self.heart_rate_ble = np.array(self.heart_rate_ble) + if self.heart_rate_ble_rr: + self.heart_rate_ble_rr = np.array(self.heart_rate_ble_rr) # Make sure each second appears sample rate times counter = Counter(self.acceleration[:, 0].astype(int)) @@ -426,6 +440,39 @@ def temperature_to_pandas(self, calibrate: bool = True): df.sort_index(kind="stable", inplace=True) return df + def hr_to_pandas(self): + """Return Bluetooth LE heart‐rate data as pandas DataFrame.""" + col_names = ["Timestamp", "HeartRate"] + # If no HR records, return empty DataFrame + if len(self.heart_rate_ble) == 0: + return pd.DataFrame( + columns=["HeartRate"], index=pd.DatetimeIndex([], name="Timestamp") + ) + # Build DataFrame from raw (timestamp, hr) + df = pd.DataFrame(self.heart_rate_ble, columns=col_names) + df.set_index("Timestamp", inplace=True, drop=True) + df = df.apply(lambda x: pd.to_numeric(x, downcast="float")) + df.sort_index(kind="stable", inplace=True) + return df + + def rr_to_pandas(self): + """Return Bluetooth LE heart‐rate RR‐interval data as pandas DataFrame.""" + # If no RR records, return empty DataFrame + if len(self.heart_rate_ble_rr) == 0: + return pd.DataFrame( + columns=["HeartRate", "RRInterval"], + index=pd.DatetimeIndex([], name="Timestamp"), + ) + + # Build DataFrame from raw (timestamp, hr, rr_s) + df = pd.DataFrame( + self.heart_rate_ble_rr, columns=["Timestamp", "HeartRate", "RRInterval"] + ) + df.set_index("Timestamp", inplace=True, drop=True) + df = df.apply(lambda col: pd.to_numeric(col, downcast="float")) + df.sort_index(kind="stable", inplace=True) + return df + class LogReader: """ diff --git a/tests/resources/hr/hf-test.gt3x b/tests/resources/hr/hf-test.gt3x new file mode 100644 index 0000000..f3c4e6e Binary files /dev/null and b/tests/resources/hr/hf-test.gt3x differ