Skip to content

Instantly share code, notes, and snippets.

@gurdiga
Last active July 26, 2026 19:41
Show Gist options
  • Select an option

  • Save gurdiga/3be6a79e88c507485d68775dbfdf0641 to your computer and use it in GitHub Desktop.

Select an option

Save gurdiga/3be6a79e88c507485d68775dbfdf0641 to your computer and use it in GitHub Desktop.
Apple Health sleep cycle analysis

Health Data

Apple Health exports and analysis scripts.

Contents

  • apple_health_export/ — Apple Health export (XML + clinical records)
  • sleep_cycles.py — Parses sleep data and prints nightly sleep cycles
  • sleep_cycles_2.py / sleep_cycles_2.js — Same report, fed by an iOS Shortcut instead of the full export (see sleep_cycles_2.md)
  • Makefile — Convenience targets
  • export.zip — Original export archive (Health app → profile picture → Export All Health Data)

sleep_cycles.py

Reads sleep stage records from an Apple Health export.xml and groups them into ~90-minute cycles using REM blocks as boundaries.

Usage

python3 sleep_cycles.py <export.xml> [--from DATE] [--to DATE] [--source <name>] [--extended]
  • export.xml — path to the XML file inside the unzipped export archive
  • --from — date to start from, format YYYY-MM-DD; defaults to all available nights
  • --to — end date for a range (inclusive); if omitted, only --from is shown
  • --source — filter by device name (e.g. "Vlad's Apple Watch")
  • --extended — add per-reading detail for HRV and resp, and a per-cycle HR breakdown

Example

python3 sleep_cycles.py apple_health_export/export.xml --from 2026-06-08
Sleep cycles for 2026-06-08

          Start    End    Duration  Composition
----------------------------------------------------------------------
Cycle 1   23:19  02:07      2h 47m  Deep 35m → Core 84m → REM 44m → Awake 3m
Cycle 2   02:07  05:00      2h 53m  Deep 3m → Core 127m → REM 38m → Awake 4m
Cycle 3   05:00  06:08      1h 07m  Core 59m → Awake 7m

                     Total sleep  6h 47m
                             HRV  48 ms  ↓1 vs 5-night median (48)
                    Overnight HR  53 bpm  →0 vs 5-night median (53)
                       Resp rate  15.0–19.0 br/min, 41 readings
                         VO2 max  44.0 mL/(kg·min)  as of 2026-06-08
                        Activity  Move 720 kcal  ·  Exercise 40 min  ·  Stand 14 h  ·  Steps 13,560

Cycle duration (3 cycles): median 2h 47m, P90 2h 53m

If a nap preceded the main sleep (separated by an awake gap of more than 60 minutes), it appears as a Nap row in the table. Cycle numbering and totals cover only the main sleep session.

HRV and Overnight HR are shown with a trend arrow vs the 5-night median preceding that night. Resp rate shows the min–max range over the sleep window with a reading count. Activity shows the previous day's Move (active energy), Exercise minutes, Stand hours, and step count from the Apple Watch activity rings. If a metric isn't available in the export for that night, it shows (no data).

HRV and Overnight HR are each the median of readings taken during the actual sleep window — daytime spot-checks and workouts are excluded. Higher HRV is better, indicating good parasympathetic activity and recovery; lower Overnight HR generally indicates better recovery.

Extended output

With --extended, additional per-reading detail is interleaved under HRV, Overnight HR, and Resp rate:

                             HRV  48 ms  ↓1 vs 5-night median (48)
  00:01 38.7 (C1-Core)  02:01 47.9 (C1-REM)  04:01 33.5 (C2-Core)  06:01 170.6 (C3-Core)
                    Overnight HR  53 bpm  →0 vs 5-night median (53)
  Cycle 1 (23:19–02:07): min 48, max 59, med 54 bpm  ← min 48 at 00:45
  Cycle 2 (02:07–05:00): min 48, max 55, med 52 bpm
  Cycle 3 (05:00–06:08): min 49, max 54, med 52 bpm
                       Resp rate  15.0–19.0 br/min, 41 readings
  23:31 16.5  23:40 16.5  23:50 15.5  23:58 16.5  00:05 17.0  00:14 16.5  00:30 16.0
  ...

Under HRV, each overnight reading is annotated with the cycle and sleep stage it fell into (e.g. C1-Core). Under Overnight HR, the breakdown is continuous heart rate (from the Watch) bucketed by sleep cycle, with the lowest reading of the night annotated. Under Resp rate, the breakdown lists each overnight reading clipped to the sleep window.

Cycle duration stats (median and P90) are always printed, computed across all cycles in the period:

python3 sleep_cycles.py apple_health_export/export.xml --from 2026-03-27 --to 2026-03-28
...
Cycle duration (8 cycles): median 1h 31m, P90 1h 49m

Performance

On first run the script builds a SQLite cache (export.db) next to the XML file. Subsequent runs skip the XML entirely.

Run Time
First (builds cache) ~5s
Subsequent ~0.1s

The cache is automatically rebuilt if the XML is newer than the .db file.

sleep_cycles_2.py / sleep_cycles_2.js

A from-scratch reimplementation that produces the same report from an iOS Shortcut instead of a full export — no Mac, no XML, no SQLite cache. See sleep_cycles_2.md for the input schema, usage, the Shortcut architecture (a small system of ~12 shortcuts), and known differences from sleep_cycles.py.

Other data in the export

Beyond sleep, the export contains:

  • Steps / Distance
  • Heart rate
  • Physical effort / Active energy
  • Walking metrics (speed, step length, asymmetry)
  • Heart rate variability (HRV)
  • Respiratory rate
  • Oxygen saturation (SpO2)
  • Stand time / Exercise time
  • Headphone & environmental audio exposure
  • Time in daylight
  • Resting heart rate
  • Wrist temperature during sleep
  • VO2 Max
  • Mindful sessions
  • AFib burden

Makefile

make                  # print last night's sleep report
make report-extended  # print last night's report with per-reading detail
make weekly           # print the past 7 nights
make import           # unzip ~/Downloads/export.zip into apple_health_export/
make push             # push to gist
make edit             # open the project in VS Code (alias: e)
.DS_Store
__pycache__/
*.db
apple_health_export/
export.zip

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

make                     # print last night's sleep report
make push                # push to gist (git push gist main)

python3 sleep_cycles.py apple_health_export/export.xml --from 2026-03-28
python3 sleep_cycles.py apple_health_export/export.xml --from 2026-03-27 --to 2026-03-28
python3 sleep_cycles.py apple_health_export/export.xml --from 2026-03-28 --source "Vlad's Apple Watch"

No test suite. Validate by running the script against the real export.

Architecture

Single script: sleep_cycles.py. No dependencies beyond lxml (optional, falls back to stdlib xml.etree).

Data flow

  1. load_all_sleep_records(xml_path) — builds a SQLite cache (export.db) next to the XML on first run; skips XML on subsequent runs (cache invalidated if XML is newer).
  2. Records are grouped by night (the date sleep ends): records ending before noon → that morning; ending at noon or later → next day's night.
  3. find_cycles(records) — splits records into ~90-minute cycles using REM blocks as boundaries. Consecutive REM blocks separated by ≤5 min gaps are merged; REM blocks under MIN_REM_CYCLE_MIN (3 min) are ignored.
  4. summarize_stages(cycle_records) — resolves overlaps (later-starting record wins), then totals Deep / Core / REM / Awake per cycle.
  5. sleep_window(records) — derives each night's (start, end) from its main sleep session; computed once for all nights and stored in metrics["sleep_window"]. HRV and HR readings are filtered to this window before use — readings keyed to a night by overnight_key (noon-to-noon) but falling outside the actual sleep span (daytime spot-checks, workouts) are excluded.
  6. HRV and Overnight HR: median of in-window readings for the night; trend arrow = delta vs. median of all in-window readings from the 5 preceding nights (each night filtered by its own sleep_window).

Key constants

  • SLEEP_TYPE — HK identifier for sleep records
  • HRV_TYPE — HK identifier for HRV (SDNN) records
  • HR_TYPE — HK identifier for heart rate records (used for the Overnight HR metric and per-cycle HR breakdown)
  • MIN_REM_CYCLE_MIN = 3 — minimum REM duration to count as a cycle boundary

Data source

apple_health_export/ is gitignored. The export XML is at apple_health_export/export.xml. Export via iPhone Health app → profile picture → Export All Health Data.

