build_viv_page.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. # -*- coding: utf-8 -*-
  2. """生成「涡激振荡仿真台」交互单页。
  3. 上半 = 可交互正演 (涡脱频率/锁频带/Scruton, 工程经验模型, 非 CFD);
  4. 下半 = 真实实测 (9 台秒级: 抗涡激动作标志 + 塔筒双向加速度), 已脱敏。"""
  5. import json, pathlib
  6. SRC = "/Users/yuanying/wind-analytics/outputs/_combined/safety_assessment.json"
  7. rows = json.load(open(SRC, encoding="utf-8"))["per_turbine"]
  8. # 脱敏: 场名 -> 甲/乙, 台号重编
  9. fmap, out, n = {}, [], 0
  10. for r in rows:
  11. f = r["farm"]
  12. if f not in fmap: fmap[f] = "甲场" if not fmap else "乙场"
  13. n += 1
  14. out.append({"t": f"{fmap[f]} #{n}", "viv": r["抗涡激触发pct"], "stop": r["塔停机max"],
  15. "gen": r["塔发电p95"], "all": r["塔全窗max"], "chain": r["振动安全链跌pct"],
  16. "blade": r["三叶极差p99"], "sec": r["n_sec"]})
  17. DATA = json.dumps(out, ensure_ascii=False)
  18. HTML = f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
  19. <title>涡激实测核验</title><style>
  20. :root{{--bg:#edf7f9;--panel:#fff;--ink:#133047;--ink2:#28536b;--ink3:#61788b;--line:#cbe2eb;
  21. --line2:#e1eef3;--spec:#0e8898;--specf:#dff4f4;--meas:#2077a8;--measf:#e5f2fa;--ok:#23875b;--warn:#d48818;--bad:#d94b43;
  22. --sans:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",ui-sans-serif,system-ui,sans-serif;--mono:"SF Mono",Menlo,Consolas,monospace}}
  23. *{{box-sizing:border-box;margin:0}}body{{background:var(--bg);color:var(--ink);font:14.5px/1.7 var(--sans);-webkit-font-smoothing:antialiased}}
  24. .wrap{{max-width:1360px;margin:0 auto;padding:0 32px}}
  25. .top{{background:linear-gradient(110deg,#10384d,#0e6470);color:#fff;padding:14px 0}}
  26. .top .wrap{{display:flex;align-items:center;gap:16px}}
  27. .logo{{width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,.16);display:grid;place-items:center;font-weight:700}}
  28. .top b{{font-size:15px}} .top small{{display:block;font:11px var(--mono);opacity:.72;letter-spacing:.12em}}
  29. .top .r{{margin-left:auto;font:12px var(--mono);opacity:.85}}
  30. section{{padding:34px 0;border-bottom:1px solid var(--line)}}
  31. .kick{{font:12px var(--mono);letter-spacing:.16em;color:var(--spec);text-transform:uppercase}}
  32. h1{{font-size:34px;line-height:1.2;font-weight:600;margin:14px 0 12px}}
  33. h2{{font-size:23px;font-weight:600;margin:10px 0 8px}}
  34. .lead{{color:var(--ink2);font-size:16px;max-width:820px;text-wrap:pretty}}
  35. .sub{{color:var(--ink3);margin-bottom:20px;max-width:860px}}
  36. .panel{{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:20px 22px;box-shadow:0 1px 3px rgba(19,48,71,.05)}}
  37. .ctl{{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;background:#f7fcfd;border:1px solid var(--line);border-radius:12px;padding:16px 20px;margin-bottom:16px}}
  38. .ctl label{{display:block;font-size:12.5px;color:var(--ink2);margin-bottom:7px}}
  39. .ctl b{{font:600 15px var(--mono);color:var(--spec)}}
  40. .ctl input[type=range]{{width:100%;accent-color:#0e8898}}
  41. .grid2{{display:grid;grid-template-columns:1.15fr .85fr;gap:16px}}
  42. .grid3{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}}
  43. canvas{{display:block;width:100%;border-radius:8px;background:#fff}}
  44. .stat{{background:#f7fcfd;border:1px solid var(--line);border-radius:10px;padding:14px 16px}}
  45. .stat .k{{font:11px var(--mono);letter-spacing:.1em;color:var(--spec);text-transform:uppercase}}
  46. .stat .v{{font:600 27px/1.15 var(--mono);margin-top:5px}}
  47. .stat .d{{color:var(--ink3);font-size:12.5px;margin-top:4px}}
  48. .badge{{display:inline-block;font:11.5px var(--mono);padding:3px 10px;border-radius:99px;margin-left:8px}}
  49. .bf{{background:var(--specf);color:var(--spec);border:1px solid #a9dfe4}}
  50. .br{{background:var(--measf);color:var(--meas);border:1px solid #b3d8ee}}
  51. .note{{border-left:3px solid var(--spec);background:var(--specf);border-radius:0 10px 10px 0;padding:14px 18px;color:var(--ink2);font-size:14px;margin-top:16px}}
  52. .warnbox{{border-left:3px solid var(--warn);background:#fdf4e3;border-radius:0 10px 10px 0;padding:14px 18px;color:var(--ink2);font-size:14px;margin-top:16px}}
  53. table{{border-collapse:collapse;width:100%;font-size:13.2px}}
  54. th,td{{border-bottom:1px solid var(--line2);padding:8px 10px;text-align:left}}
  55. th{{color:var(--ink3);font-weight:500;font-size:12px}} td.n{{font-family:var(--mono);text-align:right}}
  56. .foot{{color:var(--ink3);font:11.5px var(--mono);padding:20px 0 36px}}
  57. @media(max-width:1000px){{.ctl,.grid2,.grid3{{grid-template-columns:1fr 1fr}} h1{{font-size:26px}}}}
  58. </style></head><body>
  59. <div class="top"><div class="wrap"><div class="logo">观</div>
  60. <div><b>涡激实测核验</b><small>VORTEX-INDUCED VIBRATION</small></div>
  61. <div class="r">离线运行 · 无需网络</div></div></div>
  62. <section><div class="wrap">
  63. <div class="kick">配套:机理见「涡激共振模拟器」</div>
  64. <h1>零触发,怎么证明不是监测盲区</h1>
  65. <p class="lead">涡激振荡的机理与机型对比,看配套的<b>涡激共振模拟器</b>(十二款机型预设、三种机理可切换)。
  66. 本页只回答一个问题:某批机组的抗涡激动作<b>一次都没触发</b>,这到底是「真没事」还是「监测面根本没在工作」?
  67. <b>这是所有阴性结论都要过的一关。</b></p>
  68. </div></section>
  69. <section><div class="wrap">
  70. <div class="kick">② 实测 · 九台机组秒级<span class="badge br">回放 · 真实数据</span></div>
  71. <h2>这九台机组,抗涡激动作一次都没触发</h2>
  72. <p class="sub">数据来自两个风场共九台机组的秒级长窗,每台 43200 秒。机组本身带一个
  73. <b>「变桨执行抗涡激动作」</b>的状态标志,加上塔筒驱动侧与非驱动侧的加速度通道。已脱敏:场名与台号均已重编。</p>
  74. <div class="grid3" style="margin-bottom:16px">
  75. <div class="stat"><div class="k">抗涡激动作触发</div><div class="v" style="color:var(--ok)" id="oViv">—</div><div class="d">九台全部为零</div></div>
  76. <div class="stat"><div class="k">振动安全链跌落</div><div class="v" style="color:var(--ok)" id="oChain">—</div><div class="d">九台全部为零</div></div>
  77. <div class="stat"><div class="k">三叶桨距极差 p99</div><div class="v" style="color:var(--ok)" id="oBlade">—</div><div class="d">集距同步,无 1P 不平衡</div></div>
  78. </div>
  79. <div class="panel"><canvas id="c3" height="330"></canvas></div>
  80. <div class="warnbox"><b>零触发不等于没问题 —— 除非你能证明监测面是活的。</b>
  81. 这正是本例最值得学的地方:塔筒加速度<b>有量值、有分布,停机与发电两个工况分得开</b>(见上图),说明通道是活的,
  82. 所以「零触发」是真的阴性结果。而同一份交付里,机组自带的在线模态特征通道(塔筒一阶、叶片不平衡、传动链扭振)
  83. <b>全部是 0 或占位值</b> —— 那才是死通道,不能拿来下任何结论。<b>一活一死并排放在同一批数据里,这就是最好的教材。</b></div>
  84. </div></section>
  85. <section style="border-bottom:0"><div class="wrap">
  86. <div class="kick">③ 判据</div>
  87. <h2>要下「无共振」这个结论,得几条独立证据</h2>
  88. <table><thead><tr><th>证据</th><th>本例实测</th><th>它单独够不够</th></tr></thead><tbody>
  89. <tr><td>抗涡激动作标志</td><td class="n">0 % 触发</td><td>不够 —— 得先证明这个标志位是活的</td></tr>
  90. <tr><td>塔筒加速度(停机工况)</td><td class="n">0.11 – 0.43 m/s²</td><td>关键 —— 有量值有分布,证明通道活</td></tr>
  91. <tr><td>塔筒加速度(发电 p95)</td><td class="n">0.16 – 0.21 m/s²</td><td>关键 —— 两工况可分,非死值</td></tr>
  92. <tr><td>振动安全链</td><td class="n">0 % 跌落</td><td>不够 —— 同上,需活性证据</td></tr>
  93. <tr><td>变桨速度 p95(查 hunting)</td><td class="n">&lt; 0.5 – 0.75 °/s</td><td>旁证</td></tr>
  94. <tr><td>三叶桨距极差(查 1P)</td><td class="n">0 °</td><td>旁证 —— 集距机型本就同步</td></tr>
  95. </tbody></table>
  96. <div class="note">六条独立证据全部指向同一结论,且其中两条能证明监测面本身是活的,所以「无共振」这个阴性结论站得住。
  97. <b>少了活性证据那两条,剩下四个零就只是四个零。</b></div>
  98. </div></section>
  99. <div class="wrap foot">观澜 · 涡激实测核验 · 实测数据已脱敏 · 离线单文件 · 机理见「涡激共振模拟器」</div>
  100. <script>
  101. const DATA = {DATA};
  102. const CSS = getComputedStyle(document.documentElement);
  103. const C = {{ink:'#133047', ink2:'#28536b', ink3:'#61788b', line:'#cbe2eb', spec:'#0e8898',
  104. meas:'#2077a8', ok:'#23875b', warn:'#d48818', bad:'#d94b43'}};
  105. const St = 0.20, LOCK = [0.85, 1.30], MASS = 3000, RHO = 1.225;
  106. // 注意: 必须先把 CSS 高度钉死, 再改 backing store —— 否则改 canvas.height 会引起布局变化,
  107. // 在 resize 处理器里形成 resize -> 重绘 -> 布局变 -> resize 的反馈环, 页面直接卡死(曾踩)。
  108. function fit(cv){{
  109. const cssH = parseInt(cv.getAttribute('height')) || 300;
  110. cv.style.height = cssH + 'px';
  111. const w = Math.max(240, Math.round(cv.parentElement.clientWidth - 44));
  112. cv.style.width = '100%';
  113. const d = Math.min(window.devicePixelRatio || 1, 2);
  114. if (cv.width !== Math.round(w*d)) cv.width = Math.round(w*d);
  115. if (cv.height !== Math.round(cssH*d)) cv.height = Math.round(cssH*d);
  116. const x = cv.getContext('2d'); x.setTransform(d,0,0,d,0,0);
  117. return [x, w, cssH]; }}
  118. function axes(x,w,h,pad,xl,yl,xmax,ymax){{
  119. x.clearRect(0,0,w,h); x.strokeStyle=C.line; x.lineWidth=1; x.font='11px ui-monospace,Menlo,monospace'; x.fillStyle=C.ink3;
  120. for(let i=0;i<=5;i++){{ const gy = pad.t + (h-pad.t-pad.b)*i/5;
  121. x.beginPath(); x.moveTo(pad.l,gy); x.lineTo(w-pad.r,gy); x.stroke();
  122. x.textAlign='right'; x.fillText((ymax*(1-i/5)).toFixed(2), pad.l-6, gy+4); }}
  123. for(let i=0;i<=5;i++){{ const gx = pad.l + (w-pad.l-pad.r)*i/5;
  124. x.textAlign='center'; x.fillText((xmax*i/5).toFixed(0), gx, h-pad.b+16); }}
  125. x.textAlign='center'; x.fillStyle=C.ink3; x.fillText(xl, (pad.l+w-pad.r)/2, h-4);
  126. x.save(); x.translate(11,(pad.t+h-pad.b)/2); x.rotate(-Math.PI/2); x.fillText(yl,0,0); x.restore(); }}
  127. function P(pad,w,h,xmax,ymax){{ return (vx,vy)=>[pad.l+(w-pad.l-pad.r)*vx/xmax, pad.t+(h-pad.t-pad.b)*(1-vy/ymax)]; }}
  128. function sc(z,D){{ return 2*MASS*z/(RHO*D*D); }}
  129. function amp(u,ucr,z,D){{ const s=sc(z,D), pk=Math.min(0.9, 1/Math.max(s,0.35));
  130. return pk*Math.exp(-Math.pow((u/ucr-1.03)/0.16,2)); }}
  131. // 实测图
  132. function drawReal(){{
  133. let [x,w,h]=fit(c3); const p={{l:56,r:16,t:34,b:62}};
  134. const YM=0.9; axes(x,w,h,p,'','塔筒加速度 (m/s²)',DATA.length,YM);
  135. const bw=(w-p.l-p.r)/DATA.length;
  136. DATA.forEach((d,i)=>{{
  137. const x0=p.l+bw*i+bw*0.14, bwe=bw*0.72;
  138. const yb=p.t+(h-p.t-p.b);
  139. function bar(v,col,off,wd){{ const hh=(h-p.t-p.b)*v/YM;
  140. x.fillStyle=col; x.fillRect(x0+off,yb-hh,wd,hh); }}
  141. bar(d.all, 'rgba(32,119,168,.30)', 0, bwe);
  142. bar(d.stop, '#d48818', bwe*0.08, bwe*0.40);
  143. bar(d.gen, '#0e8898', bwe*0.52, bwe*0.40);
  144. x.save(); x.translate(x0+bwe/2, yb+12); x.rotate(-Math.PI/4);
  145. x.fillStyle=C.ink3; x.font='11px ui-monospace,Menlo,monospace'; x.textAlign='right';
  146. x.fillText(d.t,0,0); x.restore();
  147. }});
  148. x.font='12px ui-monospace,Menlo,monospace'; x.textAlign='left';
  149. const lg=[['全窗最大','rgba(32,119,168,.30)'],['停机工况最大','#d48818'],['发电工况 p95','#0e8898']];
  150. lg.forEach((L,i)=>{{ x.fillStyle=L[1]; x.fillRect(p.l+8+i*146, p.t-22, 12, 12);
  151. x.fillStyle=C.ink2; x.fillText(L[0], p.l+25+i*146, p.t-12); }});
  152. }}
  153. oViv.textContent = DATA.every(d=>d.viv===0) ? '0.0 %' : '—';
  154. oChain.textContent = DATA.every(d=>d.chain===0) ? '0.0 %' : '—';
  155. oBlade.textContent = DATA.every(d=>d.blade===0) ? '0.0 °' : '—';
  156. let busy=false, tmr=null;
  157. function all(){{ if(busy) return; busy=true; try{{ drawReal(); }} finally{{ busy=false; }} }}
  158. window.addEventListener('resize', ()=>{{ clearTimeout(tmr); tmr=setTimeout(all, 180); }}); // 去抖 + 重入保护
  159. all();
  160. </script></body></html>'''
  161. p = pathlib.Path("/Users/yuanying/guanlan-rudong-v2/outputs/rudong/windscada/_demo/sim/涡激实测核验.html")
  162. p.write_text(HTML, encoding="utf-8")
  163. print(f"written {p.name} {len(HTML)//1024} KB 台数={len(out)}")
  164. leaks={k:HTML.count(k) for k in ["庄源","新风","ZY0","XF0","中车","庄河"] if HTML.count(k)}
  165. print("标识残留:", leaks or "0 ✓")