rack:自动恢复的平线判据按实测重定(p95−p05 + 中位数下限)

原判据「窗口内 max − min < 5 mA」抓不到真卡死:10-04 第 1 路平在 520 mA 五个半小时,
偶发尖峰让 590 个窗口的 max − min 都在 17 mA 以上,一个都判不出来。

- 平线改成:p95 − p05 < flat_ua(30 mA)且中位数 ≥ flat_min_ua(100 mA,新增参数)。
  下限是给空闲低功耗留的:睡着的手机平在 ~10 mA 是正常的。
- 过低不变(窗内最大值 < 30 mA):睡着的手机每 25 s 被长轮询叫醒,窗内最大值都在 213 mA 以上。
- 真实遥测回放:两次真卡死(10-04 第 1 路、10-05 09:40 第 5 路平在 382 mA)都判出;
  亮屏常醒 ~26 h × 4 路、空闲睡着 ~6 h × 5 路、试验 / OTA 期间零误判。
- 测试 +4(带尖峰的平线 / 睡着是健康的 / 睡下去再没醒 = 过低 / 低位平线不下结论),恢复页加「中位数不低于」。

依据和局限写在 recovery.py 模块头。已部署;五路的自动恢复开关仍然全关,还没真正触发过一次。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-10-06 05:14:22 +08:00
共同撰写人 Claude Opus 5.5
父节点 d1fab040cc
当前提交 ca7435242f
共修改 5 个文件,包含 65 行新增和 15 行删除
+1 -1
查看文件
@@ -90,7 +90,7 @@ bash rack/deploy/deploy.sh # 构建 → 传 → 服务器上 Python 3.8
## 已知限制 / 下一步 ## 已知限制 / 下一步
- **自动恢复阈值没有实测依据**(平线 5 mA / 过低 30 mA / 窗口 5 分钟)。插上刀片和手机后先看几天曲线再定,默认逐台关闭。 - **自动恢复阈值 2026-10-06 按板 B 实测定过一次**(平线:p95 − p05 < 30 mA 且中位数 ≥ 100 mA / 过低:窗内最大 < 30 mA / 窗口 5 分钟;依据见 `recovery.py` 模块头)。样本只有一次真卡死 + 一晚睡眠数据,**还没开过一次自动恢复**,默认逐台关闭。
- **adb 探活**:接口 `recovery.Prober` 已留,服务器目前够不着手机(机架在 192.168.8.x 的 NAT 后面)。 - **adb 探活**:接口 `recovery.Prober` 已留,服务器目前够不着手机(机架在 192.168.8.x 的 NAT 后面)。
- 前端 Element Plus 是整包引入(gzip ≈335 KB),手机首屏偏慢;改成按需引入可以再砍一半。 - 前端 Element Plus 是整包引入(gzip ≈335 KB),手机首屏偏慢;改成按需引入可以再砍一半。
- 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。 - 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。
+29 -9
查看文件
@@ -1,12 +1,28 @@
"""自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。 """自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。
服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测: 服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测:
· FLAT:窗口内电流几乎不变(max − min < flat_ua)且不低 —— 卡死的手机常见的样子; · FLAT:窗口内电流平在一个不低的水平上 —— **中位数 ≥ flat_min_ua 且 p95 − p05 < flat_ua**。卡死的手机就是这样;
· LOW :窗口内电流一直低于 low_ua —— 关机 / 掉到待机,却本该在跑。 · LOW :窗口内电流一直低于 low_ua —— 关机,或者睡下去之后再也没醒过(代理不轮询了)。
adb 探活留了接口(`Prober`),手机能从服务器够到(比如经 frp)时再接上。 adb 探活留了接口(`Prober`),手机能从服务器够到(比如经 frp)时再接上。
⚠️ 这两条阈值**还没有实测依据**(刀片还没插、手机还没上架),默认值是按常识填的。 阈值的依据(2026-10-06,板 B 五台小米8 的 1 Hz 遥测,300 s 窗口每 30 s 滑一次):
所以自动恢复**默认关闭、逐台打开**,而且:
| 状态 | 窗口数 | 中位数 | p95 − p05 | max − min | 窗内最大值 |
|---|---|---|---|---|---|
| 真卡死(10-04 第 1 路,平在 520 mA 五个半小时) | 590 | 518~524 mA | 5~20 mA | 17~189 mA | — |
| 健康、亮屏常醒(10-04 17:00 ~ 10-05 20:00,第 1~4 路) | 4×3187 | ≥ 213 mA | ≥ 42 mA | ≥ 141 mA | — |
| 健康、空闲睡着(10-05 23:15 起 5.8 h,五路) | 5×665 | 7~12 mA | ≥ 8 mA | ≥ 213 mA | ≥ 221 mA |
· 平线原来用 max − min < 5 mA:真卡死时偶尔冒一个尖峰,那 590 个窗口**一个都判不出来**。换成对尖峰不敏感的
p95 − p05,卡死 ≤ 20 mA、健康亮屏 ≥ 42 mA,取 30 mA。
· 平线还要「不低」(中位数 ≥ 100 mA):空闲睡着的手机 95 % 的时间都平在 ~10 mA(ROM 文档 §4.18),那是正常的。
卡死的手机 CPU 是醒着的(见过 254 / 258 / 365 / 520 mA),不会掉到那么低。
· 过低仍是「窗内最大值 < 30 mA」:睡着的手机每 25 s 被代理的长轮询叫醒一次,窗内最大值都在 221 mA 以上;
5 分钟里一个尖峰都没有 = 关机了,或者睡死了。
⚠️ 样本只有**一次**真卡死和一晚上的睡眠数据;过低那一条没有真实的关机样本。还没开过一次自动恢复。
⚠️ 熄屏但 WiFi 省电没还回去(旧 ROM / rc 触发器没生效)时会平在 ~178 mA,会被判成平线 —— 那本身也是该处理的异常。
所以自动恢复仍然**默认关闭、逐台打开**,而且:
· 只在「这台手机开了自动恢复 + 板子在线 + 刀片在位 + 这一路有电 + 执行器空闲」时才判定, · 只在「这台手机开了自动恢复 + 板子在线 + 刀片在位 + 这一路有电 + 执行器空闲」时才判定,
任何一条不满足就把积累的历史清掉 —— 失联不动作(§10.2); 任何一条不满足就把积累的历史清掉 —— 失联不动作(§10.2);
· 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 —— · 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 ——
@@ -33,8 +49,9 @@ IDLE, SOFT, HARD, GAVE_UP = "idle", "soft", "hard", "gave_up"
@dataclass @dataclass
class Params: class Params:
window_s: int = 300 # 判定窗口 window_s: int = 300 # 判定窗口
flat_ua: int = 5000 # 窗口内电流摆幅小于它 = 平线(5 mA) flat_ua: int = 30000 # 平线:窗口内 p95 − p05 小于它(30 mA)……
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机(30 mA) flat_min_ua: int = 100000 # ……并且中位数不低于它(100 mA):睡着的手机平在 ~10 mA 是正常的
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机 / 睡死(30 mA)
settle_s: int = 120 # 动作之后先等它重启完,这段不看 settle_s: int = 120 # 动作之后先等它重启完,这段不看
max_per_hour: int = 3 # 每台每小时最多动作几次 max_per_hour: int = 3 # 每台每小时最多动作几次
soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s) soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s)
@@ -78,10 +95,13 @@ class Detector:
def verdict(self, now: int) -> Optional[str]: def verdict(self, now: int) -> Optional[str]:
if not self.full(now): if not self.full(now):
return None return None
vals = [u for _, u in self.s] vals = sorted(u for _, u in self.s)
if max(vals) < self.p.low_ua: n = len(vals)
if vals[-1] < self.p.low_ua:
return "low" return "low"
if max(vals) - min(vals) < self.p.flat_ua: # 用分位数而不是 max − min:卡死时偶发的一个尖峰不该把平线判据废掉(依据见模块头)
spread = vals[n * 95 // 100] - vals[n * 5 // 100]
if vals[n // 2] >= self.p.flat_min_ua and spread < self.p.flat_ua:
return "flat" return "flat"
return None return None
+1 -1
查看文件
@@ -90,7 +90,7 @@ def test_phone_settings_toggle_auto_recovery(env):
rec = c.get("/api/recovery").json() rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300 assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1}) r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收 assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 30000 # 负数不收
def test_history_after_telemetry(env): def test_history_after_telemetry(env):
+28
查看文件
@@ -237,6 +237,34 @@ def test_low_current_is_detected_and_normal_is_not():
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low" assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low"
# 2026-10-06 按实测改的平线判据(recovery.py 模块头的表):下面三条钉的是「改之前判错」的那几种形状。
def test_flat_tolerates_occasional_spikes():
"""真卡死(10-04 第 1 路):平在 ~520 mA,偶尔冒一个 +150 mA 的尖峰。max − min 判据一个窗口都抓不到。"""
ph = PhoneRecovery(Params(window_s=60, settle_s=30))
r = feed(ph, 0, 61, lambda s: 520000 + (s % 3) * 4000 + (150000 if s % 25 == 0 else 0))
assert r and r[-1][1] == SOFT and r[-1][2][0] == "flat"
def test_sleeping_phone_is_healthy():
"""空闲睡着:平在 ~10 mA,每 25 s 被长轮询叫醒一秒(~250 mA)。既不是平线(低)也不是过低(有尖峰)。"""
ph = PhoneRecovery(Params(window_s=300, settle_s=30))
assert feed(ph, 0, 900, lambda s: 250000 if s % 25 == 0 else 9000 + s % 4 * 1000) == []
def test_asleep_and_never_waking_is_low():
"""睡下去之后再也没醒(代理不轮询了):5 分钟里一个尖峰都没有 ⇒ 过低。"""
ph = PhoneRecovery(Params(window_s=300, settle_s=30))
r = feed(ph, 0, 301, lambda s: 9000 + s % 4 * 1000)
assert r and r[-1][2][0] == "low"
def test_flat_but_low_level_is_not_flat():
"""平在 60 mA(低于 flat_min_ua、高于 low_ua):不下结论 —— 没见过卡死的手机掉到这么低。"""
ph = PhoneRecovery(Params(window_s=60, settle_s=30))
assert feed(ph, 0, 200, lambda s: 60000 + s % 3 * 1000) == []
def test_rate_limit_gives_up(): def test_rate_limit_gives_up():
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2)) ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
t = 0 t = 0
+6 -4
查看文件
@@ -1,14 +1,15 @@
<template> <template>
<div class="rc"> <div class="rc">
<el-alert type="warning" :closable="false" show-icon <el-alert type="warning" :closable="false" show-icon
title="判据只看电流(服务器够不着手机的 adb);阈值还没有实测依据。自动恢复默认关,在板子页逐台打开。" /> title="判据只看电流(服务器够不着手机的 adb)。阈值 2026-10-06 按板 B 的实测定过一次(一次真卡死 + 一晚睡眠数据),还没开过一次自动恢复。默认关,在板子页逐台打开。" />
<div class="card"> <div class="card">
<div class="ttl">判据参数(全局)</div> <div class="ttl">判据参数(全局)</div>
<el-form :model="p" label-width="170px" size="small" class="form"> <el-form :model="p" label-width="170px" size="small" class="form">
<el-form-item label="判定窗口"><el-input-number v-model="p.window_s" :min="30" :max="3600" /> 秒</el-form-item> <el-form-item label="判定窗口"><el-input-number v-model="p.window_s" :min="30" :max="3600" /> 秒</el-form-item>
<el-form-item label="平线:电流摆幅小于"><el-input-number v-model="flatMa" :min="0.1" :max="200" :step="0.5" /> mA</el-form-item> <el-form-item label="平线:摆幅小于"><el-input-number v-model="flatMa" :min="1" :max="200" :step="1" /> mA(p95 − p05,不怕偶发尖峰)</el-form-item>
<el-form-item label="过低:电流一直低于"><el-input-number v-model="lowMa" :min="1" :max="500" /> mA</el-form-item> <el-form-item label="平线:且中位数不低于"><el-input-number v-model="flatMinMa" :min="1" :max="1000" :step="10" /> mA(睡着的手机平在 ~10 mA 是正常的)</el-form-item>
<el-form-item label="过低:电流一直低于"><el-input-number v-model="lowMa" :min="1" :max="500" /> mA(关机,或睡下去再没醒)</el-form-item>
<el-form-item label="动作后先等"><el-input-number v-model="p.settle_s" :min="10" :max="1800" /> 秒(不看这段电流)</el-form-item> <el-form-item label="动作后先等"><el-input-number v-model="p.settle_s" :min="10" :max="1800" /> 秒(不看这段电流)</el-form-item>
<el-form-item label="每台每小时最多"><el-input-number v-model="p.max_per_hour" :min="1" :max="10" /> 次(超了就停手等人)</el-form-item> <el-form-item label="每台每小时最多"><el-input-number v-model="p.max_per_hour" :min="1" :max="10" /> 次(超了就停手等人)</el-form-item>
<el-form-item label="一级:长按电源"><el-input-number v-model="p.soft_ms" :min="3000" :max="15000" :step="500" /> ms</el-form-item> <el-form-item label="一级:长按电源"><el-input-number v-model="p.soft_ms" :min="3000" :max="15000" :step="500" /> ms</el-form-item>
@@ -51,8 +52,9 @@ import { api } from '../api'
import { state, onMessage } from '../store' import { state, onMessage } from '../store'
import { fmtTime, RECOVERY_ZH, REASON_ZH } from '../util' import { fmtTime, RECOVERY_ZH, REASON_ZH } from '../util'
const p = reactive({ window_s: 300, flat_ua: 5000, low_ua: 30000, settle_s: 120, max_per_hour: 3, soft_ms: 10000 }) const p = reactive({ window_s: 300, flat_ua: 30000, flat_min_ua: 100000, low_ua: 30000, settle_s: 120, max_per_hour: 3, soft_ms: 10000 })
const flatMa = computed({ get: () => p.flat_ua / 1000, set: v => { p.flat_ua = Math.round(v * 1000) } }) const flatMa = computed({ get: () => p.flat_ua / 1000, set: v => { p.flat_ua = Math.round(v * 1000) } })
const flatMinMa = computed({ get: () => p.flat_min_ua / 1000, set: v => { p.flat_min_ua = Math.round(v * 1000) } })
const lowMa = computed({ get: () => p.low_ua / 1000, set: v => { p.low_ua = Math.round(v * 1000) } }) const lowMa = computed({ get: () => p.low_ua / 1000, set: v => { p.low_ua = Math.round(v * 1000) } })
const states = ref([]) const states = ref([])
const log = ref([]) const log = ref([])