EXPORT = apple_health_export/export.xml
V2_JSON ?= .tmp/sc2.json
TODAY := $(shell gdate +%Y-%m-%d)
WEEK_AGO := $(shell gdate -d '7 days ago' +%Y-%m-%d)
.PHONY: default report weekly push report-v2-py report-v2-js
default: report
report: apple_health_export/export.xml
python3 sleep_cycles.py $(EXPORT) --from $(TODAY)
report-extended: apple_health_export/export.xml
python3 sleep_cycles.py $(EXPORT) --from $(TODAY) --extended
weekly: apple_health_export/export.xml
python3 sleep_cycles.py $(EXPORT) --from $(WEEK_AGO) --to $(TODAY)
# V2_JSON is a Shortcut JSON dump (see sleep_cycles_2.md); override e.g.
# `make report-v2-py V2_JSON=path/to/dump.json`.
report-v2-py: $(V2_JSON)
python3 sleep_cycles_2.py $(V2_JSON) --from $(TODAY)
report-v2-js: $(V2_JSON)
TZ=Etc/GMT-3 node sleep_cycles_2.js $(V2_JSON) --from $(TODAY)
push:
git push gist main
e: edit
edit:
code . -n
import: ~/Downloads/export.zip
rm -rf apple_health_export && \
unzip -o $< && \
rm -v $<
open-gist:
open https://gist.github.com/3be6a79e88c507485d68775dbfdf0641
#!/usr/bin/env python3
import sys
import argparse
import os
import sqlite3
try:
from lxml import etree as ET
except ImportError:
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import datetime, timedelta
SLEEP_TYPE = "HKCategoryTypeIdentifierSleepAnalysis"
HRV_TYPE = "HKQuantityTypeIdentifierHeartRateVariabilitySDNN"
HR_TYPE = "HKQuantityTypeIdentifierHeartRate"
RESP_TYPE = "HKQuantityTypeIdentifierRespiratoryRate"
VO2_TYPE = "HKQuantityTypeIdentifierVO2Max"
STEPS_TYPE = "HKQuantityTypeIdentifierStepCount"
ENERGY_TYPE = "HKQuantityTypeIdentifierActiveEnergyBurned"
EXERCISE_TYPE = "HKQuantityTypeIdentifierAppleExerciseTime"
STAND_TYPE = "HKCategoryTypeIdentifierAppleStandHour"
STAND_MINUTES_TYPE = "HKQuantityTypeIdentifierAppleStandTime"
QUANTITY_TABLES = {
HRV_TYPE: "hrv", HR_TYPE: "hr",
RESP_TYPE: "resp", VO2_TYPE: "vo2",
STEPS_TYPE: "steps", ENERGY_TYPE: "energy", EXERCISE_TYPE: "exercise",
STAND_MINUTES_TYPE: "stand_minutes",
}
MIN_REM_CYCLE_MIN = 3 # minimum total REM to count as a cycle boundary
MAX_AWAKE_MIN = 60 # awake stretches longer than this mark a session break
def main():
parser = argparse.ArgumentParser()
parser.add_argument("export", metavar="export.xml")
parser.add_argument("--from", dest="date_from", metavar="DATE")
parser.add_argument("--to", dest="date_to", metavar="DATE")
parser.add_argument("--source", help="Filter by source name (e.g. \"Vlad's Apple Watch\")")
parser.add_argument("--extended", action="store_true", help="Show per-reading detail for series metrics")
args = parser.parse_args()
records_by_date, metrics = load_all_sleep_records(args.export, args.source)
if args.date_from:
date_to = args.date_to or args.date_from
dates = [d for d in sorted(records_by_date) if args.date_from <= d <= date_to]
else:
dates = sorted(records_by_date)
all_cycle_durations = []
for date in dates:
records = records_by_date.get(date)
if not records:
if args.date_from:
print(f"No sleep data for {date}")
continue
cycles = print_cycles(date, records, metrics, args.extended)
all_cycle_durations.extend(dur for _, _, dur, _ in cycles)
if all_cycle_durations:
all_cycle_durations.sort()
n = len(all_cycle_durations)
p90 = all_cycle_durations[min(int(n * 0.9), n - 1)]
median = all_cycle_durations[n // 2]
def fmt(m): h, m = divmod(m, 60); return f"{h}h {m:02d}m"
print(f"Cycle duration ({n} cycles): median {fmt(median)}, P90 {fmt(p90)}")
def db_path(xml_path):
return os.path.splitext(xml_path)[0] + ".db"
def build_db(xml_path, path):
print(f"Building cache {path} ...", file=sys.stderr)
root = ET.parse(xml_path).getroot()
con = sqlite3.connect(path)
con.execute("""
CREATE TABLE sleep (
startDate TEXT,
endDate TEXT,
stage TEXT,
source TEXT
)
""")
for tname in QUANTITY_TABLES.values():
con.execute(f"CREATE TABLE {tname} (startDate TEXT, value REAL)")
con.execute("CREATE TABLE stand (startDate TEXT, value TEXT)")
sleep_rows = []
quantity_rows = {t: [] for t in QUANTITY_TABLES.values()}
stand_rows = []
for r in root.findall("Record"):
rtype = r.get("type")
if rtype == SLEEP_TYPE:
if "InBed" in r.get("value", ""):
continue
stage = (
r.get("value", "")
.replace("HKCategoryValueSleepAnalysisAsleep", "")
.replace("HKCategoryValueSleepAnalysis", "")
)
sleep_rows.append((r.get("startDate"), r.get("endDate"), stage, r.get("sourceName", "")))
elif rtype in QUANTITY_TABLES:
tname = QUANTITY_TABLES[rtype]
src = r.get("sourceName", "")
if tname in ("steps", "energy", "exercise") and "Watch" not in src:
continue
quantity_rows[tname].append(
(r.get("startDate"), float(r.get("value", 0)))
)
elif rtype == STAND_TYPE:
stand_rows.append((r.get("startDate"), r.get("value", "")))
con.executemany("INSERT INTO sleep VALUES (?,?,?,?)", sleep_rows)
con.execute("CREATE INDEX idx_end ON sleep (endDate)")
for tname, rows in quantity_rows.items():
con.executemany(f"INSERT INTO {tname} VALUES (?,?)", rows)
con.execute(f"CREATE INDEX idx_{tname}_start ON {tname} (startDate)")
con.executemany("INSERT INTO stand VALUES (?,?)", stand_rows)
con.execute("CREATE INDEX idx_stand_start ON stand (startDate)")
con.commit()
con.close()
def load_all_sleep_records(xml_path, source=None):
path = db_path(xml_path)
needs_rebuild = (
not os.path.exists(path)
or os.path.getmtime(path) < os.path.getmtime(xml_path)
)
if not needs_rebuild:
con = sqlite3.connect(path)
tables = {r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)}
con.close()
if not (set(QUANTITY_TABLES.values()) | {"stand"}).issubset(tables):
needs_rebuild = True
if needs_rebuild:
if os.path.exists(path):
os.remove(path)
build_db(xml_path, path)
con = sqlite3.connect(path)
query = "SELECT startDate, endDate, stage FROM sleep"
params = []
if source:
query += " WHERE source = ?"
params.append(source)
rows = con.execute(query, params).fetchall()
hrv_rows = con.execute("SELECT startDate, value FROM hrv").fetchall()
hr_rows = con.execute("SELECT startDate, value FROM hr").fetchall()
resp_rows = con.execute("SELECT startDate, value FROM resp").fetchall()
vo2_rows = con.execute("SELECT startDate, value FROM vo2").fetchall()
steps_rows = con.execute("SELECT startDate, value FROM steps").fetchall()
energy_rows = con.execute("SELECT startDate, value FROM energy").fetchall()
exercise_rows = con.execute("SELECT startDate, value FROM exercise").fetchall()
stand_rows = con.execute("SELECT startDate, value FROM stand").fetchall()
stand_minutes_rows = con.execute("SELECT startDate, value FROM stand_minutes").fetchall()
con.close()
by_date = defaultdict(list)
for start_str, end_str, stage in rows:
s = parse_dt(start_str)
e = parse_dt(end_str)
# Records ending before noon belong to that morning's night;
# records ending at noon or later are evening/onset records for the next night.
if e.hour >= 12:
night = (e + timedelta(days=1)).strftime("%Y-%m-%d")
else:
night = e.strftime("%Y-%m-%d")
by_date[night].append((s, e, stage))
for records in by_date.values():
records.sort()
sleep_windows = {night: sleep_window(records) for night, records in by_date.items()}
def overnight_key(dt):
if dt.hour >= 12:
return (dt + timedelta(days=1)).strftime("%Y-%m-%d")
return dt.strftime("%Y-%m-%d")
hrv_by_date = defaultdict(list)
hrv_series_by_date = defaultdict(list)
for start_str, value in hrv_rows:
dt = parse_dt(start_str)
key = overnight_key(dt)
window = sleep_windows.get(key)
if window and window[0] <= dt <= window[1]:
hrv_by_date[key].append(value)
hrv_series_by_date[key].append((dt, value))
resp_by_date = defaultdict(list)
for start_str, value in resp_rows:
dt = parse_dt(start_str)
resp_by_date[overnight_key(dt)].append((dt, value))
hr_series_by_date = defaultdict(list)
for start_str, value in hr_rows:
dt = parse_dt(start_str)
hr_series_by_date[overnight_key(dt)].append((dt, value))
vo2_series = sorted(
(parse_dt(s).strftime("%Y-%m-%d"), v) for s, v in vo2_rows
)
steps_by_date = defaultdict(float)
for start_str, value in steps_rows:
steps_by_date[parse_dt(start_str).strftime("%Y-%m-%d")] += value
energy_by_date = defaultdict(float)
for start_str, value in energy_rows:
energy_by_date[parse_dt(start_str).strftime("%Y-%m-%d")] += value
exercise_by_date = defaultdict(float)
for start_str, value in exercise_rows:
exercise_by_date[parse_dt(start_str).strftime("%Y-%m-%d")] += value
stand_by_date = defaultdict(int)
for start_str, value in stand_rows:
if value == "HKCategoryValueAppleStandHourStood":
stand_by_date[parse_dt(start_str).strftime("%Y-%m-%d")] += 1
stand_minutes_by_date = defaultdict(float)
for start_str, value in stand_minutes_rows:
stand_minutes_by_date[parse_dt(start_str).strftime("%Y-%m-%d")] += value
metrics = {
"hrv": hrv_by_date,
"hrv_series": hrv_series_by_date,
"sleep_window": sleep_windows,
"resp": resp_by_date,
"hr": hr_series_by_date,
"vo2": vo2_series,
"steps": steps_by_date,
"energy": energy_by_date,
"exercise": exercise_by_date,
"stand": stand_by_date,
"stand_minutes": stand_minutes_by_date,
}
return by_date, metrics
def split_sessions(records):
"""Split records into sessions at long Awake gaps; drop the Awake record itself."""
sessions, current = [], []
for s, e, stage in records:
if stage == "Awake" and (e - s).total_seconds() / 60 > MAX_AWAKE_MIN:
if current:
sessions.append(current)
current = []
else:
current.append((s, e, stage))
if current:
sessions.append(current)
return sessions
def sleep_window(records):
"""Return (start, end) of the main sleep session for a night's records."""
main_records = split_sessions(records)[-1]
return main_records[0][0], main_records[-1][1]
def print_cycles(date, records, metrics, extended=False):
sessions = split_sessions(records)
naps, main_records = sessions[:-1], sessions[-1]
print(f"Sleep cycles for {date}")
print()
cycles = find_cycles(main_records)
labels = [f"Cycle {i+1}" for i in range(len(cycles))]
print(f"{'':8} {'Start':>5} {'End':>5} {'Duration':>10} Composition")
print("-" * 70)
for nap in naps:
nap_start, nap_end = nap[0][0], nap[-1][1]
dur = int((nap_end - nap_start).total_seconds() / 60)
h, m = divmod(dur, 60)
dur_str = f"{h}h {m:02d}m" if h else f"{m}m"
print(f"{'Nap':8} {nap_start.strftime('%H:%M'):>5} {nap_end.strftime('%H:%M'):>5} {dur_str:>10} {summarize_stages(nap)}")
for label, (start, end, dur, recs) in zip(labels, cycles):
composition = summarize_stages(recs)
h, m = divmod(dur, 60)
dur_str = f"{h}h {m:02d}m" if h else f"{m}m"
print(f"{label:8} {start.strftime('%H:%M'):>5} {end.strftime('%H:%M'):>5} {dur_str:>10} {composition}")
total = sum(dur for _, _, dur, _ in cycles)
h, m = divmod(total, 60)
print(f"\n{'Total sleep':>32} {h}h {m:02d}m")
sleep_start = main_records[0][0]
sleep_end = main_records[-1][1]
print_trend_metric("HRV", date, metrics["hrv"], "ms", decimals=0)
if extended:
print_series_pairs(date, metrics["hrv_series"], sleep_start, sleep_end, decimals=1, cycles=cycles, records=main_records, per_line=4)
print_overnight_hr_metric(date, metrics["hr"], metrics["sleep_window"])
if extended:
print_hr_per_cycle(date, metrics["hr"], cycles)
print_resp_series(date, metrics["resp"], sleep_start, sleep_end, extended)
print_vo2(date, metrics["vo2"])
print_activity(date, metrics)
print()
return cycles
def print_trend_metric(label, date, by_date, unit, decimals):
values = by_date.get(date)
if not values:
print(f"{label:>32} (no data)")
return
values = sorted(values)
current = values[len(values) // 2]
prior_dates = sorted(d for d in by_date if d < date)[-5:]
prior_vals = sorted(v for d in prior_dates for v in by_date[d])
trend_str = ""
if prior_vals:
prior_median = prior_vals[len(prior_vals) // 2]
if decimals == 0:
delta = int(current) - int(prior_median)
else:
delta = round(current - prior_median, decimals)
arrow = "↑" if delta > 0 else "↓" if delta < 0 else "→"
trend_str = f" {arrow}{abs(delta):.{decimals}f} vs 5-night median ({prior_median:.{decimals}f})"
print(f"{label:>32} {current:.{decimals}f} {unit}{trend_str}")
def print_vo2(date, vo2_series):
latest = None
for d, v in vo2_series:
if d <= date:
latest = (d, v)
else:
break
if not latest:
return
d, v = latest
print(f"{'VO2 max':>32} {v:.1f} mL/(kg·min) as of {d}")
def print_overnight_hr_metric(date, hr_series_by_date, sleep_windows):
series = hr_series_by_date.get(date)
window = sleep_windows.get(date)
if not series or not window:
print(f"{'Overnight HR':>32} (no data)")
return
sleep_start, sleep_end = window
vals = sorted(v for t, v in series if sleep_start <= t <= sleep_end)
if not vals:
print(f"{'Overnight HR':>32} (no data)")
return
current = int(vals[len(vals) // 2])
prior_dates = sorted(d for d in hr_series_by_date if d < date and d in sleep_windows)[-5:]
prior_vals = []
for d in prior_dates:
p_start, p_end = sleep_windows[d]
prior_vals.extend(v for t, v in hr_series_by_date[d] if p_start <= t <= p_end)
prior_vals.sort()
trend_str = ""
if prior_vals:
prior_median = int(prior_vals[len(prior_vals) // 2])
delta = current - prior_median
arrow = "↑" if delta > 0 else "↓" if delta < 0 else "→"
trend_str = f" {arrow}{abs(delta)} vs 5-night median ({prior_median})"
print(f"{'Overnight HR':>32} {current} bpm{trend_str}")
def print_hr_per_cycle(date, hr_by_date, cycles):
entries = hr_by_date.get(date)
if not entries:
return
entries.sort()
stats = []
global_min = (float("inf"), None, -1)
for idx, (cstart, cend, _dur, _recs) in enumerate(cycles):
in_cycle = [(t, v) for t, v in entries if cstart <= t <= cend]
if not in_cycle:
stats.append(None)
continue
vals = sorted(v for _, v in in_cycle)
cmin, cmax = vals[0], vals[-1]
cmed = vals[len(vals) // 2]
min_time = next(t for t, v in in_cycle if v == cmin)
stats.append((cstart, cend, cmin, cmax, cmed, min_time))
if cmin < global_min[0]:
global_min = (cmin, min_time, idx)
for idx, stat in enumerate(stats):
if stat is None:
continue
cstart, cend, cmin, cmax, cmed, min_time = stat
annot = ""
if idx == global_min[2]:
annot = f" ← min {int(cmin)} at {min_time.strftime('%H:%M')}"
print(f" Cycle {idx+1} ({cstart.strftime('%H:%M')}{cend.strftime('%H:%M')}): min {int(cmin)}, max {int(cmax)}, med {int(cmed)} bpm{annot}")
def print_series_pairs(date, series_by_date, sleep_start, sleep_end, decimals=1, cycles=None, records=None, per_line=7):
entries = series_by_date.get(date)
if not entries:
return
entries = sorted(e for e in entries if sleep_start <= e[0] <= sleep_end)
if not entries:
return
pairs = []
for t, v in entries:
s = f"{t.strftime('%H:%M')} {v:>4.{decimals}f}"
if cycles is not None and records is not None:
s += f" ({cycle_stage_at(t, cycles, records)})"
pairs.append(s)
for i in range(0, len(pairs), per_line):
print(" " + " ".join(pairs[i:i + per_line]))
def cycle_stage_at(t, cycles, records):
cycle = next((f"C{i+1}" for i, (cs, ce, _, _) in enumerate(cycles) if cs <= t <= ce), "?")
stage = next((st for s, e, st in records if s <= t <= e), "?")
return f"{cycle}-{stage}"
def print_resp_series(date, resp_by_date, sleep_start, sleep_end, extended=False):
entries = resp_by_date.get(date)
if entries:
entries = sorted(e for e in entries if sleep_start <= e[0] <= sleep_end)
if not entries:
print(f"{'Resp rate':>32} (no data)")
return
values = [v for _, v in entries]
vmin, vmax = min(values), max(values)
print(f"{'Resp rate':>32} {vmin:.1f}{vmax:.1f} br/min, {len(entries)} readings")
if extended:
print_series_pairs(date, resp_by_date, sleep_start, sleep_end, decimals=1)
def print_activity(date, metrics):
prev_day = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
energy = metrics["energy"].get(prev_day)
exercise = metrics["exercise"].get(prev_day)
stand = metrics["stand"].get(prev_day)
stand_min = metrics["stand_minutes"].get(prev_day)
steps = metrics["steps"].get(prev_day)
if energy is None and exercise is None and stand is None and steps is None:
print(f"{'Activity':>32} (no data)")
return
parts = []
if energy is not None: parts.append(f"Move {int(energy)} kcal")
if exercise is not None: parts.append(f"Exercise {int(exercise)} min")
if stand is not None:
if stand_min is not None:
parts.append(f"Stand {int(stand_min)} min across {stand} h")
else:
parts.append(f"Stand {stand} h")
if steps is not None: parts.append(f"Steps {int(steps):,}")
print(f"{'Activity':>32} {' · '.join(parts)}")
def find_cycles(records):
"""Split records into cycles using REM blocks as cycle boundaries."""
cycles = []
current_start = records[0][0] if records else None
i = 0
while i < len(records):
s, e, stage = records[i]
if stage == "REM":
# Merge consecutive REM blocks (possibly separated by tiny Core/Awake)
rem_end = e
rem_total = (e - s).total_seconds() / 60
j = i + 1
while j < len(records):
ns, ne, nstage = records[j]
gap = (ns - rem_end).total_seconds() / 60
if gap <= 5 and nstage in ("REM", "Core", "Awake"):
if nstage == "REM":
rem_end = ne
rem_total += (ne - ns).total_seconds() / 60
j += 1
else:
break
if rem_total < MIN_REM_CYCLE_MIN:
i = j
continue
# Collect all records up to and including this REM block
cycle_records = [r for r in records if current_start <= r[0] < rem_end]
dur = int((rem_end - current_start).total_seconds() / 60)
cycles.append((current_start, rem_end, dur, cycle_records))
# Find the next record starting at or after rem_end (don't skip
# records that the merge loop may have advanced j past).
j = next((k for k in range(i + 1, len(records)) if records[k][0] >= rem_end), len(records))
current_start = records[j][0] if j < len(records) else None
i = j
else:
i += 1
# Tail: remaining records after last REM
if current_start is not None:
tail = [r for r in records if r[0] >= current_start]
if tail:
tail_end = tail[-1][1]
dur = int((tail_end - current_start).total_seconds() / 60)
cycles.append((current_start, tail_end, dur, tail))
return cycles
def summarize_stages(cycle_records):
# Resolve overlaps: when records overlap, the later-starting one takes precedence.
# Build a list of non-overlapping segments by clipping earlier records.
segments = []
for s, e, stage in sorted(cycle_records):
if segments and s < segments[-1][1]:
ps, _, pstage = segments[-1]
segments[-1] = (ps, s, pstage)
segments.append((s, e, stage))
totals = {}
for s, e, stage in segments:
dur = (e - s).total_seconds() / 60
if dur >= 1:
totals[stage] = totals.get(stage, 0) + dur
parts = []
for stage in ("Deep", "Core", "REM", "Awake"):
if stage in totals:
parts.append(f"{stage} {int(totals[stage])}m")
return " → ".join(parts)
def parse_dt(s):
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z")
if __name__ == "__main__":
main()
// Sleep Cycles 2.0 — JS port of sleep_cycles_2.py, for running inside the iOS Shortcut's
// JS-in-WKWebView action. Mirrors the Python function-for-function; see sleep_cycles_2.py
// for the input schema and the documented divergences from sleep_cycles.py (vo2Max is a
// single latest reading). Stand is derived from AppleStandTime minutes to reproduce
// AppleStandHour exactly — see standHoursByDate below. Steps/Calories/Exercise restrict to
// Watch-sourced samples once their "sources" column is present — see series below.
//
// Local-time methods (getHours, getDate, etc.) are used throughout, relying on the
// runtime's configured timezone to match the offset embedded in the ISO timestamps —
// true in production (same device writes and reads the data), but when testing in Node
// the TZ env var must be pinned to match (e.g. `TZ=Etc/GMT-3 node sleep_cycles_2.js ...`).
//
// The four walking/gait metrics and oxygen_saturation are iPhone-sourced (not Watch) and
// never carry a "sources" column, so they're never Watch-filtered. Percent-unit metrics
// (oxygen_saturation, w_asymmetry, w_steadiness) arrive already scaled 0-100, not the 0-1
// fraction HealthKit's XML export uses for the same "%" unit.
const MIN_REM_CYCLE_MIN = 3; // minimum total REM to count as a cycle boundary
const MAX_AWAKE_MIN = 60; // awake stretches longer than this mark a session break
// Output is accumulated here rather than printed directly, since the Shortcut's JS host
// isn't guaranteed to provide a `console` global the way Node does.
let __lines = [];
function log(s = "") { __lines.push(s); }
// Entry point for the Shortcut: pass the "Sleep Cycles 2.0" output object, get the report
// text back. from/to/extended default to "every night present in the data" / non-extended,
// matching the CLI's behavior when those flags are omitted.
function generateReport(data, { from = null, to = null, extended = false } = {}) {
__lines = [];
const { recordsByDate, metrics } = loadRecords(data);
let dates;
if (from) {
const dateTo = to || from;
dates = Object.keys(recordsByDate).sort().filter(d => d >= from && d <= dateTo);
} else {
dates = Object.keys(recordsByDate).sort();
}
const allCycleDurations = [];
for (const date of dates) {
const records = recordsByDate[date];
if (!records) {
if (from) log(`No sleep data for ${date}`);
continue;
}
const cycles = printCycles(date, records, metrics, extended);
for (const c of cycles) allCycleDurations.push(c.dur);
}
if (allCycleDurations.length) {
allCycleDurations.sort((a, b) => a - b);
const n = allCycleDurations.length;
const p90 = allCycleDurations[Math.min(Math.floor(n * 0.9), n - 1)];
const median = allCycleDurations[Math.floor(n / 2)];
const fmt = m => `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
log(`Cycle duration (${n} cycles): median ${fmt(median)}, P90 ${fmt(p90)}`);
}
return __lines.join("\n");
}
function loadRecords(data) {
const sleep = data.sleep || {};
const starts = splitLines(sleep.starts);
const ends = splitLines(sleep.ends);
const stages = splitLines(sleep.values);
const byDate = new Map();
for (let i = 0; i < starts.length; i++) {
const stage = stages[i];
if (stage === "In Bed" || stage === "InBed") continue; // bracketing record, not a sleep stage
const s = parseDt(starts[i]);
const e = parseDt(ends[i]);
// Records ending before noon belong to that morning's night;
// records ending at noon or later are evening/onset records for the next night.
const night = e.getHours() >= 12 ? dateStr(addDays(e, 1)) : dateStr(e);
if (!byDate.has(night)) byDate.set(night, []);
byDate.get(night).push({ s, e, stage });
}
for (const records of byDate.values()) records.sort((a, b) => a.s - b.s);
const sleepWindows = new Map();
for (const [night, records] of byDate) sleepWindows.set(night, sleepWindow(records));
function overnightKey(dt) {
return dt.getHours() >= 12 ? dateStr(addDays(dt, 1)) : dateStr(dt);
}
// HRV and Overnight HR are restricted to the actual sleep window (below);
// daytime spot-checks keyed to the night by overnightKey are excluded.
const hrvByDate = new Map();
const hrvSeriesByDate = new Map();
for (const { dt, value } of series(data.hrv)) {
const key = overnightKey(dt);
const window = sleepWindows.get(key);
if (window && window.start <= dt && dt <= window.end) {
mapPush(hrvByDate, key, value);
mapPush(hrvSeriesByDate, key, { dt, value });
}
}
const respByDate = new Map();
for (const { dt, value } of series(data.rr)) {
mapPush(respByDate, overnightKey(dt), { dt, value });
}
const hrSeriesByDate = new Map();
for (const { dt, value } of series(data.hr)) {
mapPush(hrSeriesByDate, overnightKey(dt), { dt, value });
}
const spo2ByDate = new Map();
for (const { dt, value } of series(data.oxygen_saturation)) {
mapPush(spo2ByDate, overnightKey(dt), { dt, value });
}
// Unlike HRV/HR, wrist temperature only ever exists during actual detected sleep
// (Apple's own algorithm restricts it) — no daytime spot-checks to filter out, so
// no window-clipping is needed, just bucketing by night.
const wristTempByDate = new Map();
for (const { dt, value } of series(data.wrist_temperature)) {
mapPush(wristTempByDate, overnightKey(dt), value);
}
let vo2Series = [];
const vo2 = data.vo2Max || {};
if (vo2.value != null && vo2.date) {
const day = dateStr(parseVo2Date(vo2.date));
vo2Series = [{ day, value: Number(vo2.value) }];
}
const metrics = {
hrv: hrvByDate,
hrvSeries: hrvSeriesByDate,
sleepWindow: sleepWindows,
resp: respByDate,
hr: hrSeriesByDate,
vo2: vo2Series,
steps: dailySums(data.steps, { watchOnly: true }),
energy: dailySums(data.calories, { watchOnly: true }),
exercise: dailySums(data.exercise, { watchOnly: true }),
stand: standHoursByDate(data.stand),
standMinutes: dailySums(data.stand),
spo2: spo2ByDate,
wristTemp: wristTempByDate,
walkAsymmetry: dailyAverage(data.w_asymmetry),
walkSpeed: dailyAverage(data.w_speed),
walkStepLength: dailyAverage(data.w_step_length),
walkSteadiness: latestReadingSeries(data.w_steadiness),
};
return { recordsByDate: mapToObject(byDate), metrics };
}
function dailySums(block, opts) {
const acc = new Map();
for (const { dt, value } of series(block, opts)) {
const key = dateStr(dt);
acc.set(key, (acc.get(key) || 0) + value);
}
return acc;
}
// Averages a columnar block's values per calendar day (gait metrics).
function dailyAverage(block) {
const acc = new Map();
for (const { dt, value } of series(block)) {
const key = dateStr(dt);
if (!acc.has(key)) acc.set(key, []);
acc.get(key).push(value);
}
const out = new Map();
for (const [day, vals] of acc) out.set(day, vals.reduce((a, b) => a + b, 0) / vals.length);
return out;
}
// Turns a {starts, values} block into sorted {day, value} readings, latest reading per day.
function latestReadingSeries(block) {
const byDay = new Map();
for (const { dt, value } of series(block)) byDay.set(dateStr(dt), value);
return [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, value]) => ({ day, value }));
}
// Derives AppleStandHour-equivalent stood-hour counts from AppleStandTime minutes:
// an hour counts as "stood" if it has >=1 minute of standing — verified to reproduce
// Apple's own AppleStandHour records exactly against a week of real data (0 mismatches
// across 171 hourly buckets).
function standHoursByDate(block) {
const minutesByHour = new Map();
for (const { dt, value } of series(block)) {
const hourKey = `${dateStr(dt)} ${String(dt.getHours()).padStart(2, "0")}`;
minutesByHour.set(hourKey, (minutesByHour.get(hourKey) || 0) + value);
}
const acc = new Map();
for (const [hourKey, minutes] of minutesByHour) {
if (minutes >= 1) {
const day = hourKey.slice(0, 10);
acc.set(day, (acc.get(day) || 0) + 1);
}
}
return acc;
}
// watchOnly restricts to Watch-sourced samples, matching sleep_cycles.py's --source
// filtering — but only once a "sources" column is actually present, so metrics that
// haven't had it added yet (Calories/Exercise, for now) keep summing everything.
function series(block, { watchOnly = false } = {}) {
if (!block) return [];
const starts = splitLines(block.starts);
const values = splitLines(block.values);
const hasSources = watchOnly && block.sources != null;
const sources = hasSources ? splitLines(block.sources) : null;
const out = [];
for (let i = 0; i < starts.length; i++) {
const s = starts[i], v = values[i];
if (!s || !v) continue;
if (hasSources && !(sources[i] || "").includes("Watch")) continue;
out.push({ dt: parseDt(s), value: Number(v) });
}
return out;
}
function parseVo2Date(s) {
// VO2 date arrives ISO (oracle) or localized ("15 Jun 2026 at 11:37", device).
const iso = parseDt(s);
if (!isNaN(iso)) return iso;
const m = s.match(/^(\d{1,2}) (\w{3}) (\d{4}) at (\d{1,2}):(\d{2})$/);
if (!m) throw new Error(`Unrecognized vo2Max date: ${s}`);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const [, d, mon, y, h, min] = m;
return new Date(Number(y), months.indexOf(mon), Number(d), Number(h), Number(min));
}
function splitSessions(records) {
// Split records into sessions at long Awake gaps; drop the Awake record itself.
const sessions = [];
let current = [];
for (const r of records) {
if (r.stage === "Awake" && (r.e - r.s) / 60000 > MAX_AWAKE_MIN) {
if (current.length) sessions.push(current);
current = [];
} else {
current.push(r);
}
}
if (current.length) sessions.push(current);
return sessions;
}
function sleepWindow(records) {
// Return {start, end} of the main sleep session for a night's records.
const sessions = splitSessions(records);
const main = sessions[sessions.length - 1];
return { start: main[0].s, end: main[main.length - 1].e };
}
function printCycles(date, records, metrics, extended = false) {
const sessions = splitSessions(records);
const naps = sessions.slice(0, -1);
const mainRecords = sessions[sessions.length - 1];
log(`Sleep cycles for ${date}`);
log();
const cycles = findCycles(mainRecords);
const labels = cycles.map((_, i) => `Cycle ${i + 1}`);
log(`${"".padEnd(8)} ${"Start".padStart(5)} ${"End".padStart(5)} ${"Duration".padStart(10)} Composition`);
log("-".repeat(70));
for (const nap of naps) {
const napStart = nap[0].s, napEnd = nap[nap.length - 1].e;
const dur = Math.floor((napEnd - napStart) / 60000);
const h = Math.floor(dur / 60), m = dur % 60;
const durStr = h ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m`;
log(`${"Nap".padEnd(8)} ${hm(napStart).padStart(5)} ${hm(napEnd).padStart(5)} ${durStr.padStart(10)} ${summarizeStages(nap)}`);
}
for (let i = 0; i < cycles.length; i++) {
const c = cycles[i];
const composition = summarizeStages(c.records);
const h = Math.floor(c.dur / 60), m = c.dur % 60;
const durStr = h ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m`;
log(`${labels[i].padEnd(8)} ${hm(c.start).padStart(5)} ${hm(c.end).padStart(5)} ${durStr.padStart(10)} ${composition}`);
}
const total = cycles.reduce((sum, c) => sum + c.dur, 0);
const th = Math.floor(total / 60), tm = total % 60;
log(`\n${"Total sleep".padStart(32)} ${th}h ${String(tm).padStart(2, "0")}m`);
const sleepStart = mainRecords[0].s;
const sleepEnd = mainRecords[mainRecords.length - 1].e;
printTrendMetric("HRV", date, metrics.hrv, "ms", 0);
if (extended) printSeriesPairs(date, metrics.hrvSeries, sleepStart, sleepEnd, 1, cycles, mainRecords, 4);
printTrendMetric("Wrist temp", date, metrics.wristTemp, "°C", 1);
printOvernightHrMetric(date, metrics.hr, metrics.sleepWindow);
if (extended) printHrPerCycle(date, metrics.hr, cycles);
printRangeSeries("Resp rate", "br/min", date, metrics.resp, sleepStart, sleepEnd, 1, extended);
printRangeSeries("SpO2", "%", date, metrics.spo2, sleepStart, sleepEnd, 0, extended);
printLatestMetric("VO2 max", "mL/(kg·min)", date, metrics.vo2, 1);
printLatestMetric("Walking steadiness", "%", date, metrics.walkSteadiness, 1);
printActivity(date, metrics);
printGait(date, metrics);
log();
return cycles;
}
function printTrendMetric(label, date, byDate, unit, decimals) {
const values = byDate.get(date);
if (!values || !values.length) {
log(`${label.padStart(32)} (no data)`);
return;
}
const sorted = [...values].sort((a, b) => a - b);
const current = sorted[Math.floor(sorted.length / 2)];
const priorDates = [...byDate.keys()].filter(d => d < date).sort().slice(-5);
const priorVals = priorDates.flatMap(d => byDate.get(d)).sort((a, b) => a - b);
let trendStr = "";
if (priorVals.length) {
const priorMedian = priorVals[Math.floor(priorVals.length / 2)];
const delta = decimals === 0
? Math.trunc(current) - Math.trunc(priorMedian)
: round(current - priorMedian, decimals);
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
trendStr = ` ${arrow}${Math.abs(delta).toFixed(decimals)} vs 5-night median (${priorMedian.toFixed(decimals)})`;
}
log(`${label.padStart(32)} ${current.toFixed(decimals)} ${unit}${trendStr}`);
}
// Prints the most recent reading at or before `date` (VO2 max, Walking steadiness) — both
// update on their own slow cadence rather than nightly, so there's no trend to show.
function printLatestMetric(label, unit, date, dayValuePairs, decimals = 1) {
let latest = null;
for (const { day, value } of dayValuePairs) {
if (day <= date) latest = { day, value };
else break;
}
if (!latest) return;
log(`${label.padStart(32)} ${latest.value.toFixed(decimals)} ${unit} as of ${latest.day}`);
}
function printOvernightHrMetric(date, hrSeriesByDate, sleepWindows) {
const series = hrSeriesByDate.get(date);
const window = sleepWindows.get(date);
if (!series || !window) {
log(`${"Overnight HR".padStart(32)} (no data)`);
return;
}
const vals = series.filter(({ dt }) => window.start <= dt && dt <= window.end)
.map(({ value }) => value).sort((a, b) => a - b);
if (!vals.length) {
log(`${"Overnight HR".padStart(32)} (no data)`);
return;
}
const current = Math.trunc(vals[Math.floor(vals.length / 2)]);
const priorDates = [...hrSeriesByDate.keys()]
.filter(d => d < date && sleepWindows.has(d)).sort().slice(-5);
const priorVals = [];
for (const d of priorDates) {
const w = sleepWindows.get(d);
for (const { dt, value } of hrSeriesByDate.get(d)) {
if (w.start <= dt && dt <= w.end) priorVals.push(value);
}
}
priorVals.sort((a, b) => a - b);
let trendStr = "";
if (priorVals.length) {
const priorMedian = Math.trunc(priorVals[Math.floor(priorVals.length / 2)]);
const delta = current - priorMedian;
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
trendStr = ` ${arrow}${Math.abs(delta)} vs 5-night median (${priorMedian})`;
}
log(`${"Overnight HR".padStart(32)} ${current} bpm${trendStr}`);
}
function printHrPerCycle(date, hrByDate, cycles) {
const entries = hrByDate.get(date);
if (!entries) return;
entries.sort((a, b) => a.dt - b.dt);
const stats = [];
let globalMin = { min: Infinity, time: null, idx: -1 };
for (let idx = 0; idx < cycles.length; idx++) {
const c = cycles[idx];
const inCycle = entries.filter(({ dt }) => c.start <= dt && dt <= c.end);
if (!inCycle.length) { stats.push(null); continue; }
const vals = [...inCycle].map(e => e.value).sort((a, b) => a - b);
const cmin = vals[0], cmax = vals[vals.length - 1];
const cmed = vals[Math.floor(vals.length / 2)];
const minTime = inCycle.find(e => e.value === cmin).dt;
stats.push({ cstart: c.start, cend: c.end, cmin, cmax, cmed, minTime });
if (cmin < globalMin.min) globalMin = { min: cmin, time: minTime, idx };
}
stats.forEach((stat, idx) => {
if (!stat) return;
const { cstart, cend, cmin, cmax, cmed, minTime } = stat;
const annot = idx === globalMin.idx
? ` ← min ${Math.trunc(cmin)} at ${hm(minTime)}` : "";
log(` Cycle ${idx + 1} (${hm(cstart)}${hm(cend)}): min ${Math.trunc(cmin)}, max ${Math.trunc(cmax)}, med ${Math.trunc(cmed)} bpm${annot}`);
});
}
function printSeriesPairs(date, seriesByDate, sleepStart, sleepEnd, decimals = 1, cycles = null, records = null, perLine = 7) {
let entries = seriesByDate.get(date);
if (!entries) return;
entries = entries.filter(({ dt }) => sleepStart <= dt && dt <= sleepEnd)
.sort((a, b) => a.dt - b.dt);
if (!entries.length) return;
const pairs = entries.map(({ dt, value }) => {
let s = `${hm(dt)} ${value.toFixed(decimals).padStart(4)}`;
if (cycles !== null && records !== null) s += ` (${cycleStageAt(dt, cycles, records)})`;
return s;
});
for (let i = 0; i < pairs.length; i += perLine) {
log(" " + pairs.slice(i, i + perLine).join(" "));
}
}
function cycleStageAt(t, cycles, records) {
const ci = cycles.findIndex(c => c.start <= t && t <= c.end);
const cycle = ci >= 0 ? `C${ci + 1}` : "?";
const rec = records.find(r => r.s <= t && t <= r.e);
const stage = rec ? rec.stage : "?";
return `${cycle}-${stage}`;
}
function printRangeSeries(label, unit, date, seriesByDate, sleepStart, sleepEnd, decimals = 1, extended = false) {
let entries = seriesByDate.get(date);
if (entries) entries = entries.filter(({ dt }) => sleepStart <= dt && dt <= sleepEnd);
if (!entries || !entries.length) {
log(`${label.padStart(32)} (no data)`);
return;
}
const values = entries.map(e => e.value);
const vmin = Math.min(...values), vmax = Math.max(...values);
log(`${label.padStart(32)} ${vmin.toFixed(decimals)}${vmax.toFixed(decimals)} ${unit}, ${entries.length} readings`);
if (extended) printSeriesPairs(date, seriesByDate, sleepStart, sleepEnd, decimals);
}
function printActivity(date, metrics) {
const prevDay = dateStr(addDays(parseDateOnly(date), -1));
const energy = metrics.energy.get(prevDay);
const exercise = metrics.exercise.get(prevDay);
const stand = metrics.stand.get(prevDay);
const standMin = metrics.standMinutes.get(prevDay);
const steps = metrics.steps.get(prevDay);
if (energy == null && exercise == null && stand == null && steps == null) {
log(`${"Activity".padStart(32)} (no data)`);
return;
}
const parts = [];
if (energy != null) parts.push(`Move ${Math.trunc(energy)} kcal`);
if (exercise != null) parts.push(`Exercise ${Math.trunc(exercise)} min`);
if (stand != null) {
parts.push(standMin != null
? `Stand ${Math.trunc(standMin)} min across ${Math.trunc(stand)} h`
: `Stand ${Math.trunc(stand)} h`);
}
if (steps != null) parts.push(`Steps ${Math.trunc(steps).toLocaleString("en-US")}`);
log(`${"Activity".padStart(32)} ${parts.join(" · ")}`);
}
function printGait(date, metrics) {
const prevDay = dateStr(addDays(parseDateOnly(date), -1));
const speed = metrics.walkSpeed.get(prevDay);
const stepLen = metrics.walkStepLength.get(prevDay);
const asymmetry = metrics.walkAsymmetry.get(prevDay);
if (speed == null && stepLen == null && asymmetry == null) {
log(`${"Gait".padStart(32)} (no data)`);
return;
}
const parts = [];
if (speed != null) parts.push(`Speed ${speed.toFixed(1)} km/h`);
if (stepLen != null) parts.push(`Step length ${Math.trunc(stepLen)} cm`);
if (asymmetry != null) parts.push(`Asymmetry ${Math.trunc(asymmetry)}%`);
log(`${"Gait".padStart(32)} ${parts.join(" · ")}`);
}
function findCycles(records) {
// Split records into cycles using REM blocks as cycle boundaries.
const cycles = [];
let currentStart = records.length ? records[0].s : null;
let i = 0;
while (i < records.length) {
const { s, e, stage } = records[i];
if (stage === "REM") {
// Merge consecutive REM blocks (possibly separated by tiny Core/Awake)
let remEnd = e;
let remTotal = (e - s) / 60000;
let j = i + 1;
while (j < records.length) {
const { s: ns, e: ne, stage: nstage } = records[j];
const gap = (ns - remEnd) / 60000;
if (gap <= 5 && (nstage === "REM" || nstage === "Core" || nstage === "Awake")) {
if (nstage === "REM") {
remEnd = ne;
remTotal += (ne - ns) / 60000;
}
j += 1;
} else {
break;
}
}
if (remTotal < MIN_REM_CYCLE_MIN) {
i = j;
continue;
}
// Collect all records up to and including this REM block
const cycleRecords = records.filter(r => currentStart <= r.s && r.s < remEnd);
const dur = Math.floor((remEnd - currentStart) / 60000);
cycles.push({ start: currentStart, end: remEnd, dur, records: cycleRecords });
// Find the next record starting at or after remEnd (don't skip
// records that the merge loop may have advanced j past).
let k = i + 1;
while (k < records.length && records[k].s < remEnd) k++;
j = k < records.length ? k : records.length;
currentStart = j < records.length ? records[j].s : null;
i = j;
} else {
i += 1;
}
}
// Tail: remaining records after last REM
if (currentStart !== null) {
const tail = records.filter(r => r.s >= currentStart);
if (tail.length) {
const tailEnd = tail[tail.length - 1].e;
const dur = Math.floor((tailEnd - currentStart) / 60000);
cycles.push({ start: currentStart, end: tailEnd, dur, records: tail });
}
}
return cycles;
}
function summarizeStages(cycleRecords) {
// Resolve overlaps: when records overlap, the later-starting one takes precedence.
// Build a list of non-overlapping segments by clipping earlier records.
const sorted = [...cycleRecords].sort((a, b) => a.s - b.s || a.e - b.e);
const segments = [];
for (const r of sorted) {
if (segments.length && r.s < segments[segments.length - 1].e) {
const prev = segments[segments.length - 1];
segments[segments.length - 1] = { s: prev.s, e: r.s, stage: prev.stage };
}
segments.push({ s: r.s, e: r.e, stage: r.stage });
}
const totals = {};
for (const { s, e, stage } of segments) {
const dur = (e - s) / 60000;
if (dur >= 1) totals[stage] = (totals[stage] || 0) + dur;
}
const parts = [];
for (const stage of ["Deep", "Core", "REM", "Awake"]) {
if (stage in totals) parts.push(`${stage} ${Math.trunc(totals[stage])}m`);
}
return parts.join(" → ");
}
// --- small helpers ---
function parseDt(s) {
return new Date(s);
}
function splitLines(s) {
return s ? s.split("\n") : [];
}
function dateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function parseDateOnly(s) {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, m - 1, d);
}
function addDays(d, n) {
return new Date(d.getTime() + n * 86400000);
}
function hm(d) {
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function round(x, decimals) {
const f = 10 ** decimals;
return Math.round(x * f) / f;
}
function mapPush(map, key, value) {
if (!map.has(key)) map.set(key, []);
map.get(key).push(value);
}
function mapToObject(map) {
const obj = {};
for (const [k, v] of map) obj[k] = v;
return obj;
}
// Node CLI entry, for local testing only — not present/used when this file's contents
// are pasted into the Shortcut's JS action, which calls generateReport(data) directly.
if (typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) {
const argv = process.argv.slice(2);
let jsonPath = null, dateFrom = null, dateTo = null, extended = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--from") dateFrom = argv[++i];
else if (a === "--to") dateTo = argv[++i];
else if (a === "--extended") extended = true;
else jsonPath = a;
}
const text = jsonPath && jsonPath !== "-"
? require("fs").readFileSync(jsonPath, "utf8")
: require("fs").readFileSync(0, "utf8");
const data = JSON.parse(text);
console.log(generateReport(data, { from: dateFrom, to: dateTo, extended }));
}

Sleep Cycles 2.0

sleep_cycles.py needs a full Apple Health export — slow (~minutes), growing over time, and tied to a Mac. Sleep Cycles 2.0 produces the same report from an iOS Shortcut, entirely on-device: the Shortcut pulls just a few days of relevant samples directly from the Health app, no export and no Mac needed.

  • sleep_cycles_2.py — the same report logic, adapted to read the Shortcut's JSON instead of the XML export. Kept mainly as a reference implementation / test oracle.
  • sleep_cycles_2.js — a function-for-function JS port of sleep_cycles_2.py, run inside the Shortcut.

How the Shortcuts fit together

The Shortcut side is a small system of ~18 shortcuts, not one monolith:

  1. Fifteen metric shortcuts (SC2_Sleep, SC2_HR, SC2_HRV, SC2_RR, SC2_VO2_MAX, SC2_Steps, SC2_Calories, SC2_Exercise, SC2_Stand, SC2_SpO2, SC2_WristTemp, SC2_WalkAsymmetry, SC2_WalkSpeed, SC2_WalkStepLength, SC2_WalkSteadiness) each fetch one Health sample type and return it as a columnar JS object literal (text built with backtick-quoted fields, not strict JSON — see below).
  2. Sleep Cycles 2.0 is the aggregator — it runs all nine, collects their results, and combines them into the single JS value the report logic expects.
  3. sleep_cycles_2.js is a "module" shortcut: its only action is a block of Text holding the full script source. Other shortcuts fetch the current code by running it, rather than duplicating the script inline.
  4. JS Eval is a generic, reusable JS-execution shortcut — not specific to this project. Given arbitrary JS source as input, it evaluates it, expects the text to define a global main() function, calls it, and prints whatever it returns.
  5. SC2 Report is the entry point: runs Sleep Cycles 2.0 for the data, runs sleep_cycles_2.js for the source, splices both into a small driver script, and hands that to JS Eval.

The fifteen metric shortcuts

Each follows the same template: query one Health type over a lookback window, then flatten the results into newline-joined columns rather than looping per sample — looping hangs the Shortcut on heart rate's volume (thousands of readings over a week). The lookback differs per metric, not a uniform window:

Shortcut Health type (Shortcuts picker) Lookback Output
SC2_Sleep Sleep last 6 days { starts, ends, values }values is the sleep stage
SC2_HR Heart Rate last 6 days { starts, values }
SC2_HRV Heart Rate Variability last 6 days { starts, values }
SC2_RR Respiratory Rate last 1 day { starts, values }
SC2_Steps Steps last 1 day { starts, values, sources }sources enables Watch-only filtering
SC2_Calories Active Energy last 1 day { starts, values }
SC2_Exercise Exercise Minutes last 1 day { starts, values }
SC2_Stand Stand Minutes last 1 day { starts, values }
SC2_VO2_MAX Cardio Fitness last 1 month, latest first, limit 1 { value, date }
SC2_SpO2 Blood Oxygen last 1 day { starts, values }
SC2_WristTemp Sleeping Wrist Temperature last 6 days { starts, values }
SC2_WalkAsymmetry Walking Asymmetry Percentage last 1 day { starts, values }
SC2_WalkSpeed Walking Speed last 1 day { starts, values }
SC2_WalkStepLength Walking Step Length last 1 day { starts, values }
SC2_WalkSteadiness Walking Steadiness last 1 month, all results { starts, values }

Why HR, HRV, Sleep, and now Wrist Temp need 6 days and the rest don't: generateReport computes the HRV / Overnight HR / Wrist Temp trend as a median over the 5 most recent prior nights (.slice(-5) in the code) — anything older is fetched and then discarded, so pulling much more than that is pure runtime cost with no effect on the report. 6 days (not exactly 5) gives slack for the 5th prior night's bedtime starting the evening before that calendar day.

The non-obvious part: SC2_Sleep's window is the actual bottleneck for the trend, not HR/HRV's. Every HR/HRV reading is filtered through that night's sleep window, which is derived entirely from data.sleep — so even with HR/HRV pulling 6+ days, if SC2_Sleep only pulled 1 day, only one prior night ever had a sleep window to filter against, and the "5-night median" silently degraded to just that one night's value. Caught by comparing a real Shortcut run against sleep_cycles.py for the same night: widening SC2_HR/SC2_HRV alone (5→6 days) changed nothing — proof the bottleneck was elsewhere — and widening SC2_Sleep to 6 days fixed it, after which the Shortcut's output matched sleep_cycles.py exactly. All three need the same window for this reason.

Wrist Temp is the exception to windowing, not just the lookback: HRV/HR/RR/SpO2 readings are clipped to that night's sleep window before use, because HealthKit takes those all day and an unclipped daytime spot-check would otherwise get misassigned to a night by overnightKey. Wrist temperature never has that problem — Apple's own algorithm only ever records it during actual detected sleep — so generateReport buckets it by overnightKey alone, with no window-clipping step.

The other metrics don't carry multi-night trends: RR and SpO2 are same-night only (like Resp rate, no trend arrow); VO2 max and Walking Steadiness each keep a single latest reading via their own wider, sparser lookback — Cardio Fitness and Walking Steadiness both only update every week or so. The three walking/gait metrics (Asymmetry, Speed, Step Length) update far more often (many samples a day), so instead they're averaged per calendar day and shown on a "Gait" line, the same way Steps/Calories/Exercise/Stand are summed per day onto "Activity".

Template for the { starts, values } shortcuts:

  1. Find Health Samples — type and lookback as listed above.
  2. Set variable samples to Health Samples.
  3. Get Start Date from samplesFormat Date (ISO 8601) → Set variable starts to Formatted Date.
  4. Set variable values to samples directly — Shortcuts' default text coercion flattens the whole list into one newline-joined string, no loop needed.
  5. Text: {"starts": `‹starts›`, "values": `‹values›`} — backticks, not double quotes, around the placeholders: starts/values are newline-joined, and a regular JS string literal can't contain a raw newline — a template literal can. This is the shortcut's output.

SC2_Steps additionally has Get Source from samplesSet variable sources to Source, and adds "sources": `‹sources›` to the output object — that's what unlocks Watch-only filtering for Steps (see Known differences below). The same addition would do the same for SC2_Calories/SC2_Exercise. SC2_SpO2 and the four walking/gait shortcuts never add a sources column, since HealthKit records those from the iPhone, not the Watch — there's no Watch-only cut to make.

SC2_Sleep additionally formats the End Date into an ends variable and includes it in the output object.

SC2_VO2_MAX differs structurally because Cardio Fitness only updates every week or two: sort latest-first, limit to 1 result, and output {"value": ‹value›, "date": `‹date›`} instead of columns — value is a bare number (no quoting needed), date is backtick-quoted like the columnar fields above. In practice backticks are used as the safe default for any text placeholder, whether or not it's known to contain a newline.

Aggregator: "Sleep Cycles 2.0"

For each of the fifteen metric shortcuts: Run <shortcut>Set variable <key> to Shortcut Result. Then a final Text action assembles:

{"sleep": ‹sleep›, "hr": ‹hr›, "hrv": ‹hrv›, "rr": ‹rr›, "vo2Max": ‹vo2Max›, "steps": ‹steps›, "calories": ‹calories›, "exercise": ‹exercise›, "stand": ‹stand›, "oxygen_saturation": ‹oxygen_saturation›, "wrist_temperature": ‹wrist_temperature›, "w_asymmetry": ‹w_asymmetry›, "w_speed": ‹w_speed›, "w_step_length": ‹w_step_length›, "w_steadiness": ‹w_steadiness›}

That combined JS value is the shortcut's output — see Input schema below for the full shape.

Code container: "sleep_cycles_2.js"

A single-action shortcut: one Text action holding the full contents of sleep_cycles_2.js, nothing else. Its only purpose is to act as a single source of truth for the code — SC2 Report runs it to fetch the current copy rather than carrying its own duplicate.

Generic runner: "JS Eval"

A generic JS runner: given JS source text as input, it runs it inside a try/catch, expects the text to define a global main() function, calls it, and prints whatever it returns (or an error and stack trace on failure). Any shortcut needing to run JS can reuse this by handing it source that defines main().

Entry point: "SC2 Report"

  1. Run Sleep Cycles 2.0Set variable data to Shortcut Result.

  2. Run sleep_cycles_2.jsSet variable generateReportCode to Shortcut Result.

  3. Text action building the driver:

    const data = ‹data›;
    
    function main() {
      ‹generateReportCode›;
    
      return generateReport(data, {
        from: dateStr(new Date()),
        extended: true
      });
    }

    The pasted generateReportCode text defines generateReport and its helpers as function declarations inside main()'s body — JS hoists them within that scope, so the return generateReport(...) call below sees them with no extra wiring. extended: true requests the extended report (matching make report-extended); to is omitted, defaulting to from.

  4. Run JS Eval with that Text as input, then show/print the result.

Input schema

The logical shape below is shared by both consumers, though it arrives differently in each: sleep_cycles_2.py reads it from a real JSON file (e.g. when testing against a saved dump), while the Shortcut builds it as a JS object literal directly — no JSON.parse involved, hence the backtick-quoted fields described above.

{
  "sleep":    { "starts": "<ISO 8601 lines>", "ends": "<ISO 8601 lines>", "values": "<stage lines>" },
  "hr":       { "starts": "<ISO lines>", "values": "<bpm lines>" },
  "hrv":      { "starts": "<ISO lines>", "values": "<SDNN ms lines>" },
  "rr":       { "starts": "<ISO lines>", "values": "<resp rate lines>" },
  "vo2Max":   { "value": 44.5, "date": "22 Jun 2026 at 20:37" },
  "steps":    { "starts": "<ISO lines>", "values": "<count lines>", "sources": "<name lines>" },
  "calories": { "starts": "<ISO lines>", "values": "<kcal lines>" },
  "exercise": { "starts": "<ISO lines>", "values": "<minute lines>" },
  "stand":    { "starts": "<ISO lines>", "values": "<AppleStandTime minute lines>" },
  "oxygen_saturation": { "starts": "<ISO lines>", "values": "<% 0-100 lines>" },
  "wrist_temperature": { "starts": "<ISO lines>", "values": "<degC lines>" },
  "w_asymmetry":       { "starts": "<ISO lines>", "values": "<% 0-100 lines>" },
  "w_speed":           { "starts": "<ISO lines>", "values": "<km/hr lines>" },
  "w_step_length":     { "starts": "<ISO lines>", "values": "<cm lines>" },
  "w_steadiness":      { "starts": "<ISO lines>", "values": "<% 0-100 lines>" }
}

Each metric is columnar — parallel starts/values strings, one reading per line, zipped by index — rather than one object per sample, for the looping reason above.

The three percent-unit metrics (oxygen_saturation, w_asymmetry, w_steadiness) arrive already scaled 0-100 — Shortcuts' Health-sample values for a "%" HealthKit unit come back that way, unlike the 0-1 fraction the same unit uses in the XML export.

Usage

Python (mainly for testing against the JS port):

python3 sleep_cycles_2.py export.json [--from DATE] [--to DATE] [--extended]

JavaScript, called from SC2 Report as shown above, or standalone:

generateReport(data, { from: dateStr(new Date()) })

generateReport(data, { from, to, extended }) returns the report as a string; all three options default to "every night present in the data" / non-extended when omitted. Passing today's date as from restricts the printed report to the latest night, while still using the rest of the loaded window for the HRV / Overnight HR trend comparisons.

Known differences from sleep_cycles.py

  • Calories / Exercise aren't restricted to a specific device yet — unlike sleep_cycles.py's --source filtering, which keeps Watch-only data. (Steps is now restricted: turns out per-sample source is exposed for quantity types — the earlier "source comes back empty" finding was specific to Sleep samples. SC2_Steps adds a sources column and series()/daily_sums() filter to "Watch" in source once it's present, verified to match sleep_cycles.py exactly across 23 nights. Calories/Exercise will pick up the same filtering automatically once sources is added to those shortcuts too.)
  • Stand is derived from AppleStandTime minutes rather than read directly from AppleStandHour (not queryable via Shortcuts): an hour counts as stood if it has ≥1 minute of standing. Verified against a week of real data at 171/171 hourly buckets and 22/23 nights' daily totals exactly matching AppleStandHour; the one miss was traced to a quirk in the raw AppleStandHour data itself (a duplicate/non-hour-aligned record), not the derivation logic — rare ±1 hour discrepancies are possible.
  • VO2 max is a single latest reading, not a history — fine for same-day use; re-running over a historical range can show a missing VO2 line on nights before that reading. Walking steadiness follows the same "latest as of" pattern for the same reason (updates roughly weekly), but arrives as a real { starts, values } series rather than VO2's single { value, date }, so both share one printLatestMetric/print_latest_metric function.
  • SpO2 is shown as a same-night range like Resp rate (no 5-night trend) — Apple doesn't attach a meaningful trend to it either.
  • Gait (Speed, Step length, Asymmetry) is averaged per calendar day and shown on its own line, the same way Activity averages/sums Steps/Calories/Exercise/Stand — for the day before the reported night, since these are daytime walking metrics, not sleep metrics.
  • No SQLite cache — the input is just over a week of data, so it's cheap to recompute every run.
#!/usr/bin/env python3
"""Sleep Cycles 2.0 — same report as sleep_cycles.py, fed by the iOS Shortcut JSON.
Input is the columnar JSON the Shortcut emits (see sleep_cycles-2.0-phase1-shortcut.md).
Each metric is parallel columns of newline-joined text, zipped by index — this lets the
Shortcut bulk-extract whole columns at once instead of looping per sample:
{
"sleep": {"starts": "<ISO lines>", "ends": "<ISO lines>", "values": "<stage lines>"},
"hr": {"starts": "<ISO lines>", "values": "<bpm lines>"},
"hrv": {"starts": "<ISO lines>", "values": "<SDNN ms lines>"},
"rr": {"starts": "<ISO lines>", "values": "<resp rate lines>"},
"vo2Max": {"value": 43.9, "date": "15 Jun 2026 at 11:37"}, # single latest reading
"steps": {"starts": "<ISO lines>", "values": "<count lines>", "sources": "<name lines>"},
"calories": {"starts": "<ISO lines>", "values": "<kcal lines>"},
"exercise": {"starts": "<ISO lines>", "values": "<minute lines>"},
"stand": {"starts": "<ISO lines>", "values": "<AppleStandTime minute lines>"},
"oxygen_saturation": {"starts": "<ISO lines>", "values": "<% 0-100 lines>"},
"wrist_temperature": {"starts": "<ISO lines>", "values": "<degC lines>"},
"w_asymmetry": {"starts": "<ISO lines>", "values": "<% 0-100 lines>"},
"w_speed": {"starts": "<ISO lines>", "values": "<km/hr lines>"},
"w_step_length": {"starts": "<ISO lines>", "values": "<cm lines>"},
"w_steadiness": {"starts": "<ISO lines>", "values": "<% 0-100 lines>"}
}
"sources" is optional per metric: once present, steps/calories/exercise restrict to
Watch-sourced samples (matching sleep_cycles.py's --source filtering); until then, all
sources are summed (may inflate). Currently only "steps" has it wired up. The four
walking/gait metrics and oxygen_saturation are iPhone-sourced (not Watch) and never
carry a "sources" column, so they're never Watch-filtered.
Percent-unit metrics (oxygen_saturation, w_asymmetry, w_steadiness) arrive already
scaled 0-100, not the 0-1 fraction HealthKit's XML export uses for the same "%" unit.
Differences from sleep_cycles.py, forced by what the Shortcut can provide:
- No SQLite cache: the payload is a week, so just recompute each run.
- Stand is derived from AppleStandTime *minutes* rather than read directly
from AppleStandHour: an hour counts as "stood" if it has >=1 minute of
standing, matching Apple's own AppleStandHour records exactly (verified
against a week of real data, 0 mismatches across 171 hourly buckets).
- vo2Max carries only the single latest reading (no history), so on a
historical date range a night before that reading's date prints no VO2
line at all, where sleep_cycles.py would still show an earlier "as of"
reading. Irrelevant for normal use (always reporting on tonight, where
"latest" is current) — only shows up when re-running over past dates.
"""
import sys
import json
import argparse
from collections import defaultdict
from datetime import datetime, timedelta
MIN_REM_CYCLE_MIN = 3 # minimum total REM to count as a cycle boundary
MAX_AWAKE_MIN = 60 # awake stretches longer than this mark a session break
def main():
parser = argparse.ArgumentParser()
parser.add_argument("json", nargs="?", default="-", metavar="export.json",
help="Shortcut JSON file, or - for stdin (default)")
parser.add_argument("--from", dest="date_from", metavar="DATE")
parser.add_argument("--to", dest="date_to", metavar="DATE")
parser.add_argument("--extended", action="store_true", help="Show per-reading detail for series metrics")
args = parser.parse_args()
if args.json == "-":
text = sys.stdin.read()
else:
with open(args.json) as f:
text = f.read()
# strict=False tolerates the literal newlines inside the Shortcut's column strings
# (Shortcuts can't escape them to \n); the fixture's json.dump output parses too.
data = json.loads(text, strict=False)
records_by_date, metrics = load_records(data)
if args.date_from:
date_to = args.date_to or args.date_from
dates = [d for d in sorted(records_by_date) if args.date_from <= d <= date_to]
else:
dates = sorted(records_by_date)
all_cycle_durations = []
for date in dates:
records = records_by_date.get(date)
if not records:
if args.date_from:
print(f"No sleep data for {date}")
continue
cycles = print_cycles(date, records, metrics, args.extended)
all_cycle_durations.extend(dur for _, _, dur, _ in cycles)
if all_cycle_durations:
all_cycle_durations.sort()
n = len(all_cycle_durations)
p90 = all_cycle_durations[min(int(n * 0.9), n - 1)]
median = all_cycle_durations[n // 2]
def fmt(m): h, m = divmod(m, 60); return f"{h}h {m:02d}m"
print(f"Cycle duration ({n} cycles): median {fmt(median)}, P90 {fmt(p90)}")
def load_records(data):
"""Turn the Shortcut's columnar JSON into the (by_date, metrics) the report consumes."""
sleep = data.get("sleep", {})
starts = sleep.get("starts", "").splitlines()
ends = sleep.get("ends", "").splitlines()
stages = sleep.get("values", "").splitlines()
by_date = defaultdict(list)
for s_str, e_str, stage in zip(starts, ends, stages):
stage = stage.strip()
if stage in ("In Bed", "InBed"): # bracketing record, not a sleep stage
continue
s = parse_dt(s_str.strip())
e = parse_dt(e_str.strip())
# Records ending before noon belong to that morning's night;
# records ending at noon or later are evening/onset records for the next night.
if e.hour >= 12:
night = (e + timedelta(days=1)).strftime("%Y-%m-%d")
else:
night = e.strftime("%Y-%m-%d")
by_date[night].append((s, e, stage))
for records in by_date.values():
records.sort()
sleep_windows = {night: sleep_window(records) for night, records in by_date.items()}
def overnight_key(dt):
if dt.hour >= 12:
return (dt + timedelta(days=1)).strftime("%Y-%m-%d")
return dt.strftime("%Y-%m-%d")
# HRV and Overnight HR are restricted to the actual sleep window (below);
# daytime spot-checks keyed to the night by overnight_key are excluded.
hrv_by_date = defaultdict(list)
hrv_series_by_date = defaultdict(list)
for dt, value in series(data.get("hrv")):
key = overnight_key(dt)
window = sleep_windows.get(key)
if window and window[0] <= dt <= window[1]:
hrv_by_date[key].append(value)
hrv_series_by_date[key].append((dt, value))
resp_by_date = defaultdict(list)
for dt, value in series(data.get("rr")):
resp_by_date[overnight_key(dt)].append((dt, value))
hr_series_by_date = defaultdict(list)
for dt, value in series(data.get("hr")):
hr_series_by_date[overnight_key(dt)].append((dt, value))
spo2_by_date = defaultdict(list)
for dt, value in series(data.get("oxygen_saturation")):
spo2_by_date[overnight_key(dt)].append((dt, value))
# Unlike HRV/HR, wrist temperature only ever exists during actual detected sleep
# (Apple's own algorithm restricts it) — no daytime spot-checks to filter out, so
# no window-clipping is needed, just bucketing by night.
wristtemp_by_date = defaultdict(list)
for dt, value in series(data.get("wrist_temperature")):
wristtemp_by_date[overnight_key(dt)].append(value)
# VO2 max arrives as a single most-recent reading {value, date}.
vo2_series = []
vo2 = data.get("vo2Max") or {}
if vo2.get("value") is not None and vo2.get("date"):
day = parse_vo2_date(vo2["date"]).strftime("%Y-%m-%d")
vo2_series = [(day, float(vo2["value"]))]
metrics = {
"hrv": hrv_by_date,
"hrv_series": hrv_series_by_date,
"sleep_window": sleep_windows,
"resp": resp_by_date,
"hr": hr_series_by_date,
"vo2": vo2_series,
"steps": daily_sums(data.get("steps"), watch_only=True),
"energy": daily_sums(data.get("calories"), watch_only=True),
"exercise": daily_sums(data.get("exercise"), watch_only=True),
"stand": stand_hours_by_date(data.get("stand")),
"stand_minutes": daily_sums(data.get("stand")),
"spo2": spo2_by_date,
"wristtemp": wristtemp_by_date,
"walk_asymmetry": daily_average(data.get("w_asymmetry")),
"walk_speed": daily_average(data.get("w_speed")),
"walk_step_length": daily_average(data.get("w_step_length")),
"walk_steadiness": latest_reading_series(data.get("w_steadiness")),
}
return by_date, metrics
def daily_sums(block, watch_only=False):
"""Sum a columnar block's values per calendar day (steps, energy, exercise)."""
acc = defaultdict(float)
for dt, value in series(block, watch_only=watch_only):
acc[dt.strftime("%Y-%m-%d")] += value
return acc
def daily_average(block):
"""Average a columnar block's values per calendar day (gait metrics)."""
acc = defaultdict(list)
for dt, value in series(block):
acc[dt.strftime("%Y-%m-%d")].append(value)
return {day: sum(vals) / len(vals) for day, vals in acc.items()}
def latest_reading_series(block):
"""Turn a {starts, values} block into sorted (day, value) pairs, latest reading per day."""
by_day = {}
for dt, value in series(block):
by_day[dt.strftime("%Y-%m-%d")] = value
return sorted(by_day.items())
def stand_hours_by_date(block):
"""Derive AppleStandHour-equivalent stood-hour counts from AppleStandTime minutes.
An hour counts as "stood" if it has >=1 minute of standing — verified to
reproduce Apple's own AppleStandHour records exactly against a week of
real data (0 mismatches across 171 hourly buckets).
"""
minutes_by_hour = defaultdict(float)
for dt, value in series(block):
hour_key = dt.strftime("%Y-%m-%d %H")
minutes_by_hour[hour_key] += value
acc = defaultdict(int)
for hour_key, minutes in minutes_by_hour.items():
if minutes >= 1:
acc[hour_key[:10]] += 1
return acc
def series(block, watch_only=False):
"""Columnar {starts, values[, sources]} → [(datetime, float), ...], zipped by index.
watch_only restricts to Watch-sourced samples, matching sleep_cycles.py's --source
filtering — but only once a "sources" column is actually present, so metrics that
haven't had it added yet keep summing everything.
"""
if not block:
return []
starts = block.get("starts", "").splitlines()
values = block.get("values", "").splitlines()
has_sources = watch_only and block.get("sources") is not None
sources = block.get("sources", "").splitlines() if has_sources else None
out = []
for i, (s, v) in enumerate(zip(starts, values)):
s, v = s.strip(), v.strip()
if not (s and v):
continue
if has_sources:
src = sources[i].strip() if i < len(sources) else ""
if "Watch" not in src:
continue
out.append((parse_dt(s), float(v)))
return out
def parse_vo2_date(s):
"""VO2 date arrives ISO (oracle) or localized ('15 Jun 2026 at 11:37', device)."""
try:
return parse_dt(s)
except ValueError:
return datetime.strptime(s, "%d %b %Y at %H:%M")
def split_sessions(records):
"""Split records into sessions at long Awake gaps; drop the Awake record itself."""
sessions, current = [], []
for s, e, stage in records:
if stage == "Awake" and (e - s).total_seconds() / 60 > MAX_AWAKE_MIN:
if current:
sessions.append(current)
current = []
else:
current.append((s, e, stage))
if current:
sessions.append(current)
return sessions
def sleep_window(records):
"""Return (start, end) of the main sleep session for a night's records."""
main_records = split_sessions(records)[-1]
return main_records[0][0], main_records[-1][1]
def print_cycles(date, records, metrics, extended=False):
sessions = split_sessions(records)
naps, main_records = sessions[:-1], sessions[-1]
print(f"Sleep cycles for {date}")
print()
cycles = find_cycles(main_records)
labels = [f"Cycle {i+1}" for i in range(len(cycles))]
print(f"{'':8} {'Start':>5} {'End':>5} {'Duration':>10} Composition")
print("-" * 70)
for nap in naps:
nap_start, nap_end = nap[0][0], nap[-1][1]
dur = int((nap_end - nap_start).total_seconds() / 60)
h, m = divmod(dur, 60)
dur_str = f"{h}h {m:02d}m" if h else f"{m}m"
print(f"{'Nap':8} {nap_start.strftime('%H:%M'):>5} {nap_end.strftime('%H:%M'):>5} {dur_str:>10} {summarize_stages(nap)}")
for label, (start, end, dur, recs) in zip(labels, cycles):
composition = summarize_stages(recs)
h, m = divmod(dur, 60)
dur_str = f"{h}h {m:02d}m" if h else f"{m}m"
print(f"{label:8} {start.strftime('%H:%M'):>5} {end.strftime('%H:%M'):>5} {dur_str:>10} {composition}")
total = sum(dur for _, _, dur, _ in cycles)
h, m = divmod(total, 60)
print(f"\n{'Total sleep':>32} {h}h {m:02d}m")
sleep_start = main_records[0][0]
sleep_end = main_records[-1][1]
print_trend_metric("HRV", date, metrics["hrv"], "ms", decimals=0)
if extended:
print_series_pairs(date, metrics["hrv_series"], sleep_start, sleep_end, decimals=1, cycles=cycles, records=main_records, per_line=4)
print_trend_metric("Wrist temp", date, metrics["wristtemp"], "°C", decimals=1)
print_overnight_hr_metric(date, metrics["hr"], metrics["sleep_window"])
if extended:
print_hr_per_cycle(date, metrics["hr"], cycles)
print_range_series("Resp rate", "br/min", date, metrics["resp"], sleep_start, sleep_end, decimals=1, extended=extended)
print_range_series("SpO2", "%", date, metrics["spo2"], sleep_start, sleep_end, decimals=0, extended=extended)
print_latest_metric("VO2 max", "mL/(kg·min)", date, metrics["vo2"], decimals=1)
print_latest_metric("Walking steadiness", "%", date, metrics["walk_steadiness"], decimals=1)
print_activity(date, metrics)
print_gait(date, metrics)
print()
return cycles
def print_trend_metric(label, date, by_date, unit, decimals):
values = by_date.get(date)
if not values:
print(f"{label:>32} (no data)")
return
values = sorted(values)
current = values[len(values) // 2]
prior_dates = sorted(d for d in by_date if d < date)[-5:]
prior_vals = sorted(v for d in prior_dates for v in by_date[d])
trend_str = ""
if prior_vals:
prior_median = prior_vals[len(prior_vals) // 2]
if decimals == 0:
delta = int(current) - int(prior_median)
else:
delta = round(current - prior_median, decimals)
arrow = "↑" if delta > 0 else "↓" if delta < 0 else "→"
trend_str = f" {arrow}{abs(delta):.{decimals}f} vs 5-night median ({prior_median:.{decimals}f})"
print(f"{label:>32} {current:.{decimals}f} {unit}{trend_str}")
def print_latest_metric(label, unit, date, day_value_pairs, decimals=1):
"""Print the most recent reading at or before `date` (VO2 max, Walking steadiness) —
both update on their own slow cadence rather than nightly, so there's no trend to show."""
latest = None
for d, v in day_value_pairs:
if d <= date:
latest = (d, v)
else:
break
if not latest:
return
d, v = latest
print(f"{label:>32} {v:.{decimals}f} {unit} as of {d}")
def print_overnight_hr_metric(date, hr_series_by_date, sleep_windows):
series = hr_series_by_date.get(date)
window = sleep_windows.get(date)
if not series or not window:
print(f"{'Overnight HR':>32} (no data)")
return
sleep_start, sleep_end = window
vals = sorted(v for t, v in series if sleep_start <= t <= sleep_end)
if not vals:
print(f"{'Overnight HR':>32} (no data)")
return
current = int(vals[len(vals) // 2])
prior_dates = sorted(d for d in hr_series_by_date if d < date and d in sleep_windows)[-5:]
prior_vals = []
for d in prior_dates:
p_start, p_end = sleep_windows[d]
prior_vals.extend(v for t, v in hr_series_by_date[d] if p_start <= t <= p_end)
prior_vals.sort()
trend_str = ""
if prior_vals:
prior_median = int(prior_vals[len(prior_vals) // 2])
delta = current - prior_median
arrow = "↑" if delta > 0 else "↓" if delta < 0 else "→"
trend_str = f" {arrow}{abs(delta)} vs 5-night median ({prior_median})"
print(f"{'Overnight HR':>32} {current} bpm{trend_str}")
def print_hr_per_cycle(date, hr_by_date, cycles):
entries = hr_by_date.get(date)
if not entries:
return
entries.sort()
stats = []
global_min = (float("inf"), None, -1)
for idx, (cstart, cend, _dur, _recs) in enumerate(cycles):
in_cycle = [(t, v) for t, v in entries if cstart <= t <= cend]
if not in_cycle:
stats.append(None)
continue
vals = sorted(v for _, v in in_cycle)
cmin, cmax = vals[0], vals[-1]
cmed = vals[len(vals) // 2]
min_time = next(t for t, v in in_cycle if v == cmin)
stats.append((cstart, cend, cmin, cmax, cmed, min_time))
if cmin < global_min[0]:
global_min = (cmin, min_time, idx)
for idx, stat in enumerate(stats):
if stat is None:
continue
cstart, cend, cmin, cmax, cmed, min_time = stat
annot = ""
if idx == global_min[2]:
annot = f" ← min {int(cmin)} at {min_time.strftime('%H:%M')}"
print(f" Cycle {idx+1} ({cstart.strftime('%H:%M')}{cend.strftime('%H:%M')}): min {int(cmin)}, max {int(cmax)}, med {int(cmed)} bpm{annot}")
def print_series_pairs(date, series_by_date, sleep_start, sleep_end, decimals=1, cycles=None, records=None, per_line=7):
entries = series_by_date.get(date)
if not entries:
return
entries = sorted(e for e in entries if sleep_start <= e[0] <= sleep_end)
if not entries:
return
pairs = []
for t, v in entries:
s = f"{t.strftime('%H:%M')} {v:>4.{decimals}f}"
if cycles is not None and records is not None:
s += f" ({cycle_stage_at(t, cycles, records)})"
pairs.append(s)
for i in range(0, len(pairs), per_line):
print(" " + " ".join(pairs[i:i + per_line]))
def cycle_stage_at(t, cycles, records):
cycle = next((f"C{i+1}" for i, (cs, ce, _, _) in enumerate(cycles) if cs <= t <= ce), "?")
stage = next((st for s, e, st in records if s <= t <= e), "?")
return f"{cycle}-{stage}"
def print_range_series(label, unit, date, series_by_date, sleep_start, sleep_end, decimals=1, extended=False):
entries = series_by_date.get(date)
if entries:
entries = sorted(e for e in entries if sleep_start <= e[0] <= sleep_end)
if not entries:
print(f"{label:>32} (no data)")
return
values = [v for _, v in entries]
vmin, vmax = min(values), max(values)
print(f"{label:>32} {vmin:.{decimals}f}{vmax:.{decimals}f} {unit}, {len(entries)} readings")
if extended:
print_series_pairs(date, series_by_date, sleep_start, sleep_end, decimals=decimals)
def print_activity(date, metrics):
prev_day = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
energy = metrics["energy"].get(prev_day)
exercise = metrics["exercise"].get(prev_day)
stand = metrics["stand"].get(prev_day)
stand_min = metrics["stand_minutes"].get(prev_day)
steps = metrics["steps"].get(prev_day)
if energy is None and exercise is None and stand is None and steps is None:
print(f"{'Activity':>32} (no data)")
return
parts = []
if energy is not None: parts.append(f"Move {int(energy)} kcal")
if exercise is not None: parts.append(f"Exercise {int(exercise)} min")
if stand is not None:
if stand_min is not None:
parts.append(f"Stand {int(stand_min)} min across {int(stand)} h")
else:
parts.append(f"Stand {int(stand)} h")
if steps is not None: parts.append(f"Steps {int(steps):,}")
print(f"{'Activity':>32} {' · '.join(parts)}")
def print_gait(date, metrics):
prev_day = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
speed = metrics["walk_speed"].get(prev_day)
step_len = metrics["walk_step_length"].get(prev_day)
asymmetry = metrics["walk_asymmetry"].get(prev_day)
if speed is None and step_len is None and asymmetry is None:
print(f"{'Gait':>32} (no data)")
return
parts = []
if speed is not None: parts.append(f"Speed {speed:.1f} km/h")
if step_len is not None: parts.append(f"Step length {int(step_len)} cm")
if asymmetry is not None: parts.append(f"Asymmetry {int(asymmetry)}%")
print(f"{'Gait':>32} {' · '.join(parts)}")
def find_cycles(records):
"""Split records into cycles using REM blocks as cycle boundaries."""
cycles = []
current_start = records[0][0] if records else None
i = 0
while i < len(records):
s, e, stage = records[i]
if stage == "REM":
# Merge consecutive REM blocks (possibly separated by tiny Core/Awake)
rem_end = e
rem_total = (e - s).total_seconds() / 60
j = i + 1
while j < len(records):
ns, ne, nstage = records[j]
gap = (ns - rem_end).total_seconds() / 60
if gap <= 5 and nstage in ("REM", "Core", "Awake"):
if nstage == "REM":
rem_end = ne
rem_total += (ne - ns).total_seconds() / 60
j += 1
else:
break
if rem_total < MIN_REM_CYCLE_MIN:
i = j
continue
# Collect all records up to and including this REM block
cycle_records = [r for r in records if current_start <= r[0] < rem_end]
dur = int((rem_end - current_start).total_seconds() / 60)
cycles.append((current_start, rem_end, dur, cycle_records))
# Find the next record starting at or after rem_end (don't skip
# records that the merge loop may have advanced j past).
j = next((k for k in range(i + 1, len(records)) if records[k][0] >= rem_end), len(records))
current_start = records[j][0] if j < len(records) else None
i = j
else:
i += 1
# Tail: remaining records after last REM
if current_start is not None:
tail = [r for r in records if r[0] >= current_start]
if tail:
tail_end = tail[-1][1]
dur = int((tail_end - current_start).total_seconds() / 60)
cycles.append((current_start, tail_end, dur, tail))
return cycles
def summarize_stages(cycle_records):
# Resolve overlaps: when records overlap, the later-starting one takes precedence.
# Build a list of non-overlapping segments by clipping earlier records.
segments = []
for s, e, stage in sorted(cycle_records):
if segments and s < segments[-1][1]:
ps, _, pstage = segments[-1]
segments[-1] = (ps, s, pstage)
segments.append((s, e, stage))
totals = {}
for s, e, stage in segments:
dur = (e - s).total_seconds() / 60
if dur >= 1:
totals[stage] = totals.get(stage, 0) + dur
parts = []
for stage in ("Deep", "Core", "REM", "Awake"):
if stage in totals:
parts.append(f"{stage} {int(totals[stage])}m")
return " → ".join(parts)
def parse_dt(s):
return datetime.fromisoformat(s)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment