﻿---
title: "用 iPhone、Apple Watch 和 Mac 还原一天：站、坐、屏幕前与噪声能拼到什么程度"
description: "想知道自己一天里站了多久、坐了多久、在屏幕前多久，各个时段状态怎样、周围有多吵。手上有 iPhone、Apple Watch 和 MacBook，顺着这个需求往下追：手表的“站立”到底测了什么，坐着为什么只能推断，Mac 能交出哪些记录，工作状态和环境噪声从哪来，几路数据怎么对到同一条时间线上；最后看现有软件做到了哪一步，给一段能跑的拼接脚本。"
pubDate: 2026-11-02
tags: ["Apple Watch", "HealthKit", "量化自我", "时间追踪", "macOS"]
clc: "TP274"
hero: "./hero.svg"
refs:
  - id: stand-hour
    title: appleStandHour
    url: https://developer.apple.com/documentation/healthkit/hkcategorytypeidentifier/applestandhour
    site: Apple Developer
    note: "每小时“站起来并活动至少 1 分钟”算一个站立小时"
  - id: stand-time
    title: appleStandTime
    url: https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/applestandtime
    site: Apple Developer
    note: "iOS 13 / watchOS 6 起，累计型时间样本"
  - id: wrist-detect
    title: About wrist detection on Apple Watch
    url: https://support.apple.com/en-us/105002
    site: Apple Support
    note: "关闭手腕检测后不记录站立，也没有后台心率"
  - id: motion-activity
    title: CMMotionActivity
    url: https://developer.apple.com/documentation/coremotion/cmmotionactivity
    site: Apple Developer
    note: "`stationary` / `walking` / `running` / `automotive` / `cycling` / `unknown`，加 `confidence`"
  - id: motion-query
    title: queryActivityStarting(from:to:to:withHandler:)
    url: https://developer.apple.com/documentation/coremotion/cmmotionactivitymanager/queryactivitystarting(from:to:to:withhandler:)
    site: Apple Developer
    note: "系统最多只保留最近 7 天的活动数据"
  - id: sensor-recorder
    title: recordAccelerometer(forDuration:)
    url: https://developer.apple.com/documentation/coremotion/cmsensorrecorder/recordaccelerometer(forduration:)
    site: Apple Developer
    note: "50 Hz，单次最多 12 小时，数据保留 3 天"
  - id: device-activity
    title: DeviceActivityReport
    url: https://developer.apple.com/documentation/deviceactivity/deviceactivityreport
    site: Apple Developer
    note: "报表扩展跑在沙箱里，不能联网，也不能把数据带出扩展"
  - id: knowledgec
    title: "Knowledge is Power! Using the macOS/iOS knowledgeC.db Database to Determine Precise User and Application Usage"
    url: https://www.mac4n6.com/blog/2018/8/5/knowledge-is-power-using-the-knowledgecdb-database-on-macos-and-ios-to-determine-precise-user-and-application-usage
    author: Sarah Edwards
    site: mac4n6
    date: 2018-08-05
    note: "`/app/usage`、`/app/inFocus`、`/display/isBacklit` 等流"
  - id: biome
    title: Bringing it Back with Biome Data
    url: https://www.magnetforensics.com/blog/bringing-it-back-with-biome-data/
    site: Magnet Forensics
    note: "应用使用记录迁到 Biome，SEGB 二进制格式"
  - id: timing-st
    title: Screen Time Integration
    url: https://timingapp.com/help/screen-time
    site: Timing
    note: "读屏幕使用时间数据库需要完全磁盘访问权限；系统界面只有小时粒度、保留四周"
  - id: idle-time
    title: secondsSinceLastEventType(_:eventType:)
    url: https://developer.apple.com/documentation/coregraphics/cgeventsource/secondssincelasteventtype(_:eventtype:)
    site: Apple Developer
  - id: aw-faq
    title: ActivityWatch FAQ
    url: https://docs.activitywatch.net/en/latest/faq.html
    site: ActivityWatch
    note: "超过 3 分钟没有键鼠输入就记为 afk；读窗口标题需要辅助功能权限"
  - id: aw-rest
    title: ActivityWatch REST API
    url: https://docs.activitywatch.net/en/latest/api/rest.html
    site: ActivityWatch
  - id: aw-watchers
    title: ActivityWatch Watchers
    url: https://docs.activitywatch.net/en/latest/watchers.html
    site: ActivityWatch
    note: "`aw-watcher-input`、`aw-watcher-table`（升降桌）、Life Cycle 导入等"
  - id: heart-rate
    title: Monitor your heart rate with Apple Watch
    url: https://support.apple.com/en-us/120277
    site: Apple Support
  - id: env-audio
    title: environmentalAudioExposure
    url: https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/environmentalaudioexposure
    site: Apple Developer
    note: "单位 dBASPL，离散的等效连续声级"
  - id: noise-support
    title: Measure noise levels with Apple Watch
    url: https://support.apple.com/en-us/102315
    site: Apple Support
    note: "全天周期性测量，只用麦克风测声级，不录音也不保存"
  - id: sound-reduction
    title: environmentalSoundReduction
    url: https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/environmentalsoundreduction
    site: Apple Developer
  - id: daylight
    title: timeInDaylight
    url: https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/timeindaylight
    site: Apple Developer
    note: "iOS 17 / watchOS 10 起"
  - id: clvisit
    title: CLVisit
    url: https://developer.apple.com/documentation/corelocation/clvisit
    site: Apple Developer
  - id: sensorkit
    title: SRSensor
    url: https://developer.apple.com/documentation/sensorkit/srsensor
    site: Apple Developer
  - id: sensorkit-config
    title: Configuring your project for sensor reading
    url: https://developer.apple.com/documentation/sensorkit/configuring-your-project-for-sensor-reading
    site: Apple Developer
    note: "只授予获批的研究项目"
  - id: sensorkit-fetch
    title: SRFetchRequest
    url: https://developer.apple.com/documentation/sensorkit/srfetchrequest
    site: Apple Developer
    note: "新数据有 24 小时冻结期"
  - id: arc
    title: Arc Timeline
    url: https://www.bigpaua.com/arcapp/
    site: Big Paua
  - id: hae
    title: Health Auto Export
    url: https://www.healthyapps.dev/
    site: HealthyApps
  - id: dayflow
    title: Dayflow
    url: https://github.com/JerryZLiu/Dayflow
    site: GitHub
  - id: screenpipe
    title: screenpipe
    url: https://github.com/screenpipe/screenpipe
    site: GitHub
  - id: rescuetime
    title: RescueTime
    url: https://www.rescuetime.com/
    site: RescueTime
  - id: rize
    title: Rize
    url: https://rize.io/
    site: Rize
  - id: exist
    title: Exist
    url: https://exist.io/
    site: Exist
  - id: felix
    title: How is Felix today?
    url: https://howisfelix.today
    author: Felix Krause
    site: howisfelix.today
  - id: fxlifesheet
    title: FxLifeSheet
    url: https://github.com/KrauseFx/FxLifeSheet
    author: Felix Krause
    site: GitHub
  - id: limitless
    title: Limitless
    url: https://www.limitless.ai/
    site: Limitless
    note: "被 Meta 收购，Rewind 已停止录屏与录音"
