
Oura Analytics
- 2 installs
- 6 repo stars
- Updated July 29, 2026
- kesslerio/oura-analytics-openclaw-skill
Fetches Oura Ring sleep, readiness, activity, and HRV data from the Oura Cloud API and generates reports, correlations, and low-recovery alerts.
About
A skill that fetches Oura Ring health metrics from the Oura Cloud API and generates trend reports, correlations, and recovery alerts. A developer uses it to analyze sleep and readiness data and trigger alerts on low-recovery days.
- Fetches sleep, readiness, activity, and HRV from the Oura Cloud API
- Generates automated reports and low-recovery alerts, correlating with productivity
Oura Analytics by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,759 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kesslerio/oura-analytics-openclaw-skill --skill oura-analyticsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 29, 2026 |
| Repository | kesslerio/oura-analytics-openclaw-skill ↗ |
What it does
Fetches Oura Ring sleep, readiness, activity, and HRV data from the Oura Cloud API and generates reports, correlations, and low-recovery alerts.
Files
Oura Analytics
Quick Start
# Set Oura API token
export OURA_API_TOKEN="your_personal_access_token"
# Fetch sleep data (last 7 days)
python {baseDir}/scripts/oura_api.py sleep --days 7
# Get readiness summary
python {baseDir}/scripts/oura_api.py readiness --days 7
# Generate weekly report
python {baseDir}/scripts/oura_api.py report --type weeklyWhen to Use
Use this skill when:
- Fetching Oura Ring metrics (sleep, readiness, activity, HRV)
- Analyzing recovery trends over time
- Correlating sleep quality with productivity/events
- Setting up automated alerts for low readiness
- Generating daily/weekly/monthly health reports
Core Workflows
1. Data Fetching
export PYTHONPATH="{baseDir}/scripts"
python - <<'PY'
from oura_api import OuraClient
client = OuraClient(token="YOUR_TOKEN")
sleep_data = client.get_sleep(start_date="2026-01-01", end_date="2026-01-16")
readiness_data = client.get_readiness(start_date="2026-01-01", end_date="2026-01-16")
print(len(sleep_data), len(readiness_data))
PY2. Trend Analysis
export PYTHONPATH="{baseDir}/scripts"
python - <<'PY'
from oura_api import OuraClient, OuraAnalyzer
client = OuraClient(token="YOUR_TOKEN")
sleep_data = client.get_sleep(start_date="2026-01-01", end_date="2026-01-16")
readiness_data = client.get_readiness(start_date="2026-01-01", end_date="2026-01-16")
analyzer = OuraAnalyzer(sleep_data, readiness_data)
avg_sleep = analyzer.average_metric(sleep_data, "score")
avg_readiness = analyzer.average_metric(readiness_data, "score")
trend = analyzer.trend(sleep_data, "average_hrv")
print(avg_sleep, avg_readiness, trend)
PY3. Alerts
python {baseDir}/scripts/alerts.py --days 7 --readiness 60 --efficiency 80Environment
Required:
OURA_API_TOKEN
Optional (used for alerts/reports/timezone/output):
KESSLER_TELEGRAM_BOT_TOKEN(fallback toTELEGRAM_BOT_TOKEN)TELEGRAM_CHAT_IDUSER_TIMEZONEOURA_OUTPUT_DIR
Scripts
scripts/oura_api.py- Oura Cloud API wrapper with OuraAnalyzer and OuraReporter classesscripts/alerts.py- Threshold-based notifications (CLI:python {baseDir}/scripts/alerts.py --days 7 --readiness 60)scripts/weekly_report.py- Weekly report generator
References
references/api.md- Oura Cloud API documentationreferences/metrics.md- Metric definitions and interpretations
Automation (Cron Jobs)
Cron jobs are configured in OpenClaw's gateway, not in this repo. Add these to your OpenClaw setup:
Daily Morning Briefing (8:00 AM)
openclaw cron add \
--name "Daily Oura Health Report (Hybrid)" \
--cron "0 8 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--wake next-heartbeat \
--deliver \
--channel telegram \
--target "<YOUR_TELEGRAM_CHAT_ID>" \
--message "Run the daily Oura health report using the Lobster workflow daily-oura-hybrid.lobster, then summarize the result."Do not phrase Telegram-delivered cron prompts as "Execute bash ...". That can push the run into an approval-gated exec path that chat delivery cannot satisfy.
Weekly Sleep Report (Sunday 8:00 AM)
openclaw cron add \
--name "Weekly Oura Sleep Report" \
--cron "0 8 * * 0" \
--tz "America/Los_Angeles" \
--session isolated \
--wake next-heartbeat \
--deliver \
--channel telegram \
--target "<YOUR_TELEGRAM_CHAT_ID>" \
--message "Run the weekly Oura sleep report using the Lobster workflow weekly-sleep.lobster, then summarize the result."Daily Obsidian Note (8:15 AM)
openclaw cron add \
--name "Daily Obsidian Note" \
--cron "15 8 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--wake next-heartbeat \
--message "Create daily Obsidian note with Oura data. Run: source /path/to/venv/bin/activate && python /path/to/daily-note.py"Note: Replace /path/to/your/ with your actual paths and <YOUR_TELEGRAM_CHAT_ID> with your Telegram channel/group ID.
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -v --tb=short
- name: Run linter
run: ruff check scripts/ tests/
- name: Upload coverage
if: always() && github.event_name == 'push'
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
fail_ci_if_error: false
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install ruff
run: pip install ruff
- name: Run ruff linter
run: ruff check scripts/ tests/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
eggs/
*.egg
# Virtual environments
venv/
.venv/
ENV/
env/
# Secrets and credentials
.env
*.env
secrets.conf
tokens.json
*.pem
*.key
# IDE and editors
.vscode/
.idea/
*.swp
*.swo
*~
.project
.pydevproject
.settings/
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Local data and cache
cache/
*.log
*.sqlite
*.db
# Test and coverage
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# Jupyter
.ipynb_checkpoints/
# mypy
.mypy_cache/
.ai/
.worktrees/
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=100"]
Changelog
v0.1.2 (2026-01-23)
Updated
- Added ClawdHub badge and proper licensing (Apache 2.0)
- Added version badge and section
v0.1.0 (earlier)
Added
- Initial release
- Oura Cloud API integration
- Sleep analytics and readiness tracking
- Activity metrics
- Trend analysis
- Automated alerts
#!/usr/bin/env python3
"""
Daily Oura Health Report - Hybrid Format
Combines detailed metrics with driver analysis and sends via Telegram.
This is the main daily morning briefing script.
"""
import os
import sys
import argparse
from datetime import datetime, timedelta
from pathlib import Path
# Add oura-analytics scripts directory to path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from oura_api import OuraClient
from drivers import DriverAnalyzer, format_drivers_report
from baseline import build_baseline
def format_hours(seconds: int) -> str:
"""Format seconds as hours and minutes."""
if not seconds:
return "N/A"
hours = seconds / 3600
h = int(hours)
m = int((hours - h) * 60)
return f"{h}h {m}m"
def generate_report(date_str: str = None, baseline_days: int = 30) -> str:
"""Generate the daily Oura health report."""
# Default to today (Oura labels sleep by the day you wake up)
if date_str:
target_date = date_str
else:
target_date = datetime.now().strftime("%Y-%m-%d")
# Get Oura token
token = os.environ.get("OURA_API_TOKEN")
if not token:
return "❌ Error: OURA_API_TOKEN not set"
client = OuraClient(token, use_cache=False)
# Fetch target day data
# Note: Oura API filters by bedtime_start, not day field
# So we query a range to catch sleep starting the night before
# Using 2-day lookback to handle timezone edge cases (travel, DST)
# Note: Oura API end_date is EXCLUSIVE, so add 1 day to include target
target_dt = datetime.strptime(target_date, "%Y-%m-%d")
range_start = (target_dt - timedelta(days=2)).strftime("%Y-%m-%d")
range_end = (target_dt + timedelta(days=1)).strftime("%Y-%m-%d")
sleep_data = client.get_sleep(range_start, range_end)
readiness_data = client.get_readiness(range_start, range_end)
# Filter to get records for our target day
# Note: day field comparison assumes Oura account timezone matches report timezone
sleep_for_day = [s for s in sleep_data if s.get("day") == target_date]
readiness_for_day = [r for r in readiness_data if r.get("day") == target_date]
if not sleep_for_day:
return f"📭 No sleep data available for {target_date}"
# Prefer "long_sleep" (main sleep) over naps; fallback to longest duration
main_sleeps = [s for s in sleep_for_day if s.get("type") == "long_sleep"]
if main_sleeps:
sleep = max(main_sleeps, key=lambda s: s.get("total_sleep_duration", 0))
else:
# No long_sleep found, pick longest duration record
sleep = max(sleep_for_day, key=lambda s: s.get("total_sleep_duration", 0))
readiness = readiness_for_day[0] if readiness_for_day else {}
# Fetch baseline data
baseline_end = datetime.strptime(target_date, "%Y-%m-%d") - timedelta(days=1)
baseline_start = baseline_end - timedelta(days=baseline_days)
baseline_sleep = client.get_sleep(
baseline_start.strftime("%Y-%m-%d"),
baseline_end.strftime("%Y-%m-%d")
)
baseline_readiness = client.get_readiness(
baseline_start.strftime("%Y-%m-%d"),
baseline_end.strftime("%Y-%m-%d")
)
# Calculate baseline metrics
baseline = build_baseline(baseline_sleep, baseline_readiness, baseline_days)
# Build baseline dict for driver analyzer
baseline_dict = {
"sleep_hours": baseline.sleep_hours.mean if baseline.sleep_hours else 7.5,
"efficiency": baseline.efficiency.mean if baseline.efficiency else 85.0,
"deep_sleep": 1.5,
"rem_sleep": 1.8,
"hrv": baseline.hrv.mean if baseline.hrv else 40.0,
"rhr": baseline.rhr.mean if baseline.rhr else 60.0,
"readiness": baseline.readiness.mean if baseline.readiness else 75.0
}
analyzer = DriverAnalyzer(baseline_dict)
# Analyze drivers
sleep_drivers = analyzer.analyze_sleep_drivers(sleep)
readiness_drivers = analyzer.analyze_readiness_drivers(sleep, readiness)
readiness_score = readiness.get("score", 0) if readiness else 0
suggestion = analyzer.generate_suggestion(readiness_score, readiness_drivers)
# Build the report
lines = []
lines.append(f"📊 *Daily Oura Report - {target_date}*")
lines.append("━" * 30)
# Readiness (lead with most important metric)
if readiness_score:
baseline_ready = baseline.readiness.mean if baseline.readiness else 75.0
delta_ready = readiness_score - baseline_ready
delta_str = f"+{delta_ready:.0f}" if delta_ready > 0 else f"{delta_ready:.0f}"
if readiness_score >= 85:
emoji = "🟢"
elif readiness_score >= 70:
emoji = "🟡"
else:
emoji = "🔴"
lines.append(f"\n{emoji} *Readiness: {readiness_score}/100* ({delta_str} vs baseline)")
# Show negative drivers
negative_drivers = [d for d in readiness_drivers if d.impact == "negative"]
if negative_drivers:
lines.append(" └─ *Factors pulling down:*")
for driver in negative_drivers[:3]:
lines.append(f" • {driver.metric}: {driver.value:.0f} (baseline: {driver.baseline:.0f})")
# Sleep metrics
duration = sleep.get("total_sleep_duration")
efficiency = sleep.get("efficiency")
if duration:
baseline_dur = baseline.sleep_hours.mean if baseline.sleep_hours else 7.5
actual_dur = duration / 3600
delta_dur = actual_dur - baseline_dur
delta_str = f"+{delta_dur:.1f}h" if delta_dur > 0 else f"{delta_dur:.1f}h"
lines.append(f"\n🌙 *Sleep: {format_hours(duration)}* ({delta_str} vs baseline)")
if efficiency:
baseline_eff = baseline.efficiency.mean if baseline.efficiency else 85.0
delta_eff = efficiency - baseline_eff
delta_str = f"+{delta_eff:.0f}%" if delta_eff > 0 else f"{delta_eff:.0f}%"
lines.append(f" Efficiency: {efficiency}% ({delta_str})")
# Sleep stages
deep = sleep.get("deep_sleep_duration")
rem = sleep.get("rem_sleep_duration")
light = sleep.get("light_sleep_duration")
if deep or rem or light:
lines.append("\n *Sleep Stages:*")
if deep:
lines.append(f" 🌊 Deep: {format_hours(deep)}")
if light:
lines.append(f" 💡 Light: {format_hours(light)}")
if rem:
lines.append(f" 🧠 REM: {format_hours(rem)}")
# HRV & RHR
hrv = sleep.get("average_hrv")
rhr = sleep.get("lowest_heart_rate")
if hrv or rhr:
lines.append("\n *Recovery Markers:*")
if hrv:
baseline_hrv = baseline.hrv.mean if baseline.hrv else 40.0
delta_hrv = hrv - baseline_hrv
delta_str = f"+{delta_hrv:.0f}ms" if delta_hrv > 0 else f"{delta_hrv:.0f}ms"
status = "🟢" if hrv >= baseline_hrv else "🟡"
lines.append(f" {status} HRV: {hrv}ms ({delta_str})")
if rhr:
baseline_rhr = baseline.rhr.mean if baseline.rhr else 60.0
delta_rhr = rhr - baseline_rhr
delta_str = f"+{delta_rhr:.0f}bpm" if delta_rhr > 0 else f"{delta_rhr:.0f}bpm"
status = "🟢" if rhr <= baseline_rhr else "🟡"
lines.append(f" {status} RHR: {rhr}bpm ({delta_str})")
# Bedtime/ latency
latency = sleep.get("latency")
bedtime = sleep.get("bedtime_start")
if latency:
lat_min = int(latency / 60)
lines.append(f"\n ⏱️ Sleep latency: {lat_min} min")
# Actionable suggestion
lines.append(f"\n💡 *{suggestion}*")
lines.append("\n_Good morning! Have a great day._ ☀️")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Daily Oura Health Report (Hybrid)")
parser.add_argument("--date", help="Date (YYYY-MM-DD, default: today - the day you woke up)")
parser.add_argument("--baseline-days", type=int, default=30, help="Days for baseline")
args = parser.parse_args()
try:
report = generate_report(args.date, args.baseline_days)
print(report)
except Exception as e:
error_msg = f"❌ Error generating report: {e}"
print(error_msg)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export PYTHONPATH="$REPO_ROOT/scripts:${PYTHONPATH:-}"
python3 "$SCRIPT_DIR/daily-oura-report-hybrid.py" "$@"
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
[tool.ruff]
target-version = "py311"
[tool.ruff.lint]
Oura Analytics - OpenClaw Skill
   
