Everyone needs a good night’s sleep!
One of the factors affecting sleep quality is sleep position. For instance, sleep apnoea and snoring are much more likely in the supine (lying on your back) position than lying prone or on your side. Of course, if you have sleep apnoea or similar conditions, you need to have proper medical tests done.
I was just curious about what positions I sleep in, so I designed this Sleep Position Recorder.
Download all the code, libraries & STL files here or at my GitHub here.
The Pico is powered by either a USB cable (5V) or 3 x AAA batteries (4.5V), whichever is the higher Voltage. The diode prevents the power supplies from crossfeeding, which could damage the USB source, the batteries, or both. Raspberry Pi recommends using a Schottky diode to minimise the forward voltage drop, however, I used a 1N4004 diode, which I had handy, and despite a forward voltage drop of about 0.9V, it works well.
The recorder is worn at the front of the chest, with the USB end towards the person’s head. It can be in a button-up pocket, behind a bra, or taped onto the skin with a non-allergenic adhesive.
The Pico requests data from the IMU every second. Although the IMU measures both acceleration and rotation in three axes, we only need the acceleration data to determine body position. Unless the person is actually moving and accelerating linearly, which is unlikely when lying down, the predominant acceleration is due to gravity. The IMU is installed horizontally, so in that position, almost all of the acceleration is along the Z axis, from front to back. If the person lies on their front, the opposite is true. If they stand up (or stand on their head), gravity is pulling along the Y axis of the MPU.
If they are lying on their right or left side, the resultant gravitational acceleration vector of the X and Z axes is approximately minus or plus 90 degrees, depending on the side. If they are supine, it tends closer to zero degrees (ie, from front to back) and if prone, closer to 180 degrees.
The Pico calculates these parameters, and if there is a change, it logs an entry to a log file in flash memory with the date and time, and the position.
After (hopefully) a good night’s sleep, the device can be plugged into the USB port, and the log file (which is named from the start time) can be downloaded via Thonny. The backend may need to be restarted by pressing the stop sign icon in Thonny.
I have written a Python program called “analyse.py” which analyses the downloaded log file, and a companion program, “report.py” which calls the analysis program and produces a report in PDF format, which can be perused or printed.
Bill of Materials
- 1x Raspberry Pi Pico with MicroPython installed
- 1x 6-DOF inertial movement unit
- 1x Diode (Ideally a Schottky diode, but I used a 1N4004 that I had handy
- 3 x AAA Batteries
- 3 x AAA Battery Holders
- 2 x M3x8 screws for the case
- 2x M3x6 screws to secure the MPU
- 4 x M2.5x6 screws to secure the Pico
- 3D printed case
Build
I printed the case in PLA at 0.2mm layer height, sliced in PrusaSlicer with Snug supports for the USB port opening only.
The RPi Pico and the MPU screw into the case, and are hooked up with soldered wires to the battery holders and the diode as per the wiring diagram - no headers. Make sure that the diode polarity is correct.
I recommend installing the batteries then immediately plugging the device into a computer USB port using Thonny to upload the main.py and MPU6050.py Python files to the Pico.
Plugging in soon after installing the batteries will avoid draining the batteries too soon, because there is no power switch - I tried to keep the design as minimalist as possible.
The onboard LED of the Pico should light when the device is held upright (standing position), and extinguish when lying down. If it doesn’t, then either the device isn’t working or main.py isn’t running.
The other reason for starting with USB and Thonny is that Thonny will update the Pico’s onboard real-time clock to the correct local time, so the log entries will be correct.
Start main.py from Thonny, then the device can be unplugged from the USB, and it should run on batteries.
It drains about 27mA. AAA batteries can hold from 850 - 1200mAh, which should be plenty to last for a night. The Pico 2 has some good low-power sleep features, but unfortunately, these are not well implemented in MicroPython yet.
The Code
main.py
This is the code for the main logging function; once running, the Pico will generate a CSV with timestamped changes in sleeping position. When read in a table, the data will look something like this.
2025-05-09T20:45:26.000Z | Supine |
2025-05-09T20:45:33.000Z | Standing |
2025-05-09T20:45:35.000Z | Supine |
2025-05-09T21:31:06.000Z | Prone |
2025-05-09T21:31:11.000Z | Right |
2025-05-09T21:31:13.000Z | Standing |
# RPi Pico software for reading and recording sleep positions # from a MPU6050 6 axis IMU # We are not interested in gyroscope data, only the gravity vector, # as it is assumed that the subject is not accelerating during the reading, apart from # gravitational acceleration. # This software will not work in a zero-G environment :) import machine import time # MPU6050 driver at # https://github.com/TimHanewich/MicroPython-Collection/blob/master/MPU6050/MPU6050.py import MPU6050 import math as m debug=False _ISO_FORMAT_STRING = const("{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}.{:03d}Z") last_position="Unknown" # Name the log file now=time.localtime() dfileName=f"SM{now[3]:02d}{now[4]:02d}{now[5]:02d}.csv" if debug: print(dfileName) # Set up the I2C interface i2c = machine.I2C(1, sda=machine.Pin(14), scl=machine.Pin(15)) # Set up the MPU6050 class mpu = MPU6050.MPU6050(i2c) # Setup the onboard LED led=machine.Pin(25,machine.Pin.OUT) led.off() # Convert a timestamp to text def timestamp_iso8601(): time_tuple = time.localtime() return _ISO_FORMAT_STRING.format( time_tuple[0], time_tuple[1], time_tuple[2], time_tuple[3], time_tuple[4], time_tuple[5], 0, ) # Log an entry def record_data(position): #date and time line=f"{timestamp_iso8601()},{position}" if debug: print(line) with open(dfileName,'a') as f: f.write(line+'\n') # wake up the MPU6050 from sleep mpu.wake() time.sleep(0.1) # Wait for the MPU to wake up #Setup data file f=open(dfileName,"w") f.close() # Measure linear acceleration every one second # The MPU returns a tuple with three values: # x, y and z. Gravitational acceleration is 1 unit. while True: position="Unknown" try: accel = mpu.read_accel_data() # Calculate the roll angle from the gravity direction deg=m.degrees(m.atan2(accel[0],accel[2])) if debug: print (f"{deg:.1f} degrees") # If the gravity vector is in the general direction of the Y axis, record Standing # (Could also be standing on head, but unlikely! if abs(accel[1])>0.9: position="Standing" # Here if not Standing, otherwise ignore the roll angle and record Standing elif deg>-50 and deg <50: position="Supine" elif deg >120 or deg <-120: position="Prone" else: if deg>0: position="Right" else: position="Left" # Update the last position # We only write a log entry if it has changed if position != last_position: last_position = position record_data(position) # Light the onboard LED if standing (confirms the unit is working) if position=="Standing": led.on() else: led.off() except: record_data("IMU read error") time.sleep(1)
analyse.py
This code takes the data from the CSV and creates several charts and graphs with the data using pandas & matplotlib
# Copyright 2025 by John Lamb # Analyse a sleep position log from RPi Pico based sleep position monitor import pandas as pd import matplotlib.pyplot as plt import math inFileName='SM204526.csv' positionValues={'Supine':0,'Prone':-2,'Left':-1,'Right':1,'Standing':2} lastPosition='Supine' lastPosNumeric=0 # Import into a new Pandas dataframe header_list=["Time","Position"] parse_dates=['Time'] f=open(inFileName,'r') df = pd.read_csv(f,names=header_list,parse_dates=parse_dates) f.close() df['Position']=df['Position'].map(lambda name: name.strip()) df['PosNumeric']=df['Position'].map(lambda x: positionValues[x]) df['Position']=df['Position'].astype('string') df['Duration']=0.0 start_datetime_str=df.at[0,'Time'].strftime('%d %b %Y, %I:%M%p') end_datetime_str=df.at[len(df)-1,'Time'].strftime('%d %b %Y, %I:%M%p') delta=df.at[len(df)-1,'Time']-df.at[0,'Time'] elapsed_time=delta.total_seconds()/60 elapsed_time_str=f"{elapsed_time//60:.0f} hours {elapsed_time%60:.0f} minutes" #Calculate durations from timestamps for i in range(0,len(df)-1): a=df.at[i,'Time'] b=df.at[i+1,'Time'] df.at[i,'Duration']=(b-a).total_seconds() # Generate a totals_df dataframe for the summary totals_df=df.groupby('Position')['Duration'].sum()/60 totals_df=totals_df.reset_index() totals_df['Duration'] = totals_df['Duration'].apply(lambda x: math.ceil(x)) # Generate a pie plot for the durations pie_fig, ax = plt.subplots() pie_fig.set_size_inches(4,4) totals_df.plot.pie(ax=ax,x='Position',y='Duration',labels=totals_df['Position'],autopct='%1.1f%%') ax.set_ylabel(None) ax.legend_ = None #plt.show() # Set index to enable resampling df = df.set_index('Time') # Resample to every second, filling missing readings with last valid reading # then resample to minutely # Upsample to seconds rdf= df.asfreq(freq='1s', method='ffill') rdf=rdf.asfreq(freq='60s') # Generate a timeline plot linechart_fig,ax=plt.subplots() linechart_fig.set_figwidth(11.2) linechart_fig.set_figheight(2) rdf.plot(ax=ax, kind='line',y='PosNumeric') ax.set_yticks([-2,-1, 0, 1,2]) ax.set_yticklabels(("Prone","Left","Supine","Right","Standing")) ax.legend_ = None #plt.show()
report.py
This final code exports the analysis report from the last code into a useful PDF format.
# Format a sleep position study analysis and generate a PDF file # John Lamb 2025 # See: https://nicd.org.uk/knowledge-hub/creating-pdf-reports-with-reportlab-and-pandas import analyse from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import Frame from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.units import inch report_filename='report.pdf' padding = dict( leftPadding=72, rightPadding=72, topPadding=72, bottomPadding=18) portrait_frame = Frame(0, 0, *A4, **padding) landscape_frame = Frame(0, 0, *landscape(A4), **padding,showBoundary=0) table_frame=Frame( x1=1*inch, y1=A4[0]-4.3*inch, width=4 * inch, height=2 * inch, leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0, id=None, showBoundary=0 ) pie_frame=Frame( x1=6*inch, y1=A4[0]-4.5*inch, width=4 * inch, height=4 * inch, leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0, id=None, showBoundary=0 ) line_graph_frame =Frame( x1=1*inch, y1=0.5*inch, width=10 * inch, height=3 * inch, leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0, id=None, showBoundary=0 ) from reportlab.platypus import PageTemplate landscape_template = PageTemplate( id='landscape', frames=[landscape_frame,pie_frame,table_frame,line_graph_frame], pagesize=landscape(A4)) from reportlab.platypus import BaseDocTemplate doc = BaseDocTemplate(report_filename,pageTemplates=[ landscape_template ] ) import io from reportlab.platypus import Image from reportlab.lib.units import inch # Convert matplotlib figure to an image def fig2image(f): buf = io.BytesIO() f.savefig(buf, format='png', dpi=300) buf.seek(0) x, y = f.get_size_inches() return Image(buf, x * inch, y * inch) from reportlab.platypus import Table, Paragraph from reportlab.lib import colors # Convert pandas dataframe to reportLab Table def df2table(df): return Table( [[Paragraph(col) for col in df.columns]] + df.values.tolist(), style=[ ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('LINEBELOW',(0,0), (-1,0), 1, colors.black), ('INNERGRID', (0,0), (-1,-1), 0.25, colors.black), ('BOX', (0,0), (-1,-1), 1, colors.black), ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.lightgrey, colors.white]), ('LEFTPADDING',(0,0),(-1,-1),52), ('RIGHTPADDING',(1,0),(1,-1),52), ('ALIGN',(0,0),(0,-1),'LEFT'), ('ALIGN',(1,0),(1,-1),'RIGHT'), ] ) import pandas as pd import matplotlib.pyplot as plt from reportlab.platypus import NextPageTemplate, PageBreak,FrameBreak from reportlab.lib.styles import getSampleStyleSheet styles = getSampleStyleSheet() # Composite the page story = [ Paragraph('Sleep Position Report', styles['Heading2']), Paragraph('Start: '+analyse.start_datetime_str), Paragraph('End: '+analyse.end_datetime_str), Paragraph(f'Elapsed time: {analyse.elapsed_time_str}'), FrameBreak(), fig2image(analyse.pie_fig), FrameBreak(), Paragraph('Position Durations (minutes)', styles['Heading2']), df2table(analyse.totals_df), FrameBreak(), Paragraph('Timeline', styles['Heading2']), fig2image(analyse.linechart_fig), ] # Save the PDF doc.build(story)
Download Files
Download all the code, libraries & STL files here or at my github here.