---

<!-- TODO: 这里放一张作者自己某一天的截图：健康 App 的“站立小时”（例如 11/12）和 Mac 屏幕使用时间的总时长，并排。 -->

手腕上的 Apple Watch 每天告诉我“站立 10 小时”，Mac 的屏幕使用时间又说我今天用了 7 个多小时。两个数字放在一起就很奇怪：一天醒着十五六个小时，站了 10 个小时，又在电脑前坐了 7 个小时，加起来已经快满了，那吃饭、通勤、开会都去哪了？

我真正想知道的是这么几件事：

1. 一天里到底**站了多久、坐了多久**？
2. **在屏幕前工作了多久**？不同时间段里，工作状态怎么样？
3. 当时**周围的环境**怎么样，比如有多吵？
4. 手上只有 iPhone、Apple Watch 和 MacBook，**能不能把这一天完整还原出来**？有没有现成的软件已经做到了？

本文就顺着这几个问题，从每台设备“到底记下了什么”开始，一层一层往下拼。

---

## 一、站立与久坐：手表到底测了什么

### 现象：站立 10 小时，不等于站了 10 小时

先看那个“站立 10 小时”。它来自活动圆环的第三环，对应 HealthKit 里的 `appleStandHour`。Apple 的定义是：**这一小时里站起来并活动了至少 1 分钟，就算一个站立小时**[^stand-hour]。所以“站立 10 小时”的真实含义是“有 10 个小时，每个小时里你至少站过 1 分钟”。哪怕每小时只站 1 分钟，也能拿满。

这个指标的设计目的是提醒人别一直坐着，而不是计量站了多久。真正计量时长的是另一个类型 `appleStandTime`：iOS 13 / watchOS 6 起提供，单位是时间，样本是累计型的，加起来就是当天站立的分钟数[^stand-time]。健康 App 里“站立分钟”看到的就是它。

那坐着呢？**HealthKit 里根本没有“坐着”这个类型。** 这不是遗漏，而是手表测不出来：它戴在手腕上，坐着和站着不动时，手腕的加速度几乎一样。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="adr-stand-title adr-stand-desc">
<title id="adr-stand-title">站立小时、站立分钟和推断出的坐着时间</title>
<desc id="adr-stand-desc">10 点到 11 点这一小时，手表记下这一小时站立达标，但站立分钟只有 10:07 到 10:09 的 2 分钟。叠上 Mac 的键鼠记录：10:00 到 10:52 一直有输入，之后离开。于是推断坐了 50 分钟、站了 2 分钟，最后 8 分钟没有任何设备作证。</desc>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="148" rx="8"/>
<text class="t-zone" x="32" y="44">Apple Watch 记下的 · 10:00–11:00</text>
<rect class="dd-zone" x="16" y="180" width="768" height="184" rx="8"/>
<text class="t-zone" x="32" y="208">叠上 Mac 的键鼠记录</text>
<text class="t-mono" x="32" y="75">appleStandHour</text>
<rect class="dd-card" x="176" y="56" width="576" height="28" rx="4"/>
<text class="t-val" x="464" y="75" text-anchor="middle">Stood · 这一小时站立达标</text>
<text class="t-mono" x="32" y="123">appleStandTime</text>
<rect class="dd-track" x="176" y="104" width="576" height="28" rx="4"/>
<rect class="dd-fill-acc" x="243.2" y="104" width="19.2" height="28"/>
<text class="t-mono t-acc" x="272" y="123">2 min（10:07–10:09）</text>
<text class="t-note" x="744" y="123" text-anchor="end">其余 58 分钟没有记录</text>
<text class="t-mono" x="32" y="235">Mac not-afk</text>
<rect class="dd-card" x="176" y="216" width="499.2" height="28" rx="4"/>
<text class="t-val" x="425.6" y="235" text-anchor="middle">有键鼠输入 52 min</text>
<rect class="dd-derived" x="675.2" y="216" width="76.8" height="28" rx="4"/>
<text class="t-note" x="713.6" y="235" text-anchor="middle">afk</text>
<text class="t-mono" x="32" y="279">推断</text>
<rect class="dd-card" x="176" y="260" width="67.2" height="28" rx="4"/>
<text class="t-val" x="209.6" y="279" text-anchor="middle">坐 7</text>
<rect class="dd-fill-acc" x="243.2" y="260" width="19.2" height="28"/>
<rect class="dd-card" x="262.4" y="260" width="412.8" height="28" rx="4"/>
<text class="t-val" x="468.8" y="279" text-anchor="middle">坐 43 min</text>
<rect class="dd-derived" x="675.2" y="260" width="76.8" height="28" rx="4"/>
<text class="t-note" x="713.6" y="279" text-anchor="middle">?</text>
<text class="t-note" x="176" y="306">10:00</text>
<text class="t-note" x="243.2" y="306" text-anchor="middle">10:07</text>
<text class="t-note" x="675.2" y="306" text-anchor="middle">10:52</text>
<text class="t-note" x="752" y="306" text-anchor="end">11:00</text>
<text class="t-say" x="32" y="332">站立小时只要求这一小时里站过 1 分钟，剩下的时间，手表不说你在干什么。</text>
<text class="t-say" x="32" y="350">虚线是没有设备作证的时段：人离开了 Mac，手表也没记到站立。</text>
</svg>
</div>
<figcaption>图 1 · 站立小时打了勾，只说明这一小时里至少站过 1 分钟。把 Mac 的键鼠记录叠上来，才能把剩下的大部分时间推断为“坐着”。图中数值为示意。</figcaption>
</figure>

### 第一次尝试：坐着 = 醒着 − 站着 − 走路

最直觉的办法是做减法：把醒着的时间减去站立分钟、减去步行和运动，剩下的就是坐着。睡眠时间手表也有记录，醒着的区间不难拿到。

这个办法有三个漏洞：

- **没戴表的时间会被算成坐着。** 关掉手腕检测，或者表摘下来充电，手表就不记站立，也没有后台心率[^wrist-detect]。减法会把这段空白算进“坐”。
- **站着不动可能不算站。** Apple 的定义是“站起来并活动”，在白板前站着讲半小时、排队一动不动，手表未必记满（这一点是根据定义的推断，Apple 没有公开判定算法）。
- **坐着时手臂动得多，可能被算成站。** 社区里常有人反映坐着打字、开车也会“站立达标”，说明判断依据主要是手腕的运动（同样是推断）。

