The Snooze Logger - A Wearable Sleep Position Monitor

Updated 23 May 2025

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

 

 

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

Have a question? Ask the Author of this guide today!

Please enter minimum 20 characters

Your comment will be posted (automatically) on our Support Forum which is publicly accessible. Don't enter private information, such as your phone number.

Expect a quick reply during business hours, many of us check-in over the weekend as well.

Comments


Loading...
Feedback

Please continue if you would like to leave feedback for any of these topics:

  • Website features/issues
  • Content errors/improvements
  • Missing products/categories
  • Product assignments to categories
  • Search results relevance

For all other inquiries (orders status, stock levels, etc), please contact our support team for quick assistance.

Note: click continue and a draft email will be opened to edit. If you don't have an email client on your device, then send a message via the chat icon on the bottom left of our website.

Makers love reviews as much as you do, please follow this link to review the products you have purchased.