rack:PD 供电信息、每日耗电量与平均功率

PD 供电(板卡页新增一格):
- 优先用遥测里的实时值 status.pd(固件 ≥0.1.17);老固件退回最近一条 PD_LIMIT 事件(快照的 pd)。
  显示 20 V / 档位电流 / 整板功率上限 / 输入功率占上限的比例。
- PD_LIMIT 事件写成「PD 20 V / 3.00 A,上限 60 W」,不再把那两列当电压电流显示;arg 2 = 运行中换档。

耗电量(板卡页新增卡片):
- 新表 energy_day(永久保留):从分钟聚合滚出每日能量与有数据秒数,rail 0 = 底板 12 V 输入,1~5 = 各路主板。
  每分钟重算今天 / 昨天,更早的只补缺不覆盖(分钟数据 90 天后会被清)。按服务器本地零点切日。
- GET /api/boards/{id}/energy;前端 EnergyCard:今日 / 昨日 / 近 7 天 / 近 30 天 / 累计(各带平均功率)、
  每日柱状图、今日各路主板。量的是 12 V 输入,不含 PD 20→12 V 降压损耗。

后端测试 57 项全过;已部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-10-06 06:31:04 +08:00
共同撰写人 Claude Opus 5.5
父节点 489cd6a6f3
当前提交 580468e48e
共修改 11 个文件,包含 385 行新增和 8 行删除
+49
查看文件
@@ -4,6 +4,7 @@
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
每日耗电量(`energy_day`)也永久保留:它从分钟聚合里滚出来,分钟数据过了 90 天被清掉之后就再也算不回来了。
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。
@@ -60,6 +61,14 @@ CREATE TABLE IF NOT EXISTS board_tele_min (
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
PRIMARY KEY (board_id, ts)
);
-- 每日耗电量(Db.energy_rollup)。rail 0 = 底板 12 V 输入,1~5 = 各路手机主板。
-- mws = 这一天累计的能量(mW·s,÷3.6e6 = Wh),secs = 这一天里有数据的秒数(平均功率 = mws / secs)。
-- day 是服务器本地时间的日期:「当日」按机房的零点切,不按 UTC。
CREATE TABLE IF NOT EXISTS energy_day (
board_id TEXT NOT NULL, day TEXT NOT NULL, rail INTEGER NOT NULL,
mws INTEGER NOT NULL, secs INTEGER NOT NULL,
PRIMARY KEY (board_id, day, rail)
);
CREATE TABLE IF NOT EXISTS events (
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
@@ -190,6 +199,46 @@ class Db:
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
# ---- 耗电量 --------------------------------------------------------
def energy_rollup(self, days_back: int = 1, now: Optional[int] = None):
"""把分钟聚合滚成每日耗电量。重算最近 `days_back`+1 个本地日(含今天,幂等覆盖),
更早的日子**只补缺、不覆盖**:分钟数据过了保留期会被清,拿残缺的一天去覆盖就把历史改小了。
每分钟的能量 = 均值功率 × 这一分钟里的样本数(遥测 1 s 一条,n 就是有数据的秒数,最多按 60 算);
功率拿 mv_avg × ua_avg 近似,电压几乎不动(同 board_history)。板子离线的时段没有行,自然不计。"""
t = now if now is not None else now_ms()
day = "date(ts/1000,'unixepoch','localtime')"
srcs = (("0", "in_mv_avg", "in_ua_avg", "board_tele_min"), ("rail", "mv_avg", "ua_avg", "tele_min"))
with self._lock:
cut = self.conn.execute("SELECT date(?/1000,'unixepoch','localtime',?)",
(t, "-%d days" % max(0, days_back))).fetchone()[0]
for rail, mv, ua, table in srcs:
self.conn.execute(
"INSERT INTO energy_day (board_id, day, rail, mws, secs) "
"SELECT board_id, {day} AS d, {rail} AS r, "
"CAST(SUM({mv}*{ua}/1e6*MIN(n,60)) AS INTEGER), SUM(MIN(n,60)) FROM {table} "
"WHERE {mv} IS NOT NULL AND {ua} IS NOT NULL GROUP BY board_id, d, r "
"ON CONFLICT(board_id, day, rail) DO UPDATE SET mws=excluded.mws, secs=excluded.secs "
"WHERE excluded.day >= ?".format(day=day, rail=rail, mv=mv, ua=ua, table=table), (cut,))
def energy(self, board: str, days: int = 90) -> List[dict]:
"""最近 `days` 个有数据的本地日,旧 → 新。每天:底板输入的能量 / 有数据秒数,各路主板的能量。"""
rows = self.q("SELECT day, rail, mws, secs FROM energy_day WHERE board_id=? AND day IN "
"(SELECT DISTINCT day FROM energy_day WHERE board_id=? ORDER BY day DESC LIMIT ?) "
"ORDER BY day, rail", (board, board, days))
out: dict = {}
for r in rows:
d = out.setdefault(r["day"], {"day": r["day"], "in_mws": None, "secs": 0, "rails": [None] * 5})
if r["rail"] == 0:
d["in_mws"], d["secs"] = r["mws"], r["secs"]
elif 1 <= r["rail"] <= 5:
d["rails"][r["rail"] - 1] = {"mws": r["mws"], "secs": r["secs"]}
return list(out.values())
def energy_total(self, board: str) -> dict:
r = self.q("SELECT COALESCE(SUM(mws),0) mws, COALESCE(SUM(secs),0) secs, MIN(day) since, COUNT(*) days "
"FROM energy_day WHERE board_id=? AND rail=0", (board,))[0]
return dict(r)
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""