Heart Rate Variability Training with the Polar H10 Monitor

import os
os.environ['QT_API'] = 'PySide6' # For qasync to know which binding is being used
os.environ['QT_LOGGING_RULES'] = 'qt.pointer.dispatch=false' # Disable pointer logging

import sys
import asyncio
from PySide6.QtWidgets import QApplication
from qasync import QEventLoop
from View import View

if __name__ == "__main__":

    app = QApplication(sys.argv)
    loop = QEventLoop(app)
    asyncio.set_event_loop(loop)
    
    plot = View()
    plot.setWindowTitle("Rolling Plot")
    plot.resize(1200, 600)
    plot.show()

    loop.run_until_complete(plot.main())

EBYT.py
kbre93/every-breath-you-take
import numpy as np
import asyncio
from PolarH10 import PolarH10
import time
from Pacer import Pacer
from scipy import signal

class Model:

    def __init__(self):
        
        self.polar_sensor = None
        self.pacer = Pacer()
        self.breathing_circle_radius = -0.5
        self.hr_circle_radius = -0.5

        # Sample rates
        self.ACC_UPDATE_LOOP_PERIOD = 0.01 # s, time to sleep between accelerometer updates
        self.IBI_UPDATE_LOOP_PERIOD = 0.01 # s, time to sleep between IBI updates
        self.ACC_HIST_SAMPLE_RATE = 20 # Hz, rate to subsample acceleration (raw data @ 200 Hz)
        self.BR_ACC_SAMPLE_RATE = 10 # Hz, rate to subsample breathing acceleration
        
        # History sizes
        self.BR_ACC_HIST_SIZE = 1200 # 
        self.IBI_HIST_SIZE = 400 # roughly number of seconds (assuming 60 bpm avg)
        self.HRV_HIST_SIZE = 500 
        self.BR_HIST_SIZE = 500 # Fast breathing 20 breaths per minute, sampled once every breathing cycle, over 10 minutes this is 200 values

        # Accelerometer signal parameters
        self.GRAVITY_ALPHA = 0.999 # Exponential mean filter for gravity
        self.ACC_MEAN_ALPHA = 0.98 # Exponential mean filter for noise
        self.acc_principle_axis = np.array([0, 0, 1]) # Positive z-axis is the direction out of sensor unit (away from chest)
        
        # Breathing signal parameters
        self.BR_MAX_FILTER = 30 # breaths per minute maximum

        # HRV signal parameters
        self.IBI_MIN_FILTER = 300 # ms
        self.IBI_MAX_FILTER = 1600 # ms
        self.HRV_MIN_FILTER = 0.2 # percentage allowable of last two HRV values

        # Initialisation
        self.acc_gravity = np.full(3, np.nan)
        self.acc_zero_centred_exp_mean = np.zeros(3)
        self.t_last_breath_acc_update = 0
        self.ibi_latest_phase_duration = 0
        self.ibi_last_phase = 0
        self.ibi_last_extreme = 0
        self.br_last_phase = 0
        self.current_br = 0

        # History array initialisation
        self.breath_acc_hist = np.full(self.BR_ACC_HIST_SIZE, np.nan)
        self.breath_acc_times = np.full(self.BR_ACC_HIST_SIZE, np.nan)
        self.breath_acc_times_rel_s = np.full(self.BR_ACC_HIST_SIZE, np.nan)

        self.br_psd_freqs_hist = []
        self.br_psd_values_hist = []
        
        self.ibi_values_hist = np.full(self.IBI_HIST_SIZE, np.nan)
        self.ibi_times_hist_rel_s = np.full(self.IBI_HIST_SIZE, np.nan) 
        self.ibi_values_interp_hist = [] # Interpolated IBI values
        self.ibi_times_interp_hist = [] # Interpolated IBI times
        self.ibi_values_last_cycle = [] # IBI values in the last breathing cycle
        self.hr_values_hist = np.full(self.IBI_HIST_SIZE, np.nan)
        
        self.hrv_values_hist = np.full(self.HRV_HIST_SIZE, np.nan) # HR range based on local IBI extrema
        self.hrv_times_hist = np.arange(-self.HRV_HIST_SIZE, 0) 

        self.hrv_psd_freqs_hist = []
        self.hrv_psd_values_hist = []

        self.hr_coherence = np.nan
        self.br_coherence = np.nan
        
        self.br_values_hist = np.full(self.BR_HIST_SIZE, np.nan)
        self.br_times_hist = np.full(self.BR_HIST_SIZE, np.nan) 
        self.br_times_hist_rel_s = np.full(self.BR_HIST_SIZE, np.nan) 
        
        self.rmssd_values_hist = np.full(self.BR_HIST_SIZE, np.nan)
        self.maxmin_values_hist = np.full(self.BR_HIST_SIZE, np.nan) # Max-min HR in a breathing cycle

        self.br_pace_values_hist = np.full(self.BR_HIST_SIZE, np.nan)

        self.hr_extrema_ids = np.full(self.HRV_HIST_SIZE, -1, dtype=int)
        self.breath_cycle_ids = np.full(self.BR_HIST_SIZE, -1, dtype=int)
        
    def set_polar_sensor(self, device):
        self.polar_sensor = PolarH10(device)

    async def connect_sensor(self):
        await self.polar_sensor.connect()
        await self.polar_sensor.get_device_info()
        await self.polar_sensor.print_device_info()
        
    async def disconnect_sensor(self):
        await self.polar_sensor.disconnect()

    def update_hrv(self):

        # Update duration and determine the current phase
        self.ibi_latest_phase_duration += self.ibi_values_hist[-1]
        current_ibi_phase = np.sign(self.ibi_values_hist[-1] - self.ibi_values_hist[-2])
        
        # Exit if the phase is constant or zero
        if current_ibi_phase == 0 or current_ibi_phase == self.ibi_last_phase:
            return

        # Calculate latest HRV and phase duration
        current_ibi_extreme = self.ibi_values_hist[-2]
        latest_hrv = abs(self.ibi_last_extreme - current_ibi_extreme)
        seconds_current_phase = np.ceil(self.ibi_latest_phase_duration / 1000.0)

        # Exit if the HRV is too low
        if latest_hrv < self.HRV_MIN_FILTER*(np.amin(self.hrv_values_hist[-2:])):
            print(f"Rejected low HRV value")
            return

        # Update HRV and IBI history
        self.hrv_values_hist = np.roll(self.hrv_values_hist, -1)
        self.hrv_values_hist[-1] = latest_hrv

        self.hrv_times_hist = self.hrv_times_hist - seconds_current_phase
        self.hrv_times_hist = np.roll(self.hrv_times_hist, -1)
        self.hrv_times_hist[-1] = 0

        self.hr_extrema_ids = np.roll(self.hr_extrema_ids, -1)
        self.hr_extrema_ids[-1] = self.IBI_HIST_SIZE - 2
        
        self.ibi_latest_phase_duration = 0
        self.ibi_last_extreme = current_ibi_extreme
        self.ibi_last_phase = current_ibi_phase

    def update_hrv_spectrum(self):
        
        # Taking only last 30 seconds
        ids = self.ibi_times_hist_rel_s > (self.ibi_times_hist_rel_s[-1] - 30) # Hardcode 30 seconds
        ids = np.logical_and(ids, ~np.isnan(self.ibi_values_hist))
        
        # Interpolate with fixed interval
        values = self.ibi_values_hist[ids]
        times = self.ibi_times_hist_rel_s[ids]
        t_start = times[0] 
        t_end = times[-1]
        dt = 60.0/90.0 # Assume a max of 90 bpm, maximum of 0.75 Hz
        self.ibi_times_interp_hist = np.arange(t_start, t_end+dt, dt)
        self.ibi_values_interp_hist = np.interp(self.ibi_times_interp_hist, times, values)
        
        # Calculate HRV spectrum
        self.hrv_psd_freqs_hist, self.hrv_psd_values_hist = signal.periodogram(self.ibi_values_interp_hist, fs=1/dt, window='hann', detrend='linear')
        self.hrv_psd_values_hist /= np.sum(self.hrv_psd_values_hist)

        # Interpolating the power spectral density, to get sub-bin integration
        hrv_psd_freqs_interp = np.arange(self.hrv_psd_freqs_hist[0], self.hrv_psd_freqs_hist[-1], 0.005)
        hrv_psd_interp = np.interp(hrv_psd_freqs_interp, self.hrv_psd_freqs_hist, self.hrv_psd_values_hist)
        
        # Calculating total power and peak power
        pacer_freq = self.pacer.last_breathing_rate/60.0
        total_power = np.trapz(hrv_psd_interp, hrv_psd_freqs_interp)
        peak_indices = np.where((hrv_psd_freqs_interp >= pacer_freq - 0.015) & (hrv_psd_freqs_interp <= pacer_freq + 0.015)) # 0.03 Hz around the peak is recommended by R. McCraty
        peak_power = np.trapz(hrv_psd_interp[peak_indices], hrv_psd_freqs_interp[peak_indices])

        self.hr_coherence = peak_power/total_power

    async def update_ibi(self):
        await self.polar_sensor.start_hr_stream()

        while True:
            await asyncio.sleep(self.IBI_UPDATE_LOOP_PERIOD)
            
            # Updating IBI history
            while not self.polar_sensor.ibi_queue_is_empty():
                t, ibi = self.polar_sensor.dequeue_ibi() # t is when value was added to the queue
                
                # Skip unreasonably low or high values
                if ibi < self.IBI_MIN_FILTER or ibi > self.IBI_MAX_FILTER:
                    continue

                # Update IBI and HR history
                self.ibi_values_hist = np.roll(self.ibi_values_hist, -1)
                self.ibi_values_hist[-1] = ibi
                self.hr_values_hist = 60.0/(self.ibi_values_hist/1000.0)

                self.ibi_times_hist_rel_s = -np.flip(np.cumsum(np.flip(self.ibi_values_hist)))/1000.0 # seconds relative to the last value
                self.ibi_times_hist_rel_s = np.roll(self.ibi_times_hist_rel_s, -1)
                self.ibi_times_hist_rel_s[-1] = 0

                # Update index of heart rate extrema
                self.hr_extrema_ids = self.hr_extrema_ids - 1
                self.hr_extrema_ids[self.hr_extrema_ids < -1] = -1
                
                self.update_hrv()
                # self.update_hrv_spectrum()

    def update_breathing_rate(self):

        # Update the breathing rate and pacer history
        if np.isnan(self.br_times_hist[-1]):
            self.br_values_hist = np.roll(self.br_values_hist, -1)
            self.br_values_hist[-1] = self.current_br

            self.br_pace_values_hist = np.roll(self.br_pace_values_hist, -1)
            self.br_pace_values_hist[-1] = 0

            self.br_times_hist = np.roll(self.br_times_hist, -1)
            self.br_times_hist[-1] = self.breath_acc_times[-1]

            self.rmssd_values_hist = np.roll(self.rmssd_values_hist, -1)
            self.rmssd_values_hist[-1] = 0

            self.maxmin_values_hist = np.roll(self.maxmin_values_hist, -1)
            self.maxmin_values_hist[-1] = 0
        else:

            # Update the breathing rate history
            self.br_values_hist = np.roll(self.br_values_hist, -1)
            self.br_values_hist[-1] = self.current_br

            self.br_pace_values_hist = np.roll(self.br_pace_values_hist, -1)
            self.br_pace_values_hist[-1] = self.pacer.last_breathing_rate  

            self.br_times_hist = np.roll(self.br_times_hist, -1)
            self.br_times_hist[-1] = self.breath_acc_times[-1]

            # Update the RMSSD history
            ibi_indices_in_cycle = self.ibi_times_hist_rel_s > (self.br_times_hist[-2] - time.time_ns()/1.0e9)
            self.ibi_values_last_cycle = self.ibi_values_hist[ibi_indices_in_cycle]
            ibi_ssd = self.ibi_values_hist[ibi_indices_in_cycle] - self.ibi_values_hist[np.roll(ibi_indices_in_cycle, -1)]
            rmssd = np.sqrt(np.mean(ibi_ssd**2))
            self.rmssd_values_hist = np.roll(self.rmssd_values_hist, -1)
            self.rmssd_values_hist[-1] = rmssd

            # Update the max-min history
            maxmin = np.max(self.ibi_values_hist[ibi_indices_in_cycle]) - np.min(self.ibi_values_hist[ibi_indices_in_cycle])
            self.maxmin_values_hist = np.roll(self.maxmin_values_hist, -1)
            self.maxmin_values_hist[-1] = maxmin

    def update_breathing_spectrum(self):
        if np.sum(~np.isnan(self.breath_acc_times)) < 3:
            return

        ids = self.breath_acc_times > (self.breath_acc_times[-1] - 30) # Hardcode 30 seconds
        values = self.breath_acc_hist[ids]
        times = self.breath_acc_times[ids]
        dt = times[-1] - times[-2]

        # Calculate the spectrum
        self.br_psd_freqs_hist, self.br_psd_values_hist = signal.periodogram(values, fs=1/dt, window='hann', detrend='linear')
        self.br_psd_values_hist /= np.sum(self.br_psd_values_hist)

        # Interpolating the power spectral density, to get sub-bin integration
        br_psd_freqs_interp = np.arange(self.br_psd_freqs_hist[0], self.br_psd_freqs_hist[-1], 0.005)
        br_psd_interp = np.interp(br_psd_freqs_interp, self.br_psd_freqs_hist, self.br_psd_values_hist)
        
        # Calculating total power and peak power
        pacer_freq = self.pacer.last_breathing_rate/60.0
        total_power = np.trapz(br_psd_interp, br_psd_freqs_interp)
        peak_indices = np.where((br_psd_freqs_interp >= pacer_freq - 0.015) & (br_psd_freqs_interp <= pacer_freq + 0.015)) # 0.03 Hz around the peak is recommended by R. McCraty
        peak_power = np.trapz(br_psd_interp[peak_indices], br_psd_freqs_interp[peak_indices])

        self.br_coherence = peak_power/total_power

    def update_acc_vectors(self, acc):
        # Update the gravity acc estimate (intialised to the first value)
        if np.isnan(self.acc_gravity).any():
            self.acc_gravity = acc
        else:
            self.acc_gravity = self.GRAVITY_ALPHA*self.acc_gravity + (1-self.GRAVITY_ALPHA)*acc

        # Unbias and denoise the acceleration
        acc_zero_centred = acc - self.acc_gravity
        self.acc_zero_centred_exp_mean = self.ACC_MEAN_ALPHA*self.acc_zero_centred_exp_mean + (1-self.ACC_MEAN_ALPHA)*acc_zero_centred

    def update_breathing_cycle(self):
        # Returns new_breathing_cycle

        self.breath_cycle_ids = self.breath_cycle_ids - 1
        self.breath_cycle_ids[self.breath_cycle_ids < -1] = -1

        current_br_phase = np.sign(self.breath_acc_hist[-1])

        # Exit if the phase hasn't changed or is non-negative
        if current_br_phase == self.br_last_phase or current_br_phase >= 0:
            self.br_last_phase = current_br_phase
            return 0

        # Calculate the breathing rate
        self.current_br = 60.0 / (self.breath_acc_times[-1] - self.br_times_hist[-1])
        
        # Filter out high breathing rates
        if self.current_br > self.BR_MAX_FILTER:
            return 0 

        # Save the index of the end of the cycle, the point of descending zero-crossing
        self.breath_cycle_ids = np.roll(self.breath_cycle_ids, -1)
        self.breath_cycle_ids[-1] = self.BR_ACC_HIST_SIZE - 1

        self.br_last_phase = current_br_phase

        if self.breath_cycle_ids[-2] < 0:
            return 0
        else: 
            return 1

    def update_breathing_acc(self, t):
        # Returns new_breathing_acc

        if t - self.t_last_breath_acc_update > 1/self.BR_ACC_SAMPLE_RATE:
            self.breath_acc_hist = np.roll(self.breath_acc_hist, -1)
            self.breath_acc_hist[-1] = np.dot(self.acc_zero_centred_exp_mean, self.acc_principle_axis)
            self.breath_acc_times = np.roll(self.breath_acc_times, -1)
            self.breath_acc_times[-1] = t
            self.t_last_breath_acc_update = t
            return 1
        else:  
            return 0

    def get_breath_circle_coords(self):
        
        if ~np.isnan(self.breath_acc_hist[-1]):
            self.breathing_circle_radius = 0.7*self.breath_acc_hist[-1] + (1-0.7)*self.breathing_circle_radius
        else: 
            self.breathing_circle_radius = -0.5
        self.breathing_circle_radius = np.min([np.max([self.breathing_circle_radius + 0.5, 0]), 1])

        x = self.breathing_circle_radius * self.pacer.cos_theta
        y = self.breathing_circle_radius * self.pacer.sin_theta
        return (x, y)

    async def update_acc(self): # pmd: polar measurement data
        
        await self.polar_sensor.start_acc_stream()
        
        while True:
            await asyncio.sleep(self.ACC_UPDATE_LOOP_PERIOD)
            
            # Updating the acceleration history
            while not self.polar_sensor.acc_queue_is_empty():
                # Get the latest sensor data
                t, acc = self.polar_sensor.dequeue_acc()

                # Update the acceleration vectors
                self.update_acc_vectors(acc)

                # Update breathing acceleration history
                new_breathing_acc = self.update_breathing_acc(t)

                # Update the breathing acceleration history
                if new_breathing_acc:
                    self.update_breathing_spectrum()
                    new_breathing_cycle = self.update_breathing_cycle()
                    
                    if new_breathing_cycle:
                        self.update_breathing_rate()


    