那换一个更底层的数据呢？CoreMotion 的 `CMMotionActivity` 会把运动状态分成静止、步行、跑步、乘车、骑行和未知几类，各带一个置信度[^motion-activity]。可坐和站在这里都是 `stationary`，还是分不出来，而且系统只保留最近 7 天[^motion-query]。再往下一层，watchOS 的 `CMSensorRecorder` 可以让手表在后台以 50 Hz 录原始加速度，单次最多 12 小时，数据保留 3 天[^sensor-recorder]。有了原始数据，理论上可以自己训练一个坐站分类器，但手腕上的加速度本来就缺少区分坐站的信息（推断：研究里区分坐站通常要把传感器贴在大腿上，靠大腿是水平还是竖直来判断）。

### 解决：找一个“坐着”的旁证

既然手表测不出坐着，就找别的设备作证。**Mac 恰好是一个很好的“坐着”的证据**：键盘和鼠标有输入，人几乎一定在桌前；手表又没记到站立，那这段时间大概率是坐着的。图 1 下半部分就是这样推出来的：10:00–10:52 Mac 一直有输入，扣掉 10:07–10:09 那 2 分钟站立，坐了 50 分钟。

这个规则有一个明显的例外：用升降桌站着办公。这时候 Mac 有输入、手表也可能记到站立，两份证据互相矛盾。ActivityWatch 社区有一个 `aw-watcher-table` 插件直接读升降桌的高度[^aw-watchers]，是目前能精确区分坐站的少数办法之一。

而 10:52 之后那 8 分钟，人离开了 Mac，手表也没记到站立，就真的没有任何证据了：可能是去会议室坐着开会，也可能站在茶水间聊天。

所以“坐着”这件事，最后要靠 Mac 来补。**那 Mac 自己能交出哪些记录？**

---

## 二、屏幕前时间：Mac 能交出什么

### 现象：屏幕使用时间看得到，拿不出来

Mac 自带的“屏幕使用时间”已经在记录每个 App 的使用时长，问题是它不给第三方用。

Apple 为屏幕使用时间提供的公开接口是 `DeviceActivity` 一族框架，但它的设计很明确：报表由 App 的一个扩展来渲染，**扩展跑在沙箱里，不能联网，也不能把数据带出扩展的地址空间**[^device-activity]。也就是说，你可以在自己的 App 里“画”一张屏幕时间报表给用户看，却拿不到报表背后的原始记录。

### 第一次尝试：直接读系统数据库

数据总得存在某个地方。取证领域早就研究过：macOS 和 iOS 把应用使用记录写在 `~/Library/Application Support/Knowledge/knowledgeC.db` 里，按“流”分类存放：比如 `/app/usage` 记录前台应用，`/display/isBacklit` 记录屏幕亮灭，时间精确到秒[^knowledgec]。较新的系统又把这类记录迁到了 Biome，变成一种叫 SEGB 的私有二进制格式[^biome]。

Timing 这类商业软件就是走这条路的：它导入屏幕使用时间，需要用户给它“完全磁盘访问权限”，官方文档也直说 Apple 改了存储方式，这个功能可能就会失效[^timing-st]。同一页还提到系统界面只给到小时粒度、只保留四周。

所以读系统数据库能用，但有两个问题：格式私有、没有文档，每次系统升级都可能变；而且它只记录“哪个 App 在前台”，前台开着不等于人在看。

### 解决：自己记录“人在不在键盘前”

更稳的办法是在 Mac 上跑一个自己的记录程序，只回答两个问题：

1. **人在不在？** CoreGraphics 的 `secondsSinceLastEventType` 能直接拿到距离上一次键鼠输入过了多少秒[^idle-time]，超过一个阈值就当人离开了。
2. **在用什么？** 监听前台应用切换，就能知道是哪个 App；想要窗口标题，还得给它“辅助功能”权限[^aw-faq]。

这正是开源工具 ActivityWatch 的做法。它的 `aw-watcher-afk` 判断离开：**超过 3 分钟没有键鼠输入，就从最后一次输入的时刻起记为 afk**；`aw-watcher-window` 记录前台应用和窗口标题[^aw-faq]。两路记录都存在本机，用 `localhost:5600` 上的 REST 接口就能取出来[^aw-rest]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 356" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="adr-mac-title adr-mac-desc">
<title id="adr-mac-title">前台窗口和 afk 记录合并成屏幕前时间</title>
<desc id="adr-mac-desc">09:00 到 09:30，前台窗口依次是 Xcode、Safari、Xcode、Slack、Xcode。afk 记录在 09:12 到 09:16 标为离开，因为这段时间在 Safari 里读文档没有碰键鼠。两者取交集，屏幕前时间是 12 分钟加 14 分钟，共 26 分钟，中间 4 分钟存疑。</desc>
<rect class="dd-frame" width="800" height="356" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="148" rx="8"/>
<text class="t-zone" x="32" y="44">Mac 上的两路记录 · 09:00–09:30</text>
<rect class="dd-zone" x="16" y="180" width="768" height="160" rx="8"/>
<text class="t-zone" x="32" y="208">合并：前台窗口 ∩ 不在 afk</text>
<text class="t-mono" x="32" y="75">aw-watcher-window</text>
<rect class="dd-card" x="176" y="56" width="211.2" height="28" rx="4"/>
<text class="t-val" x="281.6" y="75" text-anchor="middle">Xcode</text>
<rect class="dd-card" x="387.2" y="56" width="115.2" height="28" rx="4"/>
<text class="t-val" x="444.8" y="75" text-anchor="middle">Safari</text>
<rect class="dd-card" x="502.4" y="56" width="134.4" height="28" rx="4"/>
<text class="t-val" x="569.6" y="75" text-anchor="middle">Xcode</text>
<rect class="dd-card" x="636.8" y="56" width="38.4" height="28" rx="4"/>
<text class="t-small" x="656" y="74" text-anchor="middle">Slack</text>
<rect class="dd-card" x="675.2" y="56" width="76.8" height="28" rx="4"/>
<text class="t-val" x="713.6" y="75" text-anchor="middle">Xcode</text>
<text class="t-mono" x="32" y="123">aw-watcher-afk</text>
<rect class="dd-card" x="176" y="104" width="230.4" height="28" rx="4"/>
<text class="t-val" x="291.2" y="123" text-anchor="middle">not-afk</text>
<rect class="dd-derived" x="406.4" y="104" width="76.8" height="28" rx="4"/>
<text class="t-mono t-acc" x="444.8" y="123" text-anchor="middle">afk</text>
<rect class="dd-card" x="483.2" y="104" width="268.8" height="28" rx="4"/>
<text class="t-val" x="617.6" y="123" text-anchor="middle">not-afk</text>
<text class="t-mono" x="32" y="235">屏幕前</text>
<rect class="dd-card" x="176" y="216" width="230.4" height="28" rx="4"/>
<text class="t-val" x="291.2" y="235" text-anchor="middle">12 min</text>
<rect class="dd-derived" x="406.4" y="216" width="76.8" height="28" rx="4"/>
<text class="t-mono t-acc" x="444.8" y="235" text-anchor="middle">?</text>
<rect class="dd-card" x="483.2" y="216" width="268.8" height="28" rx="4"/>
<text class="t-val" x="617.6" y="235" text-anchor="middle">14 min</text>
<text class="t-note" x="176" y="266">09:00</text>
<text class="t-note" x="406.4" y="266" text-anchor="middle">09:12</text>
<text class="t-note" x="483.2" y="266" text-anchor="middle">09:16</text>
<text class="t-note" x="752" y="266" text-anchor="end">09:30</text>
<text class="t-say" x="32" y="298">虚线这 4 分钟在 Safari 里读文档，没碰键鼠，超过 3 分钟就被记成了离开。</text>
<text class="t-say" x="32" y="316">合计：屏幕前 26 分钟，应用切换 4 次；这 4 分钟算不算工作，记录里看不出来。</text>
</svg>
</div>
<figcaption>图 2 · “屏幕前时间”是前台窗口和“人在键盘前”两路记录的交集。只看键鼠输入，读文档、看视频这种不动手的时间会被漏掉。图中数值为示意。</figcaption>
</figure>

