00 · 起因为什么要做这个why build it at all
暖暖已经是一只老狗了,有几个需要兽医定期看着的情况。她的基本健康状况写在她自己的页面上,这里不重复。项圈其实一直在记录,但厂商的 app 是给人「打开看一眼」用的 —— 我不会每小时去看,而真正需要知道的时候,往往正好是没人在看的时候。
She's a senior with a couple of conditions her vet monitors; her basics are on her own page, so I won't repeat them here. The collar was already recording around the clock. But the app is built for opening and glancing at, and I was never going to glance every hour — and the moments that matter tend to be the ones when nobody is looking.
所以我想要的东西很简单:平时安静;不对劲的时候告诉我;而它说「没事」的时候,得是真的没事。
So the goal was plain: stay quiet most of the time, speak up when something is off — and when it says "fine", actually mean it.
唯一的原则 · the one rule
只负责发现和提醒,不负责诊断。它能做的是「这和她平时不一样,去看一下」;判断是什么、要不要处理,永远是兽医的事。
Detect and link, never diagnose. The most it should ever say is "this isn't like her — go look." What it means, and what to do about it, belongs to her vet.
01 · 数据流数据从项圈到三个地方how the data flows — one collar, three outputs
step 1 · 项圈 · the collar
每隔几分钟一条读数
A reading every few minutes
项圈大约每几分钟记一次,最新的一条通常是十分钟以内的。有时候只拿得到更粗的每小时数据 —— 这时候系统照样工作,但会明确标出自己现在是「降级」状态,而不是假装和平时一样准。
The collar logs every few minutes, and the newest point is usually under ten minutes old. Sometimes only the coarser hourly data is available; the system keeps working, but it labels itself as degraded rather than pretending nothing changed.
step 2 · 定时任务 · the scheduled job
和她自己比,不和「一般的狗」比
Compared against her, not against "a typical dog"
大约每 30 分钟,一个小任务拉取最近的数据,和她自己过去每个钟点的正常范围作比较 —— 凌晨三点的她和下午三点的她,本来就不一样。这个基线每周重新算一次。
About every 30 minutes a small job pulls the recent readings and compares them with her own normal range for that hour of the day — 3am Yumi and 3pm Yumi are different dogs. The baseline is rebuilt weekly.
做判断的那一部分是纯粹的代码,不碰网络、不碰文件,所以可以拿她真实的历史数据整段「回放」,看它在过去每一天会说什么。警报这条路上没有 AI:发消息的那一步只是原样转发,不会自己加任何解读。
The part that decides is pure code with no I/O, so it can be replayed against her real history to see what it would have said on every past day. There's no AI in the alert path: the step that sends the message relays it word for word and adds no interpretation of its own.
step 3 · 三个出口 · three outputs
警报、每日页面、公开卡片
Alerts, a daily page, a public card
-
警报 —— 发到我手机上的一条短消息。只有我收到。
Alerts — a text to my phone. Only I get them.
-
每日页面 —— 私密的,24 小时 / 7 天 / 30 天可切换。它总是报告前一个完整的一天,因为今天的数据要到午夜才算齐。
Daily page — private, with a 24h / 7d / 30d toggle. It always reports the previous complete day, because today's counts are partial until midnight.
-
公开卡片 —— 在暖暖的页面上,只放趋势。它单独在云端定时刷新,所以我的电脑合上了,卡片也不会过期。(见 §06)
Public card — on her page, trends only. It refreshes on its own schedule in the cloud, so it doesn't go stale when my laptop is closed. (See §06.)
02 · 心率心率要对照她正在做什么score pulse against what's expected for what she's doing
第一版有一条看起来很合理的规则:心率升高 + HRV 下降 → 可能不舒服。这条观察本身来自她自己的数据,并没有错。问题是:这也正好是运动时的样子。第一版完全看不到她在不在动 —— 于是在公园里走一圈,读起来就像生病了。
The first version had a rule that sounded reasonable: pulse up plus HRV down means she might be unwell. The observation came from her own data and wasn't wrong. The trouble is that it's also exactly what exercise looks like. The first version couldn't see whether she was moving, so a walk in the park read as illness.
翻她的历史就很清楚:一次普通的散步,和一个她真的不舒服的时刻,心率几乎一模一样。不一样的是活动量 —— 差了好几倍。
Her history made it obvious: an ordinary walk and a moment when she genuinely wasn't well had almost the same heart rate. What differed was activity — by several times.
the fix · 改法
先问「此刻应该是多少」,再看差多少
Ask what pulse is expected right now, then look at the gap
-
用她自己的历史,算出在这个活动量、白天还是夜里,她的心率「应该」是多少。真正被打分的,是实际值和这个期望值的差。
From her own history, estimate what her pulse should be at this level of activity, by day or by night. What gets scored is the gap between the actual reading and that expectation.
-
散步时,期望值会跟着一起往上走,差值就保持平稳 —— 不需要一个「她是不是在运动」的开关。但如果心率连这个运动量都解释不了,照样会响。
On a walk the expectation rises in step, so the gap stays flat — no "is she exercising?" switch needed. A pulse that's high even for the exercise still trips.
-
两边都要看。心率异常地低,和异常地高一样重要:一只虚弱或倒下的狗,活动低、心率也低 —— 只看「偏高」的规则,会把这种情况读成「她在安静休息」。
It's two-sided. A pulse that's unusually low matters as much as one that's high: a weak or collapsed dog shows low activity and a low pulse, and a high-only rule would read that as a calm, resting dog.
03 · HRVHRV 看持续多久,不看低到多少HRV is a warning when it stays low, not on one dip
直觉上,HRV 越低越该担心。但回放她的历史,结果出乎意料:不舒服那一天的最低值,和很普通的一天的最低值,几乎一样。单看一个低点,分不开。
Intuitively, the lower the HRV the more worrying. Replaying her history said otherwise: the lowest reading on a day she was unwell and the lowest reading on an ordinary day were almost identical. A single dip doesn't separate them.
能分开的是持续:一天里有多大比例的小时,HRV 一直明显低于她自己在那个钟点的正常水平。不舒服的那天是大半天;普通的一天只是零星几个小时。
What separates them is persistence: the share of hours in a day where HRV sits clearly below her own norm for that hour. On the bad day it was most of the day; on an ordinary day, a few scattered hours.
the rule · 规则
比例,而且要有足够的数据
A proportion — and only with enough data behind it
-
只算真正有读数的小时;至少要有 6 个小时的数据,这个比例才有意义。
Count only hours that actually have readings, and require at least six of them before the proportion means anything.
-
HRV 持续偏低这一条,本身就足以升级,不需要等心率也一起变快才算数。
Persistently low HRV is enough to escalate on its own; it isn't held back until the heart rate also climbs.
-
单个极端读数要有旁证。心率突然跳一下、而 HRV 完全没动,多半是被吓了一跳 —— 门铃、凌晨三点的一声响。这种情况会降级成第二天早上的一条备注,除非这个读数极端到可以单独成立。
One extreme reading needs corroboration. A sudden spike with HRV undisturbed is usually a startle — the doorbell, a noise at 3am. It's demoted to a morning note unless the reading is extreme enough to stand on its own.
-
呼吸是例外,不藏在活动模型后面。持续很快的呼吸:休息时直接提醒,活动时记一条备注 —— 热天散步喘气是正常的,但呼吸信号不应该被「她可能在动」整个盖掉。持续偏低的静息心率也一样,不经过任何过滤。
Breathing is the exception — it isn't hidden behind the activity model. Sustained fast breathing alerts at rest and becomes a note while she's active (panting on a hot walk is normal), but a breathing signal never disappears just because she might be moving. A sustained very low resting pulse is also checked directly, with no filter in front of it.
04 · 缺口缺数据,不能长得像「没事」a missing reading must never look healthy
这是我最在意的一条。一个监测系统最危险的时候,不是它报错,而是它看不见了、却还显示一切正常。项圈会离开她 —— 充电、看兽医的时候。一天里活动接近零、姿态变化也接近零,并不代表她特别安静,多半是项圈根本不在她身上。
This is the one I care about most. The dangerous moment for a monitor isn't when it errors — it's when it goes blind and still reads as fine. The collar comes off her: for charging, at the vet. A day with near-zero activity and near-zero posture change doesn't mean she was especially calm; it usually means the collar wasn't on her.
看不见的时候要说出来 · say so when it's blind
-
项圈丢失、数据过期、电量低 —— 各自有一条明确的消息。
Collar lost, data stale, battery low — each gets its own explicit message.
-
项圈显示「戴着」,但大约 90 分钟没有一条心率读数 —— 说明它不在她身上,也要说。
Collar reports "on" but no heart reading for about 90 minutes — it's off her, and that gets said too.
-
只拿得到每小时数据时,标成「降级」,而不是安静下来。
Falling back to hourly data is marked degraded, not silent.
-
如果建不出一个合理的基线,它会直接发「监测现在没有正常工作」,而不是悄悄失败成一种和健康分不出来的沉默。
If it can't build a sane baseline, it sends "the monitor isn't watching properly" instead of failing quietly into a silence that looks just like health.
-
触发过最高级别警报的日子,不会被算进每周重建的基线里 —— 不然它会慢慢把「不舒服的一天」学成「正常」。
Days that triggered the top tier are kept out of the weekly baseline rebuild, so it doesn't slowly learn a bad day as normal.
the clearest example · 最典型的一次
一条平平的体重线
A perfectly flat weight line
项圈称不了体重。它只是把 app 资料里填的那个数字原样返回 —— 好几个星期都不变。于是好几个月,每日页面上画着一条完美平直的 30 天「趋势」。如果她瘦了半公斤,这条线会一模一样。一条意思是「没人称过她」的平线,被画成了「稳定」。
The collar can't weigh her. It echoes back whatever weight is set in her app profile, unchanged for weeks. So for months the daily page drew a perfectly flat 30-day "trend" — one that would have looked identical if she'd lost half a kilo. A flat line that really meant "nobody weighed her" was drawn as "stable".
-
现在体重只来自一份带日期的记录:我亲手称一次,记一次。图上只画真实称过的点,中间的空白就留着空白。
Weight now comes only from a dated log of times I actually weigh her. The chart plots real weighings on their real dates; gaps stay gaps.
-
页面写着「N 天前称的」;超过 30 天没称,会自己亮一个提醒 —— 沉默不能被读成稳定。
The page says "weighed N days ago", and a weighing older than 30 days raises a flag of its own — so silence can't read as stability.
-
顺带发现:「体重下降太多就提醒」这条检查其实一直都在,但从来没有任何东西把上一次的体重传给它,所以它从来不可能触发。现在它从记录里自己取上一次的值,我也模拟了一次下降,确认它会响。
Along the way: a check for a large weight drop had existed all along, but nothing ever passed it the previous weight, so it could never fire. It now reads the previous weighing from the log, and a simulated drop confirmed that it does.
年龄是同一类问题:写死在页面里的「14 岁」,会在某一天悄悄变成错的。所以她的年龄永远从生日算出来 —— 2027 年 4 月她满 15 岁,不需要任何人去改一个字。
Age is the same class of bug: a hard-coded "14" quietly becomes wrong one day. So her age is always computed from her birthday — she turns 15 in April 2027 without anyone editing a word.
05 · 分级三档提醒,和误报的代价three tiers, and what false alarms cost
一个经常误报的监测,很快就会被人忽略 —— 那和没有一样。所以提醒分三档:备注(第二天早上看)、提醒、升级。同一件事有冷却时间,不会反复发;只有最高一档不受冷却限制。还有「敏感」和「保守」两种灵敏度可以切换。
A monitor that cries wolf gets ignored, which is the same as not having one. So there are three tiers: note (read in the morning), alert, and escalate. Each condition has a cooldown so the same thing doesn't repeat; only escalate bypasses it. Sensitivity can be set to sensitive or conservative.
a rule that failed · 一条失败的规则
上厕所,被算成了「40 个标准差」
A bathroom trip scored as a 40-sigma event
有一条规则想抓「休息时间里异常地不安」。早期比较宽松的版本,在回放测试里 29 次提醒有 21 次是误报。原因是活动数据的形状:大部分时间是零,偶尔一个很大的尖峰。对这种分布直接算标准分,夜里起来上个厕所就成了 40 个标准差。
One rule tried to catch unusual restlessness during rest hours. An earlier, looser version produced 21 false alarms out of 29 in backtest. The cause was the shape of the data: activity is mostly zeros with the occasional big spike, so a plain z-score turns a night-time bathroom trip into a 40-sigma event.
现在它必须同时满足一个绝对水平和持续一段时间,而且只停留在「备注」这一档 —— 我还没有足够的证据证明它是对的,所以它不被允许把我叫醒。
It now needs an absolute level and persistence, and it stays at note tier — I don't have the evidence that it's right yet, so it isn't allowed to wake me up.
改阈值之前 · before changing a threshold
任何阈值的改动,先拿她的真实历史回放一遍,看它在过去每一天会说什么,再上线。没回放过的阈值,只是一个猜测。
Any threshold change gets replayed against her real history — what would it have said on every past day? — before it ships. A threshold that hasn't been replayed is a guess.
06 · 公开公开卡片:只放白名单里的东西the public card — only whitelisted, historical-trend fields
暖暖页面上的那张卡片用的是同一份数据,但只有很小的一部分能出去。原则是一句话:分享暖暖,保护我。她的生命体征可以公开;发生过什么事、兽医相关的信息、位置、账户、以及她什么时候不在家,都不行。
The card on her page draws on the same data, but only a small slice is allowed out. The principle fits in one line: share Yumi, protect me. Her vitals are fine to share; episodes, anything from the vet, location, accounts, and when she's away are not.
what passes · 能出去的
一张写在代码里的白名单
A whitelist, written in code
-
她的名字、更新时间、几项最近的读数(心率、呼吸、HRV、睡眠、体温指标)、每天早上写的一句话,以及最近 30 天的心率趋势 —— 只是整数,不带日期。
Her name, an update time, a few recent readings (pulse, breathing, HRV, sleep, a temperature indicator), a one-line note written each morning, and her pulse trend over the last 30 days — whole numbers, no dates.
-
白名单是在代码里强制执行的:不在名单上的字段根本出不去。设备编号、体重、兽医信息、发生过的事、位置,都过不了。
The whitelist is enforced in code: a field that isn't on it can't pass. No device IDs, weight, vet data, episodes or location get through.
-
公开趋势里的空白会被平滑掉 —— 正好和私密那一边相反。私密页面上,缺口必须大声说出来;公开卡片上,一段空白可能会告诉陌生人「她现在不在家」。所以在公开这一边,不暴露她的行踪优先。
Gaps in the public trend are smoothed over — the opposite of the private side. Privately, a gap has to be loud; publicly, a hole in the line could tell a stranger she's away. On the public side, not revealing where she is wins.
卡片本身也改过一次说法。最早它做成了「此时此刻」的样子,还有一个假装在跳的计时;但公开那边的数据本来就晚一个小时甚至更多。现在它叫 lately(最近),老老实实写着「X 分钟前更新」,每 5 分钟刷新一次,底下一行写着:from her PetPace collar · tracking, not veterinary advice。
The card's framing changed once, too. It started as a "right now" readout with a fake ticking update, when the data is really an hour or more behind. Now it's called lately, says honestly "updated X ago", refreshes every five minutes, and ends with: from her PetPace collar · tracking, not veterinary advice.
07 · 未完成还没做好的地方what's still rough
-
「不安」那条规则还没有被验证。它只能写备注,直到回放能证明它值得更多。
The restlessness rule is unvalidated. It stays note-only until a replay shows it deserves more.
-
热天还没被考虑进去。天热会让心率和呼吸都升高,项圈也有环境温度的数据,但模型现在还没用它。
Heat isn't modelled yet. Hot days raise both pulse and breathing; the collar reports ambient temperature, but the model doesn't use it so far.
-
警报还依赖一台电脑开着。云端的备用推送在计划里,还没做。
Alerts still depend on one computer being on. A cloud fallback is on the list, not built.
-
公开卡片的精度上限是每小时。它画的是「最近怎么样」,不是心电图。
The public card tops out at hourly resolution. It shows how she's been lately, not a live ECG.
···
aboutAbout this noteand what it isn't
This describes one setup, built for one dog, tuned to her own history. None of the rules above are clinical thresholds, and they shouldn't be copied as if they were — the point of the design is that every number is measured against her, not against dogs in general. It catches some things earlier than I would on my own; it has also been wrong, which is most of what this note is about.
这里写的是一套为一只狗做的东西,所有的规则都是按她自己的历史调出来的。上面没有任何一条是临床标准,也不应该被当成标准照搬 —— 这个设计的意义恰恰在于,每一个数字都是和她自己比,而不是和「一般的狗」比。它有时候能比我更早发现一些事;它也错过,这篇大部分写的就是那些错。