Model.py
kbre93/every-breath-you-take
import numpy as np
import time
from PySide6.QtCore import QObject


class Pacer(QObject):
    def __init__(self):
        super().__init__()

        theta = np.linspace(0, 2 * np.pi, 40)
        self.cos_theta = np.cos(theta)
        self.sin_theta = np.sin(theta)
        self.last_breathing_rate = 1
        self.phase = 0

    def breathing_pattern(self, breathing_rate, time):
        """Returns radius of pacer disk.

        Radius is modulated according to sinusoidal breathing pattern
        and scaled between 0 and 1.
        """
        if breathing_rate != self.last_breathing_rate: # Maintaining continuity by adjusting phase when breathing rate is changes
            self.phase = time - self.last_breathing_rate*(time - self.phase)/breathing_rate
            self.last_breathing_rate = breathing_rate

        radius = 0.5 + 0.5 * np.sin(2 * np.pi * breathing_rate / 60 * (time - self.phase))
        return radius

    def update(self, breathing_rate):
        """Update radius of pacer disc.

        Make current disk radius a function of real time (i.e., don't
        precompute radii with fixed time interval) in order to compensate for
        jitter or delay in QTimer calls.
        """
        radius = self.breathing_pattern(breathing_rate, time.time())
        x = radius * self.cos_theta
        y = radius * self.sin_theta
        return (x, y)