图 2 也暴露了这种做法的边界：**它测的是“手在键盘上”，不是“眼睛在屏幕上”**。读长文档、看视频、开视频会议时手不动，就会被记成离开。ActivityWatch 的缓解办法是浏览器标签页在播放声音时不判离开[^aw-faq]；把阈值从 3 分钟调到 5 分钟也能少漏一些，代价是真离开时会多算几分钟。

到这里，“在屏幕前多久”有了一个分钟级的答案。但用户还想知道**不同时间段里工作状态怎么样**，这就不只是“在不在”的问题了。

---

## 三、工作状态：从行为和身体两侧看

### 什么叫“工作状态”

“状态好不好”没有一个能直接测量的物理量。我们能做的，是找几个可以测量、又和状态有关的信号，从两个方向去逼近：**人在电脑上做了什么**（行为），以及**身体当时处在什么状态**（生理）。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="adr-state-title">逼近“工作状态”的几类信号</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="adr-state-title">
<colgroup><col style="width:22%"><col style="width:22%"><col><col></colgroup>
<thead>
<tr><th scope="col">信号</th><th scope="col">来源</th><th scope="col">能说明</th><th scope="col">说明不了</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="4" scope="rowgroup">行为（Mac）</th></tr>
<tr><th scope="row">连续停留在一个应用</th><td class="mono">aw-watcher-window</td><td class="good">长时间不切换，往往是在专注做一件事</td><td>在 IDE 里发呆也算“专注”</td></tr>
<tr><th scope="row">应用切换频率</th><td class="mono">aw-watcher-window</td><td class="good">一刻钟切换几十次，大概率在被打断</td><td>查资料时频繁切换也可能很高效</td></tr>
<tr><th scope="row">键鼠输入量</th><td class="mono">aw-watcher-input</td><td>在产出还是在浏览</td><td>思考时手是不动的</td></tr>
<tr><th scope="row">屏幕内容</th><td>截屏 + 大模型</td><td class="good">具体在干什么，语义最丰富</td><td>隐私代价最高</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">生理（Apple Watch）</th></tr>
<tr><th scope="row">心率</th><td class="mono">HeartRate</td><td>紧张、活动、咖啡因都会让它升高</td><td>分不清是哪个原因</td></tr>
<tr><th scope="row">HRV</th><td class="mono">HRV SDNN</td><td>自主神经的负荷</td><td class="bad">白天样本太稀，只能看大趋势</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">行为信号是分钟级、连续的；生理信号在白天是稀疏的点。两侧单独看都会误判，叠在一起才有参考价值。</p>
</div>
<figcaption>图 3 · 没有哪个信号直接等于“状态”。行为信号说明人在做什么，生理信号说明身体在承受什么。</figcaption>
</figure>

### 行为一侧：切换和停留

行为信号里最实用的是**应用切换**。图 2 那半小时里切换了 4 次，主要停在 Xcode；如果另一个半小时在 Slack、邮件、浏览器之间切换了 40 次，不用任何算法也看得出来，后者是一段被打断的时间。RescueTime、Rize 这类工具给出的“专注度评分”，核心也是这套逻辑：按应用分类打分，再看连续时长和切换[^rescuetime][^rize]。

它的局限也写在表里：应用名只说明“在哪”，不说明“在干什么”。同样是开着浏览器，可能在查文档，也可能在刷新闻。于是有了一类新工具：定时截屏，再让大模型读截图，总结这段时间在做什么。开源的 Dayflow 就是这样生成一天的活动卡片时间线的，模型可以跑在本地[^dayflow]；screenpipe 则持续录屏、做 OCR 和语音转写[^screenpipe]。语义最丰富，隐私代价也最高。

### 身体一侧：心率能用，HRV 太稀

Apple Watch 白天会在后台测心率。按 Apple 支持页的说法，较新的机型白天可以持续测量，老机型则在你静止时周期性地测、间隔不固定[^heart-rate]。总之，心率是一条足够密的曲线，能对上每一段工作。

HRV 就不一样了。我在[从两个对不上的 HRV 数字说起](/blog/hrv-two-numbers/)里讲过，手表只在静止、戴稳时才测 HRV，白天的样本很稀，单次读数的噪声又大。拿它去判断“10:00–10:15 是不是专注”是不现实的，它只适合回答“今天下午整体是不是比上午紧”这种大尺度的问题。

而且生理信号**不特异**：心率升高可能是紧张，也可能是刚爬完楼梯、刚喝了咖啡。所以它要和行为信号放在一起看：同样是心率 90，一段是 Mac 上连续 40 分钟写代码，一段是刚从楼下走上来，含义完全不同。

到这里，人这一侧的信号差不多齐了。还剩最后一块拼图：**人所处的环境。**

---

## 四、环境：噪声、日光与地点

### 噪声：手表一直在听，但不录音

Apple Watch 的噪声 App 用麦克风测量环境声级，写进 HealthKit 的 `environmentalAudioExposure`，单位是 A 计权声压级（dBA）[^env-audio]。按 Apple 支持页的说法，它在一天里**周期性地**测量，只测声级，**不录音也不保存任何声音**；具体多久测一次，Apple 没有公开[^noise-support]。所以我们能拿到的是一串带时间区间的分贝值，而不是音频。

这里有一个容易踩的坑：**分贝不能直接平均。** 分贝是对数刻度，65 dB 的声能是 55 dB 的 10 倍。一刻钟里 5 分钟是 54 dB、10 分钟是 66 dB，算术平均是 62 dB，但按声能平均要先换回能量：

$$
L_{eq} = 10 \log_{10}\left(\frac{1}{T}\sum_i t_i \cdot 10^{L_i/10}\right)
$$

