Skill: javiersgjavi/skill-health (version 1.0.1)

--- name: skill-health description: "Analyze wearable health CSV exports (steps, heart rate, sleep, calories, SpO2, exercise, distance) and produce compact JSON reports for hourly, daily, weekly, monthly, sleep (last 30h) and cross-temporal alerts. Use when running or packaging Skill Health analyses and when explaining output semantics, data quality, or known caveats." --- # Skill Health ## Use Run analysis scripts from `scripts/` with a `--data-path` (ZIP or folder) or `--data-dir` (directory holding health_data_*). Each script prints JSON to stdout and can also write a file with `--output-dir`. Pass `--timezone` (IANA, e.g. `Europe/Madrid`, `America/New_York`) to interpret naive CSV timestamps correctly before UTC serialization. Minimal examples: ```bash python scripts/run_sleep.py --data-path data/health_data_latest.zip --output-dir outputs python scripts/run_daily.py --data-path data/health_data_latest.zip --output-dir outputs python scripts/run_cross_alerts.py --outputs-dir outputs --output-dir outputs ``` ## Notes - Sleep metrics use suffix `last_{window_hours}h` (default `last_30h`). - Outputs are compact JSON (`time_window` uses `s/e/r`, `data_quality` uses `cov/rel`). - Dependencies: Python 3.10+ and `pandas`. ## References - `references/overview.md` (data schemas, outputs, key decisions) - `references/operations.md` (execution windows, quality rules)

path: pkg/skill_health/__init__.py
content: """
Skill Health: health metrics analysis from wearables and smartphones.
"""

from skill_health.load import (
    load_health_data_from_directory,
    load_health_data_from_path,
)
from skill_health.models import HealthDataBundle

__all__ = [
    "HealthDataBundle",
    "load_health_data_from_directory",
    "load_health_data_from_path",
]
__version__ = "0.1.0"


path: pkg/skill_health/analysis/__init__.py
content: """Analysis by time window: hourly, daily, weekly, monthly, cross-temporal alerts."""

from skill_health.analysis.cross_alerts import build_cross_alerts
from skill_health.analysis.daily import (
    build_daily_report,
    infer_target_date_from_bundle,
)
from skill_health.analysis.hourly import build_hourly_report, infer_now_from_bundle
from skill_health.analysis.monthly import (
    build_monthly_report,
    infer_month_end_date_from_bundle,
)
from skill_health.analysis.weekly import (
    build_weekly_report,
    infer_week_end_date_from_bundle,
)

__all__ = [
    "build_cross_alerts",
    "build_hourly_report",
    "build_daily_report",
    "build_weekly_report",
    "build_monthly_report",
    "infer_now_from_bundle",
    "infer_target_date_from_bundle",
    "infer_week_end_date_from_bundle",
    "infer_month_end_date_from_bundle",
]