Pacer.py
kbre93/every-breath-you-take
from bleak import BleakClient
import asyncio
import time
import numpy as np
import math

class CircularBuffer2D:
    def __init__(self, rows, cols):
        self.rows = rows
        self.cols = cols
        self.buffer = np.full((rows, cols), np.nan) 
        self.head = 0
        self.tail = 0
        self.dequeued_row = np.full((1,3), np.nan) 
    
    def enqueue(self, new_row):
        if len(new_row) != self.cols:
            raise ValueError("New row must have the same number of columns as the buffer")
        
        if self.is_full():
            print("Overwriting circular buffer!")
            print(f"Head id: {self.head}, Number of rows: {self.rows}")
            self.tail = (self.tail + 1) % self.rows
        
        self.buffer[self.head] = new_row
        self.head = (self.head + 1) % self.rows

    def dequeue(self):
        if self.is_empty():
            print(f"Circular buffer is empty! Head id: {self.head}")
            print(f"value at head: {self.buffer[self.head]}")
            print(f"value befor head: {self.buffer[self.head-1]}")
            return None
        
        self.dequeued_row = np.array(self.buffer[self.tail]) # Returns nan without np.array()
        self.buffer[self.tail] = np.full(self.cols, np.nan)
        self.tail = (self.tail + 1) % self.rows

        return self.dequeued_row
    
    def is_full(self):
        return (self.head == self.tail) and not np.isnan(np.array(self.buffer[self.tail])).all()

    def is_empty(self):
        return np.isnan(np.array(self.buffer[self.tail])).any()
    
    def get_num_in_queue(self):
        if self.is_empty():
            return 0
        else:
            if self.head > self.tail:
                return self.head - self.tail
            else:
                return self.rows - (self.tail - self.head)
    