其中 $t_i$ 是每段的时长，$L_i$ 是这段的分贝值，$T$ 是总时长。代入上面的例子：

$$
10 \log_{10}\frac{5 \cdot 10^{5.4} + 10 \cdot 10^{6.6}}{15} \approx 64.4
$$

结果是 64.4 dB。这就是“等效连续声级” $L_{eq}$，HealthKit 里每个噪声样本本身就是这种值。差的这 2 dB，对应的声能差了将近 60%。

另外两个相关的类型也值得记下：`headphoneAudioExposure` 记录耳机里的音量；`environmentalSoundReduction`（iOS 16 起）记录戴降噪耳机时环境声被压低了多少[^sound-reduction]。后者很有意思：**一段时间里一直在降噪，往往说明环境吵、人又想专注**（推断）。

### 日光与地点

- **日光时间**：watchOS 10 起，较新的 Apple Watch 用环境光传感器估算你在日光下待了多久，写进 `timeInDaylight`[^daylight]。它可以粗略地区分室内和户外。
- **地点**：Core Location 的 `CLVisit` 会在你到达、离开一个地点时通知 App，给出坐标和起止时间[^clvisit]。“办公室 09:05–12:10”“餐厅 12:20–13:05”这类停留就是这样来的。iPhone 自己的“重要地点”则没有公开接口，第三方读不到。

### 拿不到的那部分：SensorKit

Apple 其实有一个更全的传感器框架 SensorKit：环境光、气压、键盘使用指标、手机使用报告、常去地点、是否戴在手腕上、语音指标……几乎就是“还原一天”的现成原料[^sensorkit]。但它**只授予获得 Apple 批准的研究项目**[^sensorkit-config]，新数据还要等 24 小时才能读取[^sensorkit-fetch]。个人开发者和普通 App 都拿不到。

现在，人、身体、环境三方面的数据都找到了来源。**接下来的问题是：它们怎么拼到同一条时间线上？**

---

## 五、拼成一条时间线

### 问题：每路数据的“时间形状”不一样

把这些数据摆在一起，会发现它们记录时间的方式各不相同：

- **区间累计**：站立分钟、步数。一条样本说的是“10:07–10:12 之间站了 5 分钟”“09:58–10:03 走了 200 步”。
- **时间点**：心率。一条样本就是“10:07 心率 78”。
- **区间均值**：噪声。一条样本是“10:05–10:20 的等效声级 66 dB”。
- **事件区间**：Mac 的 afk 和前台窗口。一条事件是“09:00–09:12 not-afk”。

数据也分在两台机器上：健康数据从手表同步到 iPhone 的 HealthKit；Mac 的记录在本机的 ActivityWatch 里。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="adr-flow-title adr-flow-desc">
<title id="adr-flow-title">两台设备的数据汇入同一个脚本</title>
<desc id="adr-flow-desc">iPhone 的 HealthKit 里有站立分钟、步数、心率、环境噪声四类记录，时间形状分别是区间累计、区间累计、时间点和区间均值，通过健康 App 导出为 export.xml。Mac 上 ActivityWatch 有 afk 和前台窗口两个 bucket，都是事件区间，通过本机 REST 接口读取。两边都汇入 day_timeline.py，摊进 15 分钟一格的时间表。</desc>
<defs>
<marker id="adr-flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="adr-flow-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="392" height="228" rx="8"/>
<text class="t-zone" x="32" y="44">iPhone · HealthKit</text>
<rect class="dd-zone" x="424" y="16" width="360" height="228" rx="8"/>
<text class="t-zone" x="440" y="44">Mac · ActivityWatch</text>
<rect class="dd-zone" x="16" y="260" width="768" height="104" rx="8"/>
<text class="t-zone" x="32" y="288">合并</text>
<path class="dd-edge" d="M212 196 V306" marker-end="url(#adr-flow-arrow)"/>
<path class="dd-edge-acc" d="M360 326 H446" marker-end="url(#adr-flow-arrow-acc)"/>
<path class="dd-edge-acc" d="M752 144 V306" marker-end="url(#adr-flow-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="360" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H386 Q391.4 60.6 391.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="392" y2="92"/>
<text class="t-name" x="48" y="81">Record</text>
<text class="t-note" x="376" y="81" text-anchor="end">时间形状 · 单位</text>
<text class="t-mono" x="48" y="109">AppleStandTime</text><text class="t-val" x="376" y="109" text-anchor="end">区间累计 · min</text>
<line class="dd-hair" x1="48" y1="118" x2="376" y2="118"/>
<text class="t-mono" x="48" y="135">StepCount</text><text class="t-val" x="376" y="135" text-anchor="end">区间累计 · count</text>
<line class="dd-hair" x1="48" y1="144" x2="376" y2="144"/>
<text class="t-mono" x="48" y="161">HeartRate</text><text class="t-val" x="376" y="161" text-anchor="end">时间点 · count/min</text>
<line class="dd-hair" x1="48" y1="170" x2="376" y2="170"/>
<text class="t-mono" x="48" y="187">EnvironmentalAudioExposure</text><text class="t-val" x="376" y="187" text-anchor="end">区间均值 · dBASPL</text>
<text class="t-say" x="232" y="226">手表同步到 iPhone</text>
<rect class="dd-card" x="440" y="60" width="328" height="84" rx="6"/>
<path class="dd-card-head" d="M440.6 92 V66 Q440.6 60.6 446 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="440" y1="92" x2="768" y2="92"/>
<text class="t-name" x="456" y="81">bucket</text>
<text class="t-note" x="752" y="81" text-anchor="end">时间形状 · 字段</text>
<text class="t-mono" x="456" y="109">aw-watcher-afk_mbp</text><text class="t-val" x="752" y="109" text-anchor="end">事件区间 · status</text>
<line class="dd-hair" x1="456" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="456" y="135">aw-watcher-window_mbp</text><text class="t-val" x="752" y="135" text-anchor="end">事件区间 · app</text>
<text class="t-note" x="440" y="176">GET localhost:5600/api/0/buckets/…/events</text>
<rect class="dd-card" x="32" y="308" width="328" height="36" rx="6"/>
<text class="t-mono" x="48" y="331">export.xml</text>
<text class="t-note" x="344" y="331" text-anchor="end">健康 App → 导出所有健康数据</text>
<rect class="dd-card" x="448" y="308" width="320" height="36" rx="6"/>
<text class="t-mono t-acc" x="464" y="331">day_timeline.py</text>
<text class="t-note" x="736" y="331" text-anchor="end">摊进 15 分钟格子</text>
</svg>
</div>
<figcaption>图 4 · 两台设备、四种时间形状。健康数据从 iPhone 导出，Mac 的记录从本机接口读取，最后在一个脚本里对齐。</figcaption>
</figure>