path: pkg/skill_health/analysis/cross_alerts.py
content: """
Cross-temporal alerts: detect patterns by combining metrics from hourly, daily, weekly, monthly.
Reads JSON outputs from previous analyses.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

RHR_RISE_THRESHOLD_BPM = 5.0
SPO2_LOW_THRESHOLD_PCT = 95.0
SPO2_VERY_LOW_THRESHOLD_PCT = 94.0


def _find_metric(metrics: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
    """Return the first metric with metric_name equal to name, or None."""
    for m in metrics:
        if m.get("metric_name") == name:
            return m
    return None


def _find_metric_with_prefix(
    metrics: list[dict[str, Any]], prefix: str
) -> dict[str, Any] | None:
    """Return the first metric with metric_name that starts with prefix, or None."""
    for m in metrics:
        metric_name = m.get("metric_name", "")
        if isinstance(metric_name, str) and metric_name.startswith(prefix):
            return m
    return None


def _get_stat(metric: dict[str, Any] | None, key: str) -> Any:
    """Return stats[key] from metric, or None."""
    if metric is None:
        return None
    return metric.get("stats", {}).get(key)


def load_metrics_from_outputs_dir(outputs_dir: Path) -> dict[str, list[dict[str, Any]]]:
    """
    Load the most recent JSON report of each type from outputs_dir.

    Returns dict with keys: "hourly", "daily", "weekly", "monthly", "sleep".
    Each value is a list of metric dicts (empty list if no file found).
    """
    result: dict[str, list[dict[str, Any]]] = {
        "hourly": [],
        "daily": [],
        "weekly": [],
        "monthly": [],
        "sleep": [],
    }
    if not outputs_dir.exists() or not outputs_dir.is_dir():
        return result

    patterns = {
        "hourly": "hourly_*.json",
        "daily": "daily_*.json",
        "weekly": "weekly_*.json",
        "monthly": "monthly_*.json",
        "sleep": "sleep_*.json",
    }
    for key, pattern in patterns.items():
        files = sorted(outputs_dir.glob(pattern), reverse=True)
        if files:
            try:
                content = json.loads(files[0].read_text(encoding="utf-8"))
                result[key] = content if isinstance(content, list) else []
            except (json.JSONDecodeError, OSError):
                pass
    return result


def build_cross_alerts(
    metrics_context: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
    """
    Detect cross-temporal patterns from metrics. Returns list of alert dicts.

    Args:
        metrics_context: Dict from load_metrics_from_outputs_dir with keys
            "hourly", "daily", "weekly", "monthly".
    """
    alerts: list[dict[str, Any]] = []
    daily = metrics_context.get("daily", [])
    weekly = metrics_context.get("weekly", [])
    monthly = metrics_context.get("monthly", [])
    sleep = metrics_context.get("sleep", [])

    # Pre-illness: RHR↑ 5+ bpm + SpO2↓ + fragmented sleep + low steps
    rhr_trend = _find_metric(weekly, "rhr_trending_weekly")
    rhr_rise = _get_stat(rhr_trend, "value")
    spo2_weekly = _find_metric(weekly, "spo2_variance_weekly")
    spo2_sleep = _find_metric_with_prefix(sleep, "spo2_sleep_summary_last_")
    spo2_min_sleep = _get_stat(spo2_sleep, "min_pct") if spo2_sleep else None
    spo2_min_week = _get_stat(spo2_weekly, "min_pct") if spo2_weekly else None
    spo2_min = (
        spo2_min_sleep if spo2_min_sleep is not None else spo2_min_week
    )
    sleep_frag = _find_metric_with_prefix(sleep, "sleep_fragmentation_hr_spikes_last_")
    frag_events = _get_stat(sleep_frag, "fragmentation_events") or 0
    rhr_sleep = _find_metric_with_prefix(sleep, "resting_hr_during_sleep_last_")
    steps_daily = _find_metric(daily, "activity_windows_daily")
    dead_hours = _get_stat(steps_daily, "dead_hours") or 0

    if (
        rhr_rise is not None
        and rhr_rise >= RHR_RISE_THRESHOLD_BPM
        and (spo2_min is None or spo2_min < SPO2_LOW_THRESHOLD_PCT)
        and frag_events > 5
        and dead_hours >= 10
    ):
        alerts.append(
            {
                "pattern_id": "pre_illness",
                "pattern_name": "Pre-illness signature",
                "severity": "high",
                "signals": {
                    "rhr_rise_bpm": rhr_rise,
                    "spo2_min_pct": spo2_min,
                    "sleep_fragmentation_events": frag_events,
                    "dead_hours": dead_hours,
                },
                "message": "RHR up 5+ bpm, low SpO2, fragmented sleep and reduced activity. Possible illness in 24-48h.",
            }
        )

    # Overtraining: RHR elevated + more exercise + worse sleep (weekly)
    if rhr_trend and _get_stat(rhr_trend, "flag_rising_rhr"):
        ex_cons = _find_metric(weekly, "exercise_consistency_weekly")
        sleep_debt = _find_metric(weekly, "sleep_debt_weekly")
        debt = _get_stat(sleep_debt, "value") or 0
        days_ex = _get_stat(ex_cons, "days_with_exercise") or 0
        if debt > 5 and days_ex >= 4:
            alerts.append(
                {
                    "pattern_id": "overtraining",
                    "pattern_name": "Overtraining in development",
                    "severity": "medium",
                    "signals": {
                        "rhr_rising": True,
                        "sleep_debt_hours": debt,
                        "days_with_exercise": days_ex,
                    },
                    "message": "RHR elevated with high exercise load and sleep debt. Consider rest.",
                }
            )

    # Sleep apnea: SpO2 <95% persistent + fragmentation
    if spo2_min is not None and spo2_min < SPO2_LOW_THRESHOLD_PCT and frag_events > 3:
        alerts.append(
            {
                "pattern_id": "sleep_apnea",
                "pattern_name": "Possible sleep apnea",
                "severity": "medium",
                "signals": {
                    "spo2_min_pct": spo2_min,
                    "sleep_fragmentation_events": frag_events,
                },
                "message": "Persistently low SpO2 with sleep fragmentation. Consider evaluation.",
            }
        )

    # Burnout: steps down + worse sleep + RHR up + less exercise (monthly)
    cv_fitness = _find_metric(monthly, "cardiovascular_fitness_monthly")
    activity = _find_metric(monthly, "activity_sustainability_monthly")
    sleep_monthly = _find_metric(monthly, "sleep_consistency_monthly")
    ex_monthly = _find_metric(monthly, "exercise_consistency_monthly")

    step_trend = _get_stat(activity, "step_trend_second_minus_first_half")
    rhr_month_trend = _get_stat(cv_fitness, "value")
    missing_nights = _get_stat(sleep_monthly, "nights_without_data") or 0
    days_ex_month = _get_stat(ex_monthly, "days_with_exercise") or 0

    if (
        step_trend is not None
        and step_trend < -5000
        and (rhr_month_trend is None or rhr_month_trend >= 2)
        and missing_nights > 5
        and days_ex_month < 10
    ):
        alerts.append(
            {
                "pattern_id": "burnout",
                "pattern_name": "Burnout pattern",
                "severity": "medium",
                "signals": {
                    "step_trend": step_trend,
                    "rhr_trend_bpm": rhr_month_trend,
                    "nights_without_sleep_data": missing_nights,
                    "days_with_exercise": days_ex_month,
                },
                "message": "Declining steps, elevated RHR, poor sleep coverage and less exercise. Sustainability at risk.",
            }
        )

    # Device inconsistency: steps present but very few HR readings (daily)
    steps_metric = _find_metric(daily, "exercise_vs_spontaneous_ratio_daily")
    if steps_metric and rhr_sleep:
        steps_instances = steps_metric.get("n_instances", 0) or 0
        hr_instances = rhr_sleep.get("n_instances", 0) or 0
        if steps_instances > 20 and hr_instances < 5:
            alerts.append(
                {
                    "pattern_id": "device_inconsistency",
                    "pattern_name": "Device inconsistency",
                    "severity": "low",
                    "signals": {
                        "steps_data_points": steps_instances,
                        "heart_rate_data_points": hr_instances,
                    },
                    "message": "Steps data present but very few heart rate readings. Check wearable fit/settings.",
                }
            )

    return alerts


path: pkg/skill_health/analysis/daily.py
content: """
Daily analysis: filter bundle by target date, compute HR zones, activity, and energy.
"""

from __future__ import annotations

from datetime import date, datetime
from typing import Any

import pandas as pd

from skill_health.models import HealthDataBundle
from skill_health.report import TimeWindow, build_data_quality, datetime_to_iso, metric

# HR zone boundaries (bpm)
_HR_ZONE_REST_MAX = 60
_HR_ZONE_MODERATE_MAX = 100
_HR_ZONE_HIGH_MAX = 150

# Step thresholds for activity classification
_DEAD_HOUR_STEPS = 100
_BURST_HOUR_STEPS = 1000

# Device-worn threshold: days with fewer than this many HR readings likely unworn
_DEVICE_WORN_HR_MIN = 5
_TREADMILL_STRIDE_M = 0.75


def _calculate_hr_zones(hr_df: pd.DataFrame) -> dict[str, float]:
    """
    Estimate minutes in each HR zone using actual inter-sample intervals.

    Each sample is assigned a weight equal to the time until the next sample.
    This avoids the error of assuming uniform sampling when gaps vary.
    Formula: minutes_in_zone = sum(forward_interval_sec / 60) for samples in that zone.
    The last sample receives 0 weight (no following sample to measure its duration).
    """
    if hr_df.empty:
        return {
            "rest_minutes": 0.0,
            "moderate_minutes": 0.0,
            "high_minutes": 0.0,
            "maximum_minutes": 0.0,
        }
    hr_sorted = hr_df.sort_values("timestamp").reset_index(drop=True)
    bpm = hr_sorted["bpm"].values
    # forward_interval[i] = seconds between sample i and sample i+1 (last gets 0)
    intervals_sec = hr_sorted["timestamp"].diff().dt.total_seconds()
    forward_intervals_sec = intervals_sec.shift(-1).fillna(0).values

    rest_sec = float(forward_intervals_sec[bpm < _HR_ZONE_REST_MAX].sum())
    moderate_sec = float(
        forward_intervals_sec[
            (bpm >= _HR_ZONE_REST_MAX) & (bpm < _HR_ZONE_MODERATE_MAX)
        ].sum()
    )
    high_sec = float(
        forward_intervals_sec[
            (bpm >= _HR_ZONE_MODERATE_MAX) & (bpm < _HR_ZONE_HIGH_MAX)
        ].sum()
    )
    maximum_sec = float(forward_intervals_sec[bpm >= _HR_ZONE_HIGH_MAX].sum())
    return {
        "rest_minutes": rest_sec / 60.0,
        "moderate_minutes": moderate_sec / 60.0,
        "high_minutes": high_sec / 60.0,
        "maximum_minutes": maximum_sec / 60.0,
    }


def _active_kcal_is_measured(day_calories: pd.DataFrame) -> bool:
    """Return True if at least one calorie row has actual active_kcal data (not filled 0)."""
    if day_calories.empty or "active_kcal_measured" not in day_calories.columns:
        return False
    return bool(day_calories["active_kcal_measured"].any())


def _treadmill_sessions(day_exercise: pd.DataFrame) -> pd.DataFrame:
    """Return treadmill (Kingsmith) exercise sessions for the day."""
    if day_exercise.empty or "source" not in day_exercise.columns:
        return pd.DataFrame()
    return day_exercise[
        day_exercise["source"].str.contains("kingsmith", case=False, na=False)
    ]


def _steps_in_sessions(day_steps: pd.DataFrame, sessions: pd.DataFrame) -> pd.Series:
    """Return a boolean mask for steps within any session window."""
    if day_steps.empty or sessions.empty:
        return pd.Series(False, index=day_steps.index)
    mask = pd.Series(False, index=day_steps.index)
    for _, row in sessions.iterrows():
        session_mask = (day_steps["timestamp"] >= row["start_time"]) & (
            day_steps["timestamp"] <= row["end_time"]
        )
        mask |= session_mask
    return mask


def _treadmill_distance(
    day_distance: pd.DataFrame, treadmill_sessions_df: pd.DataFrame
) -> tuple[float, float]:
    """Return total treadmill distance (km, m) for matching sessions."""
    if day_distance.empty or treadmill_sessions_df.empty:
        return 0.0, 0.0
    if "source" in day_distance.columns:
        day_distance = day_distance[
            day_distance["source"].str.contains("kingsmith", case=False, na=False)
        ]
    if day_distance.empty:
        return 0.0, 0.0
    merged = treadmill_sessions_df.merge(
        day_distance, on=["start_time", "end_time"], how="left"
    )
    distance_km = float(merged.get("distance_km", pd.Series()).fillna(0.0).sum())
    if "distance_m" in merged.columns:
        distance_m = float(merged["distance_m"].fillna(0.0).sum())
    else:
        distance_m = distance_km * 1000
    return distance_km, distance_m


def _estimate_treadmill_steps(
    *,
    day_steps: pd.DataFrame,
    day_distance: pd.DataFrame,
    treadmill_sessions_df: pd.DataFrame,
) -> tuple[float, float, float, bool]:
    """
    Return (treadmill_steps_estimated, treadmill_phone_steps, treadmill_distance_km, from_distance).
    """
    treadmill_steps_estimated = 0.0
    treadmill_phone_steps = 0.0
    treadmill_distance_km = 0.0
    treadmill_steps_from_distance = False

    if treadmill_sessions_df.empty or day_steps.empty:
        return (
            treadmill_steps_estimated,
            treadmill_phone_steps,
            treadmill_distance_km,
            treadmill_steps_from_distance,
        )

    treadmill_mask = _steps_in_sessions(day_steps, treadmill_sessions_df)
    treadmill_phone_steps = float(day_steps.loc[treadmill_mask, "steps"].sum())

    distance_km, distance_m = _treadmill_distance(day_distance, treadmill_sessions_df)
    treadmill_distance_km = distance_km
    if distance_m > 0:
        treadmill_steps_estimated = distance_m / _TREADMILL_STRIDE_M
        treadmill_steps_from_distance = True
    else:
        treadmill_steps_estimated = treadmill_phone_steps

    return (
        treadmill_steps_estimated,
        treadmill_phone_steps,
        treadmill_distance_km,
        treadmill_steps_from_distance,
    )


def _exercise_steps_non_treadmill(
    *,
    day_steps: pd.DataFrame,
    day_exercise: pd.DataFrame,
    treadmill_sessions_df: pd.DataFrame,
) -> float:
    """Return steps within non-treadmill exercise sessions."""
    if day_steps.empty or day_exercise.empty:
        return 0.0
    exercise_mask = _steps_in_sessions(day_steps, day_exercise)
    treadmill_mask = _steps_in_sessions(day_steps, treadmill_sessions_df)
    non_treadmill_mask = exercise_mask & ~treadmill_mask
    return float(day_steps.loc[non_treadmill_mask, "steps"].sum())


def build_daily_report(
    bundle: HealthDataBundle, target_date: date
) -> list[dict[str, Any]]:
    """
    Build metric list for daily analysis. Filters bundle by target_date and computes metrics.
    """
    day_start = datetime.combine(target_date, datetime.min.time())
    day_end = day_start.replace(hour=23, minute=59, second=59)
    tw = TimeWindow(
        start=datetime_to_iso(day_start),
        end=datetime_to_iso(day_end),
        resolution="1d",
    )

    day_steps = (
        bundle.steps[
            (bundle.steps["timestamp"] >= day_start)
            & (bundle.steps["timestamp"] <= day_end)
        ]
        if not bundle.steps.empty
        else pd.DataFrame()
    )
    day_hr = (
        bundle.heart_rate[
            (bundle.heart_rate["timestamp"] >= day_start)
            & (bundle.heart_rate["timestamp"] <= day_end)
        ]
        if not bundle.heart_rate.empty
        else pd.DataFrame()
    )
    day_calories = (
        bundle.calories[
            (bundle.calories["start_time"] <= day_end)
            & (bundle.calories["end_time"] >= day_start)
        ]
        if not bundle.calories.empty
        else pd.DataFrame()
    )
    day_exercise = (
        bundle.exercise_sessions[
            (bundle.exercise_sessions["start_time"] >= day_start)
            & (bundle.exercise_sessions["start_time"] <= day_end)
        ]
        if not bundle.exercise_sessions.empty
        else pd.DataFrame()
    )

    metrics_out: list[dict[str, Any]] = []

    # -- Cardiovascular load zones --
    hr_zones = _calculate_hr_zones(day_hr)
    hr_zone_reliability = (
        "unavailable"
        if day_hr.empty
        else ("high" if len(day_hr) >= 20 else "medium" if len(day_hr) >= 5 else "low")
    )
    metrics_out.append(
        metric(
            metric_name="cardiovascular_load_daily",
            description=(
                "Time spent in HR zones (rest <60, moderate 60-100, high 100-150, maximum >150 bpm)."
                " Computed from actual inter-sample intervals, not uniform sampling assumption."
            ),
            purpose="Assess cardiovascular load and training intensity distribution.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=tw,
            n_instances=len(day_hr),
            stats=hr_zones,
            extra=None,
            data_quality=build_data_quality(
                coverage=min(1.0, len(day_hr) / 288) if not day_hr.empty else 0.0,
                reliability=hr_zone_reliability,
                notes=(
                    [
                        "HR zone times reflect intervals between sparse readings; gaps >30 min are not accounted for."
                    ]
                    if len(day_hr) < 20 and not day_hr.empty
                    else []
                ),
            ),
        )
    )

    # -- Activity windows --
    device_worn = len(day_hr) >= _DEVICE_WORN_HR_MIN
    if not day_steps.empty:
        day_steps_copy = day_steps.copy()
        day_steps_copy["hour"] = day_steps_copy["timestamp"].dt.hour
        hourly = day_steps_copy.groupby("hour", as_index=True)["steps"].sum()
        dead_hours = int((hourly < _DEAD_HOUR_STEPS).sum())
        bursts = int((hourly > _BURST_HOUR_STEPS).sum())
        max_sp = float(hourly.max()) if not hourly.empty else None
        avg_sp = float(hourly.mean()) if not hourly.empty else None
    else:
        dead_hours = 0
        bursts = 0
        hourly = pd.Series(dtype=float)
        max_sp = None
        avg_sp = None
    metrics_out.append(
        metric(
            metric_name="activity_windows_daily",
            description=(
                "Dead hours (< 100 steps) vs activity bursts (> 1000 steps) per hour. "
                "total_hours_with_data counts hours that have at least one step record."
            ),
            purpose="Identify inactivity and high-activity patterns.",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=tw,
            n_instances=len(hourly),
            stats={
                "dead_hours": dead_hours,
                "activity_bursts": bursts,
                "total_hours_with_data": len(hourly),
                "max_steps_per_hour": max_sp,
                "avg_steps_per_hour": avg_sp,
                "device_worn": device_worn,
            },
            extra=None,
            data_quality=build_data_quality(
                coverage=len(hourly) / 24 if len(hourly) > 0 else 0.0,
                reliability=(
                    "medium"
                    if len(hourly) >= 12
                    else "low" if len(hourly) > 0 else "unavailable"
                ),
                notes=(
                    [
                        "Device likely not worn (< 5 HR readings); step data may be incomplete."
                    ]
                    if not device_worn
                    else []
                ),
            ),
        )
    )

    # -- Exercise vs spontaneous steps --
    # Uses OR-mask deduplication: step records overlapping multiple exercise sessions
    # are counted once, avoiding double-counting from overlapping sessions.
    if not day_steps.empty:
        total_steps = float(day_steps["steps"].sum())
        treadmill_sessions_df = _treadmill_sessions(day_exercise)
        treadmill_sessions = int(len(treadmill_sessions_df))
        day_distance = (
            bundle.distance[
                (bundle.distance["start_time"] >= day_start)
                & (bundle.distance["end_time"] <= day_end)
            ]
            if not bundle.distance.empty
            else pd.DataFrame()
        )
        (
            treadmill_steps_estimated,
            treadmill_phone_steps,
            treadmill_distance_km,
            treadmill_steps_from_distance,
        ) = _estimate_treadmill_steps(
            day_steps=day_steps,
            day_distance=day_distance,
            treadmill_sessions_df=treadmill_sessions_df,
        )
        exercise_steps_non_treadmill = _exercise_steps_non_treadmill(
            day_steps=day_steps,
            day_exercise=day_exercise,
            treadmill_sessions_df=treadmill_sessions_df,
        )

        adjusted_total_steps = (
            total_steps - treadmill_phone_steps + treadmill_steps_estimated
        )
        adjusted_total_steps = max(0.0, adjusted_total_steps)
        exercise_steps = exercise_steps_non_treadmill + treadmill_steps_estimated
        spontaneous = adjusted_total_steps - exercise_steps
        ratio = (
            exercise_steps / adjusted_total_steps if adjusted_total_steps > 0 else 0.0
        )
    else:
        total_steps = 0.0
        exercise_steps = 0.0
        spontaneous = 0.0
        ratio = 0.0
        treadmill_sessions = 0
        treadmill_steps_estimated = 0.0
        treadmill_distance_km = 0.0
        treadmill_steps_from_distance = False
        adjusted_total_steps = 0.0
    metrics_out.append(
        metric(
            metric_name="exercise_vs_spontaneous_ratio_daily",
            description=(
                "Ratio of steps during exercise sessions vs total daily steps. "
                "treadmill_sessions counts Kingsmith sessions (machine exercise, not free walking)."
            ),
            purpose="Compare formal exercise with spontaneous daily movement.",
            variables_used=[
                "steps.timestamp",
                "steps.steps",
                "exercise_sessions.start_time",
                "exercise_sessions.end_time",
                "exercise_sessions.source",
            ],
            time_window=tw,
            n_instances=len(day_steps),
            stats={
                "value": float(ratio),
                "exercise_steps": float(exercise_steps),
                "spontaneous_steps": float(spontaneous),
                "total_steps": float(adjusted_total_steps),
                "exercise_sessions": len(day_exercise),
                "treadmill_sessions": treadmill_sessions,
                "treadmill_steps_estimated": float(treadmill_steps_estimated),
                "treadmill_distance_km": float(treadmill_distance_km),
            },
            extra={
                "units": "ratio (0-1)",
                "treadmill_stride_m": _TREADMILL_STRIDE_M,
            },
            data_quality=(
                build_data_quality(
                    coverage=1.0,
                    reliability=("medium" if treadmill_steps_from_distance else "low"),
                    notes=(
                        [
                            "Treadmill steps estimated from distance using fixed stride length (0.75 m/step)."
                        ]
                        if treadmill_steps_from_distance
                        else [
                            "Treadmill steps fallback to phone accelerometer (distance not available)."
                        ]
                    ),
                )
                if treadmill_sessions > 0
                else None
            ),
        )
    )

    # -- Nighttime activity --
    if not day_steps.empty:
        night_start = day_start.replace(hour=22, minute=0)
        night_end = day_start.replace(hour=6, minute=0) + pd.Timedelta(days=1)
        night_steps = day_steps[
            (day_steps["timestamp"] >= night_start)
            & (day_steps["timestamp"] <= night_end)
        ]
        night_activity = (
            float(night_steps["steps"].sum()) if not night_steps.empty else 0.0
        )
        night_n = len(night_steps)
    else:
        night_activity = 0.0
        night_n = 0
    metrics_out.append(
        metric(
            metric_name="nighttime_activity_density_daily",
            description="Total steps between 22:00 and 06:00.",
            purpose="Detect unusual nighttime activity (insomnia, nocturia, apnea).",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=tw,
            n_instances=night_n,
            stats={"value": night_activity, "night_hours": 8},
            extra=None,
        )
    )

    # -- Energy balance --
    # active_kcal is 0 in the current data export (column not populated by the source device).
    # total_kcal IS reliable. We report both clearly, marking when active is unmeasured.
    active_kcal_measured = _active_kcal_is_measured(day_calories)
    if not day_calories.empty:
        active = float(day_calories["active_kcal"].sum())
        total = float(day_calories["total_kcal"].sum())
        # Only compute ratio if active was actually measured; otherwise it is meaningless
        active_pct = (
            (active / total * 100) if (total > 0 and active_kcal_measured) else None
        )
        basal_estimate = (total - active) if active_kcal_measured else None
    else:
        active = None
        basal_estimate = None
        total = None
        active_pct = None
    metrics_out.append(
        metric(
            metric_name="energy_balance_daily",
            description=(
                "Energy balance: total_kcal (reliable) and active_kcal (unreliable if unmeasured). "
                "total_kcal is the sum of all calorie windows for the day. "
                "active_kcal_measured=false means the source export did not include active calories."
            ),
            purpose="Assess daily energy expenditure. Use total_kcal when active_kcal is not measured.",
            variables_used=[
                "calories.start_time",
                "calories.end_time",
                "calories.active_kcal",
                "calories.total_kcal",
                "calories.active_kcal_measured",
            ],
            time_window=tw,
            n_instances=len(day_calories),
            stats=(
                {
                    "active_kcal": active,
                    "active_kcal_measured": active_kcal_measured,
                    "basal_kcal_estimate": basal_estimate,
                    "total_kcal": total,
                    "active_percentage": active_pct,
                }
                if active_kcal_measured
                else {
                    "active_kcal_measured": active_kcal_measured,
                    "total_kcal": total,
                }
            ),
            extra={"units": "kcal"},
            data_quality=build_data_quality(
                coverage=1.0 if not day_calories.empty else 0.0,
                reliability=(
                    "unavailable"
                    if day_calories.empty
                    else ("medium" if active_kcal_measured else "low")
                ),
                notes=(
                    [
                        "active_kcal not measured in current export; only total_kcal is reliable."
                    ]
                    if not active_kcal_measured
                    else []
                ),
            ),
        )
    )

    # -- Peak caloric time (uses total_kcal since active is unmeasured) --
    if not day_calories.empty:
        day_cal = day_calories.copy()
        day_cal["hour"] = day_cal["start_time"].dt.hour
        # Use total_kcal as the reliable hourly calorie signal
        hourly_cal = day_cal.groupby("hour", as_index=True)["total_kcal"].sum()
        peak_hour = int(hourly_cal.idxmax()) if not hourly_cal.empty else None
        peak_kcal = float(hourly_cal.max()) if not hourly_cal.empty else None
    else:
        peak_hour = None
        peak_kcal = None
    metrics_out.append(
        metric(
            metric_name="peak_caloric_time_daily",
            description=(
                "Hour of day with highest total caloric expenditure (uses total_kcal, "
                "since active_kcal is not available in the current export)."
            ),
            purpose="Identify peak energy window for optimal exercise timing.",
            variables_used=["calories.start_time", "calories.total_kcal"],
            time_window=tw,
            n_instances=len(day_calories),
            stats={"peak_hour": peak_hour, "peak_total_kcal": peak_kcal},
            extra={"units": "hour (0-23)", "kcal_source": "total_kcal"},
        )
    )

    return metrics_out


def infer_target_date_from_bundle(bundle: HealthDataBundle) -> date:
    """
    Infer the most recent date with data in the bundle.
    Prefers steps max when available for consistency with activity metrics.
    """
    if not bundle.steps.empty:
        return bundle.steps["timestamp"].max().date()
    candidates: list[date] = []
    if not bundle.heart_rate.empty:
        candidates.append(bundle.heart_rate["timestamp"].max().date())
    if not bundle.calories.empty:
        candidates.append(bundle.calories["end_time"].max().date())
    if not bundle.sleep_sessions.empty:
        candidates.append(bundle.sleep_sessions["end_time"].max().date())
    if not candidates:
        from datetime import date as date_type

        return date_type.today()
    return max(candidates)


path: pkg/skill_health/analysis/hourly.py
content: """
Hourly analysis: last-hour metrics and real-time style alerts (inactivity, HR flags).
"""

from __future__ import annotations

from datetime import datetime, timedelta
from typing import Any

import pandas as pd

from skill_health.models import HealthDataBundle
from skill_health.report import (
    TimeWindow,
    build_data_quality,
    datetime_to_iso,
    metric,
)


def _filter_last_hour(
    df: pd.DataFrame, timestamp_column: str, window_end: datetime
) -> pd.DataFrame:
    """Return rows whose timestamp is in [window_end - 1h, window_end]."""
    window_start = window_end - timedelta(hours=1)
    if df.empty:
        return df
    return df[
        (df[timestamp_column] >= window_start) & (df[timestamp_column] <= window_end)
    ]


def _detect_hr_jumps(heart_rate_in_window: pd.DataFrame) -> dict[str, Any]:
    """
    Detect HR jumps >30 bpm within <=5 minutes (arrhythmia-like pattern).
    Returns flag, n_events, max_jump_bpm.
    """
    if heart_rate_in_window.empty:
        return {"flag": False, "n_events": 0, "max_jump_bpm": None}

    hr_sorted = heart_rate_in_window.sort_values("timestamp").copy()
    hr_sorted["delta_bpm"] = hr_sorted["bpm"].diff()
    hr_sorted["delta_t_sec"] = hr_sorted["timestamp"].diff().dt.total_seconds()

    candidates = hr_sorted[
        (hr_sorted["delta_t_sec"] > 0)
        & (hr_sorted["delta_t_sec"] <= 5 * 60)
        & (hr_sorted["delta_bpm"].abs() > 30)
    ]

    if candidates.empty:
        return {"flag": False, "n_events": 0, "max_jump_bpm": None}

    return {
        "flag": True,
        "n_events": int(len(candidates)),
        "max_jump_bpm": float(candidates["delta_bpm"].abs().max()),
    }


def build_hourly_report(
    bundle: HealthDataBundle, now: datetime
) -> list[dict[str, Any]]:
    """
    Build a list of metric dicts for the last-hour analysis (steps, calories, HR, flags).

    Args:
        bundle: Loaded and deduplicated health data.
        now: End of the one-hour window (inclusive).

    Returns:
        List of metric dicts suitable for JSON output.
    """
    window_start = now - timedelta(hours=1)
    time_window_1h = TimeWindow(
        start=datetime_to_iso(window_start),
        end=datetime_to_iso(now),
        resolution="1h",
    )
    metrics_out: list[dict[str, Any]] = []

    steps_in_window = _filter_last_hour(bundle.steps, "timestamp", now)
    steps_total = (
        float(steps_in_window["steps"].sum()) if not steps_in_window.empty else 0.0
    )

    metrics_out.append(
        metric(
            metric_name="steps_last_hour_total",
            description="Total steps recorded during the last hour.",
            purpose="Detect inactivity patterns and short-term activity changes.",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=time_window_1h,
            n_instances=len(steps_in_window),
            stats={"value": steps_total},
            extra={"units": "steps"},
        )
    )

    # Calories overlapping last hour
    if not bundle.calories.empty:
        overlap_mask = (bundle.calories["end_time"] >= window_start) & (
            bundle.calories["start_time"] <= now
        )
        calories_in_window = bundle.calories[overlap_mask]
        active_kcal = float(calories_in_window["active_kcal"].sum())
        total_kcal = float(calories_in_window["total_kcal"].sum())
        n_calories_rows = len(calories_in_window)
        active_kcal_measured = (
            bool(calories_in_window["active_kcal_measured"].any())
            if "active_kcal_measured" in calories_in_window.columns
            else False
        )
    else:
        active_kcal = 0.0
        total_kcal = 0.0
        n_calories_rows = 0
        active_kcal_measured = False

    metrics_out.append(
        metric(
            metric_name="active_kcal_last_hour_total",
            description=(
                "Total active calories overlapping the last hour. "
                "active_kcal_measured=false means the source export does not provide active calories."
            ),
            purpose="Track short-term metabolic load and detect unusual spikes.",
            variables_used=[
                "calories.start_time",
                "calories.end_time",
                "calories.active_kcal",
            ],
            time_window=time_window_1h,
            n_instances=n_calories_rows,
            stats={"value": active_kcal, "active_kcal_measured": active_kcal_measured},
            extra={"units": "kcal"},
            data_quality=build_data_quality(
                coverage=1.0 if n_calories_rows > 0 else 0.0,
                reliability=(
                    "unavailable"
                    if not active_kcal_measured
                    else ("high" if n_calories_rows >= 5 else "medium")
                ),
                notes=(
                    [
                        "active_kcal not available in current export; value is 0.0 fill, not real measurement."
                    ]
                    if not active_kcal_measured
                    else []
                ),
            ),
        )
    )
    metrics_out.append(
        metric(
            metric_name="total_kcal_last_hour_total",
            description="Total calories (active + basal) overlapping the last hour. This is the reliable caloric signal.",
            purpose="Context for energy expenditure. Prefer over active_kcal when active is unmeasured.",
            variables_used=[
                "calories.start_time",
                "calories.end_time",
                "calories.total_kcal",
            ],
            time_window=time_window_1h,
            n_instances=n_calories_rows,
            stats={"value": total_kcal},
            extra={"units": "kcal"},
            data_quality=build_data_quality(
                coverage=1.0 if n_calories_rows > 0 else 0.0,
                reliability=(
                    "high"
                    if n_calories_rows >= 2
                    else "low" if n_calories_rows > 0 else "unavailable"
                ),
            ),
        )
    )

    # Heart rate in last hour
    hr_in_window = _filter_last_hour(bundle.heart_rate, "timestamp", now)
    if not hr_in_window.empty:
        hr_mean = float(hr_in_window["bpm"].mean())
        hr_max = float(hr_in_window["bpm"].max())
        hr_min = float(hr_in_window["bpm"].min())
    else:
        hr_mean = None
        hr_max = None
        hr_min = None

    metrics_out.append(
        metric(
            metric_name="heart_rate_last_hour_mean",
            description="Average heart rate during the last hour.",
            purpose="Detect elevated heart rate at rest or stress responses.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=time_window_1h,
            n_instances=len(hr_in_window),
            stats={"value": hr_mean, "min": hr_min, "max": hr_max},
            extra={"units": "bpm"},
        )
    )
    metrics_out.append(
        metric(
            metric_name="heart_rate_last_hour_max",
            description="Maximum heart rate during the last hour.",
            purpose="Spot acute spikes that may need attention.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=time_window_1h,
            n_instances=len(hr_in_window),
            stats={"value": hr_max},
            extra={"units": "bpm"},
        )
    )

    # Long inactivity flag (>=45 min zero steps during 09:00–22:00)
    long_inactivity = False
    if 9 <= now.hour <= 22:
        if not steps_in_window.empty:
            steps_series = steps_in_window.set_index("timestamp")["steps"]
            bins_5min = steps_series.resample("5min").sum()
            consecutive_zero = 0
            for val in bins_5min.values:
                if val == 0:
                    consecutive_zero += 1
                    if consecutive_zero >= 9:  # 9 * 5 min = 45 min
                        long_inactivity = True
                        break
                else:
                    consecutive_zero = 0
        else:
            long_inactivity = True

    metrics_out.append(
        metric(
            metric_name="inactivity_long_flag",
            description="Prolonged inactivity (>=45 min zero steps) during active hours (09:00–22:00).",
            purpose="Early detection of unusual sedentarism when expected to be active.",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=time_window_1h,
            n_instances=len(steps_in_window),
            stats={"value": long_inactivity},
            extra={"threshold_minutes": 45, "active_hours": "09:00-22:00"},
        )
    )

    # High rest HR: mean HR > 100 with very low steps
    high_rest_hr = False
    if hr_mean is not None and steps_total < 50:
        high_rest_hr = hr_mean > 100
    metrics_out.append(
        metric(
            metric_name="high_rest_hr_flag",
            description="Average HR > 100 bpm with very low steps in the last hour.",
            purpose="Potential stress, dehydration, illness, or tachycardia at rest.",
            variables_used=[
                "heart_rate.timestamp",
                "heart_rate.bpm",
                "steps.timestamp",
                "steps.steps",
            ],
            time_window=time_window_1h,
            n_instances=len(hr_in_window),
            stats={"value": high_rest_hr},
            extra={"hr_threshold_bpm": 100, "steps_threshold": 50},
        )
    )

    # Possible bradycardia while awake
    possible_brady_awake = False
    if hr_min is not None and steps_total > 0:
        possible_brady_awake = hr_min < 50
    metrics_out.append(
        metric(
            metric_name="possible_bradycardia_awake_flag",
            description="HR < 50 bpm with some movement in the last hour.",
            purpose="Potential bradycardia during wakefulness (athletes differ).",
            variables_used=[
                "heart_rate.timestamp",
                "heart_rate.bpm",
                "steps.timestamp",
                "steps.steps",
            ],
            time_window=time_window_1h,
            n_instances=len(hr_in_window),
            stats={"value": possible_brady_awake},
            extra={"hr_threshold_bpm": 50, "notes": "Interpret with context."},
        )
    )

    # HR jump (arrhythmia-like)
    hr_jump = _detect_hr_jumps(hr_in_window)
    metrics_out.append(
        metric(
            metric_name="arrhythmia_like_hr_jump_flag",
            description="HR jump > 30 bpm within <= 5 minutes (pattern-only).",
            purpose="Early warning for measurement artifacts or arrhythmia-like patterns.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=time_window_1h,
            n_instances=len(hr_in_window),
            stats={
                "value": hr_jump["flag"],
                "n_events": hr_jump["n_events"],
                "max_jump_bpm": hr_jump["max_jump_bpm"],
            },
            extra={"jump_threshold_bpm": 30, "time_threshold_seconds": 300},
        )
    )

    # Calories per step (efficiency proxy)
    # Uses total_kcal since active_kcal is not measured in the current export.
    kcal_per_step = (
        (total_kcal / steps_total) if (steps_total > 0 and total_kcal > 0) else None
    )
    metrics_out.append(
        metric(
            metric_name="total_kcal_per_step_last_hour",
            description=(
                "Total calories per step during the last hour (uses total_kcal). "
                "Previously named active_kcal_per_step; renamed because active_kcal is not available."
            ),
            purpose=(
                "Detect unusual effort/fatigue vs baseline. "
                "Note: includes basal calories, so absolute value is not comparable to active-only ratios."
            ),
            variables_used=["calories.total_kcal", "steps.steps"],
            time_window=time_window_1h,
            n_instances=len(steps_in_window),
            stats={
                "value": kcal_per_step,
                "active_kcal_measured": active_kcal_measured,
            },
            extra={"units": "kcal/step", "kcal_source": "total_kcal"},
            data_quality=build_data_quality(
                coverage=1.0 if kcal_per_step is not None else 0.0,
                reliability="low" if kcal_per_step is not None else "unavailable",
                notes=[
                    "Uses total_kcal (includes basal) because active_kcal is not measured. "
                    "Do not compare directly with active-only kcal/step ratios."
                ],
            ),
        )
    )

    return metrics_out


def infer_now_from_bundle(bundle: HealthDataBundle) -> datetime:
    """
    Infer the reference timestamp 'now' from the latest data point in the bundle.
    Used to anchor the "last hour" window to real data rather than wall clock.
    """
    candidates: list[datetime] = []
    if not bundle.steps.empty:
        ts = bundle.steps["timestamp"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.heart_rate.empty:
        ts = bundle.heart_rate["timestamp"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.calories.empty:
        ts = bundle.calories["end_time"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.sleep_sessions.empty:
        ts = bundle.sleep_sessions["end_time"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not candidates:
        raise ValueError("Cannot infer 'now' from empty health data bundle")
    return max(candidates)


path: pkg/skill_health/analysis/monthly.py
content: """
Monthly analysis: filter bundle by calendar month, compute trends and sustainability.
"""

from __future__ import annotations

import calendar
from datetime import date, datetime
from typing import Any

import pandas as pd

from skill_health.analysis.weekly import (
    _rhr_for_day,
    _sleep_hours_for_day,
    _steps_for_day,
)
from skill_health.models import HealthDataBundle
from skill_health.report import TimeWindow, build_data_quality, datetime_to_iso, metric

GHOST_DAY_STEPS_THRESHOLD = 500
_DEVICE_WORN_HR_MIN = 5
RHR_TREND_THRESHOLD_BPM = 3.0
SPO2_LOW_THRESHOLD_PCT = 94


def _compute_rhr_trend(
    rhr_by_day: list[float],
) -> tuple[float | None, float | None, float | None, bool]:
    """Return mean_first, mean_second, trend_bpm, and rising flag."""
    if len(rhr_by_day) < 2:
        mean_first = float(rhr_by_day[0]) if rhr_by_day else None
        return mean_first, None, None, False

    half = len(rhr_by_day) // 2
    first_half = rhr_by_day[:half]
    second_half = rhr_by_day[half:]
    mean_first = sum(first_half) / len(first_half)
    mean_second = sum(second_half) / len(second_half)
    trend_bpm = mean_second - mean_first
    return mean_first, mean_second, trend_bpm, trend_bpm >= RHR_TREND_THRESHOLD_BPM


def infer_month_end_date_from_bundle(bundle: HealthDataBundle) -> date:
    """
    Infer the most recent date with data (end of month window).
    Prefers steps max when available, so the analyzed month has step data.
    """
    if not bundle.steps.empty:
        return bundle.steps["timestamp"].max().date()
    candidates: list[date] = []
    if not bundle.heart_rate.empty:
        candidates.append(bundle.heart_rate["timestamp"].max().date())
    if not bundle.calories.empty:
        candidates.append(bundle.calories["end_time"].max().date())
    if not bundle.sleep_sessions.empty:
        candidates.append(bundle.sleep_sessions["end_time"].max().date())
    if not candidates:
        return date.today()
    return max(candidates)


def build_monthly_report(
    bundle: HealthDataBundle, month_end_date: date
) -> list[dict[str, Any]]:
    """
    Build metric list for monthly analysis. Filters bundle by the calendar month
    containing month_end_date.
    """
    year, month = month_end_date.year, month_end_date.month
    _, last_day = calendar.monthrange(year, month)
    month_end = date(year, month, last_day)
    month_start = date(year, month, 1)
    day_start_dt = datetime.combine(month_start, datetime.min.time())
    day_end_dt = datetime.combine(month_end, datetime.max.time())

    tw = TimeWindow(
        start=datetime_to_iso(day_start_dt),
        end=datetime_to_iso(day_end_dt),
        resolution="1M",
    )

    month_steps = (
        bundle.steps[
            (bundle.steps["timestamp"] >= day_start_dt)
            & (bundle.steps["timestamp"] <= day_end_dt)
        ]
        if not bundle.steps.empty
        else pd.DataFrame()
    )
    month_hr = (
        bundle.heart_rate[
            (bundle.heart_rate["timestamp"] >= day_start_dt)
            & (bundle.heart_rate["timestamp"] <= day_end_dt)
        ]
        if not bundle.heart_rate.empty
        else pd.DataFrame()
    )
    month_sleep = (
        bundle.sleep_sessions[
            (
                (
                    (bundle.sleep_sessions["start_time"] >= day_start_dt)
                    & (bundle.sleep_sessions["start_time"] <= day_end_dt)
                )
                | (
                    (bundle.sleep_sessions["end_time"] >= day_start_dt)
                    & (bundle.sleep_sessions["end_time"] <= day_end_dt)
                )
                | (
                    (bundle.sleep_sessions["start_time"] <= day_start_dt)
                    & (bundle.sleep_sessions["end_time"] >= day_end_dt)
                )
            )
        ]
        if not bundle.sleep_sessions.empty
        else pd.DataFrame()
    )
    month_exercise = (
        bundle.exercise_sessions[
            (bundle.exercise_sessions["start_time"] >= day_start_dt)
            & (bundle.exercise_sessions["start_time"] <= day_end_dt)
        ]
        if not bundle.exercise_sessions.empty
        else pd.DataFrame()
    )
    month_spo2 = (
        bundle.oxygen_saturation[
            (bundle.oxygen_saturation["timestamp"] >= day_start_dt)
            & (bundle.oxygen_saturation["timestamp"] <= day_end_dt)
        ]
        if not bundle.oxygen_saturation.empty
        else pd.DataFrame()
    )
    month_calories = (
        bundle.calories[
            (bundle.calories["start_time"] <= day_end_dt)
            & (bundle.calories["end_time"] >= day_start_dt)
        ]
        if not bundle.calories.empty
        else pd.DataFrame()
    )
    month_distance = (
        bundle.distance[
            (bundle.distance["start_time"] <= day_end_dt)
            & (bundle.distance["end_time"] >= day_start_dt)
        ]
        if not bundle.distance.empty
        else pd.DataFrame()
    )

    metrics_out: list[dict[str, Any]] = []
    num_days = last_day

    days_with_steps = (
        int(month_steps["timestamp"].dt.date.nunique()) if not month_steps.empty else 0
    )
    last_steps_date = (
        month_steps["timestamp"].max().date() if not month_steps.empty else None
    )
    steps_coverage = days_with_steps / num_days if num_days > 0 else 0.0
    steps_coverage_notes: list[str] = []
    if last_steps_date is not None and last_steps_date < month_end:
        steps_coverage_notes.append(
            f"Month incomplete: latest steps data on {last_steps_date.isoformat()}."
        )
    if days_with_steps < num_days:
        steps_coverage_notes.append(
            f"Steps data available for {days_with_steps} of {num_days} day(s) in month."
        )
    steps_coverage_reliability = (
        "unavailable"
        if days_with_steps == 0
        else ("medium" if steps_coverage < 1.0 else "high")
    )

    # RHR trend: first vs second half of month
    rhr_by_day: list[float] = []
    for d in range(1, num_days + 1):
        day_date = date(year, month, d)
        ds = datetime.combine(day_date, datetime.min.time())
        de = datetime.combine(day_date, datetime.max.time())
        rhr = _rhr_for_day(month_hr, month_sleep, ds, de)
        if rhr is not None:
            rhr_by_day.append(rhr)

    mean_first, mean_second, rhr_trend_bpm, rhr_trend_flag = _compute_rhr_trend(
        rhr_by_day
    )

    metrics_out.append(
        metric(
            metric_name="cardiovascular_fitness_monthly",
            description="RHR trend: first vs second half of month (bpm difference).",
            purpose="Improving RHR (negative trend) indicates better fitness; rising may signal overtraining or stress.",
            variables_used=[
                "heart_rate.timestamp",
                "heart_rate.bpm",
                "sleep_sessions.start_time",
                "sleep_sessions.end_time",
            ],
            time_window=tw,
            n_instances=len(rhr_by_day),
            stats={
                "value": rhr_trend_bpm,
                "mean_rhr_first_half": mean_first,
                "mean_rhr_second_half": mean_second,
                "flag_rising_rhr": rhr_trend_flag,
            },
            extra={"units": "bpm", "threshold_bpm": RHR_TREND_THRESHOLD_BPM},
        )
    )

    # Ghost days with device-worn classification
    ghost_days = 0
    steps_per_day: dict[str, float] = {}
    steps_per_day_classified: dict[str, dict] = {}
    for d in range(1, num_days + 1):
        day_date = date(year, month, d)
        ds = datetime.combine(day_date, datetime.min.time())
        de = datetime.combine(day_date, datetime.max.time())
        s = _steps_for_day(month_steps, ds, de)
        steps_per_day[day_date.isoformat()] = s
        day_hr_count = (
            int(((month_hr["timestamp"] >= ds) & (month_hr["timestamp"] <= de)).sum())
            if not month_hr.empty
            else 0
        )
        device_worn = day_hr_count >= _DEVICE_WORN_HR_MIN
        steps_per_day_classified[day_date.isoformat()] = {
            "steps": s,
            "device_worn": device_worn,
            "hr_readings": day_hr_count,
        }
        if s < GHOST_DAY_STEPS_THRESHOLD:
            ghost_days += 1

    ghost_days_device_not_worn = sum(
        1
        for v in steps_per_day_classified.values()
        if v["steps"] < GHOST_DAY_STEPS_THRESHOLD and not v["device_worn"]
    )

    metrics_out.append(
        metric(
            metric_name="ghost_days_monthly",
            description=(
                "Days with very low activity (< 500 steps). "
                "Classified into device_not_worn vs sedentary based on HR readings."
            ),
            purpose="Identify illness, recovery, or sedentary patterns. Distinguish from device not worn.",
            variables_used=["steps.timestamp", "steps.steps", "heart_rate.timestamp"],
            time_window=tw,
            n_instances=num_days,
            stats={
                "value": ghost_days,
                "ghost_days_device_not_worn": ghost_days_device_not_worn,
                "ghost_days_sedentary": ghost_days - ghost_days_device_not_worn,
                "flag_has_ghost_days": ghost_days > 0,
            },
            extra={
                "threshold_steps": GHOST_DAY_STEPS_THRESHOLD,
                "device_worn_hr_min": _DEVICE_WORN_HR_MIN,
                "note": "See steps_per_day_classified in weekly report for per-day breakdown.",
            },
            data_quality=build_data_quality(
                coverage=steps_coverage,
                reliability=steps_coverage_reliability,
                notes=[
                    *(
                        [
                            f"{ghost_days_device_not_worn} ghost day(s) classified as device-not-worn."
                        ]
                        if ghost_days_device_not_worn > 0
                        else []
                    ),
                    *steps_coverage_notes,
                ],
            ),
        )
    )

    # Exercise consistency
    exercise_dates: set[date] = set()
    if not month_exercise.empty:
        for _, row in month_exercise.iterrows():
            exercise_dates.add(row["start_time"].date())
    days_with_exercise = len(exercise_dates)

    metrics_out.append(
        metric(
            metric_name="exercise_consistency_monthly",
            description="Number of days with at least one exercise session.",
            purpose="Track habit sustainability.",
            variables_used=[
                "exercise_sessions.start_time",
                "exercise_sessions.end_time",
            ],
            time_window=tw,
            n_instances=num_days,
            stats={
                "days_with_exercise": days_with_exercise,
                "exercise_sessions_total": len(month_exercise),
            },
            extra={"units": "days"},
        )
    )

    # Sleep: nights without data, avg hours
    nights_with_sleep = 0
    total_sleep_hours = 0.0
    for d in range(1, num_days + 1):
        day_date = date(year, month, d)
        ds = datetime.combine(day_date, datetime.min.time())
        de = datetime.combine(day_date, datetime.max.time())
        hrs = _sleep_hours_for_day(month_sleep, ds, de)
        if hrs is not None:
            nights_with_sleep += 1
            total_sleep_hours += hrs
    avg_sleep_hours = (
        total_sleep_hours / nights_with_sleep if nights_with_sleep > 0 else None
    )
    nights_without_data = num_days - nights_with_sleep

    metrics_out.append(
        metric(
            metric_name="sleep_consistency_monthly",
            description="Sleep coverage and average duration.",
            purpose="Nights without data may indicate device not worn or insomnia.",
            variables_used=["sleep_sessions.start_time", "sleep_sessions.end_time"],
            time_window=tw,
            n_instances=nights_with_sleep,
            stats={
                "nights_with_data": nights_with_sleep,
                "nights_without_data": nights_without_data,
                "avg_sleep_hours": avg_sleep_hours,
                "flag_missing_nights": nights_without_data > 3,
            },
            extra={"units": "hours"},
        )
    )

    # SpO2 anomalies
    if not month_spo2.empty and "spo2_pct" in month_spo2.columns:
        spo2_vals = month_spo2["spo2_pct"].dropna()
        spo2_min = float(spo2_vals.min()) if not spo2_vals.empty else None
        spo2_mean = float(spo2_vals.mean()) if not spo2_vals.empty else None
        low_count = int((spo2_vals < SPO2_LOW_THRESHOLD_PCT).sum())
        metrics_out.append(
            metric(
                metric_name="spo2_anomalies_monthly",
                description="SpO2 range and count of low readings (< 94%).",
                purpose="Persistently low SpO2 may indicate apnea or respiratory issues.",
                variables_used=[
                    "oxygen_saturation.timestamp",
                    "oxygen_saturation.spo2_pct",
                ],
                time_window=tw,
                n_instances=len(spo2_vals),
                stats={
                    "min_pct": spo2_min,
                    "mean_pct": spo2_mean,
                    "readings_below_threshold": low_count,
                    "flag_low_spo2": low_count > 0,
                },
                extra={"low_threshold_pct": SPO2_LOW_THRESHOLD_PCT},
            )
        )

    # Activity sustainability: total steps, trend
    total_steps = sum(steps_per_day.values())
    if len(steps_per_day) >= 2:
        sorted_days = sorted(steps_per_day.keys())
        half = len(sorted_days) // 2
        first_half_steps = sum(steps_per_day[d] for d in sorted_days[:half])
        second_half_steps = sum(steps_per_day[d] for d in sorted_days[half:])
        step_trend = second_half_steps - first_half_steps
    else:
        step_trend = None

    metrics_out.append(
        metric(
            metric_name="activity_sustainability_monthly",
            description="Total steps and trend (first vs second half of month).",
            purpose="Declining steps month-over-month may signal burnout or lifestyle change.",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=tw,
            n_instances=num_days,
            stats={
                "total_steps": total_steps,
                "step_trend_second_minus_first_half": step_trend,
            },
            extra={"units": "steps"},
            data_quality=build_data_quality(
                coverage=steps_coverage,
                reliability=steps_coverage_reliability,
                notes=steps_coverage_notes,
            ),
        )
    )

    # Caloric efficiency: distance / total_kcal (active_kcal not available in current export)
    if not month_calories.empty and not month_distance.empty:
        total_kcal_month = float(month_calories["total_kcal"].sum())
        active_kcal_month = float(month_calories["active_kcal"].sum())
        active_measured = (
            bool(month_calories["active_kcal_measured"].any())
            if "active_kcal_measured" in month_calories.columns
            else False
        )
        distance_m = (
            float(month_distance["distance_m"].sum())
            if "distance_m" in month_distance.columns
            else 0.0
        )
        distance_km = (
            float(month_distance["distance_km"].sum())
            if "distance_km" in month_distance.columns
            else (distance_m / 1000.0 if distance_m > 0 else 0.0)
        )
        # Use total_kcal as the reliable denominator; active_kcal is only used if measured
        efficiency = (
            distance_km / total_kcal_month
            if total_kcal_month > 0 and distance_km > 0
            else None
        )
        metrics_out.append(
            metric(
                metric_name="caloric_efficiency_monthly",
                description=(
                    "Distance per calorie (km / total_kcal). "
                    "Uses total_kcal because active_kcal is not measured in the current export. "
                    "Efficiency includes basal cost, so values differ from active-only formulas."
                ),
                purpose=(
                    "Track locomotion efficiency over time. "
                    "Improving ratio may indicate better fitness. "
                    "Compare only against other months with same formula."
                ),
                variables_used=[
                    "calories.total_kcal",
                    "distance.distance_km",
                ],
                time_window=tw,
                n_instances=len(month_calories),
                stats={
                    "value": efficiency,
                    "total_kcal": total_kcal_month,
                    "active_kcal_measured": active_measured,
                    "total_distance_km": distance_km,
                    **({"active_kcal": active_kcal_month} if active_measured else {}),
                },
                extra={"units": "km/total_kcal", "kcal_source": "total_kcal"},
                data_quality=build_data_quality(
                    coverage=1.0 if efficiency is not None else 0.0,
                    reliability="medium",
                    notes=[
                        "Efficiency uses total_kcal (active + basal), not active_kcal alone. "
                        "Cannot compare with standard km/active_kcal benchmarks."
                    ],
                ),
            )
        )

    # Best exercise hour: hour with most exercise activity
    if not month_exercise.empty:
        month_exercise_copy = month_exercise.copy()
        month_exercise_copy["hour"] = month_exercise_copy["start_time"].dt.hour
        hour_counts = month_exercise_copy.groupby("hour", as_index=True).size()
        best_hour = int(hour_counts.idxmax()) if not hour_counts.empty else None
        best_hour_sessions = int(hour_counts.max()) if not hour_counts.empty else None
    else:
        best_hour = None
        best_hour_sessions = None

    metrics_out.append(
        metric(
            metric_name="best_exercise_hour_monthly",
            description="Hour of day with most exercise sessions.",
            purpose="Optimize timing based on historical adherence.",
            variables_used=["exercise_sessions.start_time"],
            time_window=tw,
            n_instances=len(month_exercise),
            stats={
                "best_hour": best_hour,
                "sessions_at_best_hour": best_hour_sessions,
            },
            extra={"units": "hour (0-23)"},
        )
    )

    return metrics_out


path: pkg/skill_health/analysis/sleep.py
content: """
Sleep analysis: estimate sleep window from a recent window and compute sleep metrics.
"""

from __future__ import annotations

from datetime import datetime, timedelta
from typing import Any

import pandas as pd

from skill_health.models import HealthDataBundle
from skill_health.report import TimeWindow, build_data_quality, datetime_to_iso, metric

SLEEP_GAP_MIN_HOURS = 3
NAP_MIN_MINUTES = 15
NAP_MAX_MINUTES = 120
HR_SPIKE_THRESHOLD_BPM = 20


def _has_sleep_window(sleep_start: datetime | None, sleep_end: datetime | None) -> bool:
    """Return True when both sleep_start and sleep_end are present."""
    return sleep_start is not None and sleep_end is not None


def _slice_window(
    df: pd.DataFrame, start: datetime, end: datetime, timestamp_column: str
) -> pd.DataFrame:
    """Return rows where timestamp_column is within [start, end]."""
    if df.empty:
        return df
    return df[(df[timestamp_column] >= start) & (df[timestamp_column] <= end)]


def _estimate_sleep_window_from_steps(
    steps_df: pd.DataFrame, window_start: datetime, window_end: datetime
) -> tuple[datetime, datetime] | None:
    """
    Estimate sleep window as the longest inactivity gap between step timestamps.
    Returns (sleep_start, sleep_end) or None when no suitable gap is found.
    """
    if steps_df.empty:
        return None

    steps_in_window = steps_df[
        (steps_df["timestamp"] >= window_start) & (steps_df["timestamp"] <= window_end)
    ].sort_values("timestamp")
    if steps_in_window.empty:
        return None

    timestamps = steps_in_window["timestamp"].tolist()
    if len(timestamps) < 2:
        return None
    best_gap = timedelta(0)
    best_window: tuple[datetime, datetime] | None = None
    for current, nxt in zip(timestamps, timestamps[1:], strict=False):
        gap = nxt - current
        if gap > best_gap:
            best_gap = gap
            best_window = (current, nxt)

    if best_window is None or best_gap < timedelta(hours=SLEEP_GAP_MIN_HOURS):
        return None
    return best_window


def _sleep_sessions_in_window(
    sleep_df: pd.DataFrame, window_start: datetime, window_end: datetime
) -> pd.DataFrame:
    """Return sleep sessions that overlap the analysis window."""
    if sleep_df.empty:
        return sleep_df
    mask = (sleep_df["start_time"] <= window_end) & (
        sleep_df["end_time"] >= window_start
    )
    return sleep_df[mask]


def infer_now_from_bundle(bundle: HealthDataBundle) -> datetime:
    """
    Infer the reference timestamp 'now' from the latest data point in the bundle.
    Used to anchor the last-24h sleep window to real data rather than wall clock.
    """
    candidates: list[datetime] = []
    if not bundle.steps.empty:
        ts = bundle.steps["timestamp"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.heart_rate.empty:
        ts = bundle.heart_rate["timestamp"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.calories.empty:
        ts = bundle.calories["end_time"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.sleep_sessions.empty:
        ts = bundle.sleep_sessions["end_time"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not bundle.oxygen_saturation.empty:
        ts = bundle.oxygen_saturation["timestamp"].max()
        if pd.notna(ts):
            candidates.append(
                ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts
            )
    if not candidates:
        raise ValueError("Cannot infer 'now' from empty health data bundle")
    return max(candidates)


def build_sleep_report(
    bundle: HealthDataBundle, now: datetime, window_hours: int = 30
) -> list[dict[str, Any]]:
    """
    Build sleep-focused metrics using a recent analysis window.

    Args:
        bundle: Loaded and deduplicated health data.
        now: End timestamp for the analysis window.
        window_hours: Window size in hours (default 30).

    Returns:
        List of metric dicts suitable for JSON output.
    """
    window_start = now - timedelta(hours=window_hours)
    window_label = f"last_{window_hours}h"
    time_window = TimeWindow(
        start=datetime_to_iso(window_start),
        end=datetime_to_iso(now),
        resolution=f"{window_hours}h",
    )

    sleep_sessions = _sleep_sessions_in_window(bundle.sleep_sessions, window_start, now)
    sleep_start: datetime | None = None
    sleep_end: datetime | None = None
    sleep_duration_hours: float | None = None
    sleep_source = "unavailable"
    sleep_sessions_count = int(len(sleep_sessions))

    if not sleep_sessions.empty:
        sleep_sessions = sleep_sessions.copy()
        sleep_sessions["duration_seconds"] = (
            sleep_sessions["end_time"] - sleep_sessions["start_time"]
        ).dt.total_seconds()
        today_sessions = sleep_sessions[
            sleep_sessions["end_time"].dt.date == now.date()
        ]
        pick_from = today_sessions if not today_sessions.empty else sleep_sessions
        main_idx = pick_from["duration_seconds"].idxmax()
        sleep_start = pick_from.loc[main_idx, "start_time"]
        sleep_end = pick_from.loc[main_idx, "end_time"]
        if "duration_hours" in sleep_sessions.columns:
            sleep_duration_hours = float(
                pick_from.loc[main_idx, "duration_seconds"] / 3600
            )
        else:
            sleep_duration_hours = float(
                (sleep_end - sleep_start).total_seconds() / 3600
            )
        sleep_source = "sleep_sessions"
    else:
        estimated = _estimate_sleep_window_from_steps(bundle.steps, window_start, now)
        if estimated is not None:
            sleep_start, sleep_end = estimated
            sleep_duration_hours = float(
                (sleep_end - sleep_start).total_seconds() / 3600
            )
            sleep_source = "estimated"

    has_sleep_window = _has_sleep_window(sleep_start, sleep_end)
    base_notes: list[str] = []
    if sleep_source == "estimated":
        base_notes.append(
            "No sleep session data; sleep window estimated from inactivity in the "
            f"last {window_hours}h."
        )
    elif sleep_source == "unavailable":
        base_notes.append(
            f"No sleep window could be inferred from last {window_hours}h data."
        )

    base_quality = build_data_quality(
        coverage=1.0 if has_sleep_window else 0.0,
        reliability=(
            "high"
            if sleep_source == "sleep_sessions"
            else ("low" if sleep_source == "estimated" else "unavailable")
        ),
        notes=base_notes,
    )

    metrics_out: list[dict[str, Any]] = []

    if has_sleep_window:
        sleep_start_iso = datetime_to_iso(sleep_start)
        sleep_end_iso = datetime_to_iso(sleep_end)
    else:
        sleep_start_iso = None
        sleep_end_iso = None
    metrics_out.append(
        metric(
            metric_name=f"sleep_window_{window_label}",
            description=f"Estimated sleep window within the last {window_hours} hours.",
            purpose="Anchor sleep-related metrics to a specific time window.",
            variables_used=[
                "sleep_sessions.start_time",
                "sleep_sessions.end_time",
                "steps.timestamp",
            ],
            time_window=time_window,
            n_instances=sleep_sessions_count,
            stats={
                "start": sleep_start_iso,
                "end": sleep_end_iso,
                "duration_hours": sleep_duration_hours,
                "source": sleep_source,
            },
            data_quality=base_quality,
        )
    )

    # Naps (sleep_sessions only)
    naps: list[dict[str, Any]] = []
    if sleep_source == "sleep_sessions" and not sleep_sessions.empty:
        main_start = sleep_start
        main_end = sleep_end
        naps_source = sleep_sessions[sleep_sessions["start_time"].dt.date == now.date()]
        for _, row in naps_source.iterrows():
            start = row["start_time"]
            end = row["end_time"]
            if start is None or end is None:
                continue
            if not (end <= main_start or start >= main_end):
                continue
            duration_min = float((end - start).total_seconds() / 60)
            if NAP_MIN_MINUTES <= duration_min <= NAP_MAX_MINUTES:
                naps.append(
                    {
                        "start": datetime_to_iso(start),
                        "end": datetime_to_iso(end),
                        "duration_minutes": duration_min,
                    }
                )
        naps_quality = build_data_quality(
            coverage=1.0,
            reliability="high",
            notes=[],
        )
    else:
        naps_quality = build_data_quality(
            coverage=0.0,
            reliability="unavailable",
            notes=base_notes + ["Naps require sleep session data."],
        )

    metrics_out.append(
        metric(
            metric_name=f"naps_detected_{window_label}",
            description="Detected naps from sleep sessions that do not overlap main sleep window.",
            purpose="Identify short daytime sleep episodes.",
            variables_used=["sleep_sessions.start_time", "sleep_sessions.end_time"],
            time_window=time_window,
            n_instances=int(len(sleep_sessions)),
            stats={
                "count": len(naps),
                "total_minutes": (
                    float(sum(n["duration_minutes"] for n in naps)) if naps else 0.0
                ),
                "duration_range_minutes": [NAP_MIN_MINUTES, NAP_MAX_MINUTES],
            },
            data_quality=naps_quality,
        )
    )

    metrics_out.append(
        metric(
            metric_name=f"sleep_duration_{window_label}",
            description="Total sleep duration within the detected sleep window.",
            purpose="Track total sleep time for recovery and health.",
            variables_used=["sleep_sessions.duration_hours", "steps.timestamp"],
            time_window=time_window,
            n_instances=sleep_sessions_count,
            stats={"value": sleep_duration_hours, "source": sleep_source},
            extra={"units": "hours"},
            data_quality=base_quality,
        )
    )

    # Sleep midpoint
    if has_sleep_window:
        midpoint = sleep_start + (sleep_end - sleep_start) / 2
        midpoint_iso: str | None = datetime_to_iso(midpoint)
    else:
        midpoint_iso = None
    metrics_out.append(
        metric(
            metric_name=f"sleep_midpoint_{window_label}",
            description="Midpoint time of the detected sleep window.",
            purpose="Track circadian timing and consistency.",
            variables_used=[
                "sleep_sessions.start_time",
                "sleep_sessions.end_time",
                "steps.timestamp",
            ],
            time_window=time_window,
            n_instances=sleep_sessions_count,
            stats={"value": midpoint_iso},
            data_quality=base_quality,
        )
    )

    # Sleep latency from last activity
    latency_minutes: float | None = None
    if has_sleep_window:
        steps_before_sleep = bundle.steps[bundle.steps["timestamp"] <= sleep_start]
        if not steps_before_sleep.empty:
            last_activity = steps_before_sleep["timestamp"].max()
            latency_minutes = float((sleep_start - last_activity).total_seconds() / 60)
    latency_quality = (
        base_quality
        if latency_minutes is not None
        else build_data_quality(
            coverage=0.0,
            reliability="unavailable",
            notes=base_notes
            + ["No steps found before sleep start to estimate latency."],
        )
    )
    metrics_out.append(
        metric(
            metric_name=f"sleep_latency_estimated_{window_label}",
            description="Estimated sleep latency: time from last activity to sleep start.",
            purpose="Long latency may indicate difficulty falling asleep.",
            variables_used=["steps.timestamp", "sleep_sessions.start_time"],
            time_window=time_window,
            n_instances=sleep_sessions_count,
            stats={"value": latency_minutes},
            extra={"units": "minutes"},
            data_quality=latency_quality,
        )
    )

    # HR metrics during sleep window
    hr_metrics_notes = base_notes.copy()
    hr_sleep = pd.DataFrame()
    if has_sleep_window:
        hr_sleep = _slice_window(
            bundle.heart_rate, sleep_start, sleep_end, "timestamp"
        ).sort_values("timestamp")

    if not hr_sleep.empty:
        hr_min = float(hr_sleep["bpm"].min())
        hr_max = float(hr_sleep["bpm"].max())
        hr_range = hr_max - hr_min
        if len(hr_sleep) > 1:
            hr_diff = hr_sleep["bpm"].diff().abs()
            hr_spikes = int((hr_diff > HR_SPIKE_THRESHOLD_BPM).sum())
        else:
            hr_spikes = 0
        hr_quality = build_data_quality(
            coverage=1.0,
            reliability="high" if sleep_source == "sleep_sessions" else "medium",
            notes=hr_metrics_notes,
        )
    else:
        hr_min = None
        hr_max = None
        hr_range = None
        hr_spikes = 0
        hr_quality = build_data_quality(
            coverage=0.0,
            reliability="unavailable",
            notes=hr_metrics_notes + ["No heart rate samples during sleep window."],
        )

    metrics_out.append(
        metric(
            metric_name=f"resting_hr_during_sleep_{window_label}",
            description="Minimum HR during the sleep window (resting HR proxy).",
            purpose="Track recovery and potential stress signals.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=time_window,
            n_instances=int(len(hr_sleep)),
            stats={"value": hr_min, "hr_min": hr_min, "hr_max": hr_max},
            extra={"units": "bpm"},
            data_quality=hr_quality,
        )
    )

    metrics_out.append(
        metric(
            metric_name=f"hr_range_during_sleep_{window_label}",
            description=(
                "HR range during sleep: max(HR_sleep) - min(HR_sleep). "
                "Not clinical HRV."
            ),
            purpose="Proxy for autonomic variability during sleep.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=time_window,
            n_instances=int(len(hr_sleep)),
            stats={
                "value": hr_range,
                "hr_min_during_sleep": hr_min,
                "hr_max_during_sleep": hr_max,
            },
            extra={"units": "bpm"},
            data_quality=hr_quality,
        )
    )

    metrics_out.append(
        metric(
            metric_name=f"sleep_fragmentation_hr_spikes_{window_label}",
            description="Sleep fragmentation proxy: HR spikes > 20 bpm during sleep.",
            purpose="Detect possible micro-awakenings or restless sleep.",
            variables_used=["heart_rate.timestamp", "heart_rate.bpm"],
            time_window=time_window,
            n_instances=int(len(hr_sleep)),
            stats={
                "fragmentation_events": hr_spikes,
                "hr_spike_threshold_bpm": HR_SPIKE_THRESHOLD_BPM,
            },
            data_quality=hr_quality,
        )
    )

    # SpO2 metrics during sleep window
    spo2_sleep = pd.DataFrame()
    if has_sleep_window:
        spo2_sleep = _slice_window(
            bundle.oxygen_saturation, sleep_start, sleep_end, "timestamp"
        )
    if not spo2_sleep.empty:
        spo2_vals = spo2_sleep["spo2_pct"].dropna()
        spo2_min = float(spo2_vals.min()) if not spo2_vals.empty else None
        spo2_mean = float(spo2_vals.mean()) if not spo2_vals.empty else None
        spo2_quality = build_data_quality(
            coverage=1.0,
            reliability="medium" if sleep_source == "estimated" else "high",
            notes=base_notes,
        )
    else:
        spo2_min = None
        spo2_mean = None
        spo2_quality = build_data_quality(
            coverage=0.0,
            reliability="unavailable",
            notes=base_notes + ["No SpO2 samples during sleep window."],
        )
    metrics_out.append(
        metric(
            metric_name=f"spo2_sleep_summary_{window_label}",
            description="SpO2 summary within the sleep window.",
            purpose="Identify potential nocturnal desaturation.",
            variables_used=[
                "oxygen_saturation.timestamp",
                "oxygen_saturation.spo2_pct",
            ],
            time_window=time_window,
            n_instances=int(len(spo2_sleep)),
            stats={"min_pct": spo2_min, "mean_pct": spo2_mean},
            extra={"units": "%"},
            data_quality=spo2_quality,
        )
    )

    # Awakenings proxy from steps within sleep window
    steps_in_sleep = pd.DataFrame()
    if has_sleep_window:
        steps_in_sleep = _slice_window(
            bundle.steps, sleep_start, sleep_end, "timestamp"
        )
    awakenings = int(len(steps_in_sleep)) if not steps_in_sleep.empty else 0
    awakenings_quality = (
        base_quality
        if has_sleep_window
        else build_data_quality(
            coverage=0.0,
            reliability="unavailable",
            notes=base_notes,
        )
    )
    metrics_out.append(
        metric(
            metric_name=f"sleep_awakenings_proxy_{window_label}",
            description="Proxy awakenings count from step events during sleep window.",
            purpose="Flag possible sleep interruptions (movement-based proxy).",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=time_window,
            n_instances=int(len(steps_in_sleep)),
            stats={
                "value": awakenings,
                "steps_events_during_sleep": awakenings,
                "hr_spike_events": hr_spikes,
            },
            data_quality=awakenings_quality,
        )
    )

    return metrics_out


path: pkg/skill_health/analysis/weekly.py
content: """
Weekly analysis: filter bundle by 7-day window, compute trends, ghost days, exercise consistency.
"""

from __future__ import annotations

from datetime import date, datetime, timedelta
from typing import Any

import pandas as pd

from skill_health.models import HealthDataBundle
from skill_health.report import TimeWindow, build_data_quality, datetime_to_iso, metric

GHOST_DAY_STEPS_THRESHOLD = 500
# Days with fewer than this many HR readings are considered device-not-worn
_DEVICE_WORN_HR_MIN = 5
SLEEP_TARGET_HOURS = 8.0
RHR_TREND_THRESHOLD_BPM = 3.0


def _compute_rhr_trend(
    rhr_by_day: list[float],
) -> tuple[float | None, float | None, float | None, bool]:
    """Return mean_first, mean_second, trend_bpm, and rising flag."""
    if len(rhr_by_day) < 2:
        mean_first = float(rhr_by_day[0]) if rhr_by_day else None
        return mean_first, None, None, False

    first_half = rhr_by_day[: max(1, len(rhr_by_day) // 2)]
    second_half = rhr_by_day[len(rhr_by_day) // 2 :]
    mean_first = sum(first_half) / len(first_half)
    mean_second = sum(second_half) / len(second_half)
    trend_bpm = mean_second - mean_first
    return mean_first, mean_second, trend_bpm, trend_bpm >= RHR_TREND_THRESHOLD_BPM


def _compute_weekend_ratio(
    weekend_steps: list[float], weekday_steps: list[float]
) -> tuple[float | None, float | None, float | None]:
    """Return avg_weekend, avg_weekday, and weekend/weekday ratio."""
    avg_weekend = sum(weekend_steps) / len(weekend_steps) if weekend_steps else None
    avg_weekday = sum(weekday_steps) / len(weekday_steps) if weekday_steps else None
    if avg_weekend is None or avg_weekday is None or avg_weekday <= 0:
        return avg_weekend, avg_weekday, None
    return avg_weekend, avg_weekday, avg_weekend / avg_weekday


def _rhr_for_day(
    hr_df: pd.DataFrame, sleep_df: pd.DataFrame, day_start: datetime, day_end: datetime
) -> float | None:
    """Estimate RHR for a single day: min HR during sleep, else min HR of day."""
    if hr_df.empty or "timestamp" not in hr_df.columns:
        return None
    day_hr = hr_df[(hr_df["timestamp"] >= day_start) & (hr_df["timestamp"] <= day_end)]
    if day_hr.empty:
        return None
    if sleep_df.empty or "start_time" not in sleep_df.columns:
        return float(day_hr["bpm"].min())
    day_sleep = sleep_df[
        (
            (
                (sleep_df["start_time"] >= day_start)
                & (sleep_df["start_time"] <= day_end)
            )
            | ((sleep_df["end_time"] >= day_start) & (sleep_df["end_time"] <= day_end))
            | (
                (sleep_df["start_time"] <= day_start)
                & (sleep_df["end_time"] >= day_end)
            )
        )
    ]
    if not day_sleep.empty:
        hr_during_sleep: list[float] = []
        for _, row in day_sleep.iterrows():
            mask = (day_hr["timestamp"] >= row["start_time"]) & (
                day_hr["timestamp"] <= row["end_time"]
            )
            hr_during_sleep.extend(day_hr.loc[mask, "bpm"].tolist())
        if hr_during_sleep:
            return float(min(hr_during_sleep))
    return float(day_hr["bpm"].min())


def _steps_for_day(
    steps_df: pd.DataFrame, day_start: datetime, day_end: datetime
) -> float:
    """Total steps for a single day."""
    if steps_df.empty or "timestamp" not in steps_df.columns:
        return 0.0
    day_steps = steps_df[
        (steps_df["timestamp"] >= day_start) & (steps_df["timestamp"] <= day_end)
    ]
    return float(day_steps["steps"].sum()) if not day_steps.empty else 0.0


def _sleep_hours_for_day(
    sleep_df: pd.DataFrame, day_start: datetime, day_end: datetime
) -> float | None:
    """Total sleep duration (hours) overlapping the day."""
    if sleep_df.empty or "start_time" not in sleep_df.columns:
        return None
    day_sleep = sleep_df[
        (
            (
                (sleep_df["start_time"] >= day_start)
                & (sleep_df["start_time"] <= day_end)
            )
            | ((sleep_df["end_time"] >= day_start) & (sleep_df["end_time"] <= day_end))
            | (
                (sleep_df["start_time"] <= day_start)
                & (sleep_df["end_time"] >= day_end)
            )
        )
    ]
    if day_sleep.empty:
        return None
    total_minutes = 0.0
    for _, row in day_sleep.iterrows():
        overlap_start = max(row["start_time"], day_start)
        overlap_end = min(row["end_time"], day_end)
        total_minutes += (overlap_end - overlap_start).total_seconds() / 60
    return total_minutes / 60.0 if total_minutes > 0 else None


def infer_week_end_date_from_bundle(bundle: HealthDataBundle) -> date:
    """
    Infer the most recent date with data (end of week window).
    Prefers steps max when available, so the analyzed week has step data (avoids ghost days
    when steps are in a different range than sleep/exercise).
    """
    if not bundle.steps.empty:
        return bundle.steps["timestamp"].max().date()
    candidates: list[date] = []
    if not bundle.heart_rate.empty:
        candidates.append(bundle.heart_rate["timestamp"].max().date())
    if not bundle.calories.empty:
        candidates.append(bundle.calories["end_time"].max().date())
    if not bundle.sleep_sessions.empty:
        candidates.append(bundle.sleep_sessions["end_time"].max().date())
    if not candidates:
        return date.today()
    return max(candidates)


def build_weekly_report(
    bundle: HealthDataBundle, week_end_date: date
) -> list[dict[str, Any]]:
    """
    Build metric list for weekly analysis. Filters bundle by 7-day window ending on week_end_date.
    """
    day_end_dt = datetime.combine(week_end_date, datetime.max.time())
    day_start_dt = datetime.combine(
        week_end_date - timedelta(days=6), datetime.min.time()
    )
    tw = TimeWindow(
        start=datetime_to_iso(day_start_dt),
        end=datetime_to_iso(day_end_dt),
        resolution="7d",
    )

    week_steps = (
        bundle.steps[
            (bundle.steps["timestamp"] >= day_start_dt)
            & (bundle.steps["timestamp"] <= day_end_dt)
        ]
        if not bundle.steps.empty
        else pd.DataFrame()
    )
    week_hr = (
        bundle.heart_rate[
            (bundle.heart_rate["timestamp"] >= day_start_dt)
            & (bundle.heart_rate["timestamp"] <= day_end_dt)
        ]
        if not bundle.heart_rate.empty
        else pd.DataFrame()
    )
    week_sleep = (
        bundle.sleep_sessions[
            (
                (
                    (bundle.sleep_sessions["start_time"] >= day_start_dt)
                    & (bundle.sleep_sessions["start_time"] <= day_end_dt)
                )
                | (
                    (bundle.sleep_sessions["end_time"] >= day_start_dt)
                    & (bundle.sleep_sessions["end_time"] <= day_end_dt)
                )
                | (
                    (bundle.sleep_sessions["start_time"] <= day_start_dt)
                    & (bundle.sleep_sessions["end_time"] >= day_end_dt)
                )
            )
        ]
        if not bundle.sleep_sessions.empty
        else pd.DataFrame()
    )
    week_exercise = (
        bundle.exercise_sessions[
            (bundle.exercise_sessions["start_time"] >= day_start_dt)
            & (bundle.exercise_sessions["start_time"] <= day_end_dt)
        ]
        if not bundle.exercise_sessions.empty
        else pd.DataFrame()
    )
    week_spo2 = (
        bundle.oxygen_saturation[
            (bundle.oxygen_saturation["timestamp"] >= day_start_dt)
            & (bundle.oxygen_saturation["timestamp"] <= day_end_dt)
        ]
        if not bundle.oxygen_saturation.empty
        else pd.DataFrame()
    )

    metrics_out: list[dict[str, Any]] = []

    # RHR trending: first 3 days vs last 3 days
    rhr_by_day: list[float] = []
    for i in range(7):
        d = week_end_date - timedelta(days=6 - i)
        ds = datetime.combine(d, datetime.min.time())
        de = datetime.combine(d, datetime.max.time())
        rhr = _rhr_for_day(week_hr, week_sleep, ds, de)
        if rhr is not None:
            rhr_by_day.append(rhr)

    mean_first, mean_second, rhr_trend_bpm, rhr_trend_flag = _compute_rhr_trend(
        rhr_by_day
    )

    metrics_out.append(
        metric(
            metric_name="rhr_trending_weekly",
            description="RHR trend: first vs second half of week (bpm difference).",
            purpose="Rising RHR >3-5 bpm may signal overtraining, illness, dehydration, stress.",
            variables_used=[
                "heart_rate.timestamp",
                "heart_rate.bpm",
                "sleep_sessions.start_time",
                "sleep_sessions.end_time",
            ],
            time_window=tw,
            n_instances=len(rhr_by_day),
            stats={
                "value": rhr_trend_bpm,
                "mean_rhr_first_half": mean_first,
                "mean_rhr_second_half": mean_second,
                "rhr_by_day": rhr_by_day if rhr_by_day else None,
                "flag_rising_rhr": rhr_trend_flag,
            },
            extra={"units": "bpm", "threshold_bpm": RHR_TREND_THRESHOLD_BPM},
        )
    )

    # Ghost days: days with almost 0 activity
    # Classify each ghost day: device_not_worn (< 5 HR readings) vs sedentary (has HR)
    ghost_days = 0
    steps_per_day: dict[str, float] = {}
    ghost_days_dates: list[str] = []
    ghost_days_device_not_worn_dates: list[str] = []
    for i in range(7):
        d = week_end_date - timedelta(days=6 - i)
        ds = datetime.combine(d, datetime.min.time())
        de = datetime.combine(d, datetime.max.time())
        s = _steps_for_day(week_steps, ds, de)
        steps_per_day[d.isoformat()] = s
        day_hr_count = (
            int(((week_hr["timestamp"] >= ds) & (week_hr["timestamp"] <= de)).sum())
            if not week_hr.empty
            else 0
        )
        device_worn = day_hr_count >= _DEVICE_WORN_HR_MIN
        if s < GHOST_DAY_STEPS_THRESHOLD:
            ghost_days += 1
            ghost_days_dates.append(d.isoformat())
            if not device_worn:
                ghost_days_device_not_worn_dates.append(d.isoformat())

    ghost_days_device_not_worn = len(ghost_days_device_not_worn_dates)

    metrics_out.append(
        metric(
            metric_name="ghost_days_weekly",
            description=(
                "Days with very low activity (< 500 steps). "
                "Classified into device_not_worn (< 5 HR readings) vs sedentary (has HR data)."
            ),
            purpose="Identify illness, recovery, or sedentary patterns. Distinguish from device not worn.",
            variables_used=["steps.timestamp", "steps.steps", "heart_rate.timestamp"],
            time_window=tw,
            n_instances=7,
            stats={
                "value": ghost_days,
                "ghost_days_device_not_worn": ghost_days_device_not_worn,
                "ghost_days_sedentary": ghost_days - ghost_days_device_not_worn,
                "ghost_days_dates": ghost_days_dates,
                "ghost_days_device_not_worn_dates": ghost_days_device_not_worn_dates,
                "flag_has_ghost_days": ghost_days > 0,
            },
            extra=None,
            data_quality=build_data_quality(
                coverage=1.0,
                reliability="high",
                notes=(
                    [
                        f"{ghost_days_device_not_worn} ghost day(s) appear to be device-not-worn, not true sedentarism."
                    ]
                    if ghost_days_device_not_worn > 0
                    else []
                ),
            ),
        )
    )

    # Exercise consistency: days with formal exercise
    exercise_dates: set[date] = set()
    if not week_exercise.empty:
        for _, row in week_exercise.iterrows():
            exercise_dates.add(row["start_time"].date())
    days_with_exercise = len(exercise_dates)

    metrics_out.append(
        metric(
            metric_name="exercise_consistency_weekly",
            description="Number of days with at least one exercise session.",
            purpose="Track consistency of formal exercise vs target.",
            variables_used=[
                "exercise_sessions.start_time",
                "exercise_sessions.end_time",
            ],
            time_window=tw,
            n_instances=7,
            stats={
                "days_with_exercise": days_with_exercise,
                "exercise_sessions_total": len(week_exercise),
                "target_days": 5,
            },
            extra={"units": "days"},
        )
    )

    # Weekend compensation: weekend vs weekday steps
    weekend_steps: list[float] = []
    weekday_steps: list[float] = []
    for d_str, s in steps_per_day.items():
        d = datetime.strptime(d_str, "%Y-%m-%d").date()
        wd = d.weekday()
        if wd >= 5:
            weekend_steps.append(s)
        else:
            weekday_steps.append(s)

    avg_weekend, avg_weekday, weekend_compensation_ratio = _compute_weekend_ratio(
        weekend_steps, weekday_steps
    )

    metrics_out.append(
        metric(
            metric_name="weekend_compensation_weekly",
            description="Weekend (Sat/Sun) vs weekday average steps ratio.",
            purpose="Detect compensation: walking more on weekends after sedentary week.",
            variables_used=["steps.timestamp", "steps.steps"],
            time_window=tw,
            n_instances=7,
            stats={
                "avg_weekend_steps": avg_weekend,
                "avg_weekday_steps": avg_weekday,
                "weekend_weekday_ratio": weekend_compensation_ratio,
                "flag_weekend_compensation": (
                    weekend_compensation_ratio is not None
                    and weekend_compensation_ratio > 1.3
                ),
            },
            extra={"units": "ratio"},
        )
    )

    # Sleep debt: sum of (target - actual) per night
    sleep_debt_hours = 0.0
    nights_with_sleep = 0
    for i in range(7):
        d = week_end_date - timedelta(days=6 - i)
        ds = datetime.combine(d, datetime.min.time())
        de = datetime.combine(d, datetime.max.time())
        hrs = _sleep_hours_for_day(week_sleep, ds, de)
        if hrs is not None:
            nights_with_sleep += 1
            debt = max(0.0, SLEEP_TARGET_HOURS - hrs)
            sleep_debt_hours += debt

    metrics_out.append(
        metric(
            metric_name="sleep_debt_weekly",
            description="Accumulated sleep debt: sum of (target 8h - actual) per night.",
            purpose="Track cumulative sleep shortfall.",
            variables_used=["sleep_sessions.start_time", "sleep_sessions.end_time"],
            time_window=tw,
            n_instances=nights_with_sleep,
            stats={
                "value": sleep_debt_hours,
                "target_hours_per_night": SLEEP_TARGET_HOURS,
                "nights_with_data": nights_with_sleep,
            },
            extra={"units": "hours"},
        )
    )

    # SpO2 variance within week
    if not week_spo2.empty and "spo2_pct" in week_spo2.columns:
        spo2_vals = week_spo2["spo2_pct"].dropna()
        spo2_std = float(spo2_vals.std()) if len(spo2_vals) > 1 else 0.0
        spo2_mean = float(spo2_vals.mean())
        spo2_min = float(spo2_vals.min())
        metrics_out.append(
            metric(
                metric_name="spo2_variance_weekly",
                description="SpO2 variance within the week.",
                purpose="Week-to-week drops may indicate respiratory issues or altitude.",
                variables_used=[
                    "oxygen_saturation.timestamp",
                    "oxygen_saturation.spo2_pct",
                ],
                time_window=tw,
                n_instances=len(spo2_vals),
                stats={
                    "mean_pct": spo2_mean,
                    "std_pct": spo2_std,
                    "min_pct": spo2_min,
                    "flag_low_spo2": spo2_min < 94,
                },
                extra={"units": "%", "low_threshold_pct": 94},
            )
        )

    # Sunday night syndrome: Sunday RHR vs weekly avg (if Sunday in window)
    sunday_date = None
    for i in range(7):
        d = week_end_date - timedelta(days=6 - i)
        if d.weekday() == 6:
            sunday_date = d
            break
    sunday_rhr = None
    if sunday_date is not None:
        ds = datetime.combine(sunday_date, datetime.min.time())
        de = datetime.combine(sunday_date, datetime.max.time())
        sunday_rhr = _rhr_for_day(week_hr, week_sleep, ds, de)

    weekly_avg_rhr = sum(rhr_by_day) / len(rhr_by_day) if rhr_by_day else None
    sunday_rhr_diff = (
        (sunday_rhr - weekly_avg_rhr)
        if sunday_rhr is not None and weekly_avg_rhr is not None
        else None
    )

    metrics_out.append(
        metric(
            metric_name="sunday_night_syndrome_weekly",
            description="Sunday RHR vs weekly average (anticipatory stress pattern).",
            purpose="Higher Sunday RHR may indicate stress about the week ahead.",
            variables_used=[
                "heart_rate.timestamp",
                "heart_rate.bpm",
                "sleep_sessions.start_time",
                "sleep_sessions.end_time",
            ],
            time_window=tw,
            n_instances=len(rhr_by_day),
            stats={
                "sunday_rhr": sunday_rhr,
                "weekly_avg_rhr": weekly_avg_rhr,
                "sunday_rhr_diff_vs_avg": sunday_rhr_diff,
                "flag_sunday_elevated": (
                    sunday_rhr_diff is not None and sunday_rhr_diff >= 3.0
                ),
            },
            extra={"units": "bpm"},
        )
    )

    return metrics_out


path: pkg/skill_health/load.py
content: """
Load and normalize health data from a directory of CSVs or a ZIP archive.