class PolarH10:
    ## HEART RATE SERVICE
    HEART_RATE_SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb"
    # Characteristics
    HEART_RATE_MEASUREMENT_UUID = "00002a37-0000-1000-8000-00805f9b34fb"    # notify
    BODY_SENSOR_LOCATION_UUID = "00002a38-0000-1000-8000-00805f9b34fb"      # read

    ## USER DATA SERVICE
    USER_DATA_SERVICE_UUID = "0000181c-0000-1000-8000-00805f9b34fb"
    # Charateristics
    # ...

    ## DEVICE INFORMATION SERVICE
    DEVICE_INFORMATION_SERVICE = "0000180a-0000-1000-8000-00805f9b34fb"
    MANUFACTURER_NAME_UUID = "00002a29-0000-1000-8000-00805f9b34fb"
    MODEL_NBR_UUID = "00002a24-0000-1000-8000-00805f9b34fb"
    SERIAL_NUMBER_UUID = "00002a25-0000-1000-8000-00805f9b34fb"
    HARDWARE_REVISION_UUID = "00002a27-0000-1000-8000-00805f9b34fb"
    FIRMWARE_REVISION_UUID = "00002a26-0000-1000-8000-00805f9b34fb"
    SOFTWARE_REVISION_UUID = "00002a28-0000-1000-8000-00805f9b34fb"
    SYSTEM_ID_UUID = "00002a23-0000-1000-8000-00805f9b34fb"

    ## BATERY SERIVCE
    BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
    BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"

    ## UNKNOWN 1 SERVICE
    U1_SERVICE_UUID = "6217ff4b-fb31-1140-ad5a-a45545d7ecf3"
    U1_CHAR1_UUID = "6217ff4c-c8ec-b1fb-1380-3ad986708e2d"      # read
    U1_CHAR2_UUID = "6217ff4d-91bb-91d0-7e2a-7cd3bda8a1f3"      # write-without-response, indicate

    ## Polar Measurement Data (PMD) Service
    PMD_SERVICE_UUID = "fb005c80-02e7-f387-1cad-8acd2d8df0c8"
    PMD_CHAR1_UUID = "fb005c81-02e7-f387-1cad-8acd2d8df0c8" #read, write, indicate – Request stream settings?
    PMD_CHAR2_UUID = "fb005c82-02e7-f387-1cad-8acd2d8df0c8" #notify – Start the notify stream?

    # POLAR ELECTRO Oy SERIVCE
    ELECTRO_SERVICE_UUID = "0000feee-0000-1000-8000-00805f9b34fb"
    ELECTRO_CHAR1_UUID = "fb005c51-02e7-f387-1cad-8acd2d8df0c8" #write-without-response, write, notify
    ELECTRO_CHAR2_UUID = "fb005c52-02e7-f387-1cad-8acd2d8df0c8" #notify
    ELECTRO_CHAR3_UUID = "fb005c53-02e7-f387-1cad-8acd2d8df0c8" #write-without-response, write

    # START PMD STREAM REQUEST
    HR_ENABLE = bytearray([0x01, 0x00])
    HR_DISABLE = bytearray([0x00, 0x00])

    # ECG and ACC Notify Requests
    ECG_WRITE = bytearray([0x02, 0x00, 0x00, 0x01, 0x82, 0x00, 0x01, 0x01, 0x0E, 0x00])
    ACC_WRITE = bytearray([0x02, 0x02, 0x00, 0x01, 0xC8, 0x00, 0x01, 0x01, 0x10, 0x00, 0x02, 0x01, 0x08, 0x00])

    ACC_SAMPLING_FREQ = 200
    ECG_SAMPLING_FREQ = 130

    def __init__(self, bleak_device):
        self.bleak_device = bleak_device
        self.acc_stream_start_time = None
        self.ibi_data = None
        self.ibi_queue_values = CircularBuffer2D(200,1)
        self.ibi_queue_times = CircularBuffer2D(200,1)
        self.acc_queue_values = CircularBuffer2D(200,3)
        self.acc_queue_times = CircularBuffer2D(200,1)
        self.ecg_queue_values = CircularBuffer2D(200,1)
        self.ecg_queue_times = CircularBuffer2D(200,1)
        self.polar_to_epoch_s = 0
        self.first_acc_record = True
        self.first_ecg_record = True
    
    def hr_data_conv(self, sender, data):  
        """
        `data` is formatted according to the GATT Characteristic and Object Type 0x2A37 Heart Rate Measurement which is one of the three characteristics included in the "GATT Service 0x180D Heart Rate".
        `data` can include the following bytes:
        - flags
            Always present.
            - bit 0: HR format (uint8 vs. uint16)
            - bit 1, 2: sensor contact status
            - bit 3: energy expenditure status
            - bit 4: RR interval status
        - HR
            Encoded by one or two bytes depending on flags/bit0. One byte is always present (uint8). Two bytes (uint16) are necessary to represent HR > 255.
        - energy expenditure
            Encoded by 2 bytes. Only present if flags/bit3.
        - inter-beat-intervals (IBIs)
            One IBI is encoded by 2 consecutive bytes. Up to 18 bytes depending on presence of uint16 HR format and energy expenditure.
        """
        byte0 = data[0] # heart rate format
        uint8_format = (byte0 & 1) == 0
        energy_expenditure = ((byte0 >> 3) & 1) == 1
        rr_interval = ((byte0 >> 4) & 1) == 1

        if not rr_interval:
            return

        first_rr_byte = 2
        if uint8_format:
            hr = data[1]
            pass
        else:
            hr = (data[2] << 8) | data[1] # uint16
            first_rr_byte += 1
        
        if energy_expenditure:
            # ee = (data[first_rr_byte + 1] << 8) | data[first_rr_byte]
            first_rr_byte += 2

        for i in range(first_rr_byte, len(data), 2):
            ibi = (data[i + 1] << 8) | data[i]
            # Polar H7, H9, and H10 record IBIs in 1/1024 seconds format.
            # Convert 1/1024 sec format to milliseconds.
            # TODO: move conversion to model and only convert if sensor doesn't
            # transmit data in milliseconds.
            ibi = np.ceil(ibi / 1024 * 1000)            
            self.ibi_queue_values.enqueue(np.array([ibi]))
            self.ibi_queue_times.enqueue(np.array([time.time_ns()/1.0e9]))

    def acc_data_conv(self, sender, data): 
    # [02 EA 54 A2 42 8B 45 52 08 01 45 FF E4 FF B5 03 45 FF E4 FF B8 03 ...]
    # 02=ACC, 
    # EA 54 A2 42 8B 45 52 08 = last sample timestamp in nanoseconds, 
    # 01 = ACC frameType, 
    # sample0 = [45 FF E4 FF B5 03] x-axis(45 FF=-184 millig) y-axis(E4 FF=-28 millig) z-axis(B5 03=949 millig) , 
    # sample1, sample2,

        if data[0] == 0x02:
            time_step = 0.005 # 200 Hz sample rate
            timestamp = PolarH10.convert_to_unsigned_long(data, 1, 8)/1.0e9 # timestamp of the last sample in the record
            
            frame_type = data[9]
            resolution = (frame_type + 1) * 8 # 16 bit
            step = math.ceil(resolution / 8.0)
            samples = data[10:] 
            n_samples = math.floor(len(samples)/(step*3))
            record_duration = (n_samples-1)*time_step # duration of the current record received in seconds

            if self.first_acc_record: # First record at the start of the stream
                stream_start_t_epoch_s = time.time_ns()/1.0e9 - record_duration
                stream_start_t_polar_s = timestamp - record_duration
                self.polar_to_epoch_s = stream_start_t_epoch_s - stream_start_t_polar_s
                self.first_acc_record = False

            sample_timestamp = timestamp - record_duration + self.polar_to_epoch_s # timestamp of the first sample in the record in epoch seconds
            offset = 0
            while offset < len(samples):
                x = PolarH10.convert_array_to_signed_int(samples, offset, step)/100.0
                offset += step
                y = PolarH10.convert_array_to_signed_int(samples, offset, step)/100.0
                offset += step
                z = PolarH10.convert_array_to_signed_int(samples, offset, step)/100.0
                offset += step

                self.acc_queue_times.enqueue(np.array([sample_timestamp]))
                self.acc_queue_values.enqueue(np.array([x, y, z]))

                sample_timestamp += time_step
    
    def ecg_data_conv(self, sender, data):
    # [00 EA 1C AC CC 99 43 52 08 00 68 00 00 58 00 00 46 00 00 3D 00 00 32 00 00 26 00 00 16 00 00 04 00 00 ...]
    # 00 = ECG; EA 1C AC CC 99 43 52 08 = last sample timestamp in nanoseconds; 00 = ECG frameType, sample0 = [68 00 00] microVolts(104) , sample1, sample2, ....
        if data[0] == 0x00:
            timestamp = PolarH10.convert_to_unsigned_long(data, 1, 8)/1.0e9
            step = 3
            time_step = 1.0/ self.ECG_SAMPLING_FREQ
            samples = data[10:]
            n_samples = math.floor(len(samples)/step)
            offset = 0
            recordDuration = (n_samples-1)*time_step

            if self.first_ecg_record:
                stream_start_t_epoch_s = time.time_ns()/1.0e9 - recordDuration
                stream_start_t_polar_s = timestamp - recordDuration
                self.polar_to_epoch_s = stream_start_t_epoch_s - stream_start_t_polar_s
                self.first_ecg_record = False

            sample_timestamp = timestamp - recordDuration + self.polar_to_epoch_s # timestamp of the first sample in the record in epoch seconds
            while offset < len(samples):
                ecg = PolarH10.convert_array_to_signed_int(samples, offset, step)       
                offset += step
                self.ecg_queue_values.enqueue(np.array([ecg]))
                self.ecg_queue_times.enqueue(np.array([sample_timestamp]))
                sample_timestamp += time_step

    @staticmethod
    def convert_array_to_signed_int(data, offset, length):
        return int.from_bytes(
            bytearray(data[offset : offset + length]), byteorder="little", signed=True,
        )
    @staticmethod
    def convert_to_unsigned_long(data, offset, length):
        return int.from_bytes(
            bytearray(data[offset : offset + length]), byteorder="little", signed=False,
        )
    
    async def connect(self):
        self.bleak_client = BleakClient(self.bleak_device)
        await self.bleak_client.connect()
    
    async def disconnect(self):
        await self.bleak_client.disconnect()

    async def get_device_info(self):
        self.model_number = await self.bleak_client.read_gatt_char(PolarH10.MODEL_NBR_UUID)
        self.manufacturer_name = await self.bleak_client.read_gatt_char(PolarH10.MANUFACTURER_NAME_UUID)
        self.serial_number = await self.bleak_client.read_gatt_char(PolarH10.SERIAL_NUMBER_UUID)
        self.battery_level = await self.bleak_client.read_gatt_char(PolarH10.BATTERY_LEVEL_UUID)
        self.firmware_revision = await self.bleak_client.read_gatt_char(PolarH10.FIRMWARE_REVISION_UUID)
        self.hardware_revision = await self.bleak_client.read_gatt_char(PolarH10.HARDWARE_REVISION_UUID)
        self.software_revision = await self.bleak_client.read_gatt_char(PolarH10.SOFTWARE_REVISION_UUID)
    
    async def print_device_info(self):
        BLUE = "\033[94m"
        RESET = "\033[0m"
        print(f"Model Number: {BLUE}{''.join(map(chr, self.model_number))}{RESET}\n"
            f"Manufacturer Name: {BLUE}{''.join(map(chr, self.manufacturer_name))}{RESET}\n"
            f"Serial Number: {BLUE}{''.join(map(chr, self.serial_number))}{RESET}\n"
            f"Address: {BLUE}{self.bleak_device.address}{RESET}\n"
            f"Battery Level: {BLUE}{int(self.battery_level[0])}%{RESET}\n"
            f"Firmware Revision: {BLUE}{''.join(map(chr, self.firmware_revision))}{RESET}\n"
            f"Hardware Revision: {BLUE}{''.join(map(chr, self.hardware_revision))}{RESET}\n"
            f"Software Revision: {BLUE}{''.join(map(chr, self.software_revision))}{RESET}")

    async def start_acc_stream(self):
        await self.bleak_client.write_gatt_char(PolarH10.PMD_CHAR1_UUID, PolarH10.ACC_WRITE, response=True)
        await self.bleak_client.start_notify(PolarH10.PMD_CHAR2_UUID, self.acc_data_conv)
        print("Collecting ACC data...", flush=True)

    async def stop_acc_stream(self):
        await self.bleak_client.stop_notify(PolarH10.PMD_CHAR2_UUID)
        print("Stopping ACC data...", flush=True)

    async def start_ecg_stream(self):
        await self.bleak_client.write_gatt_char(PolarH10.PMD_CHAR1_UUID, PolarH10.ECG_WRITE, response=True)
        await self.bleak_client.start_notify(PolarH10.PMD_CHAR2_UUID, self.ecg_data_conv)
        print("Collecting ECG data...", flush=True)

    async def stop_ecg_stream(self):
        await self.bleak_client.stop_notify(PolarH10.PMD_CHAR2_UUID)
        print("Stopping ECG data...", flush=True)

    async def start_hr_stream(self):
        await self.bleak_client.start_notify(PolarH10.HEART_RATE_MEASUREMENT_UUID, self.hr_data_conv)
        print("Collecting HR data...", flush=True)

    async def stop_hr_stream(self):
        await self.bleak_client.stop_notify(PolarH10.HEART_RATE_MEASUREMENT_UUID)
        print("Stopping HR data...", flush=True)

    def dequeue_acc(self):
        value_row = self.acc_queue_values.dequeue()
        time_row = self.acc_queue_times.dequeue()
        return time_row, value_row

    def acc_queue_is_full(self):
        return self.acc_queue_values.is_full()
    
    def acc_queue_is_empty(self):
        return self.acc_queue_values.is_empty() or self.acc_queue_times.is_empty()

    def get_num_in_acc_queue(self):
        return self.acc_queue_values.get_num_in_queue()

    def dequeue_ecg(self):
        value_row = self.ecg_queue_values.dequeue()
        time_row = self.ecg_queue_times.dequeue()
        return time_row, value_row
    
    def ecg_queue_is_full(self):
        return self.ecg_queue_values.is_full()
    
    def ecg_queue_is_empty(self):
        return self.ecg_queue_values.is_empty() or self.ecg_queue_times.is_empty()
    
    def get_num_in_ecg_queue(self):
        return self.ecg_queue_values.get_num_in_queue()

    def dequeue_ibi(self):
        value_row = self.ibi_queue_values.dequeue()
        time_row = self.ibi_queue_times.dequeue()
        return time_row, value_row
    
    def ibi_queue_is_full(self):
        return self.ibi_queue_values.is_full()
    
    def ibi_queue_is_empty(self):
        return self.ibi_queue_values.is_empty() or self.ibi_queue_times.is_empty()
    
    def get_num_in_ibi_queue(self):
        return self.ibi_queue_values.get_num_in_queue()

    