健康数据怎么搬到 Mac 上？最省事的是健康 App 里“导出所有健康数据”，得到一个 `export.xml`，每条样本是一个 `<Record>` 节点，带类型、数值、单位和起止时间。缺点是要手动点、文件动辄几百 MB 甚至几个 GB。想自动化，可以用 Health Auto Export 这类 App 定时把指定类型推到本地或自建的接口[^hae]。

### 第一次尝试：按事件对齐

最直觉的做法是以 Mac 的事件为主轴：每一段 not-afk 事件，去找它时间范围内的心率、噪声、站立。问题是 Mac 事件的长短差很多，有的 10 秒，有的两小时；人离开 Mac 的时间段又根本没有事件，手表那边的数据就挂不上去。

### 解决：切成等长的格子，按重叠比例分摊

更稳的办法是**先把一天切成等长的格子**（比如 15 分钟一格），再把每路数据按它的时间形状摊进格子里：

- **区间累计型**按重叠时长的比例分摊。09:58–10:03 的 200 步，有 3 分钟落在 10:00–10:15 这一格，就分给它 120 步。
- **时间点型**取格子内的平均。
- **区间均值型**按重叠时长加权，噪声还要按上一节的声能平均。
- **事件区间型**直接算重叠了多少秒。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 392" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="adr-slot-title adr-slot-desc">
<title id="adr-slot-title">10:00 到 10:15 这一格是怎么填出来的</title>
<desc id="adr-slot-desc">五路样本铺在 09:45 到 10:30 的时间轴上。站立样本 10:07 到 10:12 整段落在格内，贡献 5 分钟；两段步数各有一部分落进格内，共 240 步；格内三个心率点平均 74；两段噪声按重叠时长做声能平均，得到 64 分贝；afk 记录全程 not-afk，屏幕前 15 分钟。结果推断为屏幕前、站过。</desc>
<rect class="dd-frame" width="800" height="392" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="244" rx="8"/>
<text class="t-zone" x="32" y="44">五路样本 · 09:45–10:30</text>
<text class="t-note" x="768" y="44" text-anchor="end">落进这一格</text>
<rect class="dd-zone" x="16" y="276" width="768" height="100" rx="8"/>
<text class="t-zone" x="32" y="304">这一格的结果</text>
<rect class="dd-derived" x="336" y="56" width="160" height="176" rx="4"/>
<text class="t-note" x="336" y="52" text-anchor="middle">10:00</text>
<text class="t-note" x="496" y="52" text-anchor="middle">10:15</text>
<line class="dd-hair" x1="176" y1="72" x2="656" y2="72"/>
<line class="dd-hair" x1="176" y1="108" x2="656" y2="108"/>
<line class="dd-hair" x1="176" y1="144" x2="656" y2="144"/>
<line class="dd-hair" x1="176" y1="180" x2="656" y2="180"/>
<line class="dd-hair" x1="176" y1="216" x2="656" y2="216"/>
<text class="t-mono" x="32" y="76">AppleStandTime</text>
<rect class="dd-fill-acc" x="410.7" y="64" width="53.3" height="16" rx="2"/>
<text class="t-val t-acc" x="768" y="76" text-anchor="end">5 min</text>
<text class="t-mono" x="32" y="112">StepCount</text>
<rect class="dd-card" x="314.7" y="100" width="53.3" height="16" rx="2"/>
<text class="t-small" x="341.3" y="112" text-anchor="middle">200</text>
<rect class="dd-card" x="474.7" y="100" width="53.3" height="16" rx="2"/>
<text class="t-small" x="501.3" y="112" text-anchor="middle">300</text>
<text class="t-val" x="768" y="112" text-anchor="end">240 步</text>
<text class="t-mono" x="32" y="148">HeartRate</text>
<circle class="dd-dot" cx="197.3" cy="144" r="3"/>
<circle class="dd-dot" cx="250.7" cy="144" r="3"/>
<circle class="dd-dot" cx="304" cy="144" r="3"/>
<circle class="dd-dot" cx="357.3" cy="144" r="3"/>
<circle class="dd-dot" cx="410.7" cy="144" r="3"/>
<circle class="dd-dot" cx="464" cy="144" r="3"/>
<circle class="dd-dot" cx="517.3" cy="144" r="3"/>
<circle class="dd-dot" cx="570.7" cy="144" r="3"/>
<circle class="dd-dot" cx="624" cy="144" r="3"/>
<text class="t-small" x="357.3" y="135" text-anchor="middle">71</text>
<text class="t-small" x="410.7" y="135" text-anchor="middle">78</text>
<text class="t-small" x="464" y="135" text-anchor="middle">74</text>
<text class="t-val" x="768" y="148" text-anchor="end">74 bpm</text>
<text class="t-mono" x="32" y="184">AudioExposure</text>
<rect class="dd-card" x="229.3" y="172" width="160" height="16" rx="2"/>
<text class="t-small" x="309.3" y="184" text-anchor="middle">54 dB</text>
<rect class="dd-card" x="389.3" y="172" width="160" height="16" rx="2"/>
<text class="t-small" x="469.3" y="184" text-anchor="middle">66 dB</text>
<text class="t-val" x="768" y="184" text-anchor="end">64 dB</text>
<text class="t-mono" x="32" y="220">aw-watcher-afk</text>
<rect class="dd-card" x="176" y="208" width="480" height="16" rx="2"/>
<text class="t-small" x="416" y="220" text-anchor="middle">not-afk</text>
<text class="t-val" x="768" y="220" text-anchor="end">15 min</text>
<text class="t-say" x="32" y="250">虚线框是 10:00–10:15 这一格：区间样本按重叠时长摊进来，时间点样本取平均。</text>
<text class="t-mono" x="32" y="332">10:00  屏幕 15  站立 <tspan class="t-acc">5</tspan>  步数 240  心率 74  噪声 64 dB</text>
<text class="t-say" x="32" y="358">推断：屏幕前·站过（Mac 一直有输入，手表也记到了 5 分钟站立，可能是升降桌）</text>
</svg>
</div>
<figcaption>图 5 · 一格是怎么填出来的。步数 = 200 × 3/5 + 300 × 2/5 = 240；噪声按声能平均是 64 dB，算术平均会得到 62 dB。图中数值为示意。</figcaption>
</figure>

每一格都填好之后，再用几条简单的规则给它贴一个状态标签：

- 步数很多：走路或通勤。
- Mac 有输入超过 10 分钟：在屏幕前；手表没记到站立就是**坐着**，记到了就是“站过”。
- Mac 没输入、手表记到站立：站着或走动。
- Mac 没输入、手表也没记到站立、但有心率：**静止，坐站不明**。
- 连心率都没有：多半是没戴表。

最后两条很关键：**规则不知道的时候，要老实写“不知道”**，而不是把它塞进“坐着”。图 1 里那 8 分钟就属于这一类。

把一整天的格子加起来，就得到了开头想要的几个数：站了多久、坐在屏幕前多久、还有多少时间是“静止但不知道坐还是站”。文末给出了完整的脚本。

