[1]:
%reload_ext autoreload
%autoreload 2

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

Bedtimes Near Midnight

Sleep times near midnight span the day boundary. Late bedtimes like 23:30 and early ones like 00:30 are actually close together — but since the 24-hour clock wraps at 0:00, those times get split across the boundary unless we treat time as circular.

[2]:
url = "https://raw.githubusercontent.com/timpyrkov/circleclust/refs/heads/master/tests/sleep.csv"

# Read sleep-wake data csv from url (sleep.jpg from github repository)
df = pd.read_csv(url, parse_dates=["sleep_start_datetime", "sleep_end_datetime"])
df.head()
[2]:
sleep_start_datetime sleep_end_datetime wake_reason
0 2025-01-01 23:37:32.628796 2025-01-02 06:28:40.737082 Weekday: Alarm clock
1 2025-01-03 00:08:25.521542 2025-01-03 06:31:02.940070 Weekday: Alarm clock
2 2025-01-03 23:41:47.385385 2025-01-04 08:03:29.127583 Weekend: Recover from sleep debt
3 2025-01-05 02:27:21.600244 2025-01-05 11:24:11.091711 Weekend: Recover from sleep debt
4 2025-01-05 22:47:46.553151 2025-01-06 06:17:20.747117 Weekday: Alarm clock

Show Sleep-Wake actogram

[3]:
# Create a raw  array of activity for 2025 (365 days x 24 hours x 60 minutes)
sleep_array = np.ones(365 * 24 * 60, dtype=int)  # Initialize with ones (awake)

# Fill in sleep intervals as zeros
year_start = datetime(2025, 1, 1)
year_end = datetime(2025, 12, 31, 23, 59)
for _, row in df.iterrows():
    # Get start-end of the sleep interval
    start = row['sleep_start_datetime']
    end = row['sleep_end_datetime']
    # Calculate start-end indices for the sleep interval
    start_idx = int((start - year_start).total_seconds() // 60)
    end_idx = int((end - year_start).total_seconds() // 60)
    # Mark sleep interval as zeros
    sleep_array[start_idx:end_idx] = 0
# Reshape to daily samples (365 days x 1440 minutes)
daily_sleep = sleep_array.reshape(365, 1440)

# Plot as a heatmap
plt.figure(figsize=(12, 6))
plt.title('Sleep-Wake Actogram for 2025', fontsize=16)

plt.imshow(daily_sleep.T, aspect='auto', cmap='cividis', origin='lower', alpha=0.8)
plt.colorbar(label='State (0=Sleep, 1=Awake)')

# Set xticks
month_starts = [datetime(2025, m, 1) for m in range(1, 13)] + [datetime(2026, 1, 1)]
xtick_days = [(m - datetime(2025, 1, 1)).days for m in month_starts]
xtick_labels = [m.strftime('%b %d') for m in month_starts]
plt.xticks(xtick_days, xtick_labels, rotation=45)

# Set yticks
ytick_minutes = [0, 360, 720, 1080, 1440]  # 12 AM, 6 AM, 12 PM, 6 PM, 12 AM
ytick_labels = ['12 AM', '6 AM', '12 PM', '6 PM', '12 AM']
plt.yticks(ytick_minutes, ytick_labels)

plt.show()
../_images/notebooks_sleepwake_4_0.png
[4]:
# Show histogram of go-to-sleep and wake-up time [minutes past midnight]
plt.figure(figsize=(10, 5))
plt.title('Histogram of go-to-sleep/wake-up times', fontsize=14)

t = pd.concat([df["sleep_start_datetime"], df["sleep_end_datetime"]]).dt
t = (t.hour * 60 + t.minute + t.second / 60.0).values
plt.hist(t, bins=np.linspace(0, 1440, 50), width=25, alpha=.3)

plt.xticks([0, 360, 720, 1080, 1440], ['12 AM', '6 AM', '12 PM', '6 PM', '12 AM'])

plt.show()
../_images/notebooks_sleepwake_5_0.png

Find Sleep-Wake Times

Detect go-to-sleep and wake-up time from distribution

[5]:
from circleclust import CircleClust

# Initialize and run CircleClust.fit() to find pixel group centroids
cc = CircleClust(verbose=False)
cc.fit(t, period=1440) # Important: provide correct range of data values period!

# Print detected centroids
cc.get_centroids()
[5]:
{'centroid': array([ 379.84629777, 1400.79611434]),
 'std': array([55.23524622, 84.57362632])}
[6]:
# Show detected centroids
cc.show_centroids()
../_images/notebooks_sleepwake_8_0.png

Sleep-Wake Stats

Note that the “Go-to-sleep” times cluster near the area of wrap at 12 AM (midnight). Both times clsoe to 1:00 AM and 11:00 PM are attributed to the same single “Go-to-sleep” cluster.

[7]:
# Convert minutes to HH:MM format
def format_minutes_to_hhmm(minutes):
    hours = int(minutes // 60) % 24
    mins = int(minutes % 60)
    return f"{hours:02d}:{mins:02d}"

# Get detected centroids
centroids = cc.get_centroids()
labels = ["Wake-up", "Go-to-sleep"]

# Put detected time ranges in a DataFrame
data = []
for i, label in enumerate(labels):
    center_minutes = centroids['centroid'][i]
    range_minutes = centroids['std'][i]
    start_minutes = center_minutes - range_minutes
    end_minutes = center_minutes + range_minutes

    center_time = format_minutes_to_hhmm(center_minutes)
    start_time = format_minutes_to_hhmm(start_minutes)
    end_time = format_minutes_to_hhmm(end_minutes)

    data.append({
        "Event": label,
        "Time": center_time,
        "± Std Dev (min)": int(range_minutes),
        "Range": f"{start_time} - {end_time}"
    })

# Create and display the DataFrame
df = pd.DataFrame(data)
df.set_index("Event", inplace=True)
df
[7]:
Time ± Std Dev (min) Range
Event
Wake-up 06:19 55 05:24 - 07:15
Go-to-sleep 23:20 84 21:56 - 00:45