PolarH10.py
kbre93/every-breath-you-take

import asyncio
from PySide6.QtCore import QTimer, Qt, QPointF, QMargins, QSize, QFile
from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QSizePolicy, QSlider, QLabel, QWidget, QGridLayout
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis, QScatterSeries, QSplineSeries, QAreaSeries
from PySide6.QtGui import QPen, QColor, QPainter, QFont
from bleak import BleakScanner
import time
import numpy as np
from Model import Model

'''
TODO: 
- Abstract the historic series type
- Exit the program nicely
'''

class CirclesWidget(QChartView):
    def __init__(self, x_values=None, y_values=None, pacer_color=None, breathing_color=None, hr_color=None):
        super().__init__()

        self.setSizePolicy(
            QSizePolicy(
                QSizePolicy.Fixed,  # enforce self.sizeHint by fixing horizontal (width) policy
                QSizePolicy.Preferred,
            )
        )
        
        self.scene().setBackgroundBrush(Qt.white)
        self.setAlignment(Qt.AlignCenter)

        self.plot = QChart()
        self.plot.legend().setVisible(False)
        self.plot.setBackgroundRoundness(0)
        self.plot.setMargins(QMargins(0, 0, 0, 0))

        # Pacer disc
        self.pacer_circumference_coord = QSplineSeries()
        self.disk = QAreaSeries(self.pacer_circumference_coord)
        self.disk.setColor(pacer_color)
        self.plot.addSeries(self.disk)

        # Breathing disc
        self.breath_circumference_coord = QSplineSeries()
        pen = QPen(breathing_color)
        pen.setWidth(2)
        self.breath_circumference_coord.setPen(pen)
        self.plot.addSeries(self.breath_circumference_coord)   

        if x_values is not None and y_values is not None:
            self._instantiate_series(x_values, y_values)

        # Axes
        self.x_axis = QValueAxis()
        self.x_axis.setRange(-1, 1)
        self.x_axis.setVisible(False)
        self.plot.addAxis(self.x_axis, Qt.AlignBottom)
        self.disk.attachAxis(self.x_axis)
        self.breath_circumference_coord.attachAxis(self.x_axis)

        self.y_axis = QValueAxis()
        self.y_axis.setRange(-1, 1)
        self.y_axis.setVisible(False)
        self.plot.addAxis(self.y_axis, Qt.AlignLeft)
        self.disk.attachAxis(self.y_axis)
        self.breath_circumference_coord.attachAxis(self.y_axis)
        
        self.setChart(self.plot)

    def _instantiate_series(self, x_values, y_values):
        for x, y in zip(x_values, y_values):
            self.pacer_circumference_coord.append(x, y)
            self.breath_circumference_coord.append(0, 0)

    def update_pacer_series(self, x_values, y_values):
        for i, (x, y) in enumerate(zip(x_values, y_values)):
            self.pacer_circumference_coord.replace(i, x, y)

    def update_breath_series(self, x_values, y_values):
        for i, (x, y) in enumerate(zip(x_values, y_values)):
            self.breath_circumference_coord.replace(i, x, y)

    def sizeHint(self):
        height = self.size().height()
        return QSize(height, height)  # force square aspect ratio

    def resizeEvent(self, event):
        if self.size().width() != self.size().height():
            self.updateGeometry()  # adjusts geometry based on sizeHint
        return super().resizeEvent(event)

class SquareWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        
    def sizeHint(self):
        return QSize(100, 100)

    def resizeEvent(self, event):
        side = min(self.width(), self.height())
        if self.width() > self.height():
            self.setMaximumHeight(side)
            self.setMaximumWidth(side)
        else:
            self.setMaximumWidth(side)
            self.setMaximumHeight(side)

class View(QChartView):
    
    def __init__(self, parent=None):
        super().__init__(parent)

        self.model = Model()

        # Load the stylesheet from the file
        style_file = QFile("style.qss")
        style_file.open(QFile.ReadOnly | QFile.Text)
        stylesheet = style_file.readAll()
        stylesheet = str(stylesheet, encoding="utf-8")

        # Set the stylesheet
        self.setStyleSheet(stylesheet)

        # Plot parameters
        self.RED = QColor(200, 30, 45)
        self.YELLOW = QColor(254, 191, 0)
        self.ORANGE = QColor(255, 130, 0)
        self.GREEN = QColor(50, 177, 108)
        self.BLUE = QColor(0, 119, 190)
        self.GRAY = QColor(34, 34, 34)
        self.GOLD = QColor(212, 175, 55)
        self.LINEWIDTH = 2.5
        self.DOTSIZE_SMALL = 4
        self.DOTSIZE_LARGE = 5

        # Series parameters
        self.UPDATE_SERIES_PERIOD = 100 # ms
        self.UPDATE_BREATHING_SERIES_PERIOD = 50 # ms
        self.UPDATE_PACER_PERIOD = 10 # 20 # ms
        self.PACER_HIST_SIZE = 6000
        self.BREATH_ACC_TIME_RANGE = 60 # s
        self.HR_SERIES_TIME_RANGE = 300 # s
        self.HRV_SERIES_TIME_RANGE = 300 # s

        # Initialisation
        self.pacer_rate = 6

        # Breathing acceleration
        self.chart_acc = self.create_chart(title='Breathing Acceleration', showTitle=False, showLegend=False)
        self.series_pacer = self.create_line_series(self.GOLD, self.LINEWIDTH)
        self.series_breath_acc = self.create_line_series(self.BLUE, self.LINEWIDTH)
        self.series_breath_cycle_marker = self.create_scatter_series(self.GRAY, self.DOTSIZE_SMALL)
        self.axis_acc_x = self.create_axis(title=None, tickCount=10, rangeMin=-self.BREATH_ACC_TIME_RANGE, rangeMax=0, labelSize=10, flip=False)
        # self.axis_y_pacer = self.create_axis(title="Pacer", color=self.GOLD, rangeMin=-1, rangeMax=1)
        self.axis_y_breath_acc = self.create_axis("Chest expansion (m/s2)", self.BLUE, rangeMin=-1, rangeMax=1, labelSize=10)

        # Heart rate chart
        self.chart_hr = self.create_chart(title='Heart rate', showTitle=False, showLegend=False)
        self.series_hr = self.create_scatter_series(self.RED, self.DOTSIZE_SMALL)
        self.axis_hr_y = self.create_axis(title="HR (bpm)", color=self.RED, rangeMin=50, rangeMax=80, labelSize=10)

        # Breathing rate
        self.series_br = self.create_spline_series(self.BLUE, self.LINEWIDTH)
        self.series_br_marker = self.create_scatter_series(self.BLUE, self.DOTSIZE_SMALL)
        self.series_br_marker.setMarkerShape(QScatterSeries.MarkerShapeTriangle)
        self.axis_br_y = self.create_axis(title="BR (bpm)", color=self.BLUE, rangeMin=0, rangeMax=20, labelSize=10)
        
        # Heart rate variability chart
        self.chart_hrv = self.create_chart(title='Heart rate variability', showTitle=False, showLegend=False)
        # self.series_hrv = self.create_spline_series(self.RED, self.LINEWIDTH)
        self.series_maxmin = self.create_spline_series(self.RED, self.LINEWIDTH)
        self.series_maxmin_marker = self.create_scatter_series(self.RED, self.DOTSIZE_SMALL)
        self.axis_hrv_x = self.create_axis(title=None, tickCount=10, rangeMin=-self.HRV_SERIES_TIME_RANGE, rangeMax=0, labelSize=10)
        self.axis_hrv_y = self.create_axis(title="HRV (ms)", color=self.RED, rangeMin=0, rangeMax=250, labelSize=10)

        self.hrv_band_line_0 = QLineSeries()
        self.hrv_band_line_0.append(-self.HRV_SERIES_TIME_RANGE, 0)
        self.hrv_band_line_0.append(0, 0)
        self.hrv_band_line_1 = QLineSeries()
        self.hrv_band_line_1.append(-self.HRV_SERIES_TIME_RANGE, 50)
        self.hrv_band_line_1.append(0, 50)
        self.hrv_band_line_2 = QLineSeries()
        self.hrv_band_line_2.append(-self.HRV_SERIES_TIME_RANGE, 150)
        self.hrv_band_line_2.append(0, 150)
        self.hrv_band_line_3 = QLineSeries()
        self.hrv_band_line_3.append(-self.HRV_SERIES_TIME_RANGE, 2000)
        self.hrv_band_line_3.append(0, 2000)
        self.hrv_band_0 = QAreaSeries(self.hrv_band_line_0, self.hrv_band_line_1)
        self.hrv_band_0.setColor(self.RED)
        self.hrv_band_0.setOpacity(0.2)
        self.hrv_band_0.setPen(QPen(Qt.NoPen))
        self.hrv_band_1 = QAreaSeries(self.hrv_band_line_1, self.hrv_band_line_2)
        self.hrv_band_1.setColor(self.YELLOW)
        self.hrv_band_1.setOpacity(0.2)
        self.hrv_band_1.setPen(QPen(Qt.NoPen))
        self.hrv_band_2 = QAreaSeries(self.hrv_band_line_2, self.hrv_band_line_3)
        self.hrv_band_2.setColor(self.GREEN)
        self.hrv_band_2.setOpacity(0.2)
        self.hrv_band_2.setPen(QPen(Qt.NoPen))

        self.pacer_slider = QSlider(Qt.Horizontal)
        self.pacer_slider.setRange(3*2,10*2)
        self.pacer_slider.setValue(self.pacer_rate*2)
        self.pacer_slider.valueChanged.connect(self.update_pacer_rate)
        
        self.pacer_label = QLabel()
        self.pacer_label.setStyleSheet("QLabel {color: black}")
        self.pacer_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.pacer_label.setText(f"{self.pacer_rate}")
        self.pacer_label.setFixedWidth(40)
        
        # Configure
        self.chart_acc.addSeries(self.series_pacer)
        self.chart_acc.addSeries(self.series_breath_acc)
        self.chart_acc.addSeries(self.series_breath_cycle_marker)
        self.chart_acc.addSeries(self.series_hr)
        self.chart_acc.addAxis(self.axis_acc_x, Qt.AlignBottom)
        self.chart_acc.addAxis(self.axis_y_breath_acc, Qt.AlignRight)
        self.chart_acc.addAxis(self.axis_hr_y, Qt.AlignLeft)
        self.series_pacer.attachAxis(self.axis_acc_x)
        self.series_pacer.attachAxis(self.axis_y_breath_acc)
        self.series_breath_acc.attachAxis(self.axis_acc_x)
        self.series_breath_acc.attachAxis(self.axis_y_breath_acc)
        self.series_breath_cycle_marker.attachAxis(self.axis_acc_x)
        self.series_breath_cycle_marker.attachAxis(self.axis_y_breath_acc)
        self.series_hr.attachAxis(self.axis_acc_x)
        self.series_hr.attachAxis(self.axis_hr_y)

        # Heart rate variability chart
        # self.chart_hrv.addSeries(self.series_hrv)
        self.chart_hrv.addSeries(self.hrv_band_0)
        self.chart_hrv.addSeries(self.hrv_band_1)
        self.chart_hrv.addSeries(self.hrv_band_2)
        self.chart_hrv.addSeries(self.series_maxmin)
        self.chart_hrv.addSeries(self.series_maxmin_marker)
        self.chart_hrv.addAxis(self.axis_hrv_x, Qt.AlignBottom)
        self.chart_hrv.addAxis(self.axis_hrv_y, Qt.AlignLeft)
        self.series_maxmin.attachAxis(self.axis_hrv_x)
        self.series_maxmin.attachAxis(self.axis_hrv_y)
        self.series_maxmin_marker.attachAxis(self.axis_hrv_x)
        self.series_maxmin_marker.attachAxis(self.axis_hrv_y)
        self.hrv_band_0.attachAxis(self.axis_hrv_x)
        self.hrv_band_0.attachAxis(self.axis_hrv_y)
        self.hrv_band_1.attachAxis(self.axis_hrv_x)
        self.hrv_band_1.attachAxis(self.axis_hrv_y)
        self.hrv_band_2.attachAxis(self.axis_hrv_x)
        self.hrv_band_2.attachAxis(self.axis_hrv_y)

        # Breathing rate on HRV chart
        # self.chart_hrv.addSeries(self.series_br)
        self.chart_hrv.addSeries(self.series_br_marker)
        self.chart_hrv.addAxis(self.axis_br_y, Qt.AlignRight)
        # self.series_br.attachAxis(self.axis_hrv_x)
        # self.series_br.attachAxis(self.axis_br_y)
        self.series_br_marker.attachAxis(self.axis_hrv_x)
        self.series_br_marker.attachAxis(self.axis_br_y)

        # Create a layout
        layout = QVBoxLayout()

        acc_widget = QChartView(self.chart_acc)
        acc_widget.setStyleSheet("background-color: transparent;")
        hrv_widget = QChartView(self.chart_hrv)
        hrv_widget.setStyleSheet("background-color: transparent;")
    
        self.circles_widget = CirclesWidget(*self.model.pacer.update(self.pacer_rate), self.GOLD, self.BLUE, self.RED)
        
        acc_widget.setRenderHint(QPainter.Antialiasing)
        hrv_widget.setRenderHint(QPainter.Antialiasing)
        self.circles_widget.setRenderHint(QPainter.Antialiasing)

        sliderLayout = QHBoxLayout()
        sliderLayout.addWidget(self.pacer_label)
        sliderLayout.addWidget(self.pacer_slider)
        sliderLayout.addSpacing(20)
        
        circlesLayout = QVBoxLayout()
        circlesLayout.addWidget(self.circles_widget, alignment=Qt.AlignHCenter | Qt.AlignVCenter)
        circlesLayout.addLayout(sliderLayout)

        squareContainer = SquareWidget()
        squareContainer.setLayout(circlesLayout)

        topRowLayout = QHBoxLayout()
        topRowLayout.addWidget(squareContainer, stretch=1)
        topRowLayout.addWidget(acc_widget, stretch=3)

        layout.addLayout(topRowLayout, stretch=1)
        layout.addWidget(hrv_widget, stretch=1)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

        # Kick off the timer
        self.update_series_timer = QTimer()
        self.update_series_timer.timeout.connect(self.update_series)
        self.update_series_timer.setInterval(self.UPDATE_SERIES_PERIOD)

        self.update_acc_series_timer = QTimer()
        self.update_acc_series_timer.timeout.connect(self.update_acc_series)
        self.update_acc_series_timer.setInterval(self.UPDATE_BREATHING_SERIES_PERIOD)
        
        self.pacer_timer = QTimer()
        self.pacer_timer.setInterval(self.UPDATE_PACER_PERIOD)  # ms (20 Hz)
        self.pacer_timer.timeout.connect(self.plot_circles)

        self.update_acc_series_timer.start()
        self.update_series_timer.start()
        self.pacer_timer.start()

        self.pacer_values_hist = np.full((self.PACER_HIST_SIZE, 1), np.nan)
        self.pacer_times_hist = np.full((self.PACER_HIST_SIZE, 1), np.nan)
        self.pacer_times_hist_rel_s = np.full(self.PACER_HIST_SIZE, np.nan) # relative seconds

    def create_chart(self, title=None, showTitle=False, showLegend=False, margins=None):
        chart = QChart()
        chart.legend().setVisible(showLegend)
        chart.setTitle(title)
        if margins:
            chart.setMargins(margins)
            chart.layout().setContentsMargins(margins)
        return chart
    
    def create_scatter_series(self, color=None, size=5):
        if color is None:
            color = self.GRAY
        series = QScatterSeries()
        series.setMarkerSize(size)
        series.setMarkerShape(QScatterSeries.MarkerShapeCircle)
        series.setColor(color)
        series.setBorderColor(color)
        return series

    def create_line_series(self, color=None, width=2, style=None):
        if color is None:
            color = self.GRAY
        series = QLineSeries()
        pen = QPen(color)
        pen.setWidth(width)
        if style:
            pen.setStyle(style)
        series.setPen(pen)
        return series

    def create_spline_series(self, color=None, width=2):
        if color is None:
            color = self.GRAY
        series = QSplineSeries()
        pen = QPen(color)
        pen.setWidth(width)
        series.setPen(pen)
        return series

    def create_axis(self, title=None, color=None, tickCount=None, rangeMin=None, rangeMax=None, labelSize=None, flip=False):
        if color is None:
            color = self.GRAY
        axis = QValueAxis()
        axis.setTitleText(title)
        axis.setLabelsColor(color)
        axis.setTitleBrush(color)
        axis.setGridLineVisible(False)
        if tickCount:
            axis.setTickCount(tickCount)
        if rangeMin:
            axis.setMin(rangeMin)
        if rangeMax:
            axis.setMax(rangeMax)
        if labelSize:
            font = QFont()
            font.setPointSize(labelSize)
            axis.setLabelsFont(font)
        if flip:
            axis.setReverse(True)
        return axis        

    def update_pacer_rate(self):
        self.pacer_rate = self.pacer_slider.value()/2
        self.pacer_label.setText(f"{self.pacer_slider.value()/2}")

    def plot_circles(self):
        # Pacer
        coordinates = self.model.pacer.update(self.pacer_rate)
        self.circles_widget.update_pacer_series(*coordinates)

        self.pacer_values_hist = np.roll(self.pacer_values_hist, -1)
        self.pacer_values_hist[-1] = np.linalg.norm([coordinates[0][0],coordinates[1][0]]) - 0.5
        self.pacer_times_hist = np.roll(self.pacer_times_hist, -1)
        self.pacer_times_hist[-1] = time.time_ns()/1.0e9

        # Breathing
        breath_coordinates = self.model.get_breath_circle_coords()
        self.circles_widget.update_breath_series(*breath_coordinates)

    async def connect_polar(self):

        polar_device_found = False
        print("Looking for Polar device...")
        while not polar_device_found:

            devices = await BleakScanner.discover()
            print(f"Found {len(devices)} BLE devices")
            for device in devices:
                if device.name is not None and "Polar" in device.name:
                    polar_device_found = True
                    print(f"Found Polar device")
                    break
            if not polar_device_found:
                print("Polar device not found, retrying in 1 second")
                await asyncio.sleep(1)
        
        self.model.set_polar_sensor(device)
        await self.model.connect_sensor()

    async def disconnect_polar(self):
        await self.model.disconnect_sensor()

    def update_acc_series(self):
        
        self.pacer_times_hist_rel_s = self.pacer_times_hist - time.time_ns()/1.0e9
            
        self.breath_acc_times_rel_s = self.model.breath_acc_times - time.time_ns()/1.0e9
        series_breath_acc_new = []

        for i, value in enumerate(self.breath_acc_times_rel_s):
            if not np.isnan(value):
                series_breath_acc_new.append(QPointF(value, self.model.breath_acc_hist[i]))
        self.series_breath_acc.replace(series_breath_acc_new)
        
        series_breath_cycle_marker_new = []
        for i, value in enumerate(self.model.breath_cycle_ids):
            if not value < 0:
                series_breath_cycle_marker_new.append(QPointF(self.breath_acc_times_rel_s[value], self.model.breath_acc_hist[value]))
        self.series_breath_cycle_marker.replace(series_breath_cycle_marker_new)

        series_pacer_new = []
        for i, value in enumerate(self.pacer_times_hist_rel_s):
            if not np.isnan(value):
                series_pacer_new.append(QPointF(value, self.pacer_values_hist[i]))
                
        if series_pacer_new:
            self.series_pacer.replace(series_pacer_new)

    def update_series(self):

        self.br_times_hist_rel_s = self.model.br_times_hist - time.time_ns()/1.0e9

        series_hr_new = []
        for i, value in enumerate(self.model.hr_values_hist):
            if not np.isnan(value):
                series_hr_new.append(QPointF(self.model.ibi_times_hist_rel_s[i], value))
        self.series_hr.replace(series_hr_new)
        
        if np.any(~np.isnan(self.model.hr_values_hist)):
            max_val = np.ceil(np.nanmax(self.model.hr_values_hist[self.model.ibi_times_hist_rel_s > -self.HR_SERIES_TIME_RANGE])/5)*5
            min_val = np.floor(np.nanmin(self.model.hr_values_hist[self.model.ibi_times_hist_rel_s > -self.HR_SERIES_TIME_RANGE])/5)*5
            self.axis_hr_y.setRange(min_val, max_val)

        # Breathing rate plot
        series_br_new = []
        for i, value in enumerate(self.model.br_values_hist):
            if not np.isnan(value):
                series_br_new.append(QPointF(self.br_times_hist_rel_s[i], value))
        self.series_br.replace(series_br_new)
        self.series_br_marker.replace(series_br_new)
        
        if np.any(~np.isnan(self.model.br_values_hist)):
            max_val = np.ceil(np.nanmax(self.model.br_values_hist[self.br_times_hist_rel_s > -self.HRV_SERIES_TIME_RANGE])/5)*5
            self.axis_br_y.setRange(0, max_val)

        if np.any(~np.isnan(self.model.hrv_values_hist)):
            max_val = np.ceil(np.nanmax(self.model.hrv_values_hist[self.model.hrv_times_hist > -self.HRV_SERIES_TIME_RANGE])/10)*10
            max_val = max(max_val, 150)
            self.axis_hrv_y.setRange(0, max_val)

        # RMSSD Series
        series_maxmin_new = []
        for i, value in enumerate(self.model.maxmin_values_hist):
            if not np.isnan(value):
                series_maxmin_new.append(QPointF(self.br_times_hist_rel_s[i], value))
        self.series_maxmin.replace(series_maxmin_new)
        self.series_maxmin_marker.replace(series_maxmin_new)

    async def main(self):
        await self.connect_polar()
        await asyncio.gather(self.model.update_ibi(), self.model.update_acc())
    
View.py
kbre93/every-breath-you-take