Performs deduplication by source: multiple devices (Samsung Health, Google Fit, etc.)
may report the same event; we group by time window and keep a single value to avoid
double-counting.
"""

from __future__ import annotations

import logging
import zipfile
from datetime import datetime
from pathlib import Path

import pandas as pd

from skill_health.models import HealthDataBundle

logger = logging.getLogger(__name__)

HEALTH_DATA_PREFIX = "health_data_"


def _parse_datetime(date_str: str, time_str: str) -> datetime:
    """Parse 'YYYY-MM-DD' and 'HH:MM:SS' strings into a single datetime."""
    combined = f"{date_str} {time_str}"
    try:
        return datetime.strptime(combined, "%Y-%m-%d %H:%M:%S")
    except ValueError as e:
        raise ValueError(f"Invalid date/time: {combined}") from e


def _load_csv(path: Path) -> pd.DataFrame | None:
    """Read a CSV file from disk. Returns None if missing or empty."""
    if not path.exists():
        return None
    try:
        raw_df = pd.read_csv(path)
    except Exception as e:
        logger.warning("Could not read %s: %s", path, e)
        return None
    if raw_df.empty:
        return None
    return raw_df


def _load_csv_from_zip(
    zip_file: zipfile.ZipFile, member_name: str
) -> pd.DataFrame | None:
    """Read a CSV member from an open ZIP. Returns None if missing or empty."""
    if member_name not in zip_file.namelist():
        return None
    try:
        with zip_file.open(member_name) as f:
            raw_df = pd.read_csv(f)
    except Exception as e:
        logger.warning("Could not read %s from ZIP: %s", member_name, e)
        return None
    if raw_df.empty:
        return None
    return raw_df


def _normalize_and_dedupe_steps(raw_df: pd.DataFrame | None) -> pd.DataFrame:
    """
    Normalize steps CSV to timestamp + steps; deduplicate by (Date, Time) taking max steps.
    Same reading is often reported by both android and Google Fit.
    """
    if raw_df is None or raw_df.empty:
        return pd.DataFrame(columns=["timestamp", "steps"])

    required_columns = {"Date", "Time", "Steps"}
    missing = required_columns - set(raw_df.columns)
    if missing:
        raise ValueError(f"steps.csv missing columns: {missing}")

    steps_df = raw_df.copy()
    steps_df["timestamp"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(steps_df["Date"], steps_df["Time"], strict=False)
    ]
    steps_df["steps"] = pd.to_numeric(steps_df["Steps"], errors="coerce")
    steps_df = steps_df.dropna(subset=["timestamp", "steps"])
    steps_df = steps_df[steps_df["steps"] >= 0]

    deduped = steps_df.groupby(["Date", "Time"], as_index=False)["steps"].max()
    deduped["timestamp"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(deduped["Date"], deduped["Time"], strict=False)
    ]
    return (
        deduped[["timestamp", "steps"]].sort_values("timestamp").reset_index(drop=True)
    )


def _normalize_and_dedupe_heart_rate(raw_df: pd.DataFrame | None) -> pd.DataFrame:
    """
    Normalize heart_rate CSV to timestamp + bpm; deduplicate by (Date, Time) taking mean.
    Filters to physiologically plausible range 30–250 bpm.
    """
    if raw_df is None or raw_df.empty:
        return pd.DataFrame(columns=["timestamp", "bpm"])

    heart_rate_column = next(
        (c for c in raw_df.columns if "heart rate" in c.lower()), None
    )
    if not heart_rate_column or {"Date", "Time"} - set(raw_df.columns):
        raise ValueError("heart_rate.csv missing Date, Time or heart rate column")

    hr_df = raw_df.copy()
    hr_df["timestamp"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(hr_df["Date"], hr_df["Time"], strict=False)
    ]
    hr_df["bpm"] = pd.to_numeric(hr_df[heart_rate_column], errors="coerce")
    hr_df = hr_df.dropna(subset=["timestamp", "bpm"])
    hr_df = hr_df[(hr_df["bpm"] >= 30) & (hr_df["bpm"] <= 250)]

    deduped = hr_df.groupby(["Date", "Time"], as_index=False)["bpm"].mean()
    deduped["timestamp"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(deduped["Date"], deduped["Time"], strict=False)
    ]
    return deduped[["timestamp", "bpm"]].sort_values("timestamp").reset_index(drop=True)


def _normalize_and_dedupe_calories(raw_df: pd.DataFrame | None) -> pd.DataFrame:
    """
    Normalize calories CSV to start_time, end_time, active_kcal, total_kcal.
    Deduplicate by (Date, Start Time, End Time); fill missing Active with 0.
    """
    if raw_df is None or raw_df.empty:
        return pd.DataFrame(
            columns=[
                "start_time",
                "end_time",
                "active_kcal",
                "total_kcal",
                "active_kcal_measured",
            ]
        )

    required_columns = {"Date", "Start Time", "End Time", "Total Calories (kcal)"}
    missing = required_columns - set(raw_df.columns)
    if missing:
        raise ValueError(f"calories.csv missing columns: {missing}")

    cal_df = raw_df.copy()
    cal_df["start_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(
            cal_df["Date"], cal_df["Start Time"], strict=False
        )
    ]
    cal_df["end_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(cal_df["Date"], cal_df["End Time"], strict=False)
    ]
    cal_df["total_kcal"] = pd.to_numeric(
        cal_df["Total Calories (kcal)"], errors="coerce"
    )
    active_calories_column = "Active Calories (kcal)"
    if active_calories_column in cal_df.columns:
        cal_df["active_kcal"] = pd.to_numeric(
            cal_df[active_calories_column], errors="coerce"
        )
    else:
        cal_df["active_kcal"] = float("nan")
    cal_df = cal_df.dropna(subset=["start_time", "end_time", "total_kcal"])

    # Drop windows where end_time <= start_time (export bug: midnight parsed as same day)
    cal_df = cal_df[cal_df["end_time"] > cal_df["start_time"]]

    # Track whether active_kcal was actually measured in the source data
    cal_df["active_kcal_measured"] = cal_df["active_kcal"].notna()
    # Export often has empty Active; total remains valid
    cal_df["active_kcal"] = cal_df["active_kcal"].fillna(0.0)

    deduped = (
        cal_df.groupby(["Date", "Start Time", "End Time"], as_index=False)
        .agg(
            {
                "start_time": "first",
                "end_time": "first",
                "active_kcal": "max",
                "total_kcal": "max",
                "active_kcal_measured": "any",
            }
        )
        .reset_index(drop=True)
    )
    return (
        deduped[
            [
                "start_time",
                "end_time",
                "active_kcal",
                "total_kcal",
                "active_kcal_measured",
            ]
        ]
        .sort_values("start_time")
        .reset_index(drop=True)
    )


def _normalize_sleep(raw_df: pd.DataFrame | None) -> pd.DataFrame:
    """
    Normalize sleep_sessions CSV; deduplicate by (Date, Start Time, End Time).
    Keeps sessions between 0.5 and 16 hours.
    """
    if raw_df is None or raw_df.empty:
        return pd.DataFrame(columns=["start_time", "end_time", "duration_hours"])

    required_columns = {"Date", "Start Time", "End Time", "Duration (hours)"}
    missing = required_columns - set(raw_df.columns)
    if missing:
        raise ValueError(f"sleep_sessions.csv missing columns: {missing}")

    sleep_df = raw_df.copy()
    sleep_df["start_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(
            sleep_df["Date"], sleep_df["Start Time"], strict=False
        )
    ]
    sleep_df["end_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(
            sleep_df["Date"], sleep_df["End Time"], strict=False
        )
    ]
    sleep_df["duration_hours"] = pd.to_numeric(
        sleep_df["Duration (hours)"], errors="coerce"
    )
    sleep_df = sleep_df.dropna(subset=["start_time", "end_time", "duration_hours"])
    sleep_df = sleep_df[
        (sleep_df["duration_hours"] > 0.5) & (sleep_df["duration_hours"] < 16)
    ]

    deduped = (
        sleep_df.groupby(["Date", "Start Time", "End Time"], as_index=False)
        .first()
        .reset_index(drop=True)
    )
    return (
        deduped[["start_time", "end_time", "duration_hours"]]
        .sort_values("start_time")
        .reset_index(drop=True)
    )


def _normalize_exercise(raw_df: pd.DataFrame | None) -> pd.DataFrame:
    """
    Normalize exercise_sessions CSV to start_time, end_time, duration_hours, type, title.
    Returns empty DataFrame if required columns are missing.
    """
    if raw_df is None or raw_df.empty:
        return pd.DataFrame(
            columns=[
                "start_time",
                "end_time",
                "duration_hours",
                "type",
                "title",
                "source",
            ]
        )

    required_columns = {"Date", "Start Time", "End Time", "Duration (hours)"}
    if required_columns - set(raw_df.columns):
        logger.warning("exercise_sessions missing required columns, returning empty")
        return pd.DataFrame(
            columns=[
                "start_time",
                "end_time",
                "duration_hours",
                "type",
                "title",
                "source",
            ]
        )

    ex_df = raw_df.copy()
    ex_df["start_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(ex_df["Date"], ex_df["Start Time"], strict=False)
    ]
    ex_df["end_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(ex_df["Date"], ex_df["End Time"], strict=False)
    ]
    ex_df["duration_hours"] = pd.to_numeric(ex_df["Duration (hours)"], errors="coerce")
    ex_df["type"] = ex_df["Type"].astype(str) if "Type" in ex_df.columns else ""
    ex_df["title"] = ex_df["Title"].astype(str) if "Title" in ex_df.columns else ""
    # Preserve source so downstream analysis can distinguish treadmill (Kingsmith/Xiaojin)
    # from free-movement sessions (Samsung Health, etc.)
    ex_df["source"] = ex_df["Source"].astype(str) if "Source" in ex_df.columns else ""
    ex_df = ex_df.dropna(subset=["start_time", "end_time", "duration_hours"])
    ex_df = ex_df[(ex_df["duration_hours"] > 0.05) & (ex_df["duration_hours"] < 24)]

    return (
        ex_df[["start_time", "end_time", "duration_hours", "type", "title", "source"]]
        .sort_values("start_time")
        .reset_index(drop=True)
    )


def _normalize_oxygen_saturation(raw_df: pd.DataFrame | None) -> pd.DataFrame:
    """
    Normalize oxygen_saturation CSV to timestamp + spo2_pct.
    Returns empty DataFrame if Date/Time or SpO2 column is missing.
    """
    if raw_df is None or raw_df.empty:
        return pd.DataFrame(columns=["timestamp", "spo2_pct"])

    if "Date" not in raw_df.columns or "Time" not in raw_df.columns:
        return pd.DataFrame(columns=["timestamp", "spo2_pct"])
    spo2_column = next(
        (c for c in raw_df.columns if "spo2" in c.lower() or "oxygen" in c.lower()),
        None,
    )
    if not spo2_column:
        return pd.DataFrame(columns=["timestamp", "spo2_pct"])

    spo2_df = raw_df.copy()
    spo2_df["timestamp"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(spo2_df["Date"], spo2_df["Time"], strict=False)
    ]
    spo2_df["spo2_pct"] = pd.to_numeric(spo2_df[spo2_column], errors="coerce")
    spo2_df = spo2_df.dropna(subset=["timestamp", "spo2_pct"])
    return (
        spo2_df[["timestamp", "spo2_pct"]]
        .sort_values("timestamp")
        .reset_index(drop=True)
    )


def load_health_data_from_path(data_path: Path) -> HealthDataBundle:
    """
    Load health data from a directory of CSVs or a single ZIP file.

    If `data_path` is a directory, reads steps.csv, heart_rate.csv, etc. inside it.
    If it is a .zip file, reads the same filenames from the archive.
    Applies normalization and source deduplication to avoid double-counting.

    Args:
        data_path: Path to a health_data_* directory or health_data_*.zip file.

    Returns:
        HealthDataBundle with normalized DataFrames per data type.

    Raises:
        FileNotFoundError: If the path does not exist or is not a directory/ZIP.
    """
    data_path = Path(data_path)
    if not data_path.exists():
        raise FileNotFoundError(f"Data path not found: {data_path}")

    if data_path.suffix.lower() == ".zip":
        with zipfile.ZipFile(data_path, "r") as zip_file:
            steps_df = _normalize_and_dedupe_steps(
                _load_csv_from_zip(zip_file, "steps.csv")
            )
            heart_rate_df = _normalize_and_dedupe_heart_rate(
                _load_csv_from_zip(zip_file, "heart_rate.csv")
            )
            calories_df = _normalize_and_dedupe_calories(
                _load_csv_from_zip(zip_file, "calories.csv")
            )
            sleep_sessions_df = _normalize_sleep(
                _load_csv_from_zip(zip_file, "sleep_sessions.csv")
            )
            exercise_sessions_df = _normalize_exercise(
                _load_csv_from_zip(zip_file, "exercise_sessions.csv")
            )
            oxygen_saturation_df = _normalize_oxygen_saturation(
                _load_csv_from_zip(zip_file, "oxygen_saturation.csv")
            )
            distance_raw = _load_csv_from_zip(zip_file, "distance.csv")
    else:
        if not data_path.is_dir():
            raise FileNotFoundError(f"Expected directory or ZIP: {data_path}")
        steps_df = _normalize_and_dedupe_steps(_load_csv(data_path / "steps.csv"))
        heart_rate_df = _normalize_and_dedupe_heart_rate(
            _load_csv(data_path / "heart_rate.csv")
        )
        calories_df = _normalize_and_dedupe_calories(
            _load_csv(data_path / "calories.csv")
        )
        sleep_sessions_df = _normalize_sleep(
            _load_csv(data_path / "sleep_sessions.csv")
        )
        exercise_sessions_df = _normalize_exercise(
            _load_csv(data_path / "exercise_sessions.csv")
        )
        oxygen_saturation_df = _normalize_oxygen_saturation(
            _load_csv(data_path / "oxygen_saturation.csv")
        )
        distance_raw = _load_csv(data_path / "distance.csv")

    if oxygen_saturation_df is None or oxygen_saturation_df.empty:
        oxygen_saturation_df = pd.DataFrame(columns=["timestamp", "spo2_pct"])
    if (
        distance_raw is not None
        and not distance_raw.empty
        and "Distance (km)" in distance_raw.columns
    ):
        distance_df = _normalize_distance(distance_raw)
    else:
        distance_df = pd.DataFrame(
            columns=["start_time", "end_time", "distance_m", "distance_km"]
        )

    return HealthDataBundle(
        steps=steps_df,
        heart_rate=heart_rate_df,
        calories=calories_df,
        sleep_sessions=sleep_sessions_df,
        exercise_sessions=exercise_sessions_df,
        oxygen_saturation=oxygen_saturation_df,
        distance=distance_df,
    )


def _normalize_distance(raw_df: pd.DataFrame) -> pd.DataFrame:
    """
    Normalize distance CSV to start_time, end_time, distance_m, distance_km, source.
    Returns empty DataFrame if required columns are missing.
    """
    required_columns = {"Date", "Start Time", "End Time"}
    if required_columns - set(raw_df.columns):
        return pd.DataFrame(
            columns=["start_time", "end_time", "distance_m", "distance_km", "source"]
        )
    dist_df = raw_df.copy()
    dist_df["start_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(
            dist_df["Date"], dist_df["Start Time"], strict=False
        )
    ]
    dist_df["end_time"] = [
        _parse_datetime(str(date_val), str(time_val))
        for date_val, time_val in zip(
            dist_df["Date"], dist_df["End Time"], strict=False
        )
    ]
    distance_km_column = "Distance (km)" if "Distance (km)" in dist_df.columns else None
    distance_m_column = (
        "Distance (meters)" if "Distance (meters)" in dist_df.columns else None
    )
    if distance_km_column:
        dist_df["distance_km"] = pd.to_numeric(
            dist_df[distance_km_column], errors="coerce"
        )
    else:
        dist_df["distance_km"] = float("nan")
    if distance_m_column:
        dist_df["distance_m"] = pd.to_numeric(
            dist_df[distance_m_column], errors="coerce"
        )
    else:
        dist_df["distance_m"] = (
            dist_df["distance_km"] * 1000 if distance_km_column else float("nan")
        )
    dist_df["source"] = (
        dist_df["Source"].astype(str) if "Source" in dist_df.columns else ""
    )
    return (
        dist_df[["start_time", "end_time", "distance_m", "distance_km", "source"]]
        .dropna(subset=["start_time", "end_time"])
        .reset_index(drop=True)
    )


def load_health_data_from_directory(directory: Path) -> HealthDataBundle:
    """
    Load health data from the most recent health_data_* folder or ZIP in a directory.

    Scans for folders or ZIPs whose name starts with `health_data_` and loads
    the lexicographically latest (typically the most recent export by date/time).

    Args:
        directory: Parent directory containing health_data_* subdirs or ZIPs.

    Returns:
        HealthDataBundle with normalized data from the latest source.

    Raises:
        FileNotFoundError: If directory does not exist or no health_data_* found.
    """
    directory = Path(directory)
    if not directory.exists():
        raise FileNotFoundError(f"Directory not found: {directory}")

    zip_paths = sorted(directory.glob(f"{HEALTH_DATA_PREFIX}*.zip"))
    data_dirs = [
        p
        for p in directory.iterdir()
        if p.is_dir() and p.name.startswith(HEALTH_DATA_PREFIX)
    ]
    data_dirs.sort(key=lambda p: p.name)

    if zip_paths and (not data_dirs or zip_paths[-1].name >= data_dirs[-1].name):
        return load_health_data_from_path(zip_paths[-1])
    if data_dirs:
        return load_health_data_from_path(data_dirs[-1])
    raise FileNotFoundError(
        f"No {HEALTH_DATA_PREFIX}* directory or ZIP found in {directory}"
    )


path: pkg/skill_health/models.py
content: """Data models for Skill Health."""

from dataclasses import dataclass

import pandas as pd


@dataclass
class HealthDataBundle:
    """
    Container for all loaded and normalized health data.

    Each attribute is a DataFrame with a fixed schema per data type (see load.py).
    """

    steps: pd.DataFrame
    heart_rate: pd.DataFrame
    calories: pd.DataFrame
    sleep_sessions: pd.DataFrame
    exercise_sessions: pd.DataFrame
    oxygen_saturation: pd.DataFrame = None
    distance: pd.DataFrame = None

    def __post_init__(self) -> None:
        if self.oxygen_saturation is None:
            self.oxygen_saturation = pd.DataFrame(columns=["timestamp", "spo2_pct"])
        if self.distance is None:
            self.distance = pd.DataFrame(
                columns=[
                    "start_time",
                    "end_time",
                    "distance_m",
                    "distance_km",
                    "source",
                ]
            )


path: pkg/skill_health/report.py
content: """
Helpers for building JSON report payloads: time windows, metric dicts, numeric stats.
"""

from __future__ import annotations

import logging
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import pandas as pd

logger = logging.getLogger(__name__)
DEFAULT_LOCAL_TZ = "UTC"
TIMEZONE_ENV_VAR = "SKILL_HEALTH_TIMEZONE"


@dataclass
class TimeWindow:
    """Time window for a metric: start, end (ISO), and resolution (e.g. '1h', '1d')."""

    start: str
    end: str
    resolution: str


def _resolve_local_timezone() -> ZoneInfo:
    """Resolve local timezone from env; fallback to UTC if invalid or missing."""
    timezone_name = os.getenv(TIMEZONE_ENV_VAR, DEFAULT_LOCAL_TZ)
    try:
        return ZoneInfo(timezone_name)
    except ZoneInfoNotFoundError:
        logger.warning(
            "Invalid timezone '%s' in %s. Falling back to %s.",
            timezone_name,
            TIMEZONE_ENV_VAR,
            DEFAULT_LOCAL_TZ,
        )
        return ZoneInfo(DEFAULT_LOCAL_TZ)


def datetime_to_iso(dt: datetime) -> str:
    """Return compact ISO string (minutes precision); treat naive datetimes as local time."""
    normalized = dt.replace(second=0, microsecond=0)
    if normalized.tzinfo is None:
        normalized = normalized.replace(tzinfo=_resolve_local_timezone())
    return normalized.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")


def _round_floats(value: Any, ndigits: int = 2) -> Any:
    """Recursively round floats in dicts/lists for compact output."""
    if isinstance(value, float):
        return round(value, ndigits)
    if isinstance(value, list):
        return [_round_floats(v, ndigits=ndigits) for v in value]
    if isinstance(value, dict):
        return {k: _round_floats(v, ndigits=ndigits) for k, v in value.items()}
    return value


def describe_numeric(series: pd.Series) -> dict[str, Any]:
    """
    Return summary stats for a numeric series (mean, std, min, quartiles, max).
    Returns dict with None values if series is empty or all-NaN.
    """
    empty_stats = {
        "mean": None,
        "std": None,
        "min": None,
        "p25": None,
        "p50": None,
        "p75": None,
        "max": None,
    }
    if series.empty:
        return empty_stats
    clean = pd.to_numeric(series, errors="coerce").dropna()
    if clean.empty:
        return empty_stats
    return {
        "mean": float(clean.mean()),
        "std": float(clean.std(ddof=0)) if len(clean) > 1 else 0.0,
        "min": float(clean.min()),
        "p25": float(clean.quantile(0.25)),
        "p50": float(clean.quantile(0.50)),
        "p75": float(clean.quantile(0.75)),
        "max": float(clean.max()),
    }


def metric(
    *,
    metric_name: str,
    description: str,
    purpose: str,
    variables_used: list[str],
    time_window: TimeWindow,
    n_instances: int,
    stats: dict[str, Any],
    extra: dict[str, Any] | None = None,
    data_quality: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """
    Build a single metric dict for JSON output.

    Args:
        metric_name: Short identifier.
        description: What the metric measures.
        purpose: Why it is useful (alerts, trends, etc.).
        variables_used: List of data sources/columns used.
        time_window: Start/end/resolution of the analysis window.
        n_instances: Number of data points used.
        stats: Value(s) and optional distribution (e.g. value, mean, min, max).
        extra: Optional units, thresholds, notes.
        data_quality: Optional quality metadata for the consuming LLM.
            Keys: coverage (float 0-1), reliability ("high"/"medium"/"low"/"unavailable"),
            notes (list[str] with caveats).

    Returns:
        Dict suitable for JSON serialization.
    """
    payload: dict[str, Any] = {
        "metric_name": metric_name,
        "description": description,
        "purpose": purpose,
        "variables_used": variables_used,
        "time_window": {
            "s": time_window.start,
            "e": time_window.end,
            "r": time_window.resolution,
        },
        "n_instances": int(n_instances),
        "stats": _round_floats(stats),
    }
    if extra:
        payload["extra"] = _round_floats(extra)
    if data_quality:
        notes = data_quality.get("notes", [])
        compact = {k: v for k, v in data_quality.items() if k != "notes"}
        if notes:
            compact["notes"] = notes
        payload["data_quality"] = _round_floats(compact)
    return payload


def build_data_quality(
    *,
    coverage: float,
    reliability: str,
    notes: list[str] | None = None,
) -> dict[str, Any]:
    """
    Build a data_quality dict for use in metric().

    Args:
        coverage: Fraction of the period with actual data (0.0–1.0).
        reliability: "high" (n>=20, complete data), "medium" (n 5-19, partial),
                     "low" (n<5, important caveats), "unavailable" (cannot compute).
        notes: List of human-readable caveats for the consuming LLM.

    Returns:
        Dict with coverage, reliability and notes.
    """
    return {
        "cov": float(coverage),
        "rel": reliability,
        "notes": notes or [],
    }


path: references/operations.md
content: # Operations and execution

## Suggested execution cadence

- Hourly: last hour, run with a small delay (e.g., minute 15).
- Daily: previous day after midnight (e.g., 01:30).
- Weekly: previous week, e.g. Monday 02:00.
- Monthly: previous month, day 1 at 03:00.
- Sleep: last 30h, run morning and/or night.
- Cross-alerts: use outputs from hourly/daily/weekly/monthly/sleep.

## Data quality rules

- `rel=high`: n_instances >= 20 and source complete.
- `rel=medium`: n_instances 5-19 or partial data.
- `rel=low`: n_instances < 5 or important caveats.
- `rel=unavailable`: no data or not computable.

## Outputs

All scripts emit JSON to stdout and optionally write files via `--output-dir`.


path: references/overview.md
content: # Skill Health overview

## Data sources (CSV)

- `heart_rate.csv`: Date, Time, Heart Rate (bpm), Source
- `steps.csv`: Date, Time, Steps (cumulative), Source
- `calories.csv`: Date, Start Time, End Time, Active Calories, Total Calories, Source
- `sleep_sessions.csv`: Date, Start Time, End Time, Duration (hours), Source
- `oxygen_saturation.csv`: Date, Time, SpO2 (%), Source
- `exercise_sessions.csv`: Date, Start Time, End Time, Duration, Type, Title, Source
- `distance.csv`: Date, Start Time, End Time, Distance (m/km), Source

## Key decisions

- Kingsmith treadmill sessions: steps are estimated from distance (`steps = distance_m / 0.75`).
- Active calories often missing; `active_kcal_measured=false` and `total_kcal` is the reliable signal.
- HR zones computed from inter-sample intervals (not uniform sampling).
- `hr_range_during_sleep` is a proxy, not clinical HRV.
- Sleep window uses last 30h by default; metrics include suffix `last_{window_hours}h`.
- Timezone for naive timestamps is configurable via `--timezone` / `SKILL_HEALTH_TIMEZONE`.

## Output format (compact JSON)

- `time_window`: `s` (start), `e` (end), `r` (resolution).
- `data_quality`: `cov` (coverage 0-1), `rel` (high/medium/low/unavailable), `notes` (optional).


path: scripts/_bootstrap.py
content: """Bootstrap sys.path for the packaged skill."""

from __future__ import annotations

import sys
from pathlib import Path


def ensure_pkg_on_path() -> None:
    """Add ../pkg to sys.path so skill_health can be imported."""
    pkg_root = Path(__file__).resolve().parents[1] / "pkg"
    if str(pkg_root) not in sys.path:
        sys.path.insert(0, str(pkg_root))


path: scripts/run_cross_alerts.py
content: #!/usr/bin/env python3
"""
Run cross-temporal alerts analysis. Reads JSON from outputs dir, detects patterns, outputs JSON.