<!-- TODO: 用作者自己一天的真实数据跑一遍脚本，把输出（或画成的四泳道图）放在这里，并回答开头“站立 10 小时 + 屏幕 7 小时”的疑问。 -->

**这件事自己拼要写不少代码，那有没有软件已经做好了？**

---

## 六、现有软件做到了哪一步

按前面拆出的几个维度，把常见的软件放在一起比一比：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="adr-apps-title">现有软件覆盖了哪些维度</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="adr-apps-title">
<colgroup><col style="width:20%"><col><col><col><col><col></colgroup>
<thead>
<tr><th scope="col">软件</th><th scope="col">站 / 坐</th><th scope="col">屏幕前</th><th scope="col">工作状态</th><th scope="col">噪声</th><th scope="col">地点</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="6" scope="rowgroup">Apple 自带</th></tr>
<tr><th scope="row">健康 / 健身</th><td>站立小时、站立分钟</td><td class="bad">无</td><td>心率、HRV 原始数据</td><td class="good">有数据</td><td class="bad">无</td></tr>
<tr><th scope="row">屏幕使用时间</th><td class="bad">无</td><td>小时粒度，不能导出</td><td class="bad">无</td><td class="bad">无</td><td class="bad">无</td></tr>
<tr class="dt-group"><th colspan="6" scope="rowgroup">Mac 时间追踪</th></tr>
<tr><th scope="row">ActivityWatch</th><td>仅升降桌插件</td><td class="good">分钟级，本地</td><td>窗口、键鼠量</td><td class="bad">无</td><td class="bad">无</td></tr>
<tr><th scope="row">Timing</th><td class="bad">无</td><td class="good">分钟级，含手机</td><td>项目归类、AI 摘要</td><td class="bad">无</td><td class="bad">无</td></tr>
<tr><th scope="row">RescueTime / Rize</th><td class="bad">无</td><td class="good">有，云端</td><td>专注度评分</td><td class="bad">无</td><td class="bad">无</td></tr>
<tr><th scope="row">Dayflow</th><td class="bad">无</td><td>截屏推断</td><td class="good">大模型总结</td><td class="bad">无</td><td class="bad">无</td></tr>
<tr class="dt-group"><th colspan="6" scope="rowgroup">手机与聚合</th></tr>
<tr><th scope="row">Arc Timeline</th><td>步行等出行方式</td><td class="bad">无</td><td>每段停留的心率</td><td class="bad">无</td><td class="good">停留与行程</td></tr>
<tr><th scope="row">Health Auto Export</th><td>导出站立分钟</td><td class="bad">无</td><td>导出心率、HRV</td><td class="good">导出分贝</td><td class="bad">无</td></tr>
<tr><th scope="row">Exist</th><td>按天</td><td>接 RescueTime</td><td>按天找相关性</td><td class="bad">无</td><td>按天</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">没有一行是全绿的。每个软件只管一两块，且没有一个把 Mac 上的工作记录和手表上的身体数据放在同一条分钟级时间线上。</p>
</div>
<figcaption>图 6 · 现有软件各管一段：Mac 端管屏幕，Arc 管地点，健康数据要靠导出工具搬出来。</figcaption>
</figure>

几个值得单独说的：

- **Mac 端**，ActivityWatch 开源、数据在本地、有 REST 接口，最适合拿来自己拼[^aw-watchers]。Timing 更省心，还能把 iPhone 的屏幕使用时间导进来，但如上一节所说，它依赖系统私有数据库[^timing-st]。
- **地点**，Arc Timeline 用 iPhone 的定位和运动传感器把一天切成“停留”和“行程”，还会在每段旁边读 Apple Health 的步数和心率，数据存在手机本地，可以每天自动导出 JSON[^arc]。它是线下这一半做得最完整的。
- **健康数据搬运**，Health Auto Export 可以把 HealthKit 里一百多种指标按分钟导出为 JSON/CSV，或推到自己的服务[^hae]。前面图 4 里手动导出 `export.xml` 那一步，可以用它替代。
- **聚合**，Exist 能把 Apple Health 和 RescueTime 等数据放在一起，但粒度是按天，做的是“哪些因素和心情相关”，不是时间线[^exist]。
- **个人实践**，Felix Krause 的 howisFelix.today 从 2019 年起记录了约 38 万条个人数据，来源是 RescueTime、地点签到、天气接口和一个 Telegram 机器人手动录入，统一存进自己的数据库[^felix][^fxlifesheet]。它说明这件事做得到，也说明了代价：相当一部分靠手动录入，粒度也主要是按天。
- **已经消失的路线**，Rewind（后改名 Limitless）曾主打 Mac 录屏加随身录音的“全量回放”，被 Meta 收购后已停止录屏和录音[^limitless]。

所以结论是：**目前没有一个软件能单独做到“还原一天”。** 最接近的组合是 Mac 上的 ActivityWatch 或 Timing、手机上的 Arc Timeline，加上 Health Auto Export 把手表数据搬出来，最后自己按时间对齐。空白主要在两处：一是坐和站的精确区分，二是把 Mac 上的工作和手表上的身体数据放在同一条分钟级时间线上。

---

## 七、边界：哪些还原不了

拼到这一步，还有几块是现有设备补不上的：

- **离开电脑后的“坐还是站”**：开会、吃饭、在沙发上看手机，手表和 Mac 都无法区分。可以借助地点（在会议室，多半坐着）或日历（这段时间有会）来猜，但仍然是猜。
- **不动手的工作**：读文档、思考、开会，在键鼠记录里都表现为“离开”。截屏加大模型能补上一部分，代价是隐私。
- **私有数据会变**：屏幕使用时间的数据库、Biome 的格式都没有文档，系统升级就可能失效；SensorKit 这样的官方通道又不对个人开放。
- **采样不均匀**：心率密、HRV 稀、噪声的测量间隔不公开。格子切得越细，空格越多；切得越粗，越看不出一段时间内部的变化。15 分钟是一个折中。

---

## 小结

从“站立 10 小时 + 屏幕 7 小时”这两个对不上的数字出发，我们一路拼到了：

1. **手表的“站立小时”不是站立时长**：它只要求每小时站过 1 分钟；真正的时长在 `appleStandTime` 里。HealthKit 没有“坐着”，手腕也很难区分坐站。
2. **“坐着”要靠 Mac 作证**：Mac 有键鼠输入、手表没记到站立，才推断为坐着；两边都没有证据的时间，要老实标成“不明”。
3. **屏幕前时间要自己记**：系统的屏幕使用时间拿不出原始数据，ActivityWatch 这类工具用“前台窗口 ∩ 不在 afk”给出分钟级记录，代价是漏掉不动手的时间。
4. **工作状态要两侧一起看**：应用切换和停留说明人在做什么，心率说明身体在承受什么；HRV 太稀，只能看大趋势。
5. **环境数据手表已经在采**：噪声以等效声级存进 HealthKit，平均时要按声能平均；日光时间和 `CLVisit` 补上室内外和地点。
6. **拼接的关键是等长格子**：四种时间形状的数据按重叠比例摊进 15 分钟一格，再用规则贴标签。
7. **没有现成软件全覆盖**，最接近的是 ActivityWatch / Timing + Arc Timeline + Health Auto Export 的组合。