Production-grade Oura Ring data integration for OpenClaw Fetch sleep scores, readiness, activity, HRV, and trends from Oura Cloud API. Generate automated health reports and trigger-based alerts.
Features
✅ Oura Cloud API Integration - Personal Access Token authentication ✅ Sleep Analytics - Score, duration, efficiency, REM/deep stages ✅ Readiness Tracking - Recovery score, HRV balance, temperature ✅ Stress Tracking - Daily status + baseline trend, weekly stress summaries ✅ Activity Metrics - Steps, calories, MET minutes ✅ Trend Analysis - Moving averages, correlations, anomaly detection ✅ Automated Alerts - Low readiness/sleep notifications via Telegram
Version
Current: v0.1.2
See CHANGELOG for version history.
Why This Exists
OpenClaw needs access to Oura Ring health data for:
- Daily morning briefings ("How did I sleep?")
- Correlating recovery with productivity/calendar
- Automated alerts for low recovery days
- Weekly/monthly health trend reports
This skill provides:
- Simple Python API client for Oura Cloud API v2
- Trend analysis and correlation tools
- Threshold-based alerting system
- Report generation templates
Installation
1. Get Oura Personal Access Token
1. Go to https://cloud.ouraring.com/personal-access-tokens 2. Create new token (select all scopes) 3. Copy token to secrets file:
echo 'OURA_API_TOKEN="your_token_here"' >> ~/.config/systemd/user/secrets.conf2. Install the skill
git clone https://github.com/kesslerio/oura-analytics-openclaw-skill.git ~/.openclaw/skills/oura-analytics
pip install -r requirements.txt3. Add to OpenClaw's TOOLS.md
### oura-analytics
- Fetch Oura Ring metrics (sleep, readiness, activity, HRV)
- Generate health reports and correlations
- Set up automated alerts for low recovery
- Usage: `python ~/.openclaw/skills/oura-analytics/scripts/oura_api.py sleep --days 7`Usage Examples
Note: For Python imports, setPYTHONPATHto thescripts/folder:
>
```bash
export PYTHONPATH="$(pwd)/scripts"
```
Fetch Sleep Data
# Last 7 days
python scripts/oura_api.py sleep --days 7Get Readiness Summary
python scripts/oura_api.py readiness --days 7Generate Reports
# Weekly summary (last 7 days)
python scripts/weekly_report.py --days 7
# Monthly trends (last 30 days)
python scripts/weekly_report.py --days 30Trigger Alerts
# Check for low readiness and send Telegram notification
python scripts/alerts.py --days 7 --readiness 60 --efficiency 80 --telegramGenerate Hybrid Morning Briefing
# Daily hybrid report (morning briefing + 7-day trends)
python scripts/oura_briefing.py --format hybridExample hybrid output:
🌅 *Morning Briefing — Jan 22*
────────────────────────
💤 *Sleep*: 6h 47m (↑75min vs avg) ⚠️
⚡ *Readiness*: 80 (stable) ✅
*Drivers*: recovery_index, body_temperature
*Recovery*: 🟡 YELLOW
*Rec*: Moderate day. Avoid heavy training.
*📊 7-Day Trends*
────────────────────────
*Sleep Score*: `89.5` ↓
*Readiness*: `77.1` ↑
• *7.3h* sleep • *89.7%* eff • *21ms* HRV
*Recent*: 01-20 → `87.4`/`73` • 01-21 → `90.4`/`80`Baseline & Comparison Analysis
# Compare last 7 days vs 30-day baseline
python scripts/baseline.py --current-days 7 --baseline-days 30
# View 90-day baseline statistics
python scripts/baseline.py --baseline-only --baseline-days 90
# JSON output for programmatic use
python scripts/baseline.py --jsonExample output:
📈 Current vs Baseline (Last 7d vs 30d baseline)
↗️ Sleep Score: 89.5 (+10.6, z=0.53)
Above baseline
➡️ Readiness: 77.1 (+1.0, z=0.15)
Within baseline
↗️ Sleep Duration: 7.3 (+1.4, z=0.52)
Above baseline
↗️ Efficiency: 89.7 (+6.8, z=0.52)
Above baseline
✅ All metrics within or above baseline rangeInterpretation:
- z-score: Standard deviations from baseline mean
z > 1.5: 🔥 Well above baseline0.5 < z < 1.5: ↗️ Above baseline-0.5 < z < 0.5: ➡️ Within baseline-1.5 < z < -0.5: ↘️ Below baselinez < -1.5: ⚠️ Well below baseline (needs attention)- Baseline range: P25-P75 (middle 50% of your historical data)
- Sample size: Number of days used to calculate baseline
Core Workflows
1. Morning Health Check
from oura_api import OuraClient, OuraAnalyzer
client = OuraClient(token=os.getenv("OURA_API_TOKEN"))
sleep_data = client.get_sleep(start_date="2026-01-18", end_date="2026-01-18")
today = sleep_data[0] if sleep_data else {}
if today:
print(f"Sleep Score: {today.get('score', 'N/A')}/100")
print(f"Total Sleep: {today.get('total_sleep_duration', 0)/3600:.1f}h")
print(f"REM: {today.get('rem_sleep_duration', 0)/3600:.1f}h")
print(f"Deep: {today.get('deep_sleep_duration', 0)/3600:.1f}h")2. Recovery Tracking
readiness = client.get_readiness(start_date="2026-01-11", end_date="2026-01-18")
avg_readiness = sum(d.get('score', 0) for d in readiness) / len(readiness) if readiness else 0
print(f"7-day avg readiness: {avg_readiness:.0f}")3. Trend Analysis
from oura_api import OuraAnalyzer
analyzer = OuraAnalyzer(sleep_data, readiness_data)
avg_sleep = analyzer.average_metric(sleep_data, "score")
avg_readiness = analyzer.average_metric(readiness_data, "score")
print(f"Avg Sleep Score: {avg_sleep}")
print(f"Avg Readiness Score: {avg_readiness}")API Client Reference
OuraClient
client = OuraClient(token="your_token")
# Sleep data (date range required)
sleep = client.get_sleep(start_date="2026-01-01", end_date="2026-01-16")
# Readiness data
readiness = client.get_readiness(start_date="2026-01-01", end_date="2026-01-16")
# Activity data
activity = client.get_activity(start_date="2026-01-01", end_date="2026-01-16")
# HRV trends
hrv = client.get_hrv(start_date="2026-01-01", end_date="2026-01-16")OuraAnalyzer
from oura_api import OuraClient, OuraAnalyzer
client = OuraClient(token="your_token")
sleep = client.get_sleep(start_date="2026-01-01", end_date="2026-01-16")
readiness = client.get_readiness(start_date="2026-01-01", end_date="2026-01-16")
analyzer = OuraAnalyzer(sleep_data=sleep, readiness_data=readiness)
# Average metrics
avg_sleep = analyzer.average_metric(sleep, "score")
avg_readiness = analyzer.average_metric(readiness, "score")
# Trend analysis
trend = analyzer.trend(sleep, "score", days=7)
# Summary
summary = analyzer.summary()OuraReporter
from oura_api import OuraClient, OuraReporter
client = OuraClient(token="your_token")
reporter = OuraReporter(client)
# Generate weekly report
report = reporter.generate_report(report_type="weekly", days=7)
print(json.dumps(report, indent=2))Metrics Reference
| Metric | Description | Range |
|---|---|---|
| Sleep Score | Overall sleep quality | 0-100 |
| Readiness Score | Recovery readiness | 0-100 |
| Stress Score | Physiological stress load (direct or derived proxy) | 0-100 |
| HRV Balance | Heart rate variability | -3 to +3 |
| Sleep Efficiency | Time asleep / time in bed | 0-100% |
| REM Sleep | REM stage duration | hours |
| Deep Sleep | Deep stage duration | hours |
| Temperature Deviation | Body temp vs baseline | °C |
See references/metrics.md for full definitions.
Architecture
- `scripts/oura_api.py` - Oura Cloud API v2 client with OuraAnalyzer and OuraReporter classes
- `scripts/alerts.py` - Threshold-based alerting CLI
- `scripts/weekly_report.py` - Weekly report generator
- `scripts/data_manager.py` - Data storage and privacy controls
- `scripts/oura_data.py` - Data management CLI
- `scripts/schema.py` - Canonical data structures with unit normalization
- `references/` - API docs, metric definitions
Data Management & Privacy
What Data is Stored
All data is stored locally in ~/.oura-analytics/:
~/.oura-analytics/
├── cache/ # Cached API responses (cleanup is manual)
│ ├── sleep/ # Sleep records by date (YYYY-MM-DD.json)
│ ├── daily_readiness/ # Readiness records
│ └── daily_activity/ # Activity records
├── events.jsonl # User-logged events (optional)
├── config.yaml # User preferences (optional)
└── alert_state.json # Alert tracking (optional)No data is sent to third parties. All Oura data stays on your local machine.
View Storage Info
python scripts/oura_data.py infoOutput:
Data directory: /home/user/.oura-analytics
Total size: 187.1 KB
Cache:
Size: 187.1 KB
Files: 21
sleep: 7 files, 46.4 KB, 2026-01-14 to 2026-01-20
daily_readiness: 7 files, 3.6 KB, 2026-01-14 to 2026-01-20
daily_activity: 7 files, 137.2 KB, 2026-01-14 to 2026-01-20Export Data (Backup)
Export all local data to a single JSON file:
# Full backup
python scripts/oura_data.py export --output backup.json
# Compressed tarball
python scripts/oura_data.py export --output backup.tar.gz --format tar.gz
# Export events only
python scripts/oura_data.py export-events --output events.csv --format csvClear Data (Privacy)
# Clear cache only (keeps events/config)
python scripts/oura_data.py clear-cache --confirm
# Clear specific endpoint
python scripts/oura_data.py clear-cache --endpoint sleep --confirm
# Clear events
python scripts/oura_data.py clear-events --confirm
# Clear ALL local data
python scripts/oura_data.py clear-all --confirmImportant: All clear commands require --confirm flag to prevent accidental deletion.
Automatic Cleanup
Delete cached data older than 90 days:
# Default: 90 days
python scripts/oura_data.py cleanup
# Custom retention period
python scripts/oura_data.py cleanup --days 180GDPR Compliance
- ✅ Data ownership: You own your data (local storage only)
- ✅ Data retention: You control retention (manual cleanup)
- ✅ No data sharing: No third-party services
- ✅ Right to deletion: Clear data anytime with
clear-all
Note: This skill is NOT HIPAA-compliant. Do not use for medical decision-making. Consult healthcare professionals for health concerns.
Troubleshooting
Authentication Failed
# Check token is set
echo $OURA_API_TOKEN
# Or use explicit token
python scripts/oura_api.py sleep --days 7 --token "your_token"No Data Returned
# Check date range (Oura data has ~24h delay)
python scripts/oura_api.py sleep --days 10
# Or fetch and inspect manually
python scripts/oura_api.py sleep --days 7 | python -m json.tool | head -50Credits
Created for production OpenClaw health tracking Developed by @kesslerio • Part of the ClawdHub ecosystem
Powered by:
- Oura Ring - Wearable health tracker
- Oura Cloud API v2 - Official API
License
Apache 2.0
Oura Cloud API Reference
Authentication
Get your Personal Access Token at: https://cloud.ouraring.com/personal-access-token
import requests
headers = {"Authorization": "Bearer YOUR_API_TOKEN"}Endpoints
Daily Sleep
GET /v2/usercollection/sleep
Returns sleep summary including:
score- Overall sleep score (0-100)deep_sleep_duration- Deep sleep in secondslight_sleep_duration- Light sleep in secondsrem_sleep_duration- REM sleep in secondsawake_duration- Time awake in secondssleep_duration- Total sleep in seconds
Daily Readiness
GET /v2/usercollection/readiness
Returns readiness scores including:
score- Overall readiness score (0-100)score_recovery_index- Recovery metricscore_sleep_balance- Sleep balance metricscore_temperature- Temperature deviation indicator
Daily Activity
GET /v2/usercollection/activity
Returns activity metrics including:
score- Activity score (0-100)steps- Total stepscalories- Calories burnedmet_minutes- MET minutes
Heart Rate Variability
GET /v2/usercollection/hrv
Returns HRV metrics:
average- Average HRV (ms)low_frequency- LF componenthigh_frequency- HF component
Sleep Time Series (Optional)
GET /v2/usercollection/sleep_time_series
Minute-by-minute data for detailed analysis.
Rate Limits
- 1000 requests/hour
- 10000 requests/day
Example Response
{
"sleep": [{
"score": 85,
"deep_sleep_duration": 5400,
"light_sleep_duration": 18000,
"rem_sleep_duration": 7200,
"awake_duration": 1800,
"sleep_duration": 30600,
"hrv": 65,
"respiratory_rate": 14.2,
"temperature_deviation": 0.12
}]
}Oura Metrics Reference
Core Scores
| Score | Range | Interpretation |
|---|---|---|
| Sleep Score | 0-100 | <50: Poor, 50-70: Fair, 70-85: Good, >85: Excellent |
| Readiness Score | 0-100 | <50: Recovery needed, 50-70: Moderate, 70-85: Good, >85: Optimal |
| Activity Score | 0-100 | Measures daily movement balance |
Sleep Stages (in seconds)
| Stage | Typical Range | Notes |
|---|---|---|
| Deep | 3000-7200 | Critical for physical recovery |
| REM | 4500-8100 | Important for cognitive recovery |
| Light | 10800-21600 | Transition stage |
| Awake | <1800 | Minimal is ideal |
HRV Metrics
| Metric | Healthy Range | Notes |
|---|---|---|
| RMSSD | 20-80 ms | Primary HRV metric |
| Balance | 40-60% | LF/HF ratio indicator |
| Low Frequency | - | Parasympathetic activity |
| High Frequency | - | Sympathetic activity |
Temperature Deviation
- Normal: ±0.1°C
- Elevated: >0.2°C (possible illness/stress)
- Suppressed: < -0.2°C (recovery indication)
Derived Metrics
# Sleep Efficiency
sleep_efficiency = sleep_duration / (sleep_duration + awake_duration)
# Recovery Index
recovery_index = (readiness_score + sleep_score + hrv_score) / 3
# Balance Score
balance = (activity_score + readiness_score + sleep_score) / 3Optimal Ranges for Performance
| Metric | Optimal | Warning |
|---|---|---|
| Sleep Score | >75 | <60 |
| Readiness Score | >75 | <60 |
| Deep Sleep % | >15% of total | <12% |
| REM Sleep % | >20% of total | <15% |
| HRV (RMSSD) | 40-80 ms | <30 or >100 |
| Temperature | ±0.1°C | >0.3°C deviation |
Stress Tracking (Direct + Derived)
- Stress score is normalized to
0-100where lower is better (lower physiological strain). LOW:<= 40MODERATE:41-65HIGH:> 65
Direct stress
- Uses Oura direct stress fields when available (for example
stress_scoreor stress status labels). - Report output labels this as
direct stress.
Derived stress proxy (fallback)
When direct stress fields are unavailable, reports derive a proxy stress score from available signals:
- HRV vs baseline (
average_hrv) - Resting HR vs baseline (
lowest_heart_rate) - Readiness contributors (
hrv_balance,resting_heart_rate,recovery_index,sleep_balance,previous_night) - Sleep efficiency (
efficiency)
Caveats
- Derived stress is a proxy, not Oura's native stress algorithm.
- Baseline quality matters: sparse history can make trends noisy.
- Missing HRV/RHR/contributor fields reduce confidence; output is labeled
derived proxyorunavailable.
Data Schema Reference
Overview
This document defines the canonical data structures used by the Oura Analytics skill. All data is normalized to consistent units, naming conventions, and types.
Design Principles
1. Explicit units in field names - No guessing if duration is seconds or hours 2. Consistent naming - _hours, _percent, _ms, _bpm, _m, _c 3. Type safety - Python dataclasses with type hints 4. Timezone awareness - All dates in local timezone (YYYY-MM-DD) 5. Optional fields - Missing data is None, not 0 or empty string
Unit Conventions
| Unit Type | Suffix | Example | Notes |
|---|---|---|---|
| Duration | _hours | total_sleep_hours | Converted from Oura's seconds |
| Duration (short) | _minutes | latency_minutes | For sub-hour durations |
| Percentage | _percent | efficiency_percent | 0-100 scale |
| Score | (no suffix) | score | 0-100 scale |
| Heart rate variability | _ms | average_hrv_ms | Milliseconds |
| Heart rate | _bpm | average_heart_rate_bpm | Beats per minute |
| Temperature | _c | temperature_deviation_c | °Celsius deviation from baseline |
| Distance | _m | equivalent_walking_distance_m | Meters |
| Calories | (no suffix) | active_calories | Kilocalories (kcal) |
| Steps | (no suffix) | steps | Count |
Core Data Structures
SleepRecord
Normalized sleep data for a single night.
@dataclass
class SleepRecord:
# Identity
date: str # YYYY-MM-DD (wake date, local timezone)
id: str # Oura record ID
# Timestamps (ISO 8601 with timezone)
bedtime_start: str # When user went to bed
bedtime_end: str # When user woke up
# Sleep durations (hours)
total_sleep_hours: float # Total sleep time
deep_sleep_hours: float # Deep (N3) sleep
rem_sleep_hours: float # REM sleep
light_sleep_hours: float # Light (N1+N2) sleep
awake_hours: float # Time awake after sleep onset
time_in_bed_hours: float # Total time in bed
# Quality metrics
efficiency_percent: float # Sleep efficiency (0-100)
latency_minutes: Optional[float] # Time to fall asleep
# Physiological
average_hrv_ms: Optional[float] # Heart rate variability
average_heart_rate_bpm: Optional[float]
lowest_heart_rate_bpm: Optional[float]
average_breath_rate: Optional[float] # Breaths per minute
# Metadata
restless_periods: Optional[int] # Count of restless periods
type: str # "long_sleep", "late_nap", etc.Key fields:
date- Wake date in local timezone (not bedtime date)- All durations converted from seconds to hours
efficiency_percent- (total_sleep / time_in_bed) × 100latency_minutes- Time from bed to sleep onset
ReadinessRecord
Normalized readiness data for a single day.
@dataclass
class ReadinessRecord:
# Identity
date: str # YYYY-MM-DD (local timezone)
id: str # Oura record ID
# Overall score
score: int # 0-100 readiness score
# Temperature
temperature_deviation_c: Optional[float] # °C from baseline
temperature_trend_deviation_c: Optional[float] # Trend deviation
# Contributors (all 0-100)
activity_balance: Optional[int]
body_temperature: Optional[int]
hrv_balance: Optional[int]
previous_day_activity: Optional[int]
previous_night: Optional[int]
recovery_index: Optional[int]
resting_heart_rate: Optional[int]
sleep_balance: Optional[int]
sleep_regularity: Optional[int]
# Timestamp
timestamp: str # ISO 8601Key fields:
score- Overall readiness (0-100), higher is bettertemperature_deviation_c- Deviation from your personal baseline (negative = cooler)- Contributors show what drove the readiness score
ActivityRecord
Normalized activity data for a single day.
@dataclass
class ActivityRecord:
# Identity
date: str # YYYY-MM-DD (local timezone)
id: str # Oura record ID
# Overall
score: int # 0-100 activity score
steps: int # Step count
# Calories (kcal)
active_calories: int # Calories from activity
total_calories: int # Total daily expenditure
target_calories: int # Daily target
# Activity time (hours)
high_activity_hours: float # High intensity
medium_activity_hours: float # Medium intensity
low_activity_hours: float # Low intensity (walking, etc.)
sedentary_hours: float # Sitting/minimal movement
resting_hours: float # Lying down/sleep
non_wear_hours: float # Ring not worn
# MET (metabolic equivalent)
average_met_minutes: float
high_activity_met_minutes: int
medium_activity_met_minutes: int
low_activity_met_minutes: int
sedentary_met_minutes: int
# Distance
equivalent_walking_distance_m: int # Total distance as walking
target_meters: int # Daily target
meters_to_target: int # Shortfall/excess
# Metadata
inactivity_alerts: int # Count of inactivity alerts
timestamp: str # ISO 8601Key fields:
- All activity times converted from seconds to hours
active_calories- Calories burned above resting metabolic ratetotal_calories- BMR + active calories- MET minutes - Metabolic equivalent of task (intensity × duration)
NightRecord (Unified)
Combined sleep, readiness, and activity for holistic analysis.
@dataclass
class NightRecord:
date: str # YYYY-MM-DD (wake date, local timezone)
sleep: Optional[SleepRecord] # Sleep from the night
readiness: Optional[ReadinessRecord] # Readiness for the day
activity: Optional[ActivityRecord] # Activity from previous dayUsage:
- Primary structure for analysis and reporting
- Joins data by calendar day (wake date)
- Activity is from previous day (yesterday's steps affect today's readiness)
- All fields are Optional (handles missing data gracefully)
Normalization Functions
normalize_sleep(raw: Dict) → SleepRecord
Converts raw Oura sleep API response to normalized SleepRecord.
Transformations:
- Converts all durations from seconds to hours
- Renames fields for clarity (
total_sleep_duration→total_sleep_hours) - Converts latency from seconds to minutes
- Preserves ISO 8601 timestamps with timezone
normalize_readiness(raw: Dict) → ReadinessRecord
Converts raw Oura readiness API response to normalized ReadinessRecord.
Transformations:
- Extracts contributors from nested object
- Preserves temperature deviation in °C
normalize_activity(raw: Dict) → ActivityRecord
Converts raw Oura activity API response to normalized ActivityRecord.
Transformations:
- Converts all time durations from seconds to hours
- Preserves MET minutes and distance in meters
create_night_record(date, sleep, readiness, activity) → NightRecord
Creates unified NightRecord from raw API data.
Usage:
from scripts.schema import create_night_record
night = create_night_record(
date="2026-01-20",
sleep=raw_sleep_data,
readiness=raw_readiness_data,
activity=raw_activity_data
)
# Access normalized data
print(f"Sleep: {night.sleep.total_sleep_hours}h")
print(f"Readiness: {night.readiness.score}")
print(f"Steps: {night.activity.steps}")Raw API Mapping
Sleep API → SleepRecord
| Oura API Field | Schema Field | Transformation |
|---|---|---|
day | date | No change |
id | id | No change |
bedtime_start | bedtime_start | No change |
bedtime_end | bedtime_end | No change |
total_sleep_duration | total_sleep_hours | seconds → hours (÷ 3600) |
deep_sleep_duration | deep_sleep_hours | seconds → hours |
rem_sleep_duration | rem_sleep_hours | seconds → hours |
light_sleep_duration | light_sleep_hours | seconds → hours |
awake_time | awake_hours | seconds → hours |
time_in_bed | time_in_bed_hours | seconds → hours |
efficiency | efficiency_percent | No change (already 0-100) |
latency | latency_minutes | seconds → minutes (÷ 60) |
average_hrv | average_hrv_ms | No change (already ms) |
average_heart_rate | average_heart_rate_bpm | No change |
lowest_heart_rate | lowest_heart_rate_bpm | No change |
average_breath | average_breath_rate | No change |
restless_periods | restless_periods | No change |
type | type | No change |
Readiness API → ReadinessRecord
| Oura API Field | Schema Field | Transformation |
|---|---|---|
day | date | No change |
id | id | No change |
score | score | No change |
temperature_deviation | temperature_deviation_c | No change (already °C) |
temperature_trend_deviation | temperature_trend_deviation_c | No change |
contributors.activity_balance | activity_balance | Extract from nested |
contributors.body_temperature | body_temperature | Extract from nested |
contributors.hrv_balance | hrv_balance | Extract from nested |
contributors.previous_day_activity | previous_day_activity | Extract from nested |
contributors.previous_night | previous_night | Extract from nested |
contributors.recovery_index | recovery_index | Extract from nested |
contributors.resting_heart_rate | resting_heart_rate | Extract from nested |
contributors.sleep_balance | sleep_balance | Extract from nested |
contributors.sleep_regularity | sleep_regularity | Extract from nested |
timestamp | timestamp | No change |
Activity API → ActivityRecord
| Oura API Field | Schema Field | Transformation |
|---|---|---|
day | date | No change |
id | id | No change |
score | score | No change |
steps | steps | No change |
active_calories | active_calories | No change |
total_calories | total_calories | No change |
target_calories | target_calories | No change |
high_activity_time | high_activity_hours | seconds → hours |
medium_activity_time | medium_activity_hours | seconds → hours |
low_activity_time | low_activity_hours | seconds → hours |
sedentary_time | sedentary_hours | seconds → hours |
resting_time | resting_hours | seconds → hours |
non_wear_time | non_wear_hours | seconds → hours |
average_met_minutes | average_met_minutes | No change |
high_activity_met_minutes | high_activity_met_minutes | No change |
medium_activity_met_minutes | medium_activity_met_minutes | No change |
low_activity_met_minutes | low_activity_met_minutes | No change |
sedentary_met_minutes | sedentary_met_minutes | No change |
equivalent_walking_distance | equivalent_walking_distance_m | No change |
target_meters | target_meters | No change |
meters_to_target | meters_to_target | No change |
inactivity_alerts | inactivity_alerts | No change |
timestamp | timestamp | No change |
CLI Usage
Get normalized data
# Coming soon: --normalize flag
python oura_api.py sleep --days 7 --normalize
# Output will use canonical schema with explicit unitsExamples
Sleep Record
{
"date": "2026-01-20",
"id": "abc123",
"bedtime_start": "2026-01-19T23:27:58.000-08:00",
"bedtime_end": "2026-01-20T07:06:44.000-08:00",
"total_sleep_hours": 6.79,
"deep_sleep_hours": 1.6,
"rem_sleep_hours": 1.53,
"light_sleep_hours": 3.67,
"awake_hours": 0.85,
"time_in_bed_hours": 7.65,
"efficiency_percent": 89,
"latency_minutes": 5.2,
"average_hrv_ms": 15,
"average_heart_rate_bpm": 58,
"lowest_heart_rate_bpm": 52,
"average_breath_rate": 14.5,
"restless_periods": 3,
"type": "long_sleep"
}Readiness Record
{
"date": "2026-01-20",
"id": "def456",
"score": 73,
"temperature_deviation_c": -1.26,
"temperature_trend_deviation_c": null,
"activity_balance": 65,
"body_temperature": 68,
"hrv_balance": 81,
"previous_day_activity": 72,
"previous_night": 81,
"recovery_index": 36,
"resting_heart_rate": 82,
"sleep_balance": 79,
"sleep_regularity": 83,
"timestamp": "2026-01-20T07:30:00.000-08:00"
}Activity Record
{
"date": "2026-01-20",
"id": "ghi789",
"score": 91,
"steps": 302,
"active_calories": 28,
"total_calories": 1816,
"target_calories": 350,
"high_activity_hours": 0.0,
"medium_activity_hours": 0.0,
"low_activity_hours": 0.55,
"sedentary_hours": 10.2,
"resting_hours": 8.5,
"non_wear_hours": 4.75,
"average_met_minutes": 1.2,
"high_activity_met_minutes": 0,
"medium_activity_met_minutes": 0,
"low_activity_met_minutes": 120,
"sedentary_met_minutes": 612,
"equivalent_walking_distance_m": 2145,
"target_meters": 8000,
"meters_to_target": -5855,
"inactivity_alerts": 0,
"timestamp": "2026-01-20T23:59:00.000-08:00"
}Night Record (Unified)
{
"date": "2026-01-20",
"sleep": { ... },
"readiness": { ... },
"activity": { ... }
}Migration Notes
For existing code
1. Import the schema module:
from scripts.schema import normalize_sleep, SleepRecord2. Convert raw API data:
raw_sleep = client.get_sleep(start, end)
normalized = [normalize_sleep(record) for record in raw_sleep]3. Access with explicit units:
# Old (ambiguous)
duration = record['total_sleep_duration'] # seconds? hours? who knows?
# New (explicit)
duration_hours = record.total_sleep_hours # clearly hoursBenefits
- No unit confusion - Field names tell you the unit
- Type safety - IDE autocomplete + type checking
- Consistent analysis - All code uses same structure
- Future-proof - Schema changes happen in one place
See Also
- schemas.md - API response schemas (output formats)
- SECURITY.md - Data storage and privacy
- Oura API Documentation - Official API reference
API Response Schemas
Output Formats
The CLI supports multiple output formats via --format flag:
| Format | Description | Use Case |
|---|---|---|
json | Full structured data (default) | API integration, OpenClaw parsing |
brief | 5-8 line human summary | Quick checks, Telegram messages |
alert | Warnings only (empty if OK) | Health monitoring, alerts |
silent | No output (exit code only) | Cron jobs, background tasks |
Summary Response (summary command)
JSON format:
{
"avg_sleep_score": 85.3,
"avg_readiness_score": 78.0,
"avg_sleep_hours": 7.2,
"avg_sleep_efficiency": 88.5,
"avg_hrv": 42.3,
"days_tracked": 7
}Brief format:
avg_sleep_score: 85.3
avg_readiness_score: 78.0
avg_sleep_hours: 7.2
avg_sleep_efficiency: 88.5
avg_hrv: 42.3
days_tracked: 7Alert format:
⚠️ Low sleep: 5.8h avg
⚠️ Low readiness: 65Schema:
| Field | Type | Range | Description |
|---|---|---|---|
avg_sleep_score | float | 0-100 | Average sleep quality score |
avg_readiness_score | float/null | 0-100 | Average readiness score (null if unavailable) |
avg_sleep_hours | float | 0-12 | Average sleep duration in hours |
avg_sleep_efficiency | float | 0-100 | Average sleep efficiency percentage |
avg_hrv | float/null | 0-200 | Average HRV in milliseconds |
days_tracked | int | 0+ | Number of days in summary |
Report Response (report command)
JSON format:
{
"report_type": "weekly",
"period": "2026-01-14 to 2026-01-21",
"timezone": "America/Los_Angeles",
"travel_days": ["2026-01-18", "2026-01-19"],
"summary": {
"avg_sleep_score": 85.3,
"avg_readiness_score": 78.0,
"avg_sleep_hours": 7.2,
"avg_sleep_efficiency": 88.5,
"avg_hrv": 42.3,
"days_tracked": 7
},
"daily_data": {
"sleep": [...],
"readiness": [...],
"activity": [...]
}
}Brief format:
📊 Weekly (2026-01-14 to 2026-01-21)
Sleep: 7.2h avg, 85.3 score
Readiness: 78.0
Efficiency: 88.5%
HRV: 42.3 ms
Days: 7
Travel: 2026-01-18, 2026-01-19Schema:
| Field | Type | Description |
|---|---|---|
report_type | string | "weekly" or "monthly" |
period | string | ISO date range "YYYY-MM-DD to YYYY-MM-DD" |
timezone | string | IANA timezone (e.g., "America/Los_Angeles") |
travel_days | array[string] | ISO dates with potential travel/timezone shifts |
summary | object | Same schema as Summary Response |
daily_data | object | Raw Oura API data (sleep, readiness, activity arrays) |
Comparison Response (comparison command)
JSON format:
{
"current": {
"avg_sleep_score": 85.3,
"avg_sleep_hours": 7.2,
...
},
"previous": {
"avg_sleep_score": 82.1,
"avg_sleep_hours": 6.8,
...
},
"diff": {
"avg_sleep_score": 3.2,
"avg_sleep_hours": 0.4,
...
}
}Schema:
| Field | Type | Description |
|---|---|---|
current | object | Summary for current period |
previous | object | Summary for previous period (same duration) |
diff | object | Difference (current - previous) for numeric fields |
Sleep Data Response (sleep command)
JSON format:
[
{
"id": "abc123",
"day": "2026-01-20",
"bedtime_start": "2026-01-19T23:30:00.000-08:00",
"bedtime_end": "2026-01-20T07:15:00.000-08:00",
"total_sleep_duration": 27000,
"efficiency": 88,
"average_hrv": 42,
"score": 85
}
]Brief format:
8 recordsSchema: See Oura API Documentation for full field definitions.
Sync Response (sync command)
JSON format:
{
"sleep": 7,
"daily_readiness": 7,
"daily_activity": 7
}Schema:
| Field | Type | Description |
|---|---|---|
<endpoint> | int | Number of days synced for endpoint |
Cache Stats Response (cache command)
JSON format:
{
"sleep": {
"cached_days": 90,
"last_sync": "2026-01-21"
},
"daily_readiness": {
"cached_days": 90,
"last_sync": "2026-01-21"
}
}Schema:
| Field | Type | Description |
|---|---|---|
<endpoint>.cached_days | int | Number of cached day files |
<endpoint>.last_sync | string/null | ISO date of last sync, null if never synced |
Error Response
All commands use consistent error handling:
Standard Error (stderr):
Error: OURA_API_TOKEN not set. Get it at https://cloud.ouraring.com/personal-access-tokenExit Codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Configuration error (missing token, invalid args) |
| 2 | API error (rate limit, auth failure, network error) |
| 3 | Data error (no data available, cache issue) |
Error Format (JSON):
{
"error": "RateLimitError",
"message": "Rate limited. Waiting 60s before retry...",
"code": 2
}Alert Thresholds
Used in `--format alert` mode:
| Metric | Threshold | Alert Triggered When |
|---|---|---|
| Sleep hours | 6h | avg_sleep_hours < 6 |
| Readiness | 70 | avg_readiness_score < 70 |
| Efficiency | 80% | avg_sleep_efficiency < 80 |
No output is produced if all metrics are above thresholds.
Usage Examples
# Get full JSON (default)
python oura_api.py summary --days 7
# Get brief summary (human-readable)
python oura_api.py report --type weekly --format brief
# Check for alerts (empty if OK)
python oura_api.py summary --days 7 --format alert
# Cron job (silent, exit code only - reflects script success, not health status)
python oura_api.py summary --days 7 --format silent
if [ $? -ne 0 ]; then
echo "Script execution failed"
fi
# For health-based cron alerts, use alert mode:
ALERTS=$(python oura_api.py summary --days 7 --format alert)
if [ -n "$ALERTS" ]; then
echo "$ALERTS"
fiTimezone & Day Alignment Guide
Problem Statement
Oura Ring data involves complex timezone handling:
- Sleep spans midnight - Which day does 11pm-7am sleep belong to?
- Travel across timezones - User flies SFO → NYC (3hr shift)
- Local vs UTC timestamps - API returns UTC, users think in local time
- DST transitions - Spring forward/fall back can cause "missing" days
Solution: Canonical Day Mapping
We use a canonical day system that maps all events to the user's local timezone.
Core Principle
Canonical day = The calendar date the user would naturally think of
- Sleep is assigned to the wake date (Oura's convention)
- All timestamps converted to user's local timezone
- Consistent day alignment across sleep/readiness/activity
Configuration
Set User Timezone
# Via environment variable
export USER_TIMEZONE="America/Los_Angeles"
# Or in ~/.bashrc / ~/.zshrc
echo 'export USER_TIMEZONE="America/Los_Angeles"' >> ~/.bashrc
# Or pass explicitly to CLI
python oura_api.py report --type weekly --timezone "America/New_York"Supported Timezones
Any IANA timezone database name:
America/Los_Angeles(PST/PDT)America/New_York(EST/EDT)Europe/London(GMT/BST)Asia/Tokyo(JST)Australia/Sydney(AEDT/AEST)
Full list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
Default Behavior
If USER_TIMEZONE not set, defaults to America/Los_Angeles.
API Functions
get_canonical_day(utc_timestamp, user_tz)
Convert UTC timestamp to user's canonical day.
from scripts.timezone_utils import get_canonical_day
# Example: Sleep ending at 7am PST
utc_ts = "2026-01-20T15:06:44.000+00:00" # 7am PST in UTC
canonical_date, local_dt = get_canonical_day(utc_ts, "America/Los_Angeles")
print(canonical_date) # 2026-01-20
print(local_dt) # 2026-01-20 07:06:44 PSTReturns: (date, datetime) tuple or (None, None) if invalid
get_canonical_day_from_date_str(date_str, user_tz)
Convert date string (YYYY-MM-DD) to canonical day.
from scripts.timezone_utils import get_canonical_day_from_date_str
canonical = get_canonical_day_from_date_str("2026-01-20", "America/Los_Angeles")
print(canonical) # 2026-01-20Returns: date object or None if invalid
is_travel_day(sleep_records, threshold_hours, user_tz)
Detect potential travel days based on bedtime shifts.
from scripts.timezone_utils import is_travel_day
from scripts.oura_api import OuraClient
client = OuraClient()
sleep_data = client.get_sleep("2026-01-01", "2026-01-31")
travel_days = is_travel_day(sleep_data, threshold_hours=3.0)
# Example output: [date(2026-01-15), date(2026-01-22)]
print(f"Travel days: {travel_days}")Algorithm: 1. Calculate median bedtime hour across all records 2. Flag days where bedtime shifts >3 hours from median 3. Handles wraparound at midnight (23:00 vs 01:00)
Threshold: Default 3 hours (detects cross-timezone travel)
group_by_canonical_day(data, timestamp_field, user_tz)
Group records by canonical day.
from scripts.timezone_utils import group_by_canonical_day
grouped = group_by_canonical_day(sleep_data, timestamp_field="day")
for date_str, records in grouped.items():
print(f"{date_str}: {len(records)} records")Returns: Dict mapping date strings to record lists
format_localized_datetime(utc_timestamp, fmt, user_tz)
Format UTC timestamp in user's local time.
from scripts.timezone_utils import format_localized_datetime
utc_ts = "2026-01-20T15:06:44.000+00:00"
local_str = format_localized_datetime(utc_ts, fmt="%Y-%m-%d %H:%M %Z")
print(local_str) # "2026-01-20 07:06 PST"Edge Cases
1. Sleep Spanning Midnight
Scenario: Bedtime 11pm Jan 19 → Wake 7am Jan 20
Oura Convention: Sleep assigned to wake date (Jan 20)
Our Handling:
sleep_record = {
"day": "2026-01-20", # Wake date
"bedtime_start": "2026-01-19T23:00:00-08:00", # Previous night
"bedtime_end": "2026-01-20T07:00:00-08:00" # Wake morning
}
canonical_date, _ = get_canonical_day(sleep_record["bedtime_end"])
print(canonical_date) # 2026-01-20 (wake date)Consistent: Always use wake date for canonical day
2. Travel Across Timezones
Scenario: User flies SFO → NYC (3hr forward) on Jan 15
Data Pattern:
Jan 14: Bedtime 23:00 PST → Wake 07:00 PST
Jan 15: Bedtime 02:00 EST → Wake 10:00 EST (travel day, unusual bedtime)
Jan 16: Bedtime 23:00 EST → Wake 07:00 EST (back to normal)Detection:
travel_days = is_travel_day(sleep_data, threshold_hours=3.0)
# Returns: [date(2026-01-15)]Usage:
- Flag travel days in reports
- Exclude from baseline calculations
- Show "(travel)" annotation in summaries
3. Local vs UTC Timestamps
Oura API Returns: UTC or offset-aware timestamps
Example:
{
"bedtime_start": "2026-01-20T07:00:00.000+00:00", # UTC
"bedtime_end": "2026-01-20T15:00:00.000+00:00" # UTC
}Conversion:
# API returns UTC
utc_start = "2026-01-20T07:00:00.000+00:00"
# Convert to user's local time (PST = UTC-8)
canonical_date, local_dt = get_canonical_day(utc_start, "America/Los_Angeles")
print(local_dt) # 2026-01-19 23:00:00 PSTReports: Always show local times, not UTC
4. DST Transitions
Spring Forward (losing 1 hour):
# On 2026-03-08 at 2am PST → 3am PDT
Bedtime: 2026-03-07 23:00 PST
Wake: 2026-03-08 07:00 PDT # Only 7 hours of wall-clock time, but 8 hours elapsedFall Back (gaining 1 hour):
# On 2026-11-01 at 2am PDT → 1am PST
Bedtime: 2026-10-31 23:00 PDT
Wake: 2026-11-01 07:00 PST # 9 hours of wall-clock time, but only 8 hours elapsedHandling:
- Oura tracks elapsed time (correct)
- Our timezone conversion handles DST automatically (via
pytz) - No manual adjustment needed
Test:
# Spring forward example
utc_ts = "2026-03-08T15:00:00.000+00:00" # 7am PDT
canonical_date, local_dt = get_canonical_day(utc_ts, "America/Los_Angeles")
print(local_dt.tzname()) # "PDT" (not "PST")CLI Examples
View Sleep with Local Times
# Sleep records with local timestamps
python oura_api.py sleep --days 7 --local-time --timezone "America/Los_Angeles"Output:
{
"day": "2026-01-20",
"bedtime_start": "2026-01-19T23:27:58.000-08:00",
"bedtime_end": "2026-01-20T07:06:44.000-08:00",
"local_bedtime_start": "2026-01-19T23:27:58-08:00"
}Weekly Report with Timezone
# Report in user's timezone
python oura_api.py report --type weekly --timezone "America/New_York"Output:
{
"report_type": "weekly",
"period": "2026-01-14 to 2026-01-21",
"timezone": "America/New_York",
"travel_days": ["2026-01-18"],
"summary": { ... }
}Travel Day Detection
# Show which days had unusual bedtimes (travel)
python oura_api.py report --type weekly --timezone "America/Los_Angeles" | \
jq '.travel_days'Output:
["2026-01-18", "2026-01-19"]Integration with Schema
The canonical schema (scripts/schema.py) preserves timezone info:
from scripts.schema import normalize_sleep
sleep_record = normalize_sleep(raw_oura_data)
# All timestamps preserve timezone
print(sleep_record.bedtime_start) # "2026-01-19T23:27:58.000-08:00"
print(sleep_record.bedtime_end) # "2026-01-20T07:06:44.000-08:00"
# Date is in local timezone
print(sleep_record.date) # "2026-01-20" (wake date in user's local timezone)Best Practices
1. Always Pass User Timezone
# Good: Explicit timezone
from scripts.timezone_utils import get_canonical_day
canonical, local_dt = get_canonical_day(timestamp, user_tz="America/Los_Angeles")
# Acceptable: Rely on USER_TIMEZONE env var
canonical, local_dt = get_canonical_day(timestamp) # Uses env var or default2. Handle None Returns
canonical, local_dt = get_canonical_day(timestamp)
if canonical is None:
print("Invalid timestamp")
return
# Safe to use canonical
print(f"Date: {canonical}")3. Flag Travel Days in Analysis
travel_days = is_travel_day(sleep_data)
for record in sleep_data:
date = record["day"]
is_travel = date in [str(d) for d in travel_days]
if is_travel:
print(f"{date} (travel) - exclude from baseline")
else:
# Include in baseline calculation
pass4. Use Canonical Day for Joining
from scripts.timezone_utils import group_by_canonical_day
# Group all data sources by canonical day
sleep_by_day = group_by_canonical_day(sleep_data, "day")
readiness_by_day = group_by_canonical_day(readiness_data, "day")
activity_by_day = group_by_canonical_day(activity_data, "day")
# Join on canonical day
for date_str in sleep_by_day.keys():
sleep = sleep_by_day[date_str][0]
readiness = readiness_by_day.get(date_str, [None])[0]
activity = activity_by_day.get(date_str, [None])[0]
# Now have aligned data for this day
print(f"{date_str}: Sleep={sleep}, Readiness={readiness}, Activity={activity}")Troubleshooting
"Unknown timezone" error
# Set valid IANA timezone
export USER_TIMEZONE="America/Los_Angeles"
# Not "PST" or "Pacific Time"
# Use IANA database nameMissing pytz module
pip install pytzUnexpected date assignments
# Check what Oura assigned
print(raw_record["day"]) # Oura's wake date
# Check your local conversion
canonical, local_dt = get_canonical_day(raw_record["bedtime_end"])
print(canonical) # Should match Oura's dayTechnical Details
Day Assignment Logic
Oura Rule: Sleep assigned to wake date
Our Rule: Use wake date as canonical day
Example:
Bedtime: Jan 19, 11:30pm
Wake: Jan 20, 7:00am
Oura day: "2026-01-20"
Canonical day: 2026-01-20Consistent: Always use Oura's day assignment
Timezone Conversion
# 1. Parse UTC timestamp
utc_dt = datetime.fromisoformat(utc_timestamp)
# 2. Localize to UTC (if naive)
utc_dt = pytz.UTC.localize(utc_dt)
# 3. Convert to user's timezone
user_tz_obj = pytz.timezone(user_tz)
local_dt = utc_dt.astimezone(user_tz_obj)
# 4. Extract date
canonical_date = local_dt.date()Travel Detection Algorithm
1. Extract bedtime hour for each record (in local time)
2. Calculate median bedtime hour
3. For each record:
- Compute shift from median
- Handle wraparound (23 vs 01 = 2hr shift, not 22hr)
- Flag if shift > threshold (default 3 hours)
4. Return list of flagged datesSee Also
- SCHEMA.md - Canonical data structures
- Oura API Docs - Official API
- pytz Documentation - Timezone library
- IANA Timezone Database - Timezone names
# Development dependencies for Oura Analytics skill
pytest>=7.0
pytest-cov
pytz # for timezone utilities in timezone_utils.py
ruff
# Oura Analytics Skill Dependencies
# Note: Uses Python's built-in urllib.request (no external HTTP library)
pytz
pyyaml
#!/usr/bin/env python3
"""
Oura Alerts - Readiness & Sleep Alerts
Sends Telegram notifications when metrics drop below thresholds.
Uses debounce, hysteresis, and configurable thresholds.
"""
import os
import sys
import json
import argparse
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
# Add scripts directory to path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent))
from oura_api import OuraClient
from config import AlertState, ConfigLoader, check_thresholds_with_quality
def seconds_to_hours(seconds):
return round(seconds / 3600, 1) if seconds else None
def check_thresholds_legacy(sleep_data, readiness_data, thresholds):
"""Legacy threshold checking (simple, no debounce)."""
readiness_by_day = {r.get("day"): r for r in readiness_data}
alerts = []
for day in sleep_data:
date = day.get("day")
readiness_record = readiness_by_day.get(date)
readiness_score = readiness_record.get("score") if readiness_record else None
efficiency = day.get("efficiency", 100)
duration_sec = day.get("total_sleep_duration", 0)
duration_hours = seconds_to_hours(duration_sec)
day_alerts = []
if readiness_score is not None and readiness_score < thresholds.get("readiness", 60):
day_alerts.append(f"Readiness {readiness_score}")
if efficiency < thresholds.get("efficiency", 80):
day_alerts.append(f"Efficiency {efficiency}%")
if duration_hours and duration_hours < thresholds.get("sleep_hours", 7):
day_alerts.append(f"Sleep {duration_hours}h")
if day_alerts:
alerts.append({"date": date, "alerts": day_alerts})
return alerts
# Keep the old name as an alias for backwards compatibility
def check_thresholds(sleep_data, readiness_data, thresholds):
"""Check all days against thresholds.
Note: For debounce and hysteresis, use check_thresholds_with_quality() from config module.
"""
return check_thresholds_legacy(sleep_data, readiness_data, thresholds)
def format_alert_message(alerts):
"""Format alerts for Telegram"""
if not alerts:
return None
msg = "⚠️ *Oura Alerts*\n\n"
for alert in alerts[-5:]: # Last 5 alerts
msg += f"📅 *{alert['date']}*\n"
for a in alert["alerts"]:
msg += f" • {a}\n"
msg += "\n"
msg += f"_Total: {len(alerts)} alert days_"
return msg
def send_telegram(message, chat_id=None, bot_token=None):
"""Send to Telegram using urllib"""
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
bot_token = bot_token or os.environ.get("KESSLER_TELEGRAM_BOT_TOKEN") or os.environ.get("TELEGRAM_BOT_TOKEN")
if not chat_id or not bot_token:
print("TELEGRAM_CHAT_ID or KESSLER_TELEGRAM_BOT_TOKEN not set")
return False
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
# Keep chat_id as string to support both numeric IDs and @channel usernames
data = json.dumps({"chat_id": chat_id, "text": message, "parse_mode": "Markdown"}).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status == 200
except urllib.error.HTTPError:
return False
def main():
parser = argparse.ArgumentParser(description="Oura Alerts")
parser.add_argument("--days", type=int, default=7, help="Check period")
parser.add_argument("--readiness", type=int, default=60, help="Readiness threshold (legacy)")
parser.add_argument("--efficiency", type=int, default=80, help="Efficiency threshold (legacy)")
parser.add_argument("--sleep-hours", type=float, default=7, help="Sleep hours threshold (legacy)")
parser.add_argument("--config", help="Path to config.yaml")
parser.add_argument("--telegram", action="store_true", help="Send to Telegram")
parser.add_argument("--token", help="Oura API token")
args = parser.parse_args()
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=args.days)).strftime("%Y-%m-%d")
try:
client = OuraClient(args.token)
sleep = client.get_sleep(start_date, end_date)
readiness = client.get_readiness(start_date, end_date)
# Use config file if specified, otherwise use CLI args
if args.config:
config_loader = ConfigLoader(Path(args.config))
config = config_loader.load()
state = AlertState()
alerts = check_thresholds_with_quality(
sleep, readiness, config, state
)
else:
# Legacy mode: simple thresholds
thresholds = {
"readiness": args.readiness,
"efficiency": args.efficiency,
"sleep_hours": args.sleep_hours
}
alerts = check_thresholds_legacy(sleep, readiness, thresholds)
if alerts:
mode = "Quality Mode" if args.config else "Legacy Mode"
print(f"\n⚠️ {mode}: {len(alerts)} Alert Days Found:\n")
for alert in alerts:
consecutive = alert.get("consecutive_days", "")
if consecutive:
print(f" {alert['date']} ({consecutive} bad days): {', '.join(alert['alerts'])}")
else:
print(f" {alert['date']}: {', '.join(alert['alerts'])}")
if args.telegram:
msg = format_alert_message(alerts)
if msg and send_telegram(msg):
print("\n✅ Alerts sent to Telegram!")
else:
print("\n❌ Telegram failed")
else:
mode = "Quality Mode" if args.config else "Legacy Mode"
print(f"\n✅ {mode}: All metrics above thresholds!")
# Save to file (portable path)
output_dir = os.environ.get("OURA_OUTPUT_DIR", str(Path.home() / ".oura-analytics" / "reports"))
alert_file = f"{output_dir}/oura_alerts_{end_date}.json"
os.makedirs(output_dir, exist_ok=True)
with open(alert_file, "w") as f:
json.dump({"period": f"{start_date} to {end_date}", "alerts": alerts}, f, indent=2)
print(f"\n💾 Saved to {alert_file}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Oura Baseline & Comparison Analysis
Calculate baselines from historical data and compare current metrics.
Provides statistical significance (z-score, percentiles) and actionable insights.
"""
import argparse
import sys
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
import statistics
# Add scripts directory to path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent))
from oura_api import OuraClient
# nightRecord unused - removed
@dataclass
class BaselineMetrics:
"""Statistical baseline for a metric."""
mean: float
median: float
std_dev: float
min: float
max: float
p25: float # 25th percentile
p75: float # 75th percentile
sample_size: int
def z_score(self, value: float) -> float:
"""Calculate z-score (standard deviations from mean)."""
if self.std_dev == 0:
return 0.0
return (value - self.mean) / self.std_dev
def percentile_rank(self, value: float, samples: List[float]) -> float:
"""Calculate percentile rank of value in distribution."""
if not samples:
return 50.0
below = sum(1 for s in samples if s < value)
return (below / len(samples)) * 100
def interpret_delta(self, value: float) -> Tuple[str, str, str]:
"""
Interpret delta from baseline.
Returns: (emoji, label, severity)
"""
z = self.z_score(value)
if z >= 1.5:
return "🔥", "Well above baseline", "excellent"
elif z >= 0.5:
return "↗️", "Above baseline", "good"
elif z >= -0.5:
return "➡️", "Within baseline", "normal"
elif z >= -1.5:
return "↘️", "Below baseline", "attention"
else:
return "⚠️", "Well below baseline", "concern"
@dataclass
class Baseline:
"""Complete baseline analysis."""
sleep_score: BaselineMetrics
readiness: BaselineMetrics
sleep_hours: BaselineMetrics
efficiency: BaselineMetrics
hrv: Optional[BaselineMetrics]
rhr: Optional[BaselineMetrics]
period_days: int
end_date: str
def calculate_baseline_metrics(values: List[float]) -> Optional[BaselineMetrics]:
"""Calculate statistical baseline from a list of values."""
if not values or len(values) < 3:
return None
sorted_values = sorted(values)
try:
return BaselineMetrics(
mean=round(statistics.mean(values), 1),
median=round(statistics.median(values), 1),
std_dev=round(statistics.stdev(values), 1) if len(values) > 1 else 0,
min=round(min(values), 1),
max=round(max(values), 1),
p25=round(sorted_values[len(values) // 4], 1),
p75=round(sorted_values[3 * len(values) // 4], 1),
sample_size=len(values)
)
except Exception:
return None
def calculate_sleep_score(sleep_data: dict) -> float:
"""Calculate sleep score from sleep data (matches weekly_report.py logic)."""
efficiency = sleep_data.get("efficiency", 0)
duration_hours = sleep_data.get("total_sleep_duration", 0) / 3600 if sleep_data.get("total_sleep_duration") else 0
eff_score = min(efficiency, 100)
dur_score = min(duration_hours / 8 * 100, 100)
return round((eff_score * 0.6) + (dur_score * 0.4), 1)
def build_baseline(sleep_data: List[dict], readiness_data: List[dict], period_days: int) -> Baseline:
"""Build baseline from historical data."""
# Build readiness lookup
readiness_by_day = {r.get("day"): r for r in readiness_data}
# Extract metrics
sleep_scores = []
readiness_scores = []
sleep_hours = []
efficiencies = []
hrv_values = []
rhr_values = []
for sleep in sleep_data:
day = sleep.get("day")
# Sleep score
score = calculate_sleep_score(sleep)
if score > 0:
sleep_scores.append(score)
# Sleep hours
duration_sec = sleep.get("total_sleep_duration", 0)
if duration_sec:
sleep_hours.append(round(duration_sec / 3600, 1))
# Efficiency
eff = sleep.get("efficiency")
if eff:
efficiencies.append(eff)
# HRV
hrv = sleep.get("average_hrv")
if hrv:
hrv_values.append(hrv)
# RHR
rhr = sleep.get("lowest_heart_rate")
if rhr:
rhr_values.append(rhr)
# Readiness
r = readiness_by_day.get(day)
if r and r.get("score"):
readiness_scores.append(r["score"])
return Baseline(
sleep_score=calculate_baseline_metrics(sleep_scores),
readiness=calculate_baseline_metrics(readiness_scores),
sleep_hours=calculate_baseline_metrics(sleep_hours),
efficiency=calculate_baseline_metrics(efficiencies),
hrv=calculate_baseline_metrics(hrv_values) if hrv_values else None,
rhr=calculate_baseline_metrics(rhr_values) if rhr_values else None,
period_days=period_days,
end_date=sleep_data[-1].get("day") if sleep_data else ""
)
def compare_to_baseline(current_data: dict, baseline: Baseline, metric_name: str, current_value: float, baseline_metric: BaselineMetrics) -> Dict:
"""Compare current value to baseline and generate insight."""
delta = current_value - baseline_metric.mean
z_score = baseline_metric.z_score(current_value)
emoji, label, severity = baseline_metric.interpret_delta(current_value)
return {
"metric": metric_name,
"current": round(current_value, 1),
"baseline_mean": baseline_metric.mean,
"baseline_range": f"{baseline_metric.p25}-{baseline_metric.p75}",
"delta": round(delta, 1),
"z_score": round(z_score, 2),
"emoji": emoji,
"label": label,
"severity": severity
}
def format_baseline_report(baseline: Baseline) -> str:
"""Format baseline report for console output."""
lines = []
lines.append(f"\n📊 Baseline Analysis ({baseline.period_days}-day period)")
lines.append(f" Period ending: {baseline.end_date}")
lines.append("")
metrics = [
("Sleep Score", baseline.sleep_score, "/100"),
("Readiness", baseline.readiness, "/100"),
("Sleep Duration", baseline.sleep_hours, "h"),
("Efficiency", baseline.efficiency, "%"),
]
if baseline.hrv:
metrics.append(("HRV", baseline.hrv, "ms"))
if baseline.rhr:
metrics.append(("RHR", baseline.rhr, "bpm"))
for name, metric, unit in metrics:
if metric:
lines.append(f" {name}:")
lines.append(f" Mean: {metric.mean}{unit} (±{metric.std_dev})")
lines.append(f" Range: {metric.min}-{metric.max}{unit}")
lines.append(f" P25-P75: {metric.p25}-{metric.p75}{unit}")
lines.append(f" Samples: {metric.sample_size}")
lines.append("")
return "\n".join(lines)
def format_comparison_report(comparisons: List[Dict], period_label: str) -> str:
"""Format comparison report for console output."""
lines = []
lines.append(f"\n📈 Current vs Baseline ({period_label})")
lines.append("")
for comp in comparisons:
emoji = comp["emoji"]
metric = comp["metric"]
current = comp["current"]
delta = comp["delta"]
z = comp["z_score"]
label = comp["label"]
delta_str = f"+{delta}" if delta > 0 else str(delta)
lines.append(f" {emoji} {metric}: {current} ({delta_str}, z={z})")
lines.append(f" {label}")
lines.append("")
# Summary
concerns = [c for c in comparisons if c["severity"] == "concern"]
if concerns:
lines.append("⚠️ Metrics needing attention:")
for c in concerns:
lines.append(f" • {c['metric']}: {c['label']}")
else:
lines.append("✅ All metrics within or above baseline range")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Oura Baseline & Comparison Analysis")
parser.add_argument("--baseline-days", type=int, default=30, help="Days for baseline calculation (default: 30)")
parser.add_argument("--current-days", type=int, default=7, help="Days for current period comparison (default: 7)")
parser.add_argument("--token", help="Oura API token")
parser.add_argument("--json", action="store_true", help="Output JSON format")
parser.add_argument("--baseline-only", action="store_true", help="Show baseline without comparison")
args = parser.parse_args()
try:
client = OuraClient(args.token)
# Fetch baseline period data
baseline_end = datetime.now()
baseline_start = baseline_end - timedelta(days=args.baseline_days)
baseline_sleep = client.get_sleep(
baseline_start.strftime("%Y-%m-%d"),
baseline_end.strftime("%Y-%m-%d")
)
baseline_readiness = client.get_readiness(
baseline_start.strftime("%Y-%m-%d"),
baseline_end.strftime("%Y-%m-%d")
)
# Calculate baseline
baseline = build_baseline(baseline_sleep, baseline_readiness, args.baseline_days)
if args.baseline_only:
if args.json:
print(json.dumps({
"baseline": {
"sleep_score": baseline.sleep_score.__dict__ if baseline.sleep_score else None,
"readiness": baseline.readiness.__dict__ if baseline.readiness else None,
"sleep_hours": baseline.sleep_hours.__dict__ if baseline.sleep_hours else None,
"efficiency": baseline.efficiency.__dict__ if baseline.efficiency else None,
"hrv": baseline.hrv.__dict__ if baseline.hrv else None,
"rhr": baseline.rhr.__dict__ if baseline.rhr else None,
"period_days": baseline.period_days,
"end_date": baseline.end_date
}
}, indent=2))
else:
print(format_baseline_report(baseline))
return
# Fetch current period data
current_end = datetime.now()
current_start = current_end - timedelta(days=args.current_days)
current_sleep = client.get_sleep(
current_start.strftime("%Y-%m-%d"),
current_end.strftime("%Y-%m-%d")
)
current_readiness = client.get_readiness(
current_start.strftime("%Y-%m-%d"),
current_end.strftime("%Y-%m-%d")
)
# Calculate current averages
current_sleep_scores = [calculate_sleep_score(s) for s in current_sleep if calculate_sleep_score(s) > 0]
current_readiness_scores = [r.get("score") for r in current_readiness if r.get("score")]
current_sleep_hours = [s.get("total_sleep_duration", 0) / 3600 for s in current_sleep if s.get("total_sleep_duration")]
current_efficiencies = [s.get("efficiency") for s in current_sleep if s.get("efficiency")]
avg_sleep_score = statistics.mean(current_sleep_scores) if current_sleep_scores else 0
avg_readiness = statistics.mean(current_readiness_scores) if current_readiness_scores else 0
avg_sleep_hours = statistics.mean(current_sleep_hours) if current_sleep_hours else 0
avg_efficiency = statistics.mean(current_efficiencies) if current_efficiencies else 0
# Generate comparisons
comparisons = []
if baseline.sleep_score and avg_sleep_score:
comparisons.append(compare_to_baseline({}, baseline, "Sleep Score", avg_sleep_score, baseline.sleep_score))
if baseline.readiness and avg_readiness:
comparisons.append(compare_to_baseline({}, baseline, "Readiness", avg_readiness, baseline.readiness))
if baseline.sleep_hours and avg_sleep_hours:
comparisons.append(compare_to_baseline({}, baseline, "Sleep Duration", avg_sleep_hours, baseline.sleep_hours))
if baseline.efficiency and avg_efficiency:
comparisons.append(compare_to_baseline({}, baseline, "Efficiency", avg_efficiency, baseline.efficiency))
# Output
period_label = f"Last {args.current_days}d vs {args.baseline_days}d baseline"
if args.json:
print(json.dumps({
"baseline": {
"period_days": baseline.period_days,
"end_date": baseline.end_date
},
"current": {
"period_days": args.current_days,
"avg_sleep_score": round(avg_sleep_score, 1),
"avg_readiness": round(avg_readiness, 1),
"avg_sleep_hours": round(avg_sleep_hours, 1),
"avg_efficiency": round(avg_efficiency, 1)
},
"comparisons": comparisons
}, indent=2))
else:
print(format_comparison_report(comparisons, period_label))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Morning Briefing Formatter for Oura Analytics
Generates concise, actionable daily briefings with:
- Headline metrics with baseline context
- Driver analysis (what's causing the scores)
- Recovery status (GREEN/YELLOW/RED)
- Decision recommendations
- Pattern detection (trends, streaks)
"""
import sys
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime
# Add scripts dir to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from schema import NightRecord, SleepRecord, ReadinessRecord
class Baseline:
"""Baseline metrics calculated from historical data."""
def __init__(self,
avg_sleep_hours: float = 7.5,
avg_readiness: float = 75.0,
avg_hrv: float = 40.0,
avg_rhr: float = 60.0):
self.avg_sleep_hours = avg_sleep_hours
self.avg_readiness = avg_readiness
self.avg_hrv = avg_hrv
self.avg_rhr = avg_rhr
@classmethod
def from_history(cls, nights: List[NightRecord]) -> 'Baseline':
"""Calculate baseline from historical night records with outlier removal."""
if not nights:
return cls()
# Calculate averages with outlier removal (remove top/bottom 10% if sample is large enough)
sleep_hours = sorted([n.sleep.total_sleep_hours for n in nights if n.sleep])
readiness_scores = sorted([n.readiness.score for n in nights if n.readiness])
hrv_values = sorted([n.sleep.average_hrv_ms for n in nights if n.sleep and n.sleep.average_hrv_ms])
rhr_values = sorted([n.sleep.lowest_heart_rate_bpm for n in nights if n.sleep and n.sleep.lowest_heart_rate_bpm])
def robust_avg(values: List[float], default: float) -> float:
"""Calculate average with outlier removal (trim 10% from each end if n >= 10)."""
if not values:
return default
if len(values) < 10:
# Too few samples for outlier removal
return sum(values) / len(values)
# Remove top/bottom 10%
trim = max(1, int(len(values) * 0.1))
trimmed = values[trim:-trim]
return sum(trimmed) / len(trimmed)
return cls(
avg_sleep_hours=robust_avg(sleep_hours, 7.5),
avg_readiness=robust_avg(readiness_scores, 75.0),
avg_hrv=robust_avg(hrv_values, 40.0),
avg_rhr=robust_avg(rhr_values, 60.0)
)
class BriefingFormatter:
"""Formats morning briefings with context and recommendations."""
def __init__(self, baseline: Optional[Baseline] = None):
self.baseline = baseline or Baseline()
def format(self, night: NightRecord, verbose: bool = False) -> str:
"""
Format a morning briefing.
Args:
night: NightRecord for the day
verbose: Include additional detail
Returns:
Formatted briefing string
"""
lines = []
# Header
lines.append(self._header(night.date))
lines.append("")
# Headline metrics
if night.sleep:
lines.append(self._sleep_line(night.sleep))
if night.readiness:
lines.append(self._readiness_line(night.readiness))
if verbose:
lines.append(self._driver_analysis(night.readiness))
lines.append("")
# Status and recommendation
status, recommendation = self._get_status_and_recommendation(night)
lines.append(f"Recovery Status: {status}")
lines.append(f"Recommendation: {recommendation}")
# Pattern detection
if verbose:
pattern = self._detect_pattern(night)
if pattern:
lines.append("")
lines.append(f"Notable: {pattern}")
return "\n".join(lines)
def _header(self, date: str) -> str:
"""Format header with date."""
dt = datetime.strptime(date, "%Y-%m-%d")
return f"☀️ Morning Briefing ({dt.strftime('%b %d')})"
def _sleep_line(self, sleep: SleepRecord) -> str:
"""Format sleep line with context."""
hours = sleep.total_sleep_hours
# Delta from baseline
delta_hours = hours - self.baseline.avg_sleep_hours
delta_min = int(delta_hours * 60)
if abs(delta_min) < 15:
delta_str = "on target"
indicator = "✓"
elif delta_min > 0:
delta_str = f"↑{delta_min}min vs avg"
indicator = "✓"
else:
delta_str = f"↓{abs(delta_min)}min vs avg"
indicator = "⚠️" if abs(delta_min) > 60 else "○"
# Format duration
h = int(hours)
m = int((hours - h) * 60)
return f"Sleep: {h}h {m}m ({delta_str}) {indicator}"
def _readiness_line(self, readiness: ReadinessRecord) -> str:
"""Format readiness line with context."""
score = readiness.score
# Delta from baseline
delta = score - self.baseline.avg_readiness
if score >= 85:
indicator = "✓"
elif score >= 70:
indicator = "○"
else:
indicator = "⚠️"
if abs(delta) < 3:
delta_str = "stable"
elif delta > 0:
delta_str = f"↑{int(delta)} vs baseline"
else:
delta_str = f"↓{abs(int(delta))} vs baseline"
return f"Readiness: {score} ({delta_str}) {indicator}"
def _driver_analysis(self, readiness: ReadinessRecord) -> str:
"""Analyze what's driving readiness score."""
# Identify low contributors
contributors = {
"HRV balance": readiness.hrv_balance,
"Sleep balance": readiness.sleep_balance,
"Recovery index": readiness.recovery_index,
"RHR": readiness.resting_heart_rate,
"Body temp": readiness.body_temperature,
"Activity balance": readiness.activity_balance
}
# Filter out None values and sort by score
valid_contributors = {k: v for k, v in contributors.items() if v is not None}
sorted_contributors = sorted(valid_contributors.items(), key=lambda x: x[1])
# Identify lowest contributors (<70)
low = [k for k, v in sorted_contributors if v < 70]
if low:
drivers = ", ".join(low[:2]) # Top 2 lowest
return f"└─ Driven by: {drivers}"
else:
return "└─ All contributors balanced"
def _get_status_and_recommendation(self, night: NightRecord) -> Tuple[str, str]:
"""Determine recovery status and recommendation."""
if not night.readiness:
return "UNKNOWN", "Insufficient data"
score = night.readiness.score
if score >= 85:
status = "🟢 GREEN"
recommendation = "Optimal day. Ready for intensity."
elif score >= 70:
status = "🟡 YELLOW"
recommendation = "Moderate day. Avoid heavy training."
else:
status = "🔴 RED"
recommendation = "Recovery day. Light activity only."
return status, recommendation
def _detect_pattern(self, night: NightRecord) -> Optional[str]:
"""Detect notable patterns (placeholder for trend analysis)."""
# This would require historical data for real trend detection
# For now, just highlight extremes
if night.sleep and night.sleep.total_sleep_hours >= 8.5:
return "Excellent sleep duration"
if night.readiness and night.readiness.score <= 65:
return "Low readiness - prioritize recovery"
if night.sleep and night.sleep.efficiency_percent >= 90:
return "High sleep efficiency"
return None
def format_brief_briefing(night: NightRecord, baseline: Optional[Baseline] = None) -> str:
"""
Format a brief 3-line briefing (for notifications).
Args:
night: NightRecord for the day
baseline: Optional baseline metrics
Returns:
3-line briefing string
"""
formatter = BriefingFormatter(baseline)
lines = []
if night.sleep:
hours = night.sleep.total_sleep_hours
h = int(hours)
m = int((hours - h) * 60)
lines.append(f"Sleep: {h}h {m}m")
if night.readiness:
score = night.readiness.score
if score >= 85:
status = "Green"
elif score >= 70:
status = "Yellow"
else:
status = "Red"
lines.append(f"Readiness: {score} ({status})")
_, recommendation = formatter._get_status_and_recommendation(night)
lines.append(recommendation)
return "\n".join(lines)
def format_json_briefing(night: NightRecord, baseline: Optional[Baseline] = None) -> Dict[str, Any]:
"""
Format briefing as JSON for API integration.
Args:
night: NightRecord for the day
baseline: Optional baseline metrics
Returns:
Dictionary with briefing data
"""
formatter = BriefingFormatter(baseline)
status, recommendation = formatter._get_status_and_recommendation(night)
# Extract status code (remove emoji)
# Status format: "🟢 GREEN" → "GREEN"
status_code = status.split()[-1] if status else "UNKNOWN"
briefing = {
"date": night.date,
"sleep": None,
"readiness": None,
"status": status_code,
"recommendation": recommendation
}
if night.sleep:
briefing["sleep"] = {
"duration_hours": round(night.sleep.total_sleep_hours, 2),
"efficiency_percent": night.sleep.efficiency_percent,
"hrv_ms": night.sleep.average_hrv_ms
}
if night.readiness:
briefing["readiness"] = {
"score": night.readiness.score,
"delta_vs_baseline": round(night.readiness.score - formatter.baseline.avg_readiness, 1) if baseline else None,
"low_contributors": _get_low_contributors(night.readiness)
}
return briefing
def _get_low_contributors(readiness: ReadinessRecord) -> List[str]:
"""Get list of low contributors (<70)."""
contributors = {
"hrv_balance": readiness.hrv_balance,
"sleep_balance": readiness.sleep_balance,
"recovery_index": readiness.recovery_index,
"resting_heart_rate": readiness.resting_heart_rate,
"body_temperature": readiness.body_temperature,
"activity_balance": readiness.activity_balance
}
return [k for k, v in contributors.items() if v is not None and v < 70]
def format_hybrid_briefing(
night: NightRecord,
baseline: Optional[Baseline] = None,
week_data: Optional[Dict[str, Any]] = None
) -> str:
"""
Format a hybrid daily report combining morning briefing with trend snapshot.
This combines:
1) Morning Briefing (top): actionable daily guidance with driver analysis
2) Trend Snapshot (bottom): 7-day averages + recent sleep/readiness history
Args:
night: NightRecord for the day
baseline: Optional baseline metrics
week_data: Optional pre-calculated 7-day statistics
Returns:
Hybrid briefing string (≤12 lines for chat readability)
"""
formatter = BriefingFormatter(baseline)
lines = []
# === SECTION 1: Morning Briefing ===
lines.append(f"🌅 *Morning Briefing — {_format_date(night.date)}*")
lines.append("─" * 24)
# Sleep line with delta
if night.sleep:
hours = night.sleep.total_sleep_hours
h = int(hours)
m = int((hours - h) * 60)
delta_min = int((hours - formatter.baseline.avg_sleep_hours) * 60)
if abs(delta_min) < 15:
delta_str = "on target"
elif delta_min > 0:
delta_str = f"↑{delta_min}min vs avg"
else:
delta_str = f"↓{abs(delta_min)}min vs avg"
sleep_indicator = "✅" if abs(delta_min) < 60 else "⚠️"
lines.append(f"💤 *Sleep*: {h}h {m}m ({delta_str}) {sleep_indicator}")
# Readiness line with delta
if night.readiness:
score = night.readiness.score
delta = score - formatter.baseline.avg_readiness
if abs(delta) < 3:
delta_str = "stable"
elif delta > 0:
delta_str = f"↑{int(delta)} vs baseline"
else:
delta_str = f"↓{abs(int(delta))} vs baseline"
ready_indicator = "✅" if score >= 70 else "⚠️"
lines.append(f"⚡ *Readiness*: {score} ({delta_str}) {ready_indicator}")
# Driver analysis (compact)
drivers = _get_low_contributors(night.readiness)
if drivers:
lines.append(f"*Drivers*: {', '.join(drivers[:2])}")
else:
lines.append("*Drivers*: All balanced")
# Recovery status + recommendation
status, recommendation = formatter._get_status_and_recommendation(night)
lines.append(f"*Recovery*: {status}")
lines.append(f"*Rec*: {recommendation}")
# === SECTION 2: Trend Snapshot ===
if week_data:
lines.append("")
lines.append("*📊 7-Day Trends*")
lines.append("─" * 24)
# 7-day averages with delta arrows
avg_sleep = week_data.get("avg_sleep_score")
avg_readiness = week_data.get("avg_readiness")
avg_duration = week_data.get("avg_duration")
avg_efficiency = week_data.get("avg_efficiency")
avg_hrv = week_data.get("avg_hrv")
# Sleep score with trend
sleep_trend = week_data.get("sleep_trend", 0)
if avg_sleep:
trend_arrow = _trend_arrow(sleep_trend)
lines.append(f"*Sleep Score*: `{avg_sleep:>2}` {trend_arrow}")
# Readiness with trend
readiness_trend = week_data.get("readiness_trend", 0)
if avg_readiness:
trend_arrow = _trend_arrow(readiness_trend)
lines.append(f"*Readiness*: `{avg_readiness:>2}` {trend_arrow}")
# Key metrics row
metrics = []
if avg_duration:
metrics.append(f"*{avg_duration}h* sleep")
if avg_efficiency:
metrics.append(f"*{avg_efficiency}%* eff")
if avg_hrv:
metrics.append(f"*{avg_hrv}ms* HRV")
if metrics:
lines.append(f"• {' • '.join(metrics)}")
# Last 2 nights
last_2_days = week_data.get("last_2_days", [])
if last_2_days and len(last_2_days) >= 2:
d1 = last_2_days[-2]
d2 = last_2_days[-1]
d1_sleep = d1.get("sleep_score", "N/A")
d1_ready = d1.get("readiness", "N/A")
d2_sleep = d2.get("sleep_score", "N/A")
d2_ready = d2.get("readiness", "N/A")
d1_date = d1.get("day", "")[-5:] # MM-DD
d2_date = d2.get("day", "")[-5:]
lines.append("")
lines.append(f"*Recent*: {d1_date} → `{d1_sleep}`/`{d1_ready}` • {d2_date} → `{d2_sleep}`/`{d2_ready}`")
return "\n".join(lines)
def _format_date(date_str: str) -> str:
"""Format date string for display."""
dt = datetime.strptime(date_str, "%Y-%m-%d")
return dt.strftime("%b %d")
def _trend_arrow(trend: float) -> str:
"""Get arrow indicator for trend value."""
if trend > 1:
return "↑" # Trending up
elif trend < -1:
return "↓" # Trending down
else:
return "→" # Stable
if __name__ == "__main__":
import argparse
from oura_api import OuraClient
from schema import create_night_record
parser = argparse.ArgumentParser(description="Oura Morning Briefing")
parser.add_argument("--date", help="Date (YYYY-MM-DD, default: yesterday)")
parser.add_argument("--format", choices=["brief", "hybrid", "json"], default="hybrid")
parser.add_argument("--token", help="Oura API token")
args = parser.parse_args()
# Default to today (same-day briefing)
if args.date:
target_date = args.date
else:
target_date = datetime.now().strftime("%Y-%m-%d")
try:
client = OuraClient(args.token)
sleep_data = client.get_sleep(target_date, target_date)
readiness_data = client.get_readiness(target_date, target_date)
if not sleep_data:
print(f"No data for {target_date}")
sys.exit(1)
# Build NightRecord from API response using schema normalizer
night = create_night_record(
date=target_date,
sleep=sleep_data[0] if sleep_data else None,
readiness=readiness_data[0] if readiness_data else None
)
if args.format == "json":
import json
print(json.dumps(format_json_briefing(night), indent=2))
elif args.format == "brief":
print(format_brief_briefing(night))
else:
print(format_hybrid_briefing(night))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
#!/usr/bin/env python3
"""Simple file-based cache for Oura API data."""
import json
import os
from pathlib import Path
from typing import Optional
# datetime import removed - not used
class OuraCache:
"""File-based cache for Oura data (sleep, readiness, activity)."""
def __init__(self, cache_dir: Optional[Path] = None):
"""Initialize cache with optional custom directory."""
if cache_dir is None:
# Check environment variable first
env_cache_dir = os.environ.get("OURA_CACHE_DIR")
if env_cache_dir:
cache_dir = Path(env_cache_dir)
else:
# XDG-compliant default: ~/.cache/oura-analytics/
xdg_cache = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
cache_dir = Path(xdg_cache) / "oura-analytics"
self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True)
def get(self, endpoint: str, date: str) -> Optional[list]:
"""
Get cached data for endpoint and date.
Args:
endpoint: API endpoint (sleep, readiness, activity)
date: ISO date string (YYYY-MM-DD)
Returns:
Cached data list (Oura API returns data: []) or None if not cached
"""
cache_file = self._get_cache_path(endpoint, date)
if not cache_file.exists():
return None
try:
with open(cache_file, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def set(self, endpoint: str, date: str, data: list) -> None:
"""
Cache data for endpoint and date.
Args:
endpoint: API endpoint (sleep, readiness, activity)
date: ISO date string (YYYY-MM-DD)
data: Data to cache
"""
cache_file = self._get_cache_path(endpoint, date)
cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w") as f:
json.dump(data, f, indent=2)
def clear(self, endpoint: Optional[str] = None) -> int:
"""
Clear cache for specific endpoint or all endpoints.
Args:
endpoint: API endpoint to clear, or None to clear all
Returns:
Number of files deleted
"""
if endpoint:
target_dir = self.cache_dir / endpoint
if not target_dir.exists():
return 0
files = list(target_dir.glob("*.json"))
else:
files = list(self.cache_dir.glob("*/*.json"))
for f in files:
f.unlink()
# Also reset sync state
sync_state_file = self.cache_dir / "sync_state.json"
if sync_state_file.exists():
try:
sync_state = json.loads(sync_state_file.read_text())
if endpoint:
# Clear specific endpoint
sync_state.pop(endpoint, None)
else:
# Clear all
sync_state = {}
sync_state_file.write_text(json.dumps(sync_state, indent=2))
except (json.JSONDecodeError, IOError):
# If sync_state is corrupted, just delete it
if not endpoint:
sync_state_file.unlink()
return len(files)
def _get_cache_path(self, endpoint: str, date: str) -> Path:
"""Get cache file path for endpoint and date."""
return self.cache_dir / endpoint / f"{date}.json"
def get_last_sync(self, endpoint: str) -> Optional[str]:
"""
Get last synced date for endpoint.
Returns:
ISO date string or None if never synced
"""
sync_state_file = self.cache_dir / "sync_state.json"
if not sync_state_file.exists():
return None
try:
with open(sync_state_file, "r") as f:
sync_state = json.load(f)
return sync_state.get(endpoint)
except (json.JSONDecodeError, IOError):
return None
def set_last_sync(self, endpoint: str, date: str) -> None:
"""
Update last synced date for endpoint.
Args:
endpoint: API endpoint
date: ISO date string (YYYY-MM-DD)
"""
sync_state_file = self.cache_dir / "sync_state.json"
# Load existing state
sync_state = {}
if sync_state_file.exists():
try:
with open(sync_state_file, "r") as f:
sync_state = json.load(f)
except (json.JSONDecodeError, IOError):
pass
# Update and save
sync_state[endpoint] = date
with open(sync_state_file, "w") as f:
json.dump(sync_state, f, indent=2)
#!/usr/bin/env python3
"""
Alert Configuration and Quality Controls
Provides debounce, hysteresis, and configurable thresholds for alerts.
"""
import yaml
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
@dataclass
class AlertThresholds:
"""Alert threshold configuration."""
# Readiness thresholds (0-100)
low_threshold: int = 60
critical_threshold: int = 45
# Sleep thresholds (hours)
min_hours: float = 6.5
critical_hours: float = 5.0
# Efficiency thresholds (percentage)
min_efficiency: int = 80
critical_efficiency: int = 70
# HRV threshold (percentage drop from baseline)
hrv_drop_percent: int = 15
# Temperature deviation threshold (Celsius)
temp_deviation: float = 0.3
@dataclass
class AlertConfig:
"""Main alert configuration with debounce and hysteresis."""
# Debounce settings
consecutive_days_required: int = 2 # Alert only after N consecutive bad days
signals_required: int = 2 # Require N signals to alert (out of checked)
# Cooldown to prevent alert spam
cooldown_hours: int = 24
# Per-category thresholds
readiness: AlertThresholds = field(default_factory=AlertThresholds)
sleep: AlertThresholds = field(default_factory=AlertThresholds)
efficiency: AlertThresholds = field(default_factory=AlertThresholds)
hrv: AlertThresholds = field(default_factory=AlertThresholds)
temperature: AlertThresholds = field(default_factory=AlertThresholds)
@dataclass
class AlertState:
"""Track alert state to prevent spam and track acknowledgments."""
last_alert_time: dict[str, datetime] = field(default_factory=dict)
acknowledged: set[str] = field(default_factory=set)
consecutive_bad_days: dict[str, int] = field(default_factory=dict)
def should_alert(self, category: str, current_time: datetime) -> bool:
"""Check if we should alert (respects cooldown)."""
last_time = self.last_alert_time.get(category)
if last_time and (current_time - last_time) < timedelta(hours=24):
return False
return True
def record_alert(self, category: str, current_time: datetime):
"""Record that we alerted for this category."""
self.last_alert_time[category] = current_time
def record_bad_day(self, category: str):
"""Record a consecutive bad day for this category."""
current = self.consecutive_bad_days.get(category, 0)
self.consecutive_bad_days[category] = current + 1
def reset_bad_days(self, category: str):
"""Reset consecutive bad day counter."""
self.consecutive_bad_days[category] = 0
def get_consecutive_bad_days(self, category: str) -> int:
"""Get consecutive bad day count for category."""
return self.consecutive_bad_days.get(category, 0)
class ConfigLoader:
"""Load configuration from YAML file with environment overrides."""
DEFAULT_CONFIG_PATH = Path.home() / ".oura-analytics" / "config.yaml"
def __init__(self, config_path: Optional[Path] = None):
self.config_path = config_path or self.DEFAULT_CONFIG_PATH
self._config: Optional[AlertConfig] = None
def load(self) -> AlertConfig:
"""Load configuration from file or return defaults."""
if self._config is not None:
return self._config
if not self.config_path.exists():
self._config = AlertConfig()
return self._config
try:
with open(self.config_path) as f:
data = yaml.safe_load(f) or {}
self._config = self._parse_config(data)
except Exception as e:
print(f"Warning: Failed to load config: {e}. Using defaults.")
self._config = AlertConfig()
return self._config
def _parse_config(self, data: dict) -> AlertConfig:
"""Parse YAML data into AlertConfig."""
# Parse debounce settings
debounce = data.get("debounce", {})
consecutive_days = debounce.get("consecutive_days_required", 2)
signals_required = debounce.get("signals_required", 2)
cooldown_hours = debounce.get("cooldown_hours", 24)
# Parse thresholds
def parse_thresholds(category: str) -> AlertThresholds:
cat_data = data.get(category, {})
return AlertThresholds(
low_threshold=cat_data.get("low_threshold", 60),
critical_threshold=cat_data.get("critical_threshold", 45),
min_hours=cat_data.get("min_hours", 6.5),
critical_hours=cat_data.get("critical_hours", 5.0),
min_efficiency=cat_data.get("min_efficiency", 80),
critical_efficiency=cat_data.get("critical_efficiency", 70),
hrv_drop_percent=cat_data.get("hrv_drop_percent", 15),
temp_deviation=cat_data.get("temp_deviation", 0.3),
)
return AlertConfig(
consecutive_days_required=consecutive_days,
signals_required=signals_required,
cooldown_hours=cooldown_hours,
readiness=parse_thresholds("readiness"),
sleep=parse_thresholds("sleep"),
efficiency=parse_thresholds("efficiency"),
hrv=parse_thresholds("hrv"),
temperature=parse_thresholds("temperature"),
)
def save(self, config: AlertConfig, path: Optional[Path] = None):
"""Save configuration to YAML file."""
save_path = path or self.config_path
save_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"debounce": {
"consecutive_days_required": config.consecutive_days_required,
"signals_required": config.signals_required,
"cooldown_hours": config.cooldown_hours,
},
"readiness": {
"low_threshold": config.readiness.low_threshold,
"critical_threshold": config.readiness.critical_threshold,
},
"sleep": {
"min_hours": config.sleep.min_hours,
"critical_hours": config.sleep.critical_hours,
},
"efficiency": {
"min_efficiency": config.efficiency.min_efficiency,
"critical_efficiency": config.efficiency.critical_efficiency,
},
"hrv": {
"hrv_drop_percent": config.hrv.hrv_drop_percent,
},
"temperature": {
"temp_deviation": config.temperature.temp_deviation,
},
}
with open(save_path, "w") as f:
yaml.dump(data, f, default_flow_style=False)
self._config = config
def check_thresholds_with_quality(
sleep_data: list,
readiness_data: list,
config: AlertConfig,
state: Optional[AlertState] = None,
) -> list:
"""Check thresholds with debounce and hysteresis.
Args:
sleep_data: List of sleep records
readiness_data: List of readiness records
config: AlertConfig with thresholds and debounce settings
state: Optional AlertState for tracking
Returns:
List of alert dictionaries
"""
if state is None:
state = AlertState()
# Build lookups by day
readiness_by_day = {r.get("day"): r for r in readiness_data}
alerts = []
for day in sleep_data:
date = day.get("day")
category_issues = []
bad_categories = set()
# Check readiness
readiness_record = readiness_by_day.get(date)
readiness_score = readiness_record.get("score") if readiness_record else None
if readiness_score and readiness_score < config.readiness.low_threshold:
category_issues.append(("readiness", readiness_score))
bad_categories.add("readiness")
# Check efficiency
efficiency = day.get("efficiency", 100)
if efficiency < config.efficiency.min_efficiency:
category_issues.append(("efficiency", efficiency))
bad_categories.add("efficiency")
# Check sleep duration
duration_hours = day.get("total_sleep_duration", 0) / 3600
if duration_hours and duration_hours < config.sleep.min_hours:
category_issues.append(("sleep", duration_hours))
bad_categories.add("sleep")
# Reset consecutive counter for categories that improved
for cat in ["readiness", "efficiency", "sleep"]:
if cat not in bad_categories:
state.reset_bad_days(cat)
# Apply debounce: require consecutive bad days
for category, value in category_issues:
state.record_bad_day(category)
consecutive = state.get_consecutive_bad_days(category)
if consecutive < config.consecutive_days_required:
continue
# Check cooldown using config value
last_time = state.last_alert_time.get(category)
if last_time:
cooldown = timedelta(hours=config.cooldown_hours)
if (datetime.now() - last_time) < cooldown:
continue
# Create alert message
if category == "readiness":
msg = f"Readiness {value}"
elif category == "efficiency":
msg = f"Efficiency {value}%"
else:
msg = f"Sleep {value:.1f}h"
alerts.append({
"date": date,
"alerts": [msg],
"consecutive_days": consecutive,
})
state.record_alert(category, datetime.now())
return alerts
def main():
"""CLI for alert configuration."""
import argparse
parser = argparse.ArgumentParser(description="Manage Oura alert configuration")
parser.add_argument("--config", type=str, help="Path to config file")
parser.add_argument("--show", action="store_true", help="Show current config")
parser.add_argument("--reset", action="store_true", help="Reset to defaults")
args = parser.parse_args()
loader = ConfigLoader(Path(args.config) if args.config else None)
if args.reset:
config = AlertConfig()
loader.save(config)
print("Configuration reset to defaults.")
return
config = loader.load()
if args.show:
print("Alert Configuration:")
print(f" Consecutive days required: {config.consecutive_days_required}")
print(f" Cooldown hours: {config.cooldown_hours}")
print(f" Readiness threshold: {config.readiness.low_threshold}")
print(f" Sleep minimum hours: {config.sleep.min_hours}")
print(f" Efficiency minimum: {config.efficiency.min_efficiency}%")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Debug Oura API response structure."""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from oura_api import OuraClient
# Load token
token = os.environ.get("OURA_API_TOKEN")
if not token:
print("Error: OURA_API_TOKEN not set")
sys.exit(1)
client = OuraClient(token=token)
print("=" * 60)
print("DEBUG: Oura API Response Structure")
print("=" * 60)
# Get sleep data
print("\n📊 SLEEP DATA:")
sleep = client.get_sleep(start_date="2026-01-17", end_date="2026-01-20")
if sleep:
print(f"Found {len(sleep)} records")
for i, day in enumerate(sleep[:2]): # Show first 2
print(f"\nDay {i+1}: {day.get('day')}")
print(f" All keys: {list(day.keys())}")
print(f" score: {day.get('score')}")
print(f" efficiency: {day.get('efficiency')}")
print(f" total_sleep_duration: {day.get('total_sleep_duration')}")
else:
print("No sleep data found!")
# Get readiness data
print("\n⚡ READINESS DATA:")
readiness = client.get_readiness(start_date="2026-01-17", end_date="2026-01-20")
if readiness:
print(f"Found {len(readiness)} records")
for i, day in enumerate(readiness[:2]): # Show first 2
print(f"\nDay {i+1}: {day.get('day')}")
print(f" All keys: {list(day.keys())}")
print(f" score: {day.get('score')}")
print(f" contributors: {day.get('contributors')}")
else:
print("No readiness data found!")
# Get daily_sleep (alternative endpoint)
print("\n📊 DAILY_SLEEP (alternative):")
daily_sleep = client.get_daily_sleep(start_date="2026-01-17", end_date="2026-01-20")
if daily_sleep:
print(f"Found {len(daily_sleep)} records")
for i, day in enumerate(daily_sleep[:2]):
print(f"\nDay {i+1}: {day.get('day')}")
print(f" All keys: {list(day.keys())}")
print(f" score: {day.get('score')}")
else:
print("No daily_sleep data found!")
print("\n" + "=" * 60)
[
{"day": "2026-01-15", "stress_score": 62, "day_summary": "stressed"},
{"day": "2026-01-16", "stress_score": 38, "day_summary": "restored"},
{"day": "2026-01-17", "stress_score": 71, "day_summary": "high_stress"}
]