Usage:
  uv run python code/scripts/run_cross_alerts.py --outputs-dir outputs --output-dir outputs
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from _bootstrap import ensure_pkg_on_path

ensure_pkg_on_path()

from skill_health.analysis.cross_alerts import (
    build_cross_alerts,
    load_metrics_from_outputs_dir,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Cross-temporal health alerts; reads JSON from --outputs-dir, outputs alerts to stdout and/or --output-dir."
    )
    parser.add_argument(
        "--outputs-dir",
        type=Path,
        default=Path("outputs"),
        help="Directory containing hourly_*.json, daily_*.json, weekly_*.json, monthly_*.json.",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory to save alerts JSON (e.g. outputs/cross_alerts.json).",
    )
    args = parser.parse_args()

    metrics_context = load_metrics_from_outputs_dir(args.outputs_dir)
    alerts = build_cross_alerts(metrics_context)
    payload = {"alerts": alerts, "count": len(alerts)}
    json_str = json.dumps(payload, indent=2, ensure_ascii=False)

    if args.output_dir is not None:
        args.output_dir.mkdir(parents=True, exist_ok=True)
        out_path = args.output_dir / "cross_alerts.json"
        out_path.write_text(json_str, encoding="utf-8")
        print(f"Wrote {out_path}", file=sys.stderr)

    print(json_str)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_daily.py
