Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,14 @@ from pygt3x.reader import FileReader
with FileReader("FILENAME") as reader:
df = reader.temperature_to_pandas()
print(df.head(5))
```
```

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))
```
41 changes: 41 additions & 0 deletions pygt3x/activity_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 48 additions & 1 deletion pygt3x/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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:
"""
Expand Down
Binary file added tests/resources/hr/hf-test.gt3x
Binary file not shown.