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