content: #!/usr/bin/env python3
"""
Run daily analysis and output JSON (stdout and/or file).

Usage:
  uv run python code/scripts/run_daily.py --data-dir data --output-dir outputs
  uv run python code/scripts/run_daily.py --data-path data/health_data_20260209_124646 --output-dir outputs --date 2026-01-15
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from _bootstrap import ensure_pkg_on_path

ensure_pkg_on_path()

from skill_health.analysis.daily import (
    build_daily_report,
    infer_target_date_from_bundle,
)
from skill_health.load import (
    load_health_data_from_directory,
    load_health_data_from_path,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Daily health analysis; outputs JSON to stdout and/or --output-dir."
    )
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "--data-dir",
        type=Path,
        help="Directory with health_data_* folder(s) or ZIP(s).",
    )
    group.add_argument(
        "--data-path", type=Path, help="Path to health_data_* directory or ZIP."
    )
    parser.add_argument(
        "--date",
        type=str,
        default=None,
        help="Target date YYYY-MM-DD; default: most recent date with data.",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory to save JSON (e.g. outputs/daily_2026-01-15.json).",
    )
    parser.add_argument(
        "--timezone",
        type=str,
        default="UTC",
        help="IANA timezone for naive timestamps (e.g. Europe/Madrid, America/New_York).",
    )
    args = parser.parse_args()

    os.environ["SKILL_HEALTH_TIMEZONE"] = args.timezone

    if args.data_dir is not None:
        bundle = load_health_data_from_directory(args.data_dir)
    else:
        bundle = load_health_data_from_path(args.data_path)

    if args.date is not None:
        target_date = datetime.strptime(args.date, "%Y-%m-%d").date()
    else:
        target_date = infer_target_date_from_bundle(bundle)

    payload = build_daily_report(bundle, target_date)
    json_str = json.dumps(payload, indent=2, ensure_ascii=False)

    if args.output_dir is not None:
        args.output_dir.mkdir(parents=True, exist_ok=True)
        filename = f"daily_{target_date.isoformat()}.json"
        out_path = args.output_dir / filename
        out_path.write_text(json_str, encoding="utf-8")
        print(f"Wrote {out_path}", file=sys.stderr)

    print(json_str)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_hourly.py
content: #!/usr/bin/env python3
"""
Run hourly analysis and output JSON (stdout and/or file).

