你家 13 升的热水器,冬天其实只有 8.8 升
先说这东西是什么
一个零依赖的单页网页。你填四个数:机器标称升数、进水温度、想洗的温度、花洒实际流量。它告诉你三件事——这台机器此刻最多能给你多少升热水、够不够你家花洒、要撑住这个流量得买多大升数。
仓库在 atomgit.com/u010392524/heater-liters,MIT,双击 index.html 就能跑。
为什么做这个
我家热水器铭牌写 13 升。冬天晚上三个人排队洗澡,第三个人进去就被浇一激灵。家里老人的结论是"机器坏了,该换了",师傅来的结论是"你这花洒出水太大,13 升带不动"。
两句都对,但都没说清到底为什么。我查了一下才发现,「13 升」这四个字里藏着一个大多数人不知道的定义:它不是容量,一升水都存不住;它是温升 25K 时每分钟能出多少升热水。也就是说,厂家是在一个人为规定的工况下测的数,而这个工况(进水 20℃ 左右、升 25 度)恰好是春天。
一旦知道这个定义,"冬天水不热"就不再是玄学,而是一道除法。
「13 升」这个定义本身
国标 GB 6932 对家用燃气快速热水器规定额定产热水能力时,用的温升是 25K。所以:
13 L/min @ 温升 25K 额定工况出水温度 = 参考进水 20℃ + 25K = 45℃ 质量流量 = 13 L/min × 0.99021 kg/L(45℃ 纯水密度) = 12.8727 kg/min 热功率 = 12.8727 × 4.1868 kJ/(kg·K) × 25 K ÷ 60 s = 22.456 kW22.456 kW 才是这台机器真正的能力,"13 升"只是它在某个特定温差下的表现。功率是恒定的(忽略效率随负荷的漂移),温差一变,能给的流量就得跟着变。
这里有一个我自己踩出来的坑,值得单独说,因为它差点进了最终版本。
密度该用哪一侧的?我第一版用 20℃ 的 0.99821 折算,理由是"额定工况进水 20℃"。技术评审直接指出:GB 6932 的产热水能力定义是每分钟流出的热水量,量的是出水侧体积,所以该用 45℃ 的密度 0.99021。两者差 0.8%,而更要命的是不一致——我算功率用进水侧密度,算最大流量又除以进水侧密度,界面却拿这个数去和"花洒实际流量"(出水体积)比较。数字对不上只有 0.8%,但口径是错的。
改完之后:额定输出从 22.638 kW 变成 22.456 kW;如果干脆按 ρ=1 折算会得到 22.678 kW,与正确口径差 0.99%。这三列数字现在都写在 README 的已知边界里,因为它们是"你以为的精度"和"实际口径"之间最便宜的一课。
先跑起来看
同一台 13 升,把进水温度从夏天拉到冬天:
目标 42℃,13 升机(出水侧体积口径): 进水 25℃(夏天) ΔT = 17K -> 19.09 L/min 随便洗 进水 20℃(春天) ΔT = 22K -> 14.75 L/min 进水 10℃(深秋) ΔT = 32K -> 10.14 L/min 开始勉强 进水 5℃(冬天) ΔT = 37K -> 8.77 L/min 花洒开大就凉 进水 0℃(严寒) ΔT = 42K -> 7.73 L/min 不够界面里我特意放了一张固定六行的表(进水 0/5/10/15/20/25℃),因为这五行连起来看才看得出"冬天不热"是定义的直接后果,不是故障。表里 20℃ 那一行是 13.00 L/min——这个数字待会儿有用。
如果冬天想洗 45℃,同一台 13 升机器给到的就只有 8.13 L/min,而多数顶喷花洒要 10~12 L/min(16 升机在同一工况下是 10.00 L/min,也仍然不够 12):
白色系是硬约束
这次的三个项目统一要白色系。我把色值和禁止清单写死进提示词,因为 AI 写界面时控制不住要加阴影和渐变。
这一篇的强调色是陶土橙 #c05621(只用在主数字、进度条、表头下边框),警示色 #a4262c(只在"不够"时出现)。整体像一张打印出来的产品铭牌。
提示词钉到函数级
第一轮只建一个文件,规格写到表达式级别,连运算顺序都不许改:
1) ratedPowerKW(L) // 标称升数 -> 额定输出热功率 kW ((L * RHO20) * C_WATER * DT_RATED) / 60 2) maxFlowLmin(Pkw, tin, tset) // 热功率与进/出水温 -> 稳态最大体积流量 L/min ((Pkw * 60) / C_WATER / (tset - tin)) / rho(tin) // tset <= tin 返回 +Infinity 3) neededLiters(q, tin, tset) pNeeded = ((q * rho(tin)) * C_WATER * (tset - tin)) / 60 ;返回 pNeeded / ratedPowerKW(1) 4) mixTemp(mh, th, mc, tc) (mh * th + mc * tc) / (mh + mc) ;mh + mc === 0 时 throw new Error("no flow")第一句永远是那句:禁止在对话里贴任何代码正文。你的输出只能是仓库里的文件。
分层
| 层 | 文件 | 职责 |
|---|---|---|
| 计算 | src/engine.js | 7 个纯函数,零依赖 |
| 界面 | index.html | 只调 window.HeaterEngine,不许自己算 |
| 测试 | test/engine.test.js | node:test,8 个用例 |
| 证据 | evidence/diff.mjs | 独立有理数实现 + 跨实现差分 |
界面层"不许自己算"这条不是讲究,是上一篇真踩过:合并计算写在 UI 里,就成了没有测试覆盖的第二个实现。
核心算法:一条能量平衡加一个密度修正
稳态部分就一条式子,热水放出的热 = 水吸收的热:
P = m · c · ΔT m = 体积流量 × 密度 c = 4.1868 kJ/(kg·K)密度这一项很多人省。省掉的后果是 13 升机器算成 22.678 kW,而按出水侧 45℃ 密度折算是 22.456 kW,差 0.99%。1% 不影响选型结论,但影响"你的数和别人复算的数能不能对上"。既然要做可验证的东西,就把密度做成一张 0~80℃ 的表,中间线性插值:
0:0.99984 5:0.99996 10:0.99970 15:0.99910 20:0.99820 25:0.99704 30:0.99565 35:0.99403 40:0.99221 45:0.99021 50:0.98804 55:0.98573 60:0.98320 70:0.97776 80:0.97180 (CRC 常用值,5 位)顺带一个反直觉的事实:水的密度在 5℃ 附近最大(0.99997),不是 0℃。所以那张表在冬天区间几乎是平的,而夏天反而降得多。
混水那部分更简单,是纯代数:
冷水流量 = 热水流量 × (Th - Tset) / (Tset - Tin) 混合温度 = (mh·Th + mc·Tc) / (mh + mc)两条不变式,比一堆用例更值钱
测试里我最看重的是这两条,它们不依赖任何我手算的数:
// 额定工况就是温升 25K,所以从功率绕回流量必须等于标称升数本身for(constLof[8,11,13,16,20,24,32])assert.ok(Math.abs(eng.maxFlowLmin(eng.ratedPowerKW(L),20,45)-L)<1e-9);// 热水兑冷水再混回去,必须精确回到目标温度for(const[Th,tin,tset]of[[60,5,42],[55,10,40],[80,5,45],[45,25,40]]){constcf=eng.coldFlow(10,Th,tin,tset);assert.ok(Math.abs(eng.mixTemp(10,Th,cf,tin)-tset)<1e-9);}第一条是代数恒等式的编程版:额定功率 = L·ρ₂₀·c·25/60,反解流量 = P·60/c/25/ρ₂₀ = L。任何一处把密度漏了、把 60 秒漏了、把 ΔT 写死成 25,都会立刻崩。界面上那张表 20℃ 行显示 13.00,就是这条不变式在跑动中自证。
护城河:一份互不引用的有理数实现
浮点实现说"我算对了"不算数,得有个不共享任何代码的东西来判。我写了一份 BigInt 精确有理数版本:所有常数按十进制字符串转成分数,加减乘除全程整数,最后一步才转小数。
$ node evidence/diff.mjs 跨实现断言 2299 次,分歧 0 条覆盖范围是升数 1..40 全枚举、进水温 0..25 逐度 × 目标温 35/38/40/42/45/50 全组合、密度表端点与半度插值、四组混水闭环、四组一阶响应。这份脚本就在仓库evidence/diff.mjs,谁都可以跑。
闭式解 vs 数值积分:把收敛阶跑出来
恒温机在水量突变下的响应可以写成 dT/dt = (Tss − T)/τ,它有闭式解 T(t) = Tss + (T₀ − Tss)e^(−t/τ)。我用四阶 Runge-Kutta 从另一个方向逼近同一个解,然后看误差随步长怎么变:
步长 h RK4 绝对误差 误差比(上一档/本档) 实测阶数 log2(比) 0.5 1.175702e-5 0.25 6.617724e-7 17.766 4.151 0.125 3.925701e-8 16.857 4.075 0.0625 2.390408e-9 16.423 4.038 0.03125 1.475016e-10 16.206 4.018误差比稳定收敛到 16 = 2⁴,实测阶数 4.075 → 4.038 → 4.018。一阶欧拉在同一问题上的比值是 1.895(理论 2)。这不是为了炫技——它证明"闭式解"和"逐步积分"这两条独立路径真的指向同一个答案,而不是我把两个都写错了同一个方向。
改完口径,第一个报错的是我自己的证据脚本
这件事我觉得比任何功能都有意思,所以单独写一节。
把密度从进水侧改到出水侧之后,我做的第一件事是跑仓库里的evidence/diff.mjs——那份 BigInt 精确有理数参考实现。结果它当场报了 20 条分歧:
$ node evidence/diff.mjs 跨实现断言 2299 次,分歧 20 条 rated(1): 参考 1.741377345 vs engine 1.727421345 rated(2): 参考 3.482754690 vs engine 3.454842690 rated(3): 参考 5.224132035 vs engine 5.182264035 ...引擎是对的,参考实现是旧的。这条报错的价值在于:它证明那份差分脚本真的在算,不是在给引擎鼓掌。如果我把参考实现和引擎绑在同一份常数处理逻辑上,改一侧忘另一侧这种事根本发现不了。
更新参考实现之后重新对:2306 次断言,0 分歧。断言数从 2299 变成 2306,是因为我顺手把输入守卫也纳入了差分(8 条 throws 检查)。
三轮评审的分数,以及我认账的部分
代码写完我自己觉得挺好。开三个独立评审各读代码和截图打分,第一轮:UI 84、技术 61、产品 74。
技术评审给 61 我不意外,因为它抓到的三条都是硬伤:
一,密度用错侧(上面那节讲的)。
二,neededLiters(8, 45, 40)会返回 -1.587 升、coldFlow(10, 40, 5, 42)会返回 -0.541——负升数、负流量。守卫只加在 maxFlowLmin 上,另外两个函数漏了。
三,最扎心的一条:密度表几乎没有被测试锚定。评审做了变异测试,把表里 40℃ 的值改成 0.97222(错 2%),10 个用例全绿。也就是说我的"测试覆盖"在那个区间是假的。
第二条我加了 8 个 RangeError/TypeError 守卫;第三条我加了 T4:15 个表点逐个 assert.equal 到 5 位小数字面量,另加插值点与钳位行为。
产品评审抓到的一条更让我出汗:我在文章里写了"闭式解 vs RK4 收敛阶验证",但那个脚本只在我本地,仓库里没有。评审原话是"这条写进文章=能力虚报,评审一跑仓库就穿"。完全正确。所以我把它提交成了evidence/convergence.mjs,现在任何人都能node evidence/convergence.mjs复现那串 4.075 → 4.038 → 4.018。
UI 评审抓到的是那个"够不够 8L 花洒"写死的问题——花洒填 12 时主区说"差 3.9"、表里同一行说"够",自相矛盾。改成判定跟随实际花洒值、表头动态显示。另外"会被限流"这个说法被指出是物理错误:燃气热水器不会限制流量,是热功率不够、出水温度掉不下来。文案改成了「出水温度到不了 45 ℃」。
三轮评审一共让我改了 11 处。分数从 84/61/74 涨到多少我没再评——因为剩下的分歧点已经不在"有没有错",而在"要不要更多功能",那是另一个取舍。
我自己犯的错,比机器的更有意思
给了 6 位小数的期望值,却要求 1e-9 的容差。第一轮锚点我写成ratedPowerKW(11) = 19.155151,比较规则写Math.abs(实际 - 期望) < 1e-9。精确值是 19.155150795,我按 6 位打印了——那这条锚点在数学上就不可能被满足。码道拿到之后没有报错,它开始推理:"用户期望我们用某种累积方式算出恰好等于 19.155151?"然后一路想了四分钟,零产出。
我停掉它,把 16 条锚点全部改成 9 位小数,并在提示词里写清"容差已吸收打印舍入"。教训很直接:递给 AI 的标准答案必须能被唯一复现。它不会告诉你题目错了,它会一直做下去。
我的 exp 在大指数上崩了。差分脚本里我用定点泰勒级数算 e^(−x)。x=25 时中间项高达 25²⁵/25! ≈ 1.3×10⁹,而结果只有 1.4×10⁻¹¹,二十个数量级的差距把定点截断误差放大到完全失真——我那份"精确参考实现"算出了负数。被测的浮点实现反而是对的。修法是先做参数约化(x 拆成 k 份,每份 ≤4),再连乘回去:
exp(-25) 有理泰勒(约化后) 1.388794386496e-11 libm 1.388794386496e-11 相对差 4.65e-15两套实现互相纠错这件事,比我预想的双向。
模型配额见底那次。中途有一次界面那轮跑到一半,页面回了一行「错误: 当前模型配额不足 [APIError]」。当时以为今晚废了,后来发现配额是按模型分池的——换一个模型继续就行。仓库远端还停在上一轮,工作区的 index.html 没提交,我补了一句"上一轮中断了,按同样规格收尾",72 秒后 commit 就在了。
提效数据
| 轮次 | 交付 | 发送→commit | 备注 |
|---|---|---|---|
| R1 | 规格含 6 位锚点 | — | 不可满足,我主动停 |
| R1b | engine.js 7 函数 | 29 s | 锚点改 9 位后一次过 |
| R2 | package.json + 8 测试 | 60 s | |
| R3 | 浏览器导出垫片 | 35 s | |
| R4 | index.html | — | 模型配额中断 |
| R4b | index.html 收尾 | 72 s | |
| R5 | 差分脚本入库 | — | 字节数校验条件写死,卡住 |
| R5b | 差分脚本入库 | 109 s | 允许 ±2 字节后一次过 |
| R6 | README + LICENSE | 约 100 s |
两轮是我自己造成的返工(锚点精度、校验条件),合计浪费约 9 分钟。机器本身的有效交付时间只有 6 分 15 秒。
仓库长这样
已知边界,不粉饰
模型是稳态热平衡,不含管路散热、不含水箱分层。储水式电热水器完全不适用。
恒温机的瞬态用一阶惯性近似,时间常数 τ 是可调参数,不是实测值。真实机器有点火延迟和水流反馈,二阶都打不住。
比热取常数 4.1868,实际水在 0~80℃ 之间有约 ±0.2% 漂移。我选常数口径是为了可复算,不是为了更准。
「升数」只反映额定工况,机器的最小启动流量、水压要求、风压限制都没进模型。
GB 6932 的具体条款文字我没能在这台机器上取到可访问的一手全文,温升 25K 这个规定值来自公开转载与行业通行口径,属于本项目的软肋,写在这里而不是藏起来。
本地怎么跑
gitclone https://atomgit.com/u010392524/heater-liters.gitcdheater-litersnpmtest# 零依赖,Node 18+nodeevidence/diff.mjs# 跨实现差分,看最后是不是 0 分歧# 界面双击 index.html,file:// 直接可用写在最后
这篇里最有用的产出不是那个网页,是我在写锚点时被迫搞明白的一件事:"13 升"是一个条件句。它省略了"当进水 20 度、你想洗 45 度"这个前提,而这个前提在冬天根本不成立。
厂家没撒谎,标准也没问题,问题在所有人都把这个带条件的数当作品质等级来比较。13 升和 16 升的差距在夏天是 30%,在冬天是同一个数字——因为决定洗澡体验的是功率,而功率从来不变。
工具做的事就是把那个被省略的条件句重新写出来,并且让每个数字都能被第二个人复算。第二篇算盐是同一个套路,第三篇算分期利率也是。
(本工具用于理解铭牌参数与选型参考,不构成安装或用电用气安全建议。涉及燃气安装请以产品说明书与具备资质的师傅意见为准。)