下面是图 4、图 5 那条路的完整实现：读健康 App 导出的 `export.xml` 和本机 ActivityWatch 的记录，输出 15 分钟一格的时间表和全天合计。只用 Python 标准库。

```python
"""把 Apple 健康导出（export.xml）和 ActivityWatch 的记录拼成一张 15 分钟一格的时间表。

用法：python3 day_timeline.py export.xml 2026-11-01
依赖：只用标准库；ActivityWatch 需在本机 5600 端口运行。
"""
import json
import math
import sys
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import datetime, timedelta

EXPORT, DAY = sys.argv[1], sys.argv[2]
TZ = datetime.now().astimezone().tzinfo
START = datetime.fromisoformat(DAY).replace(hour=7, tzinfo=TZ)
END = START + timedelta(hours=17)          # 07:00 – 24:00
SLOT = timedelta(minutes=15)
SLOTS = [START + i * SLOT for i in range(int((END - START) / SLOT))]

WANTED = {
    "HKQuantityTypeIdentifierAppleStandTime": "stand",      # 分钟
    "HKQuantityTypeIdentifierStepCount": "steps",
    "HKQuantityTypeIdentifierHeartRate": "hr",              # 次/分
    "HKQuantityTypeIdentifierEnvironmentalAudioExposure": "noise",  # dBA
}


def overlap(a0, a1, b0, b1):
    """两段时间重叠的秒数。"""
    return max(0.0, (min(a1, b1) - max(a0, b0)).total_seconds())


def spread(table, key, t0, t1, value, mode):
    """把一条样本分摊到它覆盖的格子里。sum 按重叠比例摊，mean 记成带权样本。"""
    span = max((t1 - t0).total_seconds(), 1.0)
    for s in SLOTS:
        sec = overlap(s, s + SLOT, t0, max(t1, t0 + timedelta(seconds=1)))
        if sec:
            if mode == "sum":
                table[s][key] += value * sec / span
            else:
                table[s].setdefault(key + "_w", []).append((value, sec))


# 1. 健康数据：流式解析，导出文件动辄上 GB
health = defaultdict(lambda: defaultdict(float))
for _, el in ET.iterparse(EXPORT):
    kind = WANTED.get(el.get("type")) if el.tag == "Record" else None
    if kind:
        t0 = datetime.strptime(el.get("startDate"), "%Y-%m-%d %H:%M:%S %z")
        t1 = datetime.strptime(el.get("endDate"), "%Y-%m-%d %H:%M:%S %z")
        if t1 >= START and t0 < END:
            mode = "sum" if kind in ("stand", "steps") else "mean"
            spread(health, kind, t0, t1, float(el.get("value")), mode)
    el.clear()


# 2. ActivityWatch：键鼠是否活跃 + 前台应用
def aw_events(prefix):
    base = "http://localhost:5600/api/0/buckets/"
    buckets = json.load(urllib.request.urlopen(base))
    bid = next(b for b in buckets if b.startswith(prefix))
    q = urllib.parse.urlencode({"start": START.isoformat(), "end": END.isoformat(), "limit": -1})
    for e in json.load(urllib.request.urlopen(f"{base}{bid}/events?{q}")):
        t0 = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
        yield t0, t0 + timedelta(seconds=e["duration"]), e["data"]


active = [(t0, t1) for t0, t1, d in aw_events("aw-watcher-afk_") if d["status"] == "not-afk"]
apps = defaultdict(lambda: defaultdict(float))
for t0, t1, d in aw_events("aw-watcher-window_"):
    for a0, a1 in active:                  # 只算人在键盘前的那部分
        lo, hi = max(t0, a0), min(t1, a1)
        for s in SLOTS:
            sec = overlap(s, s + SLOT, lo, hi) if lo < hi else 0
            if sec:
                apps[s][d["app"]] += sec


def mean(pairs):
    return sum(v * w for v, w in pairs) / sum(w for _, w in pairs)


def db_mean(pairs):
    """分贝不能直接平均，先换回声能再平均（等效连续声级 Leq）。"""
    return 10 * math.log10(mean([(10 ** (v / 10), w) for v, w in pairs]))


# 3. 逐格输出，并按规则猜一个状态
print("时间   屏幕  站立  步数  心率  噪声  前台应用        推断")
total = defaultdict(float)
for s in SLOTS:
    h = health[s]
    screen = sum(apps[s].values()) / 60
    stand, steps = min(h["stand"], 15), h["steps"]
    hr = round(mean(h["hr_w"])) if "hr_w" in h else None
    noise = round(db_mean(h["noise_w"])) if "noise_w" in h else None
    top = max(apps[s], key=apps[s].get) if apps[s] else "-"
    if steps > 600:
        state = "走路/通勤"
    elif screen >= 10:              # 推断：Mac 在用，手表没记到站立就当坐着
        state = "屏幕前·坐" if stand <= 3 else "屏幕前·站过"
    elif stand >= 5:
        state = "站着/走动"
    elif hr is not None:
        state = "静止·离开屏幕"     # 坐着开会、站着聊天、吃饭……这里分不出来
    else:
        state = "没数据"            # 多半是没戴表
    total[state] += 15
    total["屏幕"] += screen
    total["站立"] += stand
    print(f"{s:%H:%M}  {screen:4.0f}  {stand:4.0f}  {steps:4.0f}  "
          f"{hr or '-':>4}  {noise or '-':>4}  {top[:14]:<14}  {state}")

print("\n合计（分钟）：", {k: round(v) for k, v in total.items()})
```

用模拟数据跑出来的一段输出长这样：

```text
时间   屏幕  站立  步数  心率  噪声  前台应用        推断
08:30     0     0  1650    69    48  -               走路/通勤
09:00    15     0     0    72    45  Code            屏幕前·坐
09:15    15     0     0    70    45  Code            屏幕前·坐
10:00    15     5     0    69    66  Code            屏幕前·站过
12:00     0     0     0    73    52  -               静止·离开屏幕
...
合计（分钟）： {'静止·离开屏幕': 510, '屏幕': 480, '站立': 25, '走路/通勤': 30, '屏幕前·坐': 450, '屏幕前·站过': 30}
```

规则部分故意写得很朴素，方便按自己的情况改：用升降桌的，可以把“屏幕前·站过”改成按站立分钟的比例拆开；有 Arc Timeline 导出的地点，可以把“静止·离开屏幕”按地点再细分成“会议室”“餐厅”。

---

## 参考资料