Usage:
  uv run python code/scripts/run_hourly.py --data-dir /path/to/data --output-dir outputs
  uv run python code/scripts/run_hourly.py --data-path data/health_data_20260209_124646 --output-dir outputs
  uv run python code/scripts/run_hourly.py --data-dir /path/to/data --now 2026-02-09T14:00:00 --output-dir outputs
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path

from _bootstrap import ensure_pkg_on_path

ensure_pkg_on_path()

from skill_health.analysis.hourly import build_hourly_report, infer_now_from_bundle
from skill_health.load import (
    load_health_data_from_directory,
    load_health_data_from_path,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Hourly health analysis; outputs JSON to stdout and/or --output-dir."
    )
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "--data-dir",
        type=Path,
        help="Directory containing health_data_* folder(s) or ZIP(s).",
    )
    group.add_argument(
        "--data-path",
        type=Path,
        help="Direct path to a health_data_* directory or health_data_*.zip file.",
    )
    parser.add_argument(
        "--now",
        type=str,
        default=None,
        help="Override 'now' in ISO format (e.g. 2026-02-09T14:00:00) for reproducibility.",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory to save JSON file (e.g. outputs/hourly_2026-02-09T14-00-00.json).",
    )
    parser.add_argument(
        "--timezone",
        type=str,
        default="UTC",
        help="IANA timezone for naive timestamps (e.g. Europe/Madrid, America/New_York).",
    )
    args = parser.parse_args()

    os.environ["SKILL_HEALTH_TIMEZONE"] = args.timezone

    if args.data_dir is not None:
        bundle = load_health_data_from_directory(args.data_dir)
    else:
        bundle = load_health_data_from_path(args.data_path)

    if args.now is not None:
        from datetime import datetime

        now = datetime.fromisoformat(args.now)
    else:
        now = infer_now_from_bundle(bundle)

    payload = build_hourly_report(bundle, now)
    json_str = json.dumps(payload, indent=2, ensure_ascii=False)

    if args.output_dir is not None:
        args.output_dir.mkdir(parents=True, exist_ok=True)
        filename = now.strftime("hourly_%Y-%m-%dT%H-%M-%S.json")
        out_path = args.output_dir / filename
        out_path.write_text(json_str, encoding="utf-8")
        print(f"Wrote {out_path}", file=sys.stderr)

    print(json_str)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_monthly.py
content: #!/usr/bin/env python3
"""
Run monthly analysis and output JSON (stdout and/or file).

Usage:
  uv run python code/scripts/run_monthly.py --data-dir data --output-dir outputs
  uv run python code/scripts/run_monthly.py --data-path data/health_data_20260209_124646 --output-dir outputs --date 2026-01-31
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from _bootstrap import ensure_pkg_on_path

ensure_pkg_on_path()

from skill_health.analysis.monthly import (
    build_monthly_report,
    infer_month_end_date_from_bundle,
)
from skill_health.load import (
    load_health_data_from_directory,
    load_health_data_from_path,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Monthly health analysis; outputs JSON to stdout and/or --output-dir."
    )
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "--data-dir",
        type=Path,
        help="Directory with health_data_* folder(s) or ZIP(s).",
    )
    group.add_argument(
        "--data-path", type=Path, help="Path to health_data_* directory or ZIP."
    )
    parser.add_argument(
        "--date",
        type=str,
        default=None,
        help="Month end date YYYY-MM-DD; default: most recent date with data.",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory to save JSON (e.g. outputs/monthly_2026-01-31.json).",
    )
    parser.add_argument(
        "--timezone",
        type=str,
        default="UTC",
        help="IANA timezone for naive timestamps (e.g. Europe/Madrid, America/New_York).",
    )
    args = parser.parse_args()

    os.environ["SKILL_HEALTH_TIMEZONE"] = args.timezone

    if args.data_dir is not None:
        bundle = load_health_data_from_directory(args.data_dir)
    else:
        bundle = load_health_data_from_path(args.data_path)

    if args.date is not None:
        month_end_date = datetime.strptime(args.date, "%Y-%m-%d").date()
    else:
        month_end_date = infer_month_end_date_from_bundle(bundle)

    payload = build_monthly_report(bundle, month_end_date)
    json_str = json.dumps(payload, indent=2, ensure_ascii=False)

    if args.output_dir is not None:
        args.output_dir.mkdir(parents=True, exist_ok=True)
        filename = f"monthly_{month_end_date.isoformat()}.json"
        out_path = args.output_dir / filename
        out_path.write_text(json_str, encoding="utf-8")
        print(f"Wrote {out_path}", file=sys.stderr)

    print(json_str)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_sleep.py
content: #!/usr/bin/env python3
"""
Run sleep analysis for the last 24 hours and output JSON (stdout and/or file).

Usage:
  uv run python code/scripts/run_sleep.py --data-dir data --output-dir outputs
  uv run python code/scripts/run_sleep.py --data-path data/health_data_20260209_124646 --output-dir outputs --now 2026-02-15T08:00:00
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from _bootstrap import ensure_pkg_on_path

ensure_pkg_on_path()

from skill_health.analysis.sleep import build_sleep_report, infer_now_from_bundle
from skill_health.load import (
    load_health_data_from_directory,
    load_health_data_from_path,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Sleep analysis for the last 24h; outputs JSON to stdout and/or --output-dir."
    )
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "--data-dir",
        type=Path,
        help="Directory with health_data_* folder(s) or ZIP(s).",
    )
    group.add_argument(
        "--data-path", type=Path, help="Path to health_data_* directory or ZIP."
    )
    parser.add_argument(
        "--now",
        type=str,
        default=None,
        help="Window end timestamp (ISO). Default: inferred from latest data.",
    )
    parser.add_argument(
        "--window-hours",
        type=int,
        default=30,
        help="Hours to look back from --now (default: 30).",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory to save JSON (e.g. outputs/sleep_2026-01-16T08-00-00.json).",
    )
    parser.add_argument(
        "--timezone",
        type=str,
        default="UTC",
        help="IANA timezone for naive timestamps (e.g. Europe/Madrid, America/New_York).",
    )
    args = parser.parse_args()

    os.environ["SKILL_HEALTH_TIMEZONE"] = args.timezone

    if args.data_dir is not None:
        bundle = load_health_data_from_directory(args.data_dir)
    else:
        bundle = load_health_data_from_path(args.data_path)

    if args.now is not None:
        now = datetime.fromisoformat(args.now)
    else:
        now = infer_now_from_bundle(bundle)

    payload = build_sleep_report(bundle, now=now, window_hours=args.window_hours)
    json_str = json.dumps(payload, indent=2, ensure_ascii=False)

    if args.output_dir is not None:
        args.output_dir.mkdir(parents=True, exist_ok=True)
        filename = f"sleep_{now.isoformat().replace(':', '-')}.json"
        out_path = args.output_dir / filename
        out_path.write_text(json_str, encoding="utf-8")
        print(f"Wrote {out_path}", file=sys.stderr)

    print(json_str)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_weekly.py
content: #!/usr/bin/env python3
"""
Run weekly analysis and output JSON (stdout and/or file).

Usage:
  uv run python code/scripts/run_weekly.py --data-dir data --output-dir outputs
  uv run python code/scripts/run_weekly.py --data-path data/health_data_20260209_124646 --output-dir outputs --date 2026-01-21
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from _bootstrap import ensure_pkg_on_path

ensure_pkg_on_path()

from skill_health.analysis.weekly import (
    build_weekly_report,
    infer_week_end_date_from_bundle,
)
from skill_health.load import (
    load_health_data_from_directory,
    load_health_data_from_path,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Weekly health analysis; outputs JSON to stdout and/or --output-dir."
    )
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "--data-dir",
        type=Path,
        help="Directory with health_data_* folder(s) or ZIP(s).",
    )
    group.add_argument(
        "--data-path", type=Path, help="Path to health_data_* directory or ZIP."
    )
    parser.add_argument(
        "--date",
        type=str,
        default=None,
        help="Week end date YYYY-MM-DD; default: most recent date with data.",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory to save JSON (e.g. outputs/weekly_2026-01-21.json).",
    )
    parser.add_argument(
        "--timezone",
        type=str,
        default="UTC",
        help="IANA timezone for naive timestamps (e.g. Europe/Madrid, America/New_York).",
    )
    args = parser.parse_args()

    os.environ["SKILL_HEALTH_TIMEZONE"] = args.timezone

    if args.data_dir is not None:
        bundle = load_health_data_from_directory(args.data_dir)
    else:
        bundle = load_health_data_from_path(args.data_path)

    if args.date is not None:
        week_end_date = datetime.strptime(args.date, "%Y-%m-%d").date()
    else:
        week_end_date = infer_week_end_date_from_bundle(bundle)

    payload = build_weekly_report(bundle, week_end_date)
    json_str = json.dumps(payload, indent=2, ensure_ascii=False)

    if args.output_dir is not None:
        args.output_dir.mkdir(parents=True, exist_ok=True)
        filename = f"weekly_{week_end_date.isoformat()}.json"
        out_path = args.output_dir / filename
        out_path.write_text(json_str, encoding="utf-8")
        print(f"Wrote {out_path}", file=sys.stderr)

    print(json_str)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)