windscada_serve.py 416 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """windscada serve (M7c): 交互后端 — 工作台(全场)+分台页, 全部带时间窗.
  4. 设计: ①台页只渲染"有问题的系统"的证据图(分册两型: A=判据线+摆幅标注, B=本台vs全场四分位带), 无问题不出图;
  5. ②台内交互查询: 任一契约通道×时间窗×聚合 即时出图; ③工作台: 系统分类问题(全系统)+故障统计梳理+SOP控制策略;
  6. ④时间窗: 月度类数据真窗; 判级类(变桨/偏航/蓄能=近90日, 曲线=2025H2判别窗)固定窗如实标注.
  7. 端口 8033. 判级=筛查级/候选, P级归综合报告线."""
  8. import sys as _sys, pathlib as _plb
  9. _sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1]))
  10. from src import paths as P
  11. import sys, json, pathlib, re, threading, urllib.parse, collections
  12. import hashlib, time
  13. sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # 安装根入 sys.path (先于 src.* 导入)
  14. from src import paths as _P # 路径唯一真源 (与 cwd 无关)
  15. import numpy as np, pandas as pd
  16. import math
  17. def _jclean(o):
  18. """NaN/Inf → null (非法 JSON 防线; pd.DataFrame 会把 None 变 NaN)."""
  19. if isinstance(o, float) and (math.isnan(o) or math.isinf(o)): return None
  20. if isinstance(o, dict): return {k: _jclean(v) for k, v in o.items()}
  21. if isinstance(o, (list, tuple)): return [_jclean(x) for x in o]
  22. return o
  23. # ── 客户交付脱敏 (2026-08-28 demo 门禁逮出 7 处特征频率 + 21 处判据门槛上屏) ──
  24. # 规则来自 memory client-deliverable-hide-core-parameters: 判据名与实测值可见, 数值门槛与特征频率隐去。
  25. # 分级而非一刀切 — 一刀切会把业主必须知道的口径也糊掉:
  26. # 脱 特征频率 (134.9Hz 是我们解出来的轴承几何, 属核心参数)
  27. # 脱 判据门槛 (≥40kW / |z|≥3 / ≥2.5× — 我们的判别阈值)
  28. # 留 时间口径 (≥1h 段 / ≥3 天 — 业主要据此理解统计范围)
  29. # 留 物理常数 (Cp ≤ 0.593 贝兹极限 — 公开知识, 不是我们的)
  30. # 留 台数与占比 (≥2 个系统报警 — 业主自己数得出来)
  31. # 内部模式: WINDSCADA_INTERNAL=1
  32. import os as _os
  33. INTERNAL = _os.environ.get('WINDSCADA_INTERNAL', '') == '1'
  34. # ★ Hz 后不能用 \b: 中文属于 \w, "597Hz线" 里 z 与 线 之间没有词边界 ⇒ \b 不成立, 实测漏脱 WTG05 的 597Hz。
  35. # ★ 前瞻也不能用 \w: 中文同属 \w, "HS_gen测点274Hz" 里 274 前是"点" ⇒ 断言失败漏脱。
  36. # 只需排除数字与小数点 (防止把 1274 截成 274)。
  37. _RE_HZ = re.compile(r'(?<![\d.])\d+(?:\.\d+)?\s*(?=Hz)')
  38. _KEEP_UNIT = ('h', '小时', '天', '日', '月', '年', '台', '次', '条', '起', '个')
  39. # ★ 判据符号前缀: |z|≥3 / σ≥2 / ×≥2.5 这类无单位阈值必须脱 —
  40. # 否则会被"无单位小整数=计数口径"那条规则放行 (实测 |z|≥3 漏脱)。
  41. _RE_THR = re.compile(r'([≥≤])\s*(\d+(?:\.\d+)?)\s*([a-zA-Z%℃×/·²]*)')
  42. _CRIT_MARK = ('z', 'σ', '×', '倍', 'dev', 'resid', '选择性', '峰', '结构度', 'ratio')
  43. def _redact_text(t):
  44. if INTERNAL or not isinstance(t, str) or not t:
  45. return t
  46. if '0.593' in t or '贝兹' in t or 'Betz' in t: # 物理常数不动
  47. return _RE_HZ.sub('▪▪', t)
  48. t = _RE_HZ.sub('▪▪', t)
  49. def _th(m):
  50. sign, num, unit = m.group(1), m.group(2), m.group(3) or ''
  51. pre = t[max(0, m.start() - 8):m.start()]
  52. if any(c in pre for c in _CRIT_MARK): # |z|≥3 / ×≥2.5 = 判据阈值, 必脱
  53. return f'{sign}▪{unit}'
  54. if unit in _KEEP_UNIT or (not unit and float(num) <= 10 and float(num) == int(float(num))):
  55. return m.group(0) # 时间/计数口径保留 (≥1h段 / ≥2 个系统)
  56. return f'{sign}▪{unit}'
  57. return _RE_THR.sub(_th, t)
  58. def _redact(o):
  59. if INTERNAL:
  60. return o
  61. if isinstance(o, str):
  62. return _redact_text(o)
  63. if isinstance(o, dict):
  64. return {k: _redact(v) for k, v in o.items()}
  65. if isinstance(o, list):
  66. return [_redact(v) for v in o]
  67. return o
  68. # ── 语言投影 (2026-08-31) ────────────────────────────────────────────
  69. # 诊断: 骨架页 HTML 里**一个中文字都没有** (实测 0 字符), 内容全部由 JS 从 API 拉取,
  70. # 而 API 不认 lang —— 所以"英文版"拿到的一直是中文数据。此前三轮翻译 HTML 源码,
  71. # 改的是一个没有内容的文件, 这是英文残留反复修不好的真正原因。
  72. # 修法: 在 API 唯一出口 _jdump 上做文本层投影。**不带 lang 时原样返回**, 中文版行为零变化。
  73. _REQ = threading.local()
  74. try:
  75. from src.ontology import codes as _codes
  76. except Exception: # 码表缺失不应让服务起不来
  77. _codes = None
  78. def _lang_of():
  79. return getattr(_REQ, 'lang', '') or ''
  80. def _enum_fallback(field, value, lang):
  81. """枚举值映射不出码时的回落 —— **走翻译, 不留中文**。
  82. 根因 (2026-08-31 实测): `part` 这类字段被当成枚举, 但它在工单里其实是**现场手写的
  83. 自由文本** ('齿轮箱高速轴' / 'IGBT、直流保险' / '(振动线未收录)' / '无')。码表只有 18 条,
  84. 覆盖不了; 而 normalize 失败时原实现**原样返回中文**, 于是英文页上直接显示中文。
  85. 把这些硬塞进码表是错的 —— 它们不是枚举语义, 是描述。正确做法是让它们走翻译层。
  86. 查不到才留原文 (不猜译)。
  87. """
  88. if lang == 'zh' or not isinstance(value, str) or not value.strip():
  89. return value
  90. try:
  91. from windscada import en_text as _et
  92. except Exception:
  93. return value
  94. for scope in (field, 'part', None):
  95. try:
  96. en, _src = _et.lookup(value, scope) if scope else _et.lookup(value)
  97. except Exception:
  98. en = None
  99. if en:
  100. return en
  101. return value
  102. # ★这些字段 **不翻译** —— 它们是 JS 的判别值与查表键, 不是给人读的文案。
  103. # 翻了会静默劈叉: 页面上看不出错, 但整段内容消失或整片颜色变灰。
  104. # 2026-09-02 线上实测 (用户逮「有问题的看不见? 只有 good」):
  105. # · st 被翻成 Alarm → `rows.filter(r=>r.st==='报警')` 零命中 → **报警 12 台整表不渲染**,
  106. # 而柱状条读的是 sysdist (**键**没被翻) 仍显示 "Alarm 12" ⇒ 数字与明细自相矛盾。
  107. # · ostate 被翻成 Good/Excellent → `C[c.ostate]` (C 是中文键) 全部 MISS
  108. # → 融合矩阵**每一格颜色都退成灰**。
  109. # 判据: 凡是 JS 里 `X[obj.字段]` 查中文键表、或 `obj.字段==='中文'` 比较的字段, 一律进本表。
  110. # 显示层 (ui_en.value_script) 负责把渲染出来的中文文本换成英文, 所以人看到的仍是英文。
  111. # tests/test_keep_zh_discriminators.py 扫源码断言本表齐全, 防以后加字段漏登记。
  112. _KEEP_ZH = frozenset((
  113. 'st', 'ostate', 'wlevel', '状态', '设备状态', '证据状态', '部件', 'liveness',
  114. 'loc', # 模型本地/云端档位: `v.loc==='本地'` 决定"数据不出本地"这句能不能说
  115. # ★第三类判别用法, 本闸的扫描器 (tests/test_keep_zh_discriminators.py) 抓不到:
  116. # 不是比较也不是查表, 而是拼进 URL 路径当 ID (`/problem/${t}/${encodeURIComponent(x)}`)。
  117. # syss 被翻成 'Yaw' 后, 浮层请求 sys=Yaw 永远查不到烤的是 sys=偏航 的快照键,
  118. # 点开必是"本窗无记录"——页面不报错, 数据静默消失, 和 st/ostate 那两类同一后果不同机制。
  119. # 2026-09-03 用户实测逮出 (Watch list → 系统名链接)。显示文字仍由 sysName(x) 客户端译。
  120. 'syss',
  121. ))
  122. # ★按**值**豁免 —— 给 `k` 这种一名多用的字段用。
  123. # `k` 在告警排行里是显示串 (`"7122 偏航液压油位低"`, 要翻),
  124. # 在控制状态与部件配色里是枚举键 (`'停机'` / `'齿轮箱'`, 不能翻)。
  125. # 键名分不开两者, 只能按值。2026-09-02 实测: 我先把 'k' 整个放进 _KEEP_ZH,
  126. # 结果**报警名全部不再翻译** —— 修一个洞挖一个洞, 按值豁免才收得住边界。
  127. # 来源: JS 里 CO / LOSSPLAIN 两张中文键表的键 + `x.k==='停机'` 比较。
  128. _KEEP_ZH_VALS = frozenset((
  129. '齿轮箱', '发电机', '主轴承', '变桨叶片', '偏航', '测风', '其他', # CO 配色键
  130. '限电·绑定', '限电命令·未绑定', '停机(调度令)', '停机', '正常发电', '低风待机', # LOSSPLAIN 键
  131. ))
  132. def _proj(o, lang):
  133. """把 `key_zh`/`key_en` 投影成裸 `key`。
  134. 英文侧取不到 `_en` 时写 `[translation pending]` 而**不回落中文** ——
  135. 回落会让缺口永远不被发现, 正是英文版残留三轮没被逮住的机制。
  136. """
  137. if isinstance(o, list):
  138. return [_proj(x, lang) for x in o]
  139. if not isinstance(o, dict):
  140. return o
  141. suf = '_' + lang
  142. other = '_zh' if lang == 'en' else '_en'
  143. # ★裸 `zh` / `en` 成对 (framework.py 的 SYSTEMS/RECORDS/stages 就是这个写法)。
  144. # 它们不带下划线, 走不到下面的后缀规则; 而末尾"残留中文一律写 [translation pending]"
  145. # 的兜底会把 zh 打成 pending —— **旁边的 en 明明就是对的**。实测 145 处 pending
  146. # 里的大多数是这一种。消费端 JS 读的是 `.zh`, 所以英文版把 en 的值投影到 zh 上。
  147. if lang != 'zh' and isinstance(o, dict) and 'zh' in o and 'en' in o:
  148. o = dict(o, zh=o['en'])
  149. # ★只有 k_en **有值**时才让裸 k 让位 (2026-09-01 parity 闸逮)。
  150. # 原来无条件让位: 上游给了 action_en=None, 裸 action 就被顶成 None ——
  151. # 页面上显示 "None", 比留中文糟得多。空的语言副本等于没给, 不该覆盖原值。
  152. out = {}
  153. source_fields = {'name', '步骤', '故障', '排查', '原因', '处置', 'check'}
  154. originals = {key: o.get(key + '_zh', o.get(key)) for key in source_fields} if lang == 'en' else {}
  155. originals = {key: value for key, value in originals.items() if isinstance(value, str) and _CJK.search(value)}
  156. langed = {k[:-len(suf)] for k in o if k.endswith(suf) and o[k] not in (None, '', [])}
  157. for k, v in o.items():
  158. if k == '_original':
  159. out[k] = dict(v) if isinstance(v, dict) else v
  160. continue
  161. if k.endswith(other): # 另一语言的副本不出 API
  162. continue
  163. if k.endswith(suf):
  164. if v not in (None, '', []):
  165. out[k[:-len(suf)]] = _proj(v, lang)
  166. continue
  167. if k in langed: # 已有 key_<lang>, 裸 key 让位
  168. continue
  169. v2 = _proj(v, lang)
  170. if k in _KEEP_ZH or (isinstance(v2, str) and v2 in _KEEP_ZH_VALS):
  171. out[k] = v2 # 判别值/查表键: 原样留中文 (见 _KEEP_ZH 注释)
  172. continue
  173. # 枚举层先查码表 — 这些值有确定译名, 不该退化成 [translation pending]。
  174. # 列表型枚举 (如 facets) 逐项查。
  175. if lang != 'zh' and _codes is not None and k in _codes.CODES:
  176. if isinstance(v2, str):
  177. c = _codes.normalize(k, v2)
  178. if c:
  179. v2 = _codes.label(k, c, lang)
  180. else:
  181. v2 = _enum_fallback(k, v2, lang)
  182. elif isinstance(v2, list):
  183. v2 = [(_codes.label(k, _codes.normalize(k, x), lang)
  184. if isinstance(x, str) and _codes.normalize(k, x) else x) for x in v2]
  185. # 单数字段名对应复数码表 (facet ← facets) 也走一遍
  186. elif lang != 'zh' and _codes is not None and k.rstrip('s') in _codes.CODES:
  187. f = k.rstrip('s')
  188. if isinstance(v2, str):
  189. c = _codes.normalize(f, v2)
  190. if c:
  191. v2 = _codes.label(f, c, lang)
  192. elif isinstance(v2, list):
  193. v2 = [(_codes.label(f, _codes.normalize(f, x), lang)
  194. if isinstance(x, str) and _codes.normalize(f, x) else x) for x in v2]
  195. # ★这里原来只判 isinstance(v2, str) —— **字符串列表整个绕过了英文投影**。
  196. # _proj 递归进 list, 但 _proj(str) 原样返回, 所以 ["偏航","变桨"] 这种
  197. # 一路穿到 API。实测漏掉 5 条路径 (parts[] / syss[] / srcs[] /
  198. # open_items[] / blind[]), 是英文页数据层残留中文的唯一来源。
  199. if lang == 'en':
  200. # Action counts use display labels as dictionary keys, so _en_deep
  201. # cannot reach them. Only this display map uses the established
  202. # work-order action glossary; all other dictionary/URL IDs stay put.
  203. if k == '动作统计' and isinstance(v2, dict):
  204. from src.windscada.maint.workorder_en import ACTION
  205. v2 = {ACTION.get(action) or action: count for action, count in v2.items()}
  206. v2 = _en_deep(v2, k)
  207. out[k] = v2
  208. for key, original in originals.items():
  209. if key in out and out[key] != original:
  210. out.setdefault('_original', {})[key] = original
  211. return out
  212. def _en_deep(v, field='', _depth=0):
  213. """对字符串与**字符串列表/嵌套列表**一律走 _data_text_en。
  214. 只对 str 生效的旧写法会让 list-of-str 静默穿透 —— 这类漏法的特征是
  215. "抽查单值全对, 页面上却成片中文", 因为漏的是容器不是值。
  216. """
  217. if isinstance(v, str):
  218. return _data_text_en(v, field) if _CJK.search(v) else v
  219. if isinstance(v, list) and _depth < 4:
  220. return [_en_deep(x, field, _depth + 1) for x in v]
  221. return v
  222. _DATA_EN_CACHE = {}
  223. _DATA_EN_MISS = set() # 查不到的原文集合 (与命中缓存分开: 未命中的返回值随请求模式而异)
  224. def _data_text_en(v, field=''):
  225. """数据层中文文本 → 英文。取不到才写 [translation pending]。
  226. ★为什么要有这一层: 原实现是"残留中文一律 [translation pending]" —— 干净但过于绝对。
  227. 实测 /api/fleet 一次就打出 **2199 处** pending, 而其中大量是厂商报警名、工单原文、
  228. 部件名、判级词, 这些在本项目里**已经有译源**: 厂商中英对照表 3834 条、IPC 部件表
  229. 2113 条、枚举码表、工单名构词表。不查就直接标 pending, 等于把已有的资产扔掉,
  230. 还让英文页看起来像没做完。
  231. 查不到仍写 pending —— **不猜译**。这条不能松: 猜译出来的厂商报警名会误导现场。
  232. """
  233. if v in _DATA_EN_CACHE:
  234. return _DATA_EN_CACHE[v]
  235. if v in _DATA_EN_MISS:
  236. return v if getattr(_REQ, 'keep_orig', False) else '[translation pending]'
  237. out = None
  238. # ⓪⁻ 数据层**整串**直译表 (融合面盲区/未决项长文)。放在最前: 这些是整段人写的
  239. # 分析结论, 走模板反解或分层词表都只会碎成片段。
  240. try:
  241. from src.windscada import data_tpl_en as _tplN
  242. out = getattr(_tplN, 'NOTES', {}).get(v)
  243. if out is None:
  244. from src.windscada.en_text import standard_term
  245. out = standard_term(v)
  246. if out is None:
  247. out = getattr(_tplN, 'RECORD_EN', {}).get(v)
  248. except Exception:
  249. out = None
  250. if out is not None:
  251. _DATA_EN_CACHE[v] = out
  252. return out
  253. # ⓪ 「码 + 中文描述」→ 本体 AlarmCode 的权威 name_en (查表, 不翻译)
  254. try:
  255. from src.windscada import en_text as _et0
  256. out = _et0.alarm_en(v)
  257. except Exception:
  258. out = None
  259. if out is not None:
  260. _DATA_EN_CACHE[v] = out
  261. return out
  262. # ① 服务端拼串走**模板反解** (精确, 非翻译): 抽数字得形态 → 查英文模板 → 数字按序填回
  263. try:
  264. from src.windscada import data_tpl_en as _tpl
  265. out = _tpl.render(v)
  266. except ValueError as e:
  267. print('[_data_text_en] 模板占位符对不上, 已跳过不填: %s' % e, file=sys.stderr)
  268. out = None
  269. except Exception:
  270. out = None
  271. if out is not None:
  272. _DATA_EN_CACHE[v] = out
  273. return out
  274. try:
  275. from src.windscada import en_text as _et
  276. # ① 分层查表 (厂商报警 → IPC 部件 → 枚举/文案)
  277. for sc in ('fault', 'part', 'ui', None):
  278. hit, _src = _et.lookup(v, sc) if sc else _et.lookup(v)
  279. if hit:
  280. out = hit
  281. break
  282. # ② 部件自由文本解析 (复合名/带注解/多部件连写)
  283. if out is None:
  284. out = _et.part_en(v)
  285. except Exception:
  286. pass
  287. # ②b 词表直查 —— 部件名/判级词/系统名作为**独立字段**出现时 (fus.表[].部件 / .振动结论 /
  288. # rel.表[].部件), 既不是模板串也不在 en_text 分层表里, 而 data_tpl_en 的
  289. # CHAN/GRADE2/SYSTEM_EN 早就登记了同一批词 (供模板内部用)。不查就整片留中文
  290. # (2026-09-03 实逮 fus.表 328 字)。**同一批词只因出现位置不同就译不出, 是接线漏不是词条漏。**
  291. if out is None:
  292. try:
  293. from src.windscada import data_tpl_en as _t2
  294. out = _t2.CHAN.get(v) or _t2.SYSTEM_EN.get(v)
  295. if out is None:
  296. g = _t2.GRADE2.get(v) or _t2.GRADE.get(v)
  297. if g: # 判级词在表里是小写短语 (模板内嵌用), 独立成字段要首字母大写
  298. out = g[0].upper() + g[1:]
  299. except Exception:
  300. pass
  301. if out is None:
  302. try:
  303. from src.windscada.maint import workname_en as _wn
  304. # ★只用 work_name_en (逐段都命中才拼, 命不中返回 None), **不用 category_en**。
  305. # category_en 是正则归类兜底: 它对看不懂的文本也会给出一个"像样"的答案。
  306. # 实测它把化验样注记译成 work item:
  307. # '末样2025-01-09 (距振动现窗>12月, stale: 只作检测链回测)'
  308. # → 'Condition monitoring — work item' ← 错, 那不是工单
  309. # '末样2026-07-13: 华标判正常(NB/T宽限); 重点值…'
  310. # → 'Generator — work item' ← 错且误导
  311. # 我在本函数的说明里写了"不猜译"然后把猜译器接了进来。
  312. # **看得懂就译, 看不懂就标 pending —— pending 是诚实的, 猜译不是。**
  313. out = _wn.work_name_en(v)
  314. except Exception:
  315. pass
  316. if out:
  317. _DATA_EN_CACHE[v] = out
  318. return out
  319. # 查不到: 经典页写 [translation pending] (显示层再藏); v2 (keep=1) **留原文** —— 前端按原文
  320. # 标注为"手册原文", 不丢信息也不冒充英文。命中与未命中分开缓存, 两种模式互不污染。
  321. _DATA_EN_MISS.add(v)
  322. return v if getattr(_REQ, 'keep_orig', False) else '[translation pending]'
  323. def maint_framework_view():
  324. """运维管理体系三维骨架 —— **唯一实现** (在线 API 与快照预烤共用)。
  325. ★同 dq_findings_view 的教训: 这段原来也是两份, 在线那份我接了 unmapped_top 的
  326. 英文化, 预烤那份没有 ⇒ 在线干净、快照里还是中文, 而上云的是快照。
  327. 抽出来一次, 消掉这类"改了一半"的可能。
  328. """
  329. import json as _j, pathlib as _p
  330. from src.windscada.maint import framework as _fw
  331. db = _j.loads(_P.objects_json().read_text(encoding='utf-8'))
  332. wo = [(v.get('props') or {}) for k, v in db.items() if k.startswith('workorder/')]
  333. r = dict(systems=_fw.system_load(wo)['rows'], tiers=_fw.systems_by_tier(),
  334. stages=_fw.stage_matrix(), records=_fw.RECORDS,
  335. gaps=_fw.capability_gaps(wo), coverage=_fw.coverage(wo))
  336. # unmapped_top = **未映射到系统码的现场手写部件名**, 不是枚举, 码表投影对它无效。
  337. # 走 part_en 自由文本解析; 空值在这个语境下写 "(not recorded)" 而非破折号。
  338. lang = _lang_of()
  339. if lang and lang != 'zh' and (r.get('gaps') or {}).get('unmapped_top'):
  340. try:
  341. from src.windscada import en_text as _et
  342. out = []
  343. for it in r['gaps']['unmapped_top']:
  344. if isinstance(it, (list, tuple)) and len(it) == 2:
  345. e = _et.part_en(it[0]) or it[0]
  346. out.append(['(not recorded)' if e == '\u2014' else e, it[1]])
  347. else:
  348. out.append(it)
  349. r['gaps'] = dict(r['gaps'], unmapped_top=out)
  350. except Exception as e:
  351. print('[maint_framework_view] unmapped_top 英文化失败: %s' % e, file=sys.stderr)
  352. return r
  353. def dq_findings_view():
  354. """数据可信度 finding 行 —— **唯一实现**。
  355. ★2026-08-31: 这段原来存在两份 —— 一份在 /api/dq_findings 处理分支, 一份在快照的
  356. `_dq()` 预烤闭包里。我给 API 那份补了 sensitivity / age_basis 等字段, 预烤那份没动
  357. ⇒ **在线看得到, 快照里没有**。而上云交付的恰恰是快照。
  358. 同型教训已在 memory `library-gate-not-wired-to-production`: 落库不等于接上生产路径。
  359. 这里直接消掉重复: 两边都调本函数。
  360. """
  361. import json as _j, pathlib as _p
  362. db = _j.loads(_P.objects_json().read_text(encoding='utf-8'))
  363. KEEP = ('finding/data_quality/', 'finding/caliber/',
  364. 'finding/system_coverage/', 'finding/tier_trend/')
  365. rows = []
  366. for k, o in db.items():
  367. if not k.startswith(KEEP) or not isinstance(o, dict):
  368. continue
  369. p2 = o.get('props') or {}
  370. rows.append(dict(
  371. id=k, kind=k.split('/')[1],
  372. name=p2.get('name_zh') or p2.get('name'),
  373. name_zh=p2.get('name_zh') or p2.get('name'),
  374. name_en=p2.get('name_en'),
  375. grade=p2.get('grade'), claim_window=p2.get('claim_window'),
  376. evidence=p2.get('evidence_zh') or p2.get('evidence') or [],
  377. evidence_zh=p2.get('evidence_zh') or [],
  378. evidence_en=p2.get('evidence_en') or [],
  379. caveat=p2.get('caveat'), caveat_en=p2.get('caveat_en'),
  380. impact=p2.get('discriminator_zh') or p2.get('disposition_zh')
  381. or p2.get('implication_zh') or p2.get('corrected_comparison_zh'),
  382. impact_en=p2.get('discriminator_en') or p2.get('disposition_en')
  383. or p2.get('implication_en') or p2.get('corrected_comparison_en'),
  384. got_wrong=p2.get('what_i_got_wrong_zh'),
  385. got_wrong_en=p2.get('what_i_got_wrong_en'),
  386. quality_flag=p2.get('quality_flag_zh'), quality_flag_en=p2.get('quality_flag_en'),
  387. robust=p2.get('robust_zh'), robust_en=p2.get('robust_en'),
  388. basis=p2.get('basis_zh'), basis_en=p2.get('basis_en'),
  389. sensitivity=(p2.get('sensitivity') or {}).get('note_zh'),
  390. sensitivity_en=(p2.get('sensitivity') or {}).get('note_en'),
  391. age_basis=p2.get('age_basis_zh'), age_basis_en=p2.get('age_basis_en'),
  392. ))
  393. rows.sort(key=lambda x: (x['kind'] != 'data_quality', x['id']))
  394. r = dict(rows=rows, n=len(rows))
  395. if not rows:
  396. # ★2026-09-18 用户令("页面只能基于输入数据重算")之后实逮: 清过产物再重算的机器上
  397. # `objects.json` 里 **0 条 finding/* 对象** ⇒ 本页只是"空", 不说原因就等于让人以为
  398. # "系统自查没问题"。这四族的生成端确实不在重算链上(见族表 guanlan/ontology 说明),
  399. # 所以如实写清, 并明确"不用旧版产出补"。
  400. r['note'] = ('本体对象库里没有 finding/* 对象 (四族: data_quality / caliber / system_coverage / '
  401. 'tier_trend) ⇒ 本页**如实为空**, 不是"自查无发现"。这些 finding 的生成端不在重算链上'
  402. '(唯一写方 scripts/ingest_ops_2025.py 的输入 data/raw/工作库 不在现场数据里), '
  403. '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端。')
  404. return r
  405. def _en_page(page):
  406. """lang=en 时把页面文案换成英文。**中文版不经过这里** (lang 空 → 直接返回原页)。
  407. 用的是 ui_en 的**整条短语表**, 不是 i18n_en 的逐词词表 —— 后者会产出
  408. "Verification 状态机" 这类混杂串 (已实测作废)。缺条目由 ui_en.audit 报出,
  409. 不静默留在页面上。
  410. """
  411. lang = _lang_of()
  412. if not lang or lang == 'zh':
  413. return page
  414. try:
  415. from src.windscada import ui_en
  416. page = ui_en.apply(page)
  417. # ★译后语法闸: 保护规则一松一紧都能把 JS 译坏, 而症状是**空白骨架页** ——
  418. # 此时"可见中文=0", 自查全绿。必须用外部解析器判, 不能靠数引号。
  419. for _m in re.finditer(r'<script[^>]*>(.*?)</script>', page, re.S):
  420. _code = _m.group(1)
  421. if _code.strip() and 'application/json' not in _m.group(0):
  422. ui_en.check_js(_code, label='英文页内联脚本')
  423. # 数据值 (报警/定论/工单…) 既做比较又要显示。比较照旧用中文, 显示层在 render
  424. # 之后替换文本节点 —— 只碰显示不碰逻辑, 所以判断不会失效。
  425. return page.replace('</body>', ui_en.value_script() + '</body>') \
  426. if '</body>' in page else page + ui_en.value_script()
  427. except Exception as e:
  428. # ★退回中文页比给个坏页安全, 但**必须响亮** —— 静默退回会让"英文版没做完"
  429. # 看起来像"英文版做完了只是有中文残留"。守护步骤失败必须可见。
  430. print('[_en_page] 🔴 英文化失败, 已退回中文页: %s: %s' % (type(e).__name__, e),
  431. file=sys.stderr)
  432. return page
  433. def _jdump(obj, **kw):
  434. o = _redact(_jclean(obj))
  435. lang = _lang_of()
  436. if lang and lang != 'zh': # 中文版走这行以外的路径, 一字不动
  437. o = _proj(o, lang)
  438. return json.dumps(o, allow_nan=False, **kw)
  439. def _gap_page(path: str, missing: 'pathlib.Path | None' = None, what: str = '这一块') -> str:
  440. r"""产物缺失时的兜底页 (2026-09-17 现场问题驱动)。
  441. 原来 `/static/…`、`/turbines/…` 取不到文件就回一个裸 `404` 文本 —— 浏览器里表现为**一块空白**,
  442. 用户看到的现象是"这个板块没有信息", 而不是"缺件"。最典型的就是总览页那个
  443. `<iframe src="/static/index.html">`(全场状态总览页)。
  444. ★2026-09-19 更新 (用户令「所有的计算均要形成观澜的源代码」): 该页**已有生成端** ——
  445. `scripts/windscada_overview_build.py`(用组件同一个快照烘焙器从重算产物生成), 重算链第 ⑦b 步会跑它,
  446. 所以现在"不在位"只意味着**还没重算**, 不再是"包内没有生成端"。本页说明文字已据此改写。
  447. 现在改成结构化说明: 缺哪一件、它归哪条产物线、**怎么补**、去哪儿看全局状态。
  448. 口径与 CMS 的"无产物"页一致 (docs §11.4: 无产物时必须还能打开页面, 且要写明缺件与补齐办法)。
  449. """
  450. import html as _h
  451. rel = ''
  452. try:
  453. rel = pathlib.Path(missing).relative_to(pathlib.Path(_P.ROOT)).as_posix() if missing else ''
  454. except Exception:
  455. rel = str(missing or '')
  456. return ('<!doctype html><meta charset="utf-8"><title>产物不在位</title>'
  457. '<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;color:#183548;background:#f2f6f8;'
  458. 'margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh}'
  459. '.b{background:#fff;border:1px solid #d4e3e7;border-radius:10px;padding:22px 26px;max-width:800px;'
  460. 'box-shadow:0 6px 20px rgba(14,74,87,.08)}h1{font-size:17px;margin:0 0 10px}'
  461. 'code{background:#eef4f6;border-radius:4px;padding:1px 5px}.s{color:#667f8e;font-size:13px}'
  462. 'a{color:#087f89}li{margin:3px 0}</style>'
  463. f'<div class="b"><h1>{_h.escape(what)}的数据产物不在位</h1>'
  464. f'<p class="s">请求路径 <code>{_h.escape(path)}</code></p>'
  465. + (f'<p>缺的是 <code>{_h.escape(rel)}</code>(属 <code>outputs/&lt;场站&gt;/</code> 下的产物)。</p>'
  466. if rel else '')
  467. + '<p>两种情形分开补:</p><ul>'
  468. '<li><b>有生成端的产物</b>(alarms / workorders / temp_monthly / 振动窗索引谱 / '
  469. '<code>windscada/index.html</code> …): 放好 '
  470. '<code>data/raw/&lt;场站&gt;/</code> 后跑 <code>python scripts/rebuild_all.py</code>, '
  471. '或点运维控制台的「数据重算」。</li>'
  472. '<li><b>仍缺生成端的那几族</b>(如 <code>sop/</code>、<code>paradigm_r1/</code> 里的人工底稿、'
  473. '<code>windcms/</code> 的厂家报告转录): 按用户令 2026-09-19「所有的计算均要形成观澜的源代码」'
  474. '正在逐族补(清单见 <code>docs/源代码化清单_v0.1.md</code>); 在此之前页面如实显示缺件, '
  475. '且按用户令 2026-09-17 <b>运行期不从交付包补齐</b>。'
  476. '(2026-09-19 已补上生成端的: 变桨面 <code>pitch/**</code> ← <code>scripts/pitch_face_build.py</code>; '
  477. '振动在升/闭环 <code>m5_cms_tcm/component_history.json</code>、三层基线 <code>baseline_38.json</code> '
  478. '← <code>scripts/component_history_build.py</code> / <code>scripts/baseline_38_build.py</code>; '
  479. '事实契约 <code>guanlan/**</code> ← 重算台账生成 claim; '
  480. '发布层 <code>ontology/release_r1|r2</code> ← <code>scripts/ontology_release_build.py</code>。)</li></ul>'
  481. '<p class="s">逐件来源见 <code>outputs/&lt;场站&gt;/_provenance.json</code>;'
  482. '缺口清单见 <code>docs/系统设计说明.md</code> §7 与 docs/重算缺口与补件清单_v0.1.md。</p>'
  483. '<p><a href="/v2" target="_top">← 返回工作台</a> · '
  484. '<a href="http://127.0.0.1:28084/ops" target="_top">运维控制台 · 数据重算</a> · '
  485. '<a href="http://127.0.0.1:28084/healthz" target="_top">全部模块状态</a></p></div>')
  486. from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
  487. from src.windscada.config import farm
  488. from src.windscada import taxonomy
  489. from src.windscada.subsys import temp_nbm, hydraulic, yaw as yawmod, pitch as pitchmod
  490. from src.windscada.perf import availability
  491. from src.windscada import i18n
  492. CFG = farm(); ST = pathlib.Path(CFG['store'])
  493. _CJK = re.compile(r'[\u4e00-\u9fff]')
  494. def vibcms_results():
  495. """windcms 评估报告 → 结果层转录 (2026-08-28 用户令: 生接改融合·只显示结果·模型与计算隐藏).
  496. 取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms.
  497. ★2026-09-18 用户令"页面只能基于输入数据重算、不许用旧版产出补"之后实逮: 清过产物再重算的机器上
  498. `windcms/` 里没有 `报告_CMS振动状态评估报告_*.md`, 于是 `sorted(glob)[-1]` 抛
  499. **IndexError: list index out of range**, 页面看到的是"windcms 报告解析失败: list index out of range"
  500. —— 与 `ProductsMissing` 的教训同一个病: **缺产物被报成了程序坏了**。现在按缺产物如实回结构化的
  501. `no_report`, 并写清它由谁生成。★2026-09-19 再修: 报告步的生成端(`scripts/windcms.py report`)与它的
  502. 上游(`rudong_model_run.py`/`rudong_fusion_run.py`)都已随包并上链, 缺件只剩"还没重算到那一步"这一个原因,
  503. 故这里再附一句"重算是否正在跑"(读 run/ops_job.json), 免得把"正在跑"读成"系统坏了"。
  504. """
  505. reps = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))
  506. if not reps:
  507. # ★2026-09-19 修: 原话还写着"本包六层链的 model_run/fusion 两步脚本未随包" —— 那两步已在
  508. # 2026-09-19 按口径重建并上链(scripts/rudong_model_run.py / rudong_fusion_run.py), 话说反了。
  509. # 真实原因只有两种: ① 重算还没走到 ④b 的报告步(清过产物后这一步要跑很久); ② windcms 产物被清且未重算。
  510. try:
  511. from src import opsjob as _oj
  512. _run = (' ' + _oj.running_text()) if _oj.running_text() else ''
  513. except Exception:
  514. _run = ''
  515. return dict(date=None, overall='', action=[], grades=[], diff=dict(only_cms=[], only_handoff=[]),
  516. no_report=True,
  517. note_zh='本机没有 CMS 振动状态评估报告 (由重算链 ④b 的 report 步生成: '
  518. 'python scripts/windcms.py report)。如实为空, 不用旧版产出补。' + _run,
  519. err='无产物: windcms/报告_CMS振动状态评估报告_*.md 不在位')
  520. try:
  521. rp = reps[-1]
  522. md = rp.read_text(encoding='utf-8') # 不写 encoding 会按系统 locale(cp936) 读 → 中文报告乱码
  523. date = rp.stem.rsplit('_', 1)[-1]
  524. overall = ''
  525. if '### 3.1' in md:
  526. seg = md.split('### 3.1')[1].split('###')[0]
  527. overall = next((l.strip() for l in seg.splitlines() if l.strip().startswith('全场')), '')
  528. def _tab(sec):
  529. return [[c.strip() for c in l.strip('|').split('|')] for l in sec.splitlines() if l.startswith('| WTG')]
  530. action = _tab(md.split('### 3.2')[1].split('## ')[0]) if '### 3.2' in md else []
  531. grades = []
  532. if '## 附录 A' in md:
  533. for r in _tab(md.split('## 附录 A')[1]):
  534. # 列: 机组/前/后/齿/发/综合/融合级(模型·隐)/CMS红黄(隐)/行动
  535. grades.append(dict(t=r[0], 主轴承前=r[1], 主轴承后=r[2], 齿轮箱=r[3], 发电机=r[4],
  536. 综合=r[5], 行动=r[8] if len(r) > 8 else ''))
  537. # 与融合矩阵(handoff 定谳链)口径差异台
  538. from src.windscada.subsys import fusion as _f
  539. ft, _ = _f.fusion_table()
  540. hset = {r.turbine for _, r in ft.iterrows() if _f.verdict_class(str(r.振动结论))[1] in ('bad', 'warn')}
  541. cset = {r[0] for r in action}
  542. diff = dict(only_cms=sorted(cset - hset), only_handoff=sorted(hset - cset))
  543. # ★2026-09-19: 把**这个窗真实覆盖的日期区间**一并回给页面。原来只回 date(出件日) 与表格,
  544. # 页面上看不到"数据从哪天到哪天" ⇒ 用户拿 2026-03~04 的导出却只看到 4 月的谱时,
  545. # 无法判断是"数据没呈现"还是"源件本身就那么点"(实测源件最早只到 2026-03-16, 逐台更晚)。
  546. _span, _win = '', ''
  547. try:
  548. from src.windcms import config as _wcfg, data as _wdata
  549. _w2 = _wcfg.farm(str(CFG.get('key') or 'rudong'))
  550. _span = _wdata.span_text(_w2) or ''
  551. _win = ','.join(_wdata.windows(_w2))
  552. except Exception:
  553. _span = _win = ''
  554. return dict(date=date, overall=overall, action=action, grades=grades, diff=diff,
  555. 时间范围=_span, 窗=_win)
  556. except Exception as e:
  557. return dict(error=f'windcms 报告解析失败: {e}') # 响亮, 不静默空表
  558. LOCK = threading.Lock()
  559. _CACHE = {}
  560. # ── 产物指纹 → **运行中自动重载** (2026-09-16 用户令: "重算后的产物可即时用于页面呈现") ──────
  561. # 背景 (实测): _load() 把产物**一次性**读进内存, 之后只在启动时读一次 ⇒ 重算完成后页面仍显示旧数,
  562. # 必须重启组件服务才更新。判定依据: 改产物后 /detail/api/fleet 等仍回旧值, 重启组件后才变。
  563. # 做法: 取"页面取数依赖的产物文件"的 (路径, mtime_ns, size) 摘要当指纹; _load() 前比一次
  564. # (带 TTL, 免得每请求都 stat 一遍), 指纹变了就重载 ⇒ 重算产物**不再需要重启服务**。
  565. # 也提供 /api/reload 显式重载 (运维控制台重算结束后可点一下)。
  566. _STAMP_TTL = 2.0 # 秒: 指纹有效期 (stat ~150 个文件 ≈ 1~3 ms, 不值得每请求都做)
  567. _STAMP = {'val': None, 'at': 0.0}
  568. def _product_files():
  569. """页面取数依赖的产物文件清单 (顺序稳定: 供指纹; 只列**产物**, 不含 reference/ 随包契约)。
  570. 覆盖 _load() 直读的件 + 它经 taxonomy/temp_nbm/hydraulic/fusion 间接读的件:
  571. windscada/*.parquet|csv|json · ontology/*.json · pitch/*.parquet
  572. m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json · windcms/报告_CMS*.md
  573. """
  574. try:
  575. from src.windcms.config import cms_out as _cms_out # 与写侧同一解析口 (WINDCMS_OUT)
  576. _cms_dir = _cms_out()
  577. except Exception:
  578. _cms_dir = _P.cms()
  579. pats = ((ST, ('*.parquet', '*.csv', '*.json')),
  580. (_P.ont(), ('*.json',)),
  581. (_P.pitch(), ('*.parquet',)),
  582. (_P.m5(), ('handoff_vibration_v2.json', 'component_history.json', 'baseline_38.json')),
  583. (_cms_dir, ('报告_CMS振动状态评估报告_*.md',)))
  584. files = []
  585. for d, ps in pats:
  586. for pat in ps:
  587. files.extend(sorted(d.glob(pat)))
  588. return files
  589. def products_stamp(force=False):
  590. """产物指纹 (sha1 前 16 位)。force=True 时忽略 TTL 立即重算。"""
  591. now = time.time()
  592. if not force and _STAMP['val'] is not None and (now - _STAMP['at']) < _STAMP_TTL:
  593. return _STAMP['val']
  594. h = hashlib.sha1()
  595. for p in _product_files():
  596. try:
  597. st = p.stat()
  598. h.update(f'{_P.rel(p)}|{st.st_mtime_ns}|{st.st_size}\n'.encode('utf-8'))
  599. except OSError:
  600. h.update(f'{_P.rel(p)}|MISSING\n'.encode('utf-8'))
  601. _STAMP.update(val=h.hexdigest()[:16], at=now)
  602. return _STAMP['val']
  603. class ProductsMissing(RuntimeError):
  604. """产物缺失 (outputs/<场>/… 被清空或尚未生成)。
  605. 2026-09-11 人工检查空状态时逮: 原来 `temp_monthly.parquet` 一缺, `_load()` 直接抛 FileNotFoundError,
  606. 外层处理器把它当**未知异常**回 500 + traceback —— 人看到的是"服务器错误", 分不清"没算产物"还是"程序坏了"。
  607. 现在这类缺失单独成类, 由请求层回**结构化的无数据**(HTTP 200 + err=no_products), 与 `adhoc_query` 的
  608. `no_source` 一致。真 bug 仍然照旧 500 并带 traceback (不静默)。"""
  609. def __init__(self, name, path):
  610. super().__init__(f'产物缺失: {name} (期望 {path})')
  611. self.what, self.path = name, path
  612. def reload_products(reason=''):
  613. """显式清缓存 (下一次请求重载)。返回清前的指纹, 供日志/接口回显。"""
  614. with LOCK:
  615. old = _CACHE.get('__stamp')
  616. _CACHE.clear()
  617. print(f'[reload] 清产物缓存 ({reason or "手动"}) 旧指纹={old}', flush=True)
  618. return old
  619. def _load():
  620. with LOCK:
  621. stamp = products_stamp()
  622. if _CACHE.get('__loaded'):
  623. if _CACHE.get('__stamp') == stamp:
  624. return
  625. # 产物变了 (典型: 刚跑完重算) → 重载。**先建后换**: 下面任何一步抛异常都不会破坏旧缓存,
  626. # 请求照旧能用旧数 (降级但不空白), 同时日志留痕。
  627. print(f'[reload] 产物指纹变化 {_CACHE.get("__stamp")} → {stamp}, 重载', flush=True)
  628. tmp = {}
  629. try:
  630. for key, name in (('tm', 'temp_monthly.parquet'), ('al', 'alarms.parquet'),
  631. ('lm', 'loss_monthly.parquet'), ('bins', 'powercurve_bins.parquet'),
  632. ('pcd', 'powercurve_dev.parquet')):
  633. f = ST / name
  634. if not f.exists():
  635. raise ProductsMissing(name, f)
  636. tmp[key] = pd.read_parquet(f)
  637. tmp['al']['month'] = tmp['al']['t_on'].dt.to_period('M').astype(str)
  638. tmp['pcd'] = tmp['pcd'].set_index('turbine')
  639. for key, name in (('duty', 'duty_monthly.parquet'),):
  640. f = ST / name
  641. tmp[key] = pd.read_parquet(f) if f.exists() else None
  642. try:
  643. tmp['sysmx'] = taxonomy.system_matrix()
  644. tmp['treg'] = temp_nbm.registry()
  645. tmp['treg'] = tmp['treg'][0] if isinstance(tmp['treg'], tuple) else tmp['treg']
  646. tmp['hyd'], _ = hydraulic.registry()
  647. tmp['hyd'] = tmp['hyd'].set_index('turbine')
  648. except FileNotFoundError as e: # 这些派生件同样在产物仓里; 缺了就按"无产物"处理
  649. raise ProductsMissing(getattr(e, 'filename', '派生产物'), getattr(e, 'filename', ST))
  650. zp = _P.pitch() / 'pitch_zero_monthly.parquet'
  651. tmp['zero'] = pd.read_parquet(zp) if zp.exists() else None
  652. tmp['__stamp'] = stamp
  653. tmp['__loaded'] = True
  654. _CACHE.update(tmp) # 原子提交: 失败时不留下半截缓存
  655. except Exception:
  656. if _CACHE.get('__loaded'):
  657. print('[reload] 重载失败 → 继续用上一份缓存 (页面不会空白, 但数是旧的; 看上面的异常)', flush=True)
  658. raise
  659. WINDOWS = ['近30日', '近90日', '2026年', '2026H1', '2025H2', '全程']
  660. def _month_end(m):
  661. """'YYYY-MM' → 该月最后一天 'YYYY-MM-DD'。"""
  662. import calendar
  663. y, mo = int(m[:4]), int(m[-2:])
  664. return f'{m}-{calendar.monthrange(y, mo)[1]:02d}'
  665. def months_of(win):
  666. """窗 → 覆盖到的月份列表(月度类数据按此过滤)。"""
  667. _load()
  668. all_m = sorted(_CACHE['tm'].month.unique())
  669. if win == '全程': return all_m
  670. if win == '2025H2': return [m for m in all_m if '2025-07' <= m <= '2025-12']
  671. if win == '2026H1': return [m for m in all_m if '2026-01' <= m <= '2026-06']
  672. if win == '2026年': return [m for m in all_m if m >= '2026-01']
  673. import re as _re
  674. if _re.fullmatch(r'\d{4}-\d{2}', win): # 单月窗 (2026-08-27 用户令: 按月份过滤和选择)
  675. return [m for m in all_m if m == win]
  676. m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
  677. if m2: # 月区间窗 "2025-04~2025-10"
  678. return [m for m in all_m if m2.group(1) <= m <= m2.group(2)]
  679. m3 = _re.fullmatch(r'(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})', win)
  680. if m3: # ★自定义起止(含)→ 相交的月 (用户令 2026-09-21)
  681. a, b = sorted((m3.group(1), m3.group(2)))
  682. return [m for m in all_m if _month_end(m) >= a and f'{m}-01' <= b]
  683. n = 1 if win == '近30日' else 3
  684. return all_m[-n:]
  685. def win_range(win):
  686. """窗 → (起, 止) 日期串,**含两端**(用户令 2026-09-21:时间窗支持自定义起止日期)。
  687. 预设/逐月/月区间窗一律落到"该窗覆盖月的月首/月末",于是与 `months_of()` 同口径;
  688. 日区间窗原样返回。日粒度件(停机事件/报警/日粒度判据)用它做**含端**过滤。
  689. """
  690. import re as _re
  691. if win:
  692. if _re.fullmatch(r'\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}', win):
  693. a, b = win.split('~')
  694. return (a, b) if a <= b else (b, a)
  695. if _re.fullmatch(r'\d{4}-\d{2}', win):
  696. return (f'{win}-01', _month_end(win))
  697. m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
  698. if m2:
  699. return (f'{m2.group(1)}-01', _month_end(m2.group(2)))
  700. if win == '2025H2':
  701. return ('2025-07-01', '2025-12-31')
  702. if win == '2026H1':
  703. return ('2026-01-01', '2026-06-30')
  704. ms = months_of(win)
  705. if not ms:
  706. return ('0001-01-01', '9999-12-31')
  707. return (f'{ms[0]}-01', _month_end(ms[-1]))
  708. def in_win(ts, win):
  709. """按窗过滤**有日粒度**的时间列(含两端)。月度列请用 `months_of()`(闰月/跨月取整不同)。"""
  710. import pandas as pd
  711. a, b = win_range(win)
  712. t = pd.to_datetime(ts, errors='coerce')
  713. return (t >= pd.Timestamp(a)) & (t <= pd.Timestamp(b) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1))
  714. def span_of(win):
  715. """窗 → 计算层用的 (起, 止) 元组(含);判级/曲线等按窗重算的口子都吃这个。"""
  716. return win_range(win)
  717. # ── 按窗重算:后台算 + 缓存(用户令 2026-09-21)─────────────────────────────────
  718. # 判级矩阵 ≈25 s/窗、七镜头 ≈9~44 s/窗 —— 直接同步算会把 HTTP 请求挂几十秒(远端还要过网关,
  719. # 长请求有被掐的风险)。所以统一口径:**缓存命中即回;未命中就起后台线程,本次先回 pending,
  720. # 页面轮询再取**。同一窗只算一次;算完常驻内存(进程内)。
  721. import threading as _thr
  722. _WIN_CACHE: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
  723. _WIN_BUSY: dict = {'sysmx': set(), 'curves': set(), 'm9': set()}
  724. _WIN_ERR: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
  725. _WIN_LOCK = _thr.Lock()
  726. # ★重活**串行**(2026-09-21 服务器实逮): 预热与请求各自起线程 ⇒ 同时跑两个按窗重算(各几百 MB 级
  727. # 中间帧),小内存机器上进程被系统杀掉(日志停在"按窗重算启动"两行、随后网关 503)。
  728. # 现在全部重活排队执行:峰值 = 1 份中间帧。
  729. _HEAVY_LOCK = _thr.Lock()
  730. def _mem_mb():
  731. """本进程可用内存(MB);拿不到就返回 None(不因为这些诊断信息把重算搞挂)。"""
  732. try:
  733. import ctypes
  734. class _MS(ctypes.Structure):
  735. _fields_ = [('dwLength', ctypes.c_ulong), ('dwMemoryLoad', ctypes.c_ulong),
  736. ('ullTotalPhys', ctypes.c_ulonglong), ('ullAvailPhys', ctypes.c_ulonglong),
  737. ('ullTotalPageFile', ctypes.c_ulonglong), ('ullAvailPageFile', ctypes.c_ulonglong),
  738. ('ullTotalVirtual', ctypes.c_ulonglong), ('ullAvailVirtual', ctypes.c_ulonglong),
  739. ('ullAvailExtendedVirtual', ctypes.c_ulonglong)]
  740. st = _MS()
  741. st.dwLength = ctypes.sizeof(_MS)
  742. ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st))
  743. return int(st.ullAvailPhys // (1024 * 1024))
  744. except Exception:
  745. return None
  746. def _win_key(win):
  747. a, b = span_of(win)
  748. return f'{a}~{b}'
  749. def _win_get(kind, win, fn):
  750. """→ 值 或 None(None = 正在算/刚起算)。失败**记名**(`_WIN_ERR`)而不是静默当"没数据"。"""
  751. key = _win_key(win)
  752. with _WIN_LOCK:
  753. if key in _WIN_CACHE[kind]:
  754. return _WIN_CACHE[kind][key]
  755. if key not in _WIN_BUSY[kind]:
  756. _WIN_BUSY[kind].add(key)
  757. def _run():
  758. try:
  759. with _HEAVY_LOCK: # 重活串行: 同一时刻只算一份
  760. m0 = _mem_mb()
  761. print(f'[win] {kind} 按窗重算开算 {key}(可用内存 {m0} MB)', flush=True)
  762. v = fn()
  763. print(f'[win] {kind} 按窗重算完成 {key}(可用内存 {_mem_mb()} MB)', flush=True)
  764. except Exception as e: # 守护失败必响亮
  765. _WIN_ERR[kind][key] = f'{type(e).__name__}: {e}'[:200]
  766. print(f'[win] {kind} 按窗重算失败 {key}: {_WIN_ERR[kind][key]}', flush=True)
  767. v = None
  768. with _WIN_LOCK:
  769. if v is not None:
  770. _WIN_CACHE[kind][key] = v
  771. _WIN_BUSY[kind].discard(key)
  772. _thr.Thread(target=_run, name=f'win-{kind}-{key}', daemon=True).start()
  773. print(f'[win] {kind} 按窗重算启动 {key}(首次约数十秒,页面会先出 pending)', flush=True)
  774. return None
  775. def _win_wait(kind, win, timeout=120.0):
  776. """等某窗的按窗重算落地(预热用)。超时/失败返回 None,绝不抛。"""
  777. import time as _t
  778. key = _win_key(win)
  779. t0 = _t.time()
  780. while _t.time() - t0 < timeout:
  781. with _WIN_LOCK:
  782. if key in _WIN_CACHE[kind]:
  783. return _WIN_CACHE[kind][key]
  784. if key in _WIN_ERR[kind]:
  785. return None
  786. _t.sleep(1.0)
  787. return None
  788. def sysmx_of(win):
  789. """→ (判级矩阵, pending)。`pending=True` 时返回的是"另一口径"的旧矩阵,页面必须如实标注。"""
  790. from src.windscada import taxonomy
  791. m = _win_get('sysmx', win, lambda: taxonomy.system_matrix(CFG, span=span_of(win)))
  792. if m is None:
  793. return _CACHE['sysmx'], True
  794. return m, False
  795. def curves_of(win):
  796. """→ (七镜头分箱件 或 None, pending)。按窗重算(干净窗就是正式产物本身)。"""
  797. from src.windscada.perf import curves as _cv
  798. a, b = span_of(win)
  799. if (a, b) == (_cv.WIN[0], '2025-12-31'): # 与正式产物的判别窗一致 ⇒ 直接用产物, 不重算
  800. import pandas as _pd
  801. return _pd.read_parquet(ST / 'curve_lenses.parquet'), False
  802. st = _win_get('curves', win, lambda: _cv.build_store(CFG, span=(a, b), write=False))
  803. return st, st is None
  804. def m9_of(win):
  805. """→ (控制参数一致性, pending)。按窗重算(走窄仓 ≈5 s/窗);干净窗直接用正式产物。"""
  806. from src.windscada.perf import control as _cm
  807. a, b = span_of(win)
  808. if (a, b) == (_cm.WIN[0], '2025-12-31'):
  809. return _cm.registry(CFG), False
  810. v = _win_get('m9', win, lambda: _cm.registry(CFG, span=(a, b)))
  811. if v is None:
  812. return _cm.registry(CFG), True # 未就位: 先给上一口径, 页面按 pending 标注
  813. return v, False
  814. TS = {'报警': 0, '危险': 0, '良好': 1, '不可判': 2, '优秀': 3}
  815. # ---------- 图规格 (分册两型) ----------
  816. def fig_temp(t, ch, win):
  817. tm = _CACHE['tm']; ms = months_of(win)
  818. g = tm[tm.channel == ch]
  819. piv = g.pivot_table(index='month', columns='turbine', values='med').reindex(ms)
  820. if t not in piv.columns or piv[t].dropna().empty: return None
  821. nm = i18n.ch_cn(ch, with_tag=False)
  822. return dict(kind='band', title=f'{t} {nm} 月度 (发电态P>500kW)', unit='℃', months=ms,
  823. self=[None if v != v else round(v, 2) for v in piv[t]],
  824. med=[None if v != v else round(v, 2) for v in piv.median(axis=1)],
  825. q1=[None if v != v else round(v, 2) for v in piv.quantile(.25, axis=1)],
  826. q3=[None if v != v else round(v, 2) for v in piv.quantile(.75, axis=1)],
  827. note='灰带=全场四分位区间, 深线=全场中位, 红线=本台')
  828. def fig_duty(t, ch, title, unit, win, thresholds=None):
  829. d = _CACHE['duty']
  830. if d is None: return None
  831. ms = months_of(win)
  832. g = d[d.channel == ch]
  833. piv = g.pivot_table(index='month', columns='turbine', values='val').reindex(ms)
  834. if t not in piv.columns or piv[t].dropna().empty: return None
  835. return dict(kind='band', title=f'{t} {title}', unit=unit, months=ms,
  836. self=[None if v != v else round(v, 2) for v in piv[t]],
  837. med=[None if v != v else round(v, 2) for v in piv.median(axis=1)],
  838. q1=[None if v != v else round(v, 2) for v in piv.quantile(.25, axis=1)],
  839. q3=[None if v != v else round(v, 2) for v in piv.quantile(.75, axis=1)],
  840. thresholds=thresholds or [], note='灰带=全场四分位区间')
  841. def fig_alarm_bars(t, codes, title, win):
  842. al = _CACHE['al']; ms = months_of(win)
  843. g = al[(al.turbine == t) & (al.code.isin(codes)) & (al.month.isin(ms))]
  844. if not len(g): return None
  845. cnt = g.groupby('month').size().reindex(ms).fillna(0)
  846. return dict(kind='bars', title=f'{t} {title} 月度条数', unit='条', months=ms,
  847. vals=[int(v) for v in cnt], note='窗内合计 %d 条' % int(cnt.sum()))
  848. def fig_curve(t):
  849. b = _CACHE['bins']; g = b[b.turbine == t].sort_values('bin')
  850. if not len(g): return None
  851. dv = _CACHE['pcd'].loc[t]
  852. return dict(kind='xy', title=f'{t} 功率曲线 vs fleet中位 [固定判别窗 2025-H2, 机舱风口径]', unit='kW',
  853. x=[float(x) for x in g['bin']], self=[round(float(v), 1) for v in g.p],
  854. med=[round(float(v), 1) for v in g.fleet_p], xlab='风速 m/s',
  855. note=f"偏差 {dv.dev_w:+.1%} · 风速计偏置 {dv.ws_bias:+.2f} m/s · {dv['判别']}")
  856. def fig_zero(t, win):
  857. z = _CACHE['zero']
  858. if z is None: return None
  859. tt = t.replace('WTG', '')
  860. g = z[(z.turbine.astype(str).str.contains(tt)) | (z.turbine == t)] if 'turbine' in z.columns else None
  861. if g is None or not len(g): return None
  862. ms = months_of(win)
  863. mcol = [c for c in g.columns if 'month' in c.lower() or '月' in c]
  864. if not mcol: return None
  865. g = g[g[mcol[0]].astype(str).isin(ms)]
  866. # 只画零位偏差本身 (样本数量纲不同, 混画会把判据线压扁 → 移入图注)
  867. n_med = int(g['n_rows'].median()) if 'n_rows' in g.columns and len(g) else 0
  868. return dict(kind='multiline', title=f'{t} 桨叶零位偏差 月度', unit='°', months=[str(x) for x in g[mcol[0]]],
  869. series=[dict(name='零位偏差', vals=[round(float(v), 3) if v == v else None for v in g['zero_dev']])],
  870. thresholds=[dict(v=0.6, label='报警 0.6°'), dict(v=-0.6, label='')],
  871. note=f'判据: 变桨分册 §3.2 (报警门 ±0.6°); 月样本中位 {n_med} 行')
  872. SYS_ALARM = {'偏航': yawmod.FAM, '变桨': {'蓄能缸检': ['3411', '3412', '3413'], '泵/过热': ['7106', '7100'], '油位/滤芯': ['7101', '7103', '7104']}}
  873. SYS_CODES = {
  874. '变桨': ['3130', '3145', '3173', '3224', '3225', '3411', '3412', '3413', '3418', '7100', '7101', '7102', '7103', '7104', '7106', '9306', '9309'],
  875. '偏航': ['10100', '10104', '10105', '10113', '10125', '10201', '10400', '10401', '7122', '1012'],
  876. '齿轮箱': ['4101', '4102', '4103'],
  877. '变流器': ['13902', '13905', '19024', '19170', '19285', '20000', '63036'],
  878. '主控与传感网': ['8108', '8110', '8171', '8184', '8185', '8186', '8187', '63209', '63004'],
  879. }
  880. def fig_sys_alarms(t, sysname, win):
  881. """该台该系统报警族 月度条数 (与工单处理史并读)."""
  882. codes = SYS_CODES.get(sysname)
  883. if not codes: return None
  884. ms = months_of(win)
  885. al = _CACHE['al']
  886. g = al[(al.turbine == t) & (al.code.isin(codes)) & (al.month.isin(ms))]
  887. if not len(g): return None
  888. v = g.groupby('month').size().reindex(ms).fillna(0)
  889. top = g.groupby('code').size().sort_values(ascending=False).head(3)
  890. note = '窗内合计 %d 条; top: ' % len(g) + '; '.join(f'{c} {i18n.alarm_label(c)[:12]}×{n}' for c, n in top.items())
  891. return dict(kind='bars', title=f'{t} {sysname}系统 报警月度条数', unit='条', months=ms,
  892. vals=[int(x) for x in v], note=note)
  893. def turbine_problems(t, win):
  894. """有问题的系统 → 证据图 (分册式); 无问题不出."""
  895. _load()
  896. sysd = _CACHE['sysmx'][t]
  897. treg = _CACHE['treg']
  898. out = []
  899. for s in taxonomy.SYSTEMS:
  900. st = sysd[s]['状态']
  901. if st == '优秀': continue
  902. figs = []
  903. # 温度证据
  904. for _, r in treg[(treg.turbine == t)].iterrows():
  905. ch = r['channel']
  906. nm = ch.replace('tmp_wtc_', '').replace('_mean', '').replace('_max', '')
  907. if taxonomy.TEMP2SYS.get(nm) == s and str(r['判']) != '—':
  908. f = fig_temp(t, ch, win)
  909. if f: f['title'] = f"{t} {i18n.ch_cn(ch, with_tag=False)} — {r['判']} ({r['dev_K']:+.1f}K)"; figs.append(f)
  910. # 系统专属证据
  911. if s == '变桨':
  912. hv = _CACHE['hyd'].loc[t]
  913. if hv['判'] != '—':
  914. f = fig_duty(t, 'HydPress_band', '变桨液压10min摆幅 月度', 'bar', win,
  915. thresholds=[dict(v=52, label='正常充压滞环≈52bar')])
  916. if f: f['title'] = f"{t} 蓄能行为 — {hv['判']}"; figs.append(f)
  917. for nm, codes in SYS_ALARM['变桨'].items():
  918. f = fig_alarm_bars(t, codes, f'变桨{nm}报警', win)
  919. if f: figs.append(f)
  920. f = fig_zero(t, win)
  921. if f: figs.append(f)
  922. if s == '偏航':
  923. yrow_d = dict(zip(['D1润滑不可能', 'D2过热', 'D2变流器警告', 'D2解缆', 'D3制动液压错误', 'D3油位低', 'D4偏航失败', 'D4风传感'], [0]*8))
  924. f = fig_duty(t, 'dot_wtc_YawLubPu_timeon', '偏航润滑泵运行时长 月度', '秒/日', win)
  925. if f: figs.append(f)
  926. for nm, codes in yawmod.FAM.items():
  927. f = fig_alarm_bars(t, codes, '偏航·' + nm.lstrip('D1234'), win)
  928. if f: figs.append(f)
  929. if s in ('主轴承', '齿轮箱', '发电机'):
  930. vf = vib_scalar_fig(t, s)
  931. if vf: figs.append(vf)
  932. # 振动驱动的问题 (taxonomy 振动映射) → 配套分析曲线: 部件双端温度 + 主轴承柱塞 duty
  933. if '振动:' in sysd[s]['依据']:
  934. vib_chs = {'主轴承': ['tmp_wtc_MBearGTm_mean', 'tmp_wtc_MBearHTm_mean'],
  935. '发电机': ['tmp_wtc_GenBeRTm_mean', 'tmp_wtc_GenBeGTm_mean'],
  936. '齿轮箱': ['tmp_wtc_IMSGenTm_mean', 'tmp_wtc_HSGenTmp_mean']}[s]
  937. for ch in vib_chs:
  938. f = fig_temp(t, ch, win)
  939. if f: f['note'] = (f.get('note') or '') + ' | 振动判级交叉轴: 温度=同机制果侧观测(观测面≠源数)'; figs.append(f)
  940. if s == '主轴承':
  941. f = fig_duty(t, 'din_wtc_MBLubPis_timeon', '主轴承润滑柱塞运行时长 月度 (因侧执行链)', '秒/日', win)
  942. if f: f['note'] = (f.get('note') or '') + ' | 29#签名: 单次时长塌→回升→2026-07单月突变(泵打不满)'; figs.append(f)
  943. # 振动摘要配传动链图形 (2026-08-28 用户令: 关联图形填入) — comp=依据里的部件短语, sev=结论级
  944. _why = sysd[s]['依据']
  945. _m = re.search(r'振动: ([^ ]+) ([^ (]+)', _why)
  946. _comp, _lv = (_m.group(1), _m.group(2)) if _m else (s, '')
  947. _sev = 'var(--bad)' if any(k in _lv for k in ('定论', '报警')) else ('var(--warn)' if '预警' in _lv else 'var(--ok)')
  948. figs.append(dict(kind='vib', title=f'{t} {s} 振动分析摘要',
  949. comp=_comp, sev=_sev,
  950. text=_why + ' | 谱级深潜与逐窗趋势 → 振动融合板块·振动分析入口 (windcms)'))
  951. if '配对差' in sysd[s]['依据']:
  952. pair_chs = {'主轴承': ['tmp_wtc_MBearHTm_mean', 'tmp_wtc_MBearGTm_mean'],
  953. '发电机': ['tmp_wtc_GenBeGTm_mean', 'tmp_wtc_GenBeRTm_mean']}.get(s, [])
  954. for ch in pair_chs:
  955. f = fig_temp(t, ch, win)
  956. if f: figs.append(f)
  957. if '润滑' in sysd[s]['依据']:
  958. ch = {'主轴承': 'din_wtc_MBLubPis_timeon', '齿轮箱': 'din_wtc_GBLubPis_timeon', '发电机': 'dot_wtc_GenLubPu_timeon'}[s]
  959. f = fig_duty(t, ch, f'{s}润滑 月度', '秒/日', win)
  960. if f: figs.append(f)
  961. if s == '主控与传感网':
  962. if '测风' in sysd[s]['依据'] or '风速计' in sysd[s]['依据']:
  963. f = fig_alarm_bars(t, yawmod.FAM['D4风传感'], '风传感故障报警', win)
  964. if f: figs.append(f)
  965. if '曲线判风速计' in sysd[s]['依据'] or '风速计偏置' in sysd[s]['依据']:
  966. f = fig_curve(t)
  967. if f: figs.append(f)
  968. # ★每个问题配: 该系统报警月度图 + 工单处理史 (用户令)
  969. try:
  970. from src.windscada.subsys import workorder as womod
  971. hist = womod.history(t, s)
  972. except Exception as e:
  973. hist = dict(n=0, rows=[], note=f'工单不可用 {e}'[:60], 动作统计={}, 窗='')
  974. af = fig_sys_alarms(t, s, win)
  975. if af: figs.append(af)
  976. # 排版 (2026-08-28 用户令"排版更合理"): ①同标题图去重 (配对差/温度证据两处会重复挂同一通道)
  977. # ②按阅读顺序排 — 结论定位(传动链) → 证据强度(振动标量) → 时序(温度/润滑/报警) → 曲线
  978. seen, uniq = set(), []
  979. for _f in figs:
  980. k = (_f.get('kind'), _f.get('title'))
  981. if k in seen: continue
  982. seen.add(k); uniq.append(_f)
  983. _ORD = {'vib': 0, 'vibbar': 1, 'band': 2, 'line': 3, 'bar': 4, 'xy': 5, 'text': 6}
  984. figs = sorted(uniq, key=lambda x: _ORD.get(x.get('kind'), 9))
  985. out.append(dict(sys=s, 状态=st, 依据=i18n.humanize(sysd[s]['依据']), figs=figs, orders=hist))
  986. dv = _CACHE['pcd'].loc[t] if t in _CACHE['pcd'].index else None
  987. if dv is not None and abs(dv.dev_w) > 0.03:
  988. f = fig_curve(t)
  989. out.append(dict(sys='功率曲线', 状态='报警' if 'A类' in str(dv['判别']) else '良好',
  990. 依据=f"偏差 {dv.dev_w:+.1%}, 风速计偏置 {dv.ws_bias:+.2f} m/s → {dv['判别']}", figs=[f] if f else []))
  991. out.sort(key=lambda x: TS.get(x['状态'], 9))
  992. return out
  993. LENSES = [('wsb', 'grd_wtc_ActPower_mean', 'L1 功率曲线 (风速-功率)', '风速 m/s (机舱风)', 'kW'),
  994. ('wsb', 'cp', 'L6 风能利用系数 Cp (风速-Cp)', '风速 m/s', 'Cp'),
  995. ('wsb', 'lam', 'L5 叶尖速比 λ (风速-λ)', '风速 m/s', 'λ'),
  996. ('grb', 'tq', 'L4 转矩-转速 (发电机侧)', '发电机转速 rpm', 'kNm'),
  997. ('pwb', 'tur_wtc_PitcPosA_mean', 'L2 功率-桨距 (控制律/削峰)', '功率 kW', '°'),
  998. ('pwb', 'tur_wtc_GenRpm_mean', 'L3 功率-发电机转速 (饱和点)', '功率 kW', 'rpm'),
  999. ('pwb', 'p3', 'L2b 三叶桨距极差 (集距/不平衡)', '功率 kW', '°'),
  1000. ('pwb', 'ratio', 'L7 转速比 gen/rot (传动链)', '功率 kW', '—')]
  1001. def curve_view(win='2025H2'):
  1002. """特性曲线多镜头 (SOP §4.6c): fleet 中位+四分位带 + 过绝对锚的离群台曲线.
  1003. ★2026-09-21 用户令「发电性能随时间窗变化」: 七镜头**按所选时间窗重算**(首次后台算,先回 pending),
  1004. 7 张月度时序图按所选窗的月份过滤。原口径(2025H2 干净判别窗)仍可作为窗之一被选中,
  1005. 选到它时直接用正式产物,不重算。
  1006. """
  1007. import pandas as pd
  1008. from src.windscada.perf import curves as cv
  1009. a, b = span_of(win)
  1010. ms = months_of(win)
  1011. store, pending = curves_of(win)
  1012. if pending:
  1013. return dict(building=True, win=win, span=[a, b], months=ms,
  1014. note=f'正在按所选时间窗 {a} ~ {b} 重算七镜头曲线(首次约 10~45 秒),完成后自动刷新。'
  1015. f'期间下方月度时序图已按所选窗过滤,可直接看。')
  1016. wlabel = f'{a} ~ {b}'
  1017. figs = []
  1018. for xc, yc, title, xlab, unit in LENSES:
  1019. r = cv.lens(xc, yc, store_df=store, win_label=wlabel)
  1020. if not r: continue
  1021. outs = sorted(r['离群'].items(), key=lambda kv: -abs(kv[1]['z']))[:3]
  1022. a = r['anchor']
  1023. series = [dict(name='全场中位', vals=r['fleet'])]
  1024. series += [dict(name=f"{t.replace('WTG','')}# {v['resid']:+.4g}{a['单位']}", vals=r['per_t'][t]) for t, v in outs]
  1025. figs.append(dict(kind='multiline', title=title, unit=unit, months=[f"{x:g}" for x in r['x']],
  1026. series=series, xlab=xlab,
  1027. note=(f"显著门 |残差|≥{a['门']}{a['单位']} ({a['说明']}) ∧ |z|≥3 → 离群 "
  1028. f"{'/'.join(t.replace('WTG','')+'#' for t,_ in outs) if outs else '无'}"
  1029. f" | 灰域=全场四分位, 逐档中位聚合, 已剥限电(只正常发电态)"
  1030. f" | 样本 {r['n_total']:,} 个10min点 / {r['n_turbines']} 台 / 最小档 {r['n_min_bin']:,}"
  1031. f" | 窗 {r['win']} (随所选窗)"),
  1032. band=dict(q1=r['q1'], q3=r['q3'])))
  1033. # 残差视图 (§4.6c④ per机必算同型残差): 绝对量尺度上四分位带只有几个像素, 残差面才看得见
  1034. if outs:
  1035. rs = [dict(name=f"{t.replace('WTG','')}# {v['resid']:+.4g}{a['单位']}",
  1036. vals=[None if (r['per_t'][t][i] is None or r['fleet'][i] is None) else round(r['per_t'][t][i] - r['fleet'][i], 4)
  1037. for i in range(len(r['x']))]) for t, v in outs]
  1038. q1d = [round(r['q1'][i] - r['fleet'][i], 4) for i in range(len(r['x']))]
  1039. q3d = [round(r['q3'][i] - r['fleet'][i], 4) for i in range(len(r['x']))]
  1040. figs.append(dict(kind='multiline', title=title.split(' (')[0] + ' · 同型机群残差 (本台−全场中位)', unit=unit,
  1041. months=[f"{x:g}" for x in r['x']], series=rs, xlab=xlab,
  1042. thresholds=[dict(v=a['门'], label=f"显著门 +{a['门']}{a['单位']}"), dict(v=-a['门'], label='')],
  1043. band=dict(q1=q1d, q3=q3d),
  1044. note=(f"虚线=物理绝对锚 ±{a['门']}{a['单位']} ({a['说明']}); 灰域=全场四分位残差带; 出带且过锚才算离群"
  1045. f" | 样本 {r['n_total']:,} 点 / {r['n_turbines']} 台 | 窗 {r['win']}")))
  1046. # ---- 时序件: 控制参数月度 (M9b) + 偏航动态月度 (M12) ----
  1047. def _ts(path, val, title, unit, xlab, picks=None, note='', thr=None):
  1048. f = ST / path
  1049. if not f.exists(): return None
  1050. d = pd.read_parquet(f)
  1051. if val not in d.columns: return None
  1052. piv = d.pivot_table(index='month', columns='turbine', values=val)
  1053. # ★2026-09-21 用户令: 月度时序图按**所选时间窗**的月份过滤(月度件无法按日切,如实写"按月取整")
  1054. if ms:
  1055. piv = piv.reindex([m for m in [str(x) for x in piv.index] if m in set(ms)])
  1056. if not len(piv): return None
  1057. msx = [str(x) for x in piv.index]
  1058. med = piv.median(axis=1)
  1059. dev = (piv.sub(med, axis=0)).abs().mean()
  1060. picks = picks or list(dev.sort_values(ascending=False).head(3).index)
  1061. series = [dict(name='全场中位', vals=[None if v != v else round(float(v), 3) for v in med])]
  1062. series += [dict(name=t.replace('WTG', '') + '#', vals=[None if v != v else round(float(v), 3) for v in piv[t]])
  1063. for t in picks if t in piv.columns]
  1064. # 样本量与窗随图走 (2026-08-28 门禁 F1): 7 张时序图原来只有结论没有依据规模,
  1065. # 违反第一性原理②"Sample size declared"。逐月×台的非空格数即样本量。
  1066. _n = int(piv.notna().to_numpy().sum())
  1067. _nt = int(piv.shape[1])
  1068. note2 = (note + f" | 样本 {_n:,} 个月×台 / {_nt} 台 / {len(msx)} 个月"
  1069. f" | 窗 {msx[0]}~{msx[-1]} (月度聚合, 随所选窗·按月取整)") if msx else note
  1070. return dict(kind='multiline', title=title, unit=unit, months=msx, series=series, xlab=xlab,
  1071. note=note2, thresholds=thr or [],
  1072. band=dict(q1=[None if v != v else round(float(v), 3) for v in piv.quantile(0.25, axis=1)],
  1073. q3=[None if v != v else round(float(v), 3) for v in piv.quantile(0.75, axis=1)]))
  1074. for args in (
  1075. ('control_monthly.parquet', 'p_cap', '时序① 满发功率封顶 月度 (取每月高位稳定值; 阶跃=配置变更)', 'kW', '月', None,
  1076. '灰域=全场四分位; 参数级分组见发电性能页 (4175/4200/4225 三组); 阶跃=配置变更, 平移=工况'),
  1077. ('control_monthly.parquet', 'w_cap', '时序② 转速封顶 月度 (取每月高位稳定值)', 'rpm', '月', None, '两轴独立分组 (1672/1680)'),
  1078. ('control_monthly.parquet', 'pitch_rated', '时序③ 额定段桨距角 月度 (标定漂移)', '°', '月',
  1079. ['WTG03', 'WTG34', 'WTG19'], '离群台=桨距调度同档差 top3'),
  1080. ('yaw_dynamic_monthly.parquet', 'err_sd', '时序④ 对风散布 σ 月度 (原始, 未清洗)', '°', '月',
  1081. ['WTG08', 'WTG16', 'WTG04'], '三台19个月全程高 → 非发作型; 清洗后降至5-7°, 判为机舱位置通道拖偏(A类数据质量)非对风故障'),
  1082. ('yaw_dynamic_monthly.parquet', 'travel_day', '时序⑤ 偏航行程 月度 (抗采样物理量)', '°/日', '月', None,
  1083. '行程=活动量代理, 非真磨损量; 与状态位次数互核'),
  1084. ('yaw_dynamic_monthly.parquet', 'twist_span', '时序⑥ 扭缆角月跨度 (ScYawPos 累计位置)', '°', '月', None, '解缆动作后回零'),
  1085. ('yaw_dynamic_monthly.parquet', 'pump_med', '时序⑦ 偏航泵压 月度 (小站泵压)', 'bar', '月', None, '全场极齐 → 无泵压异常'),
  1086. ):
  1087. f = _ts(*args)
  1088. if f: figs.append(f)
  1089. return dict(figs=figs, physics=cv.physics_check(),
  1090. note=('镜头口径 (SOP §4.6c): X轴优先功率(直测干净量); 机舱风 self-ref → 只判形状/相对, '
  1091. '绝对达成率须现场测风; per机异常必算同型机群残差(禁眼估); 工况段筛选=剔除0kW与低转速混合态档'))
  1092. _VIBSENS = {'主轴承': ['Main_bearing_front', 'Main_bearing_rear'],
  1093. '齿轮箱': ['Gear_planet', 'Gear_IMS', 'Gear_HS_rotor_side', 'Gear_HS_generator_side'],
  1094. '发电机': ['Generator_DE', 'Generator_NDE']}
  1095. _SENS_CN = {'Main_bearing_front': '主轴承前', 'Main_bearing_rear': '主轴承后', 'Gear_planet': '行星级',
  1096. 'Gear_IMS': '中间轴', 'Gear_HS_rotor_side': '高速轴转子侧', 'Gear_HS_generator_side': '高速轴发电机侧',
  1097. 'Generator_DE': '发电机DE', 'Generator_NDE': '发电机NDE'}
  1098. _VIBMET = [('Peak', '冲击峰值'), ('Kurtosis', '峭度'), ('Rms_Vel', '速度有效值'), ('iso_rms', 'ISO烈度')]
  1099. _VIBC = {}
  1100. def vib_scalar_fig(t, sysname):
  1101. """TCM 标量: 本台 vs 全场中位 (按测点×指标) + 六窗趋势. 数据=振动线 m5_cms_tcm 产物, 本页只转录不判级."""
  1102. import pandas as _pd
  1103. if 'z' not in _VIBC:
  1104. base = _P.m5()
  1105. try:
  1106. _VIBC['z'] = _pd.read_parquet(base / 'fleet_scalar_z.parquet')
  1107. _VIBC['tr'] = _pd.read_parquet(base / 'scalar_trend_6win.parquet')
  1108. except Exception:
  1109. _VIBC['z'] = _VIBC['tr'] = None
  1110. z, tr = _VIBC['z'], _VIBC['tr']
  1111. if z is None: return None
  1112. sens = _VIBSENS.get(sysname, [])
  1113. if not sens: return None
  1114. g = z[(z.turbine == t) & (z.sensor.isin(sens)) & (z.meas.isin([m for m, _ in _VIBMET]))]
  1115. if not len(g): return None
  1116. # 取最高功率档 (载荷高处信噪比最好); 该档缺则退最近档
  1117. order = sorted(g.bin.unique())
  1118. gb = g[g.bin == order[-1]]
  1119. rows = []
  1120. for sn in sens:
  1121. for mk, mcn in _VIBMET:
  1122. r = gb[(gb.sensor == sn) & (gb.meas == mk)]
  1123. if not len(r): continue
  1124. r = r.iloc[0]
  1125. tv = None
  1126. if tr is not None:
  1127. q = tr[(tr.turbine == t) & (tr.sensor == sn) & (tr.metric == mk)]
  1128. if len(q): tv = float(q.iloc[0]['ratio'])
  1129. rows.append(dict(测点=_SENS_CN.get(sn, sn), 指标=mcn, 本台=round(float(r.val), 3),
  1130. 全场中位=round(float(r.fleet_med), 3), z=round(float(r.z), 2),
  1131. 倍中位=round(float(r.val) / float(r.fleet_med), 2) if r.fleet_med else None,
  1132. 六窗比=round(tv, 2) if tv else None))
  1133. if not rows: return None
  1134. return dict(kind='vibbar', size='L', title=f'{t} {sysname} 振动标量 · 本台 vs 全场 ({str(order[-1]).replace("WPS-ActivePower ", "功率 ").rstrip(",")} kW 档)',
  1135. rows=rows, note='数据=TCM 六窗标量 (振动线 m5_cms_tcm 产物转录); z=fleet 稳健离群度, 六窗比=末窗/首窗 (>1 上升)')
  1136. def single_problem(t, sys, win):
  1137. """只回该台该系统这一个问题 (用户令: 点开就是一个问题, 其他不带出来)."""
  1138. return [p for p in turbine_problems(t, win) if p['sys'] == sys]
  1139. def fleet_view(win):
  1140. _load()
  1141. ms = months_of(win)
  1142. # ★2026-09-21 用户令「判级也按所选时间窗重算」: 判级矩阵按窗算(后台+缓存,首次 pending)。
  1143. sysmx, sysmx_pending = sysmx_of(win)
  1144. # ① 系统分类问题 (全系统)
  1145. systems, sysdist = {}, {}
  1146. for s in taxonomy.SYSTEMS:
  1147. rows = [dict(t=t, st=sysmx[t][s]['状态'], why=i18n.humanize(sysmx[t][s]['依据']))
  1148. for t in CFG['turbines'] if sysmx[t][s]['状态'] in ('报警', '不可判')] # 良好不出 (用户令: 只显示有问题的)
  1149. rows.sort(key=lambda r: TS.get(r['st'], 9))
  1150. systems[s] = rows
  1151. # 全场分布 (系统入口卡用: rows 只含问题台, 不能当全场分母 — 会显示"共12台")
  1152. from collections import Counter as _C
  1153. sysdist[s] = dict(_C(sysmx[t][s]['状态'] for t in CFG['turbines']))
  1154. # 良好台清单单列 (系统详情页第三档)
  1155. systems[s + '·良好'] = [dict(t=t, st='良好', why=i18n.humanize(sysmx[t][s]['依据']))
  1156. for t in CFG['turbines'] if sysmx[t][s]['状态'] == '良好']
  1157. # ①b 关注清单: ≥2 系统报警 (逐台独立台页)
  1158. watch = []
  1159. for t in CFG['turbines']:
  1160. al_sys = [x for x in taxonomy.SYSTEMS if sysmx[t][x]['状态'] == '报警']
  1161. if len(al_sys) >= 2:
  1162. watch.append(dict(t=t, n=len(al_sys), syss=al_sys,
  1163. # ★不截断: 截到 70 字会把依据切成半句, 而模板反解要完整形态
  1164. # ⇒ 英文侧整条落回中文 (2026-09-03 实逮, 总览 7 卡 4 张中文)。
  1165. # 显示长度归前端 CSS 管。
  1166. why=';'.join(f"{x}: {i18n.humanize(sysmx[t][x]['依据'])}" for x in al_sys)))
  1167. watch.sort(key=lambda r: -r['n'])
  1168. n_alarm_t = sum(1 for t in CFG['turbines'] if any(sysmx[t][x]['状态'] == '报警' for x in taxonomy.SYSTEMS))
  1169. # ② 故障统计梳理 (真时间窗)
  1170. al = _CACHE['al']; a = al[al.month.isin(ms)]
  1171. top_txt = a.groupby(['code', 'text']).agg(n=('code', 'size'), dur_h=('dur_s', lambda x: x.sum() / 3600)).reset_index()
  1172. pareto_n = top_txt.sort_values('n', ascending=False).head(12)
  1173. pareto_d = top_txt.sort_values('dur_h', ascending=False).head(12)
  1174. monthly = a.groupby('month').size().reindex(ms).fillna(0)
  1175. per_t = a.groupby('turbine').size().sort_values(ascending=False).head(10)
  1176. # 经典故障分析: 条数与时长必须配对看 (2026-08-28)。两个独立排行榜分不出
  1177. # "高频短时(信号抖动或重复触发)" 与 "低频长时(硬故障)" — 处置方向相反, 混在一起会误派工。
  1178. # 第三维=影响台数, 分离"单台刷屏"与"全场批次共性"。
  1179. # 残月识别 (2026-08-28 审核逮): 2026-07 只有 6 天数据(覆盖 19.4%), 却被当整月
  1180. # 并进"2026年"的占比与月度趋势 → 末柱"下降"是假象。占比类分母也因此不完整。
  1181. import calendar as _cal
  1182. _lmc = _CACHE['lm']; _lmc = _lmc[_lmc.month.astype(str).isin(ms)]
  1183. _cov = {}
  1184. for _mm in ms:
  1185. _sub = _lmc[_lmc.month.astype(str) == _mm]
  1186. if not len(_sub):
  1187. _cov[_mm] = 0.0; continue
  1188. _y, _mo = int(_mm[:4]), int(_mm[-2:])
  1189. _cal_h = _cal.monthrange(_y, _mo)[1] * 24
  1190. _cov[_mm] = round(float(_sub['rows_'].sum() / 6 / max(_sub['turbine'].nunique(), 1) / _cal_h), 3)
  1191. _nt = a.groupby(['code', 'text'])['turbine'].nunique().rename('nt')
  1192. _med = a.groupby(['code', 'text'])['dur_s'].median().rename('med_s')
  1193. qd = top_txt.set_index(['code', 'text']).join([_nt, _med]).reset_index()
  1194. qd = qd.sort_values('n', ascending=False).head(30)
  1195. faults = dict(
  1196. pareto_n=[dict(k=f"{r.code} {i18n.alarm_label(r.code, r.text)[:18]}", v=int(r.n)) for _, r in pareto_n.iterrows()],
  1197. pareto_d=[dict(k=f"{r.code} {i18n.alarm_label(r.code, r.text)[:18]}", v=round(float(r.dur_h), 1)) for _, r in pareto_d.iterrows()],
  1198. monthly=dict(months=ms, vals=[int(v) for v in monthly],
  1199. cov=[_cov.get(x, 0.0) for x in ms],
  1200. per_day=[round(float(monthly[x]) / max(_cov.get(x, 0.0) * _cal.monthrange(int(x[:4]), int(x[-2:]))[1], 1e-9), 1)
  1201. if _cov.get(x, 0) > 0.02 else None for x in ms]),
  1202. cov=_cov, cov_min=min(_cov.values()) if _cov else 1.0,
  1203. partial=[x for x in ms if _cov.get(x, 1) < 0.5],
  1204. quad=[dict(code=str(r.code), k=i18n.alarm_label(r.code, r.text)[:20], n=int(r.n),
  1205. h=round(float(r.dur_h), 1), nt=int(r.nt), med=round(float(r.med_s), 1))
  1206. for _, r in qd.iterrows()],
  1207. n_codes=int(len(top_txt)),
  1208. per_t=[dict(k=k, v=int(v)) for k, v in per_t.items()], total=int(len(a)))
  1209. # ③ SOP 控制策略
  1210. lm = _CACHE['lm']; l = lm[lm.month.astype(str).isin(ms)]
  1211. hrs = l.groupby('state')['rows_'].sum() / 6
  1212. loss = l.groupby('state')['loss'].sum() / 1000
  1213. hrs_tot = max(hrs.sum(), 1)
  1214. stop_h = hrs.get('停机', 0) + hrs.get('停机(调度令)', 0)
  1215. disp_h = hrs.get('停机(调度令)', 0)
  1216. lw_h = hrs.get('低风待机', 0)
  1217. # 口径与 availability.summary 单源一致: 1 − 停机/(总 − 调度令 − 低风待机)
  1218. avail = 1 - (stop_h - disp_h) / max(hrs_tot - disp_h - lw_h, 1)
  1219. pcd = _CACHE['pcd']
  1220. # 能量账闭合 (2026-08-28 经典分析): 四行工况表看不出"电量流向"。
  1221. # 理论可发 = 实际上网 + Σ各态损失; 过物理上限核 (不可超 38台×4.0MW×窗时长)。
  1222. _act = l.groupby('state')['act'].sum() / 1000
  1223. _wf = [dict(k=str(k), loss=round(float(loss.get(k, 0)), 0), act=round(float(_act.get(k, 0)), 0),
  1224. h=round(float(hrs.get(k, 0)), 0)) for k in hrs.index]
  1225. _A = float(_act.sum()); _L = float(loss.sum())
  1226. _cap = len(CFG['turbines']) * 4.0 * float(hrs_tot) / max(len(CFG['turbines']), 1)
  1227. # 逐月能量 (2026-08-28 报表端口): 汇报要环比与趋势, 全窗聚合给不出。
  1228. # 残月覆盖率一并带出 — 报表里拿残月和整月比环比会读反 (故障月度图已踩过一次)。
  1229. _em = []
  1230. for _mm in ms:
  1231. _sub = l[l.month.astype(str) == _mm]
  1232. if not len(_sub):
  1233. continue
  1234. _a1 = float(_sub['act'].sum()) / 1000
  1235. _l1 = float(_sub['loss'].sum()) / 1000
  1236. _em.append(dict(m=_mm, act=round(_a1, 0), loss=round(_l1, 0), theo=round(_a1 + _l1, 0),
  1237. loss_pct=round(_l1 / max(_a1 + _l1, 1) * 100, 1),
  1238. cov=_cov.get(_mm, 1.0),
  1239. eflh=round(_a1 / max(len(CFG['turbines']) * 4.0, 1), 0)))
  1240. energy = dict(monthly=_em, act=round(_A, 0), loss=round(_L, 0), theo=round(_A + _L, 0),
  1241. loss_pct=round(_L / max(_A + _L, 1) * 100, 1),
  1242. eflh=round(_A / max(len(CFG['turbines']) * 4.0, 1), 0),
  1243. cap=round(_cap, 0), cap_ok=bool(_A + _L < _cap),
  1244. items=sorted(_wf, key=lambda r: -r['loss']))
  1245. control = dict(
  1246. energy=energy,
  1247. states=[dict(k=k, h=round(float(v), 0), pct=round(float(v / hrs_tot * 100), 1), loss=round(float(loss.get(k, 0)), 0)) for k, v in hrs.items()],
  1248. avail=round(float(avail * 100), 1),
  1249. curve=dict(sigma=round(float(pcd.dev_w.std() * 100), 2),
  1250. cands=[dict(t=i, dev=round(float(r.dev_w * 100), 2), 判=r['判别']) for i, r in pcd.iterrows() if r['判别'] != '—'],
  1251. note='固定判别窗 2025-H2'),
  1252. anchors='限电命令面占比 2025≈0.197 → 2026≈0.769 (专项锚)')
  1253. m8 = None
  1254. try:
  1255. from src.windscada.perf import faults as fmod
  1256. if (ST / 'stop_events.parquet').exists():
  1257. m8 = dict(mtbf=fmod.mtbf_summary(ms), stop_pareto=fmod.stop_pareto(ms), seasonal=fmod.seasonal())
  1258. except Exception as e:
  1259. m8 = dict(err=str(e)[:120])
  1260. m9 = None
  1261. try:
  1262. from src.windscada.perf import control as cmod
  1263. if (ST / 'control_profile.parquet').exists():
  1264. m9, m9_pending = m9_of(win) # ★按窗重算(用户令 2026-09-21)
  1265. except Exception as e:
  1266. m9 = dict(err=str(e)[:120])
  1267. m9_pending = False
  1268. try:
  1269. from src.windscada.perf import reliability as relmod
  1270. rel = relmod.overview(CFG, span=span_of(win))
  1271. except Exception as e:
  1272. rel = dict(err=str(e)[:120])
  1273. try:
  1274. from src.windscada.subsys import fusion as fusmod
  1275. ftab, fmeta = fusmod.fusion_table(CFG, all_turbines=True)
  1276. mrows, mheads = fusmod.matrix(CFG, ms) # 整体时间窗: 矩阵现窗/记忆轨迹随全局窗选择器
  1277. cap = fusmod.capability()
  1278. ev, ev_bounds = fusmod.drivetrain_events(ms, CFG)
  1279. lvcnt = {}
  1280. for r in mrows:
  1281. lv = r['振动']['level']; lvcnt[lv] = lvcnt.get(lv, 0) + 1
  1282. fkpi = dict(定论=lvcnt.get('bad', 0), 预警=lvcnt.get('warn', 0), 候选观察=lvcnt.get('note', 0),
  1283. 销案正常=lvcnt.get('ok', 0), 未列=lvcnt.get('unlisted', 0),
  1284. 收录台=len(CFG['turbines']) - lvcnt.get('unlisted', 0), 全场=len(CFG['turbines']),
  1285. 机制链=sum(1 for r in mrows if '机制链' in r['润滑'].get('note', '')),
  1286. 系数=dict(wear=cap['classes'].get('wear_progressive', {}).get('coef'),
  1287. thermal=cap['classes'].get('thermal_acute', {}).get('coef'),
  1288. unknown=cap['classes'].get('unknown', {}).get('coef')))
  1289. # 矩阵补 报警/工单 两列 (2026-08-28 用户令) — 报警=窗内真窗计数+族分布; 工单=历史台账(窗见列头)
  1290. # 报警/工单两列只统计传动链三部件 (2026-08-28 用户令: 只说发电机/齿轮箱/主轴) —
  1291. # 本矩阵每行是"该台传动链状态", 全场报警计数会把变桨/偏航/主控的量混进来 (316 条里绝大多数与传动链无关)
  1292. _DT_PAT = '齿轮|齿箱|润滑油|油冷|滤芯|发电机|定子|绕组|滑环|主轴承|主轴|轴承'
  1293. _a = _CACHE['al']; _aw = _a[_a.month.isin(ms)]
  1294. _aw = _aw[_aw.text.str.contains(_DT_PAT, na=False)]
  1295. _acnt = _aw.groupby('turbine').size()
  1296. _arank = _acnt.rank(ascending=False, method='min')
  1297. _FAM = [('齿轮箱', '齿轮|齿箱|润滑油|油冷|滤芯'), ('发电机', '发电机|定子|绕组|滑环'),
  1298. ('主轴承', '主轴承|主轴')]
  1299. try:
  1300. from src.windscada.subsys import workorder as _wo
  1301. _wod = _wo.load(CFG)
  1302. except Exception:
  1303. _wod = None
  1304. for _r in mrows:
  1305. _t = _r['turbine']; _g = _aw[_aw.turbine == _t]
  1306. _fam, _taken = [], set()
  1307. _CODEFAM = {'3225': '变桨叶片'} # 文本为空的码走码号归族 (3225=变桨液压, 现场西门子资料对译表)
  1308. for _nm, _pat in _FAM:
  1309. _byc = _g.code.astype(str).map(_CODEFAM) == _nm
  1310. _sub = _g[~_g.index.isin(_taken) & (_g.text.str.contains(_pat, na=False) | _byc)]
  1311. _taken |= set(_sub.index)
  1312. if len(_sub): _fam.append(dict(k=_nm, n=int(len(_sub))))
  1313. _oth = len(_g) - sum(f['n'] for f in _fam)
  1314. if _oth > 0: _fam.append(dict(k='其他', n=int(_oth)))
  1315. # 刷屏闸: 单码占比过半 → 条数不代表"问题多"而是一个码在抖 (08# 4609/4918=93.7% 码3225 集中2026-01)
  1316. _burst = None
  1317. if len(_g):
  1318. _bc = _g.groupby('code').size().sort_values(ascending=False)
  1319. _share = float(_bc.iloc[0]) / len(_g)
  1320. if _share >= 0.5:
  1321. _sub2 = _g[_g.code == _bc.index[0]]
  1322. _burst = dict(code=str(_bc.index[0]), share=round(_share, 3), n=int(_bc.iloc[0]),
  1323. months=sorted({str(m)[:7] for m in _sub2.t_on.dt.to_period('M').astype(str)}),
  1324. med_s=float(_sub2.dur_s.median()) if 'dur_s' in _sub2 else None)
  1325. # 明细 (用户令"浮窗说明之前发生问题"): top 码 + 首末时间
  1326. _top = []
  1327. if len(_g):
  1328. for (_c2, _tx), _sub3 in _g.groupby(['code', 'text']):
  1329. _top.append(dict(code=str(_c2), text=str(_tx)[:34], n=int(len(_sub3)),
  1330. first=str(_sub3.t_on.min())[:10], last=str(_sub3.t_on.max())[:10]))
  1331. _top.sort(key=lambda x: -x['n'])
  1332. _r['报警'] = dict(n=int(_acnt.get(_t, 0)), rank=int(_arank.get(_t, 0)) if _t in _arank else None,
  1333. tot=len(CFG['turbines']), fam=_fam, burst=_burst, items=_top[:6])
  1334. if _wod is not None and len(_wod):
  1335. _w = _wod[_wod.turbine == _t]
  1336. _wtxt = (_w.get('故障名称', '').astype(str) + ' ' + _w.get('故障位置二级', '').astype(str)
  1337. + ' ' + _w.get('维修对象', '').astype(str) + ' ' + _w.get('元器件名称', '').astype(str))
  1338. _w = _w[_wtxt.str.contains(_DT_PAT, na=False)]
  1339. _acts = _w['维修动作'].replace('', pd.NA).dropna().value_counts() if len(_w) else None
  1340. _wi = []
  1341. for _, _wr in _w.sort_values('t_report', ascending=False).head(6).iterrows():
  1342. _wi.append(dict(date=str(_wr.get('t_report'))[:10], name=str(_wr.get('故障名称') or '')[:30],
  1343. act=str(_wr.get('维修动作') or ''), part=str(_wr.get('维修对象') or _wr.get('元器件名称') or '')[:14]))
  1344. _r['工单'] = dict(n=int(len(_w)),
  1345. acts=[dict(k=str(k), n=int(v)) for k, v in (_acts.head(3).items() if _acts is not None else [])],
  1346. items=_wi,
  1347. last=(lambda v: str(v.max())[:10] if len(v) else None)(_w.t_report.dropna()[_w.t_report.dropna().dt.year > 2000]) if len(_w) else None)
  1348. else:
  1349. _r['工单'] = dict(n=0, acts=[], last=None)
  1350. _wyr = ''
  1351. if _wod is not None and len(_wod):
  1352. _tv = _wod.t_report.dropna()
  1353. _tv = _tv[_tv.dt.year > 2000] # 空日期落 1970 epoch, 剔除后再报窗 (否则列头写"1970~")
  1354. if len(_tv): _wyr = f"{_tv.min():%Y}~{_tv.max():%Y}"
  1355. mheads = dict(mheads, 报警=f"传动链三部件 · {ms[0]}~{ms[-1]}" if ms else '传动链三部件',
  1356. 工单=f"传动链三部件 · 台账 {_wyr}" if _wyr else '台账不可用')
  1357. # 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用"): 原来只有 29# 一台的静态六格,
  1358. # 是展示牌不是工作面 — 打开它做不了任何决定。改为对全部需跟踪台报"走到第几步、卡在哪、下一步做什么"。
  1359. # 判定规则单源在此, 前端只渲染不判断。
  1360. _CLOOP = {}
  1361. try:
  1362. _ch = json.loads((ST.parent / 'm5_cms_tcm' / 'component_history.json').read_text(encoding='utf-8'))
  1363. for _k, _v in _ch.get('summary', {}).items():
  1364. if _k.startswith('★换件闭环') and isinstance(_v, list):
  1365. for _r in _v:
  1366. _CLOOP[_r['turbine']] = _r
  1367. if _k.startswith('★当前在升') and isinstance(_v, list):
  1368. for _r in _v:
  1369. _CLOOP.setdefault(_r['turbine'], {}).update(rising=_r)
  1370. except Exception:
  1371. pass
  1372. _HIT = {'bad', 'warn', 'note'}
  1373. # ★一台可能有多行 (29#/17# 各 2 行: 齿轮箱 + 主轴承)。用 {turbine: row} 直接建字典会被后一行覆盖,
  1374. # 实测 29# 因此取到"齿轮箱二级行星内齿圈·监视"那行, 而它的决策链讲的是"主轴承+集中润滑泵·定论"
  1375. # ⇒ 浮窗里部件、温度通道、油样全是另一个部件的 (memory: 画图须确认各维度来自同一个体)。
  1376. _SEV = {'bad': 0, 'warn': 1, 'note': 2, 'stale': 3, 'ok': 4, 'unlisted': 5}
  1377. _tbi = {}
  1378. for _row in ftab.to_dict('records'):
  1379. _k = _row['turbine']
  1380. if _k not in _tbi or _SEV.get(_row.get('级'), 9) < _SEV.get(_tbi[_k].get('级'), 9):
  1381. _tbi[_k] = _row
  1382. board = []
  1383. for _r in mrows:
  1384. t = _r['turbine']
  1385. srcs = [k for k in ('振动', '温度', '润滑', '油液') if (_r.get(k) or {}).get('level') in _HIT]
  1386. lvl = (_r.get('振动') or {}).get('level')
  1387. tb = _tbi.get(t, {})
  1388. mech = ('机制链' in ((_r.get('润滑') or {}).get('note') or '')
  1389. or '机制链' in (tb.get('振动结论') or ''))
  1390. n_wo = (_r.get('工单') or {}).get('n') or 0
  1391. cl = _CLOOP.get(t) or {}
  1392. closed = bool(cl.get('verdict') == '恢复')
  1393. rising = cl.get('rising')
  1394. if not (lvl in _HIT or (srcs and lvl != 'ok')):
  1395. continue
  1396. # 六步: done=已达 / open=未达 / unknown=数据不足不可判
  1397. steps = [
  1398. dict(k='证据', st='done' if srcs else 'open', v=f"{len(srcs)} 源", d='/'.join(srcs)),
  1399. dict(k='机制', st='done' if mech else 'open',
  1400. v='已定性' if mech else '未定性', d='根因定到部件与失效模式'),
  1401. dict(k='判级', st='done' if lvl in _HIT else 'open',
  1402. v=tb.get('证据状态') or lvl or '—', d=f"设备状态 {tb.get('设备状态') or '—'}"),
  1403. dict(k='排期', st='done' if t == 'WTG29' else 'open',
  1404. v='情景A 2026-09' if t == 'WTG29' else '未排',
  1405. d='进检修排程沙盘 (风险×损失双轴)'),
  1406. # ★这一列一律"不可判", 不是偷懒: 工单台账止 2024-11 而判级窗在 2026,
  1407. # 台账里的历史工单不可能是针对本次问题的 ⇒ 用 n>0 判"已派工"会造出假的完成态。
  1408. dict(k='动作', st='unknown',
  1409. v=(f"台账 {n_wo} 单" if n_wo else '台账无'),
  1410. d='工单台账 2020~2024-11, 25-26 现场未提供 — 历史单不对应本次问题, 须向现场核实'),
  1411. # ★历史闭环不占本格: 05#/20# 2024~2025 换过齿轮箱且已验证恢复, 但本轮又有新的候选级证据,
  1412. # 把历史闭环填进第六格会出现"验收已达而机制未定"的自相矛盾行。历史闭环走 hist_loop 单独标记。
  1413. dict(k='验收', st='open', v='未验',
  1414. d='本轮动作执行后复测同一判据是否回落' + (
  1415. f" (该台 {cl.get('replace_date')} 有过一次闭环: {cl.get('component')} 降至 {cl.get('ratio')}×)" if closed else '')),
  1416. ]
  1417. _first = next((x for x in steps if x['st'] != 'done'), None)
  1418. stuck = _first['k'] if _first else None
  1419. stuck_kind = _first['st'] if _first else None
  1420. NEXT = {'证据': '补测: 四源全为常态, 先确认监测面有效',
  1421. '机制': '定性: 现场/取样把根因定到部件与失效模式 — 机制不清则排期与动作都无依据',
  1422. '判级': '送审: 按 RV-1 触发相应审级',
  1423. '排期': '排程: 进沙盘比情景, 出建议窗口',
  1424. '动作': '核实: 向现场调取 2025~2026 工单 — 台账止于 2024-11, 系统无法判定是否已派工',
  1425. '验收': '复测: 动作已执行, 复测同一判据是否回落'}
  1426. board.append(dict(t=t, lvl=lvl, ostate=tb.get('设备状态'), estate=tb.get('证据状态'),
  1427. part=tb.get('部件'), steps=steps, stuck=stuck, stuck_kind=stuck_kind,
  1428. next=NEXT.get(stuck, ''), done=sum(1 for x in steps if x['st'] == 'done'),
  1429. rising=rising, hist_loop=(cl if closed else None), srcs=srcs))
  1430. _ORD = {'bad': 0, 'warn': 1, 'note': 2}
  1431. board.sort(key=lambda r: (_ORD.get(r['lvl'], 3), -r['done']))
  1432. _cl_rows = [v for v in _CLOOP.values() if v.get('verdict') == '恢复']
  1433. fus = dict(链盘=dict(rows=board, closed=_cl_rows,
  1434. stuck=dict(collections.Counter(r['stuck'] for r in board if r['stuck'])),
  1435. wo_bound='工单台账 2020~2024-11 (现场未提供 25-26)'),
  1436. 表=ftab.to_dict('records'),
  1437. 矩阵=mrows, 列窗=mheads, kpi=fkpi,
  1438. 事件=ev, 事件边界=ev_bounds,
  1439. 能力=dict(classes=cap['classes'], blind=[str(b) for b in cap['blind']], loop=cap.get('loop', {})),
  1440. 窗=dict(振动=fmeta.get('振动窗', '—'), SCADA=fmeta.get('SCADA窗', ''), 油样=fmeta.get('油样窗', '')),
  1441. 色标=fmeta.get('色标', {}), 数据时点=fmeta.get('数据时点', ''), handoff日期=fmeta.get('handoff日期', ''),
  1442. 证据窗末=fmeta.get('证据窗末', ''),
  1443. 纪律=str(fmeta.get('纪律', '')), 盲区=[str(b) for b in fmeta.get('盲区', [])],
  1444. open_items=[str(o) for o in fmeta.get('open_items', [])], gap=fmeta.get('gap'))
  1445. except Exception as e:
  1446. fus = dict(err=str(e)[:160])
  1447. kpi = dict(报警台=n_alarm_t, 关注台=len(watch), 全场=len(CFG['turbines']))
  1448. return dict(win=win, months=ms, all_months=sorted(_CACHE['tm'].month.unique()),
  1449. systems=systems, sysdist=sysdist, watch=watch, kpi=kpi, rel=rel, fus=fus, faults=faults, control=control, m8=m8, m9=m9,
  1450. # ★2026-09-21: 判级轴现在**按所选窗重算**;首次是后台算,这一份仍是旧口径 ⇒ 如实标 pending
  1451. sysmx_pending=bool(sysmx_pending),
  1452. m9_pending=bool(m9_pending),
  1453. win_pending=bool(sysmx_pending or m9_pending),
  1454. sysmx_span=list(span_of(win)),
  1455. note=('判级轴(变桨/偏航/蓄能/温度)按所选窗重算;曲线按所选窗重算;'
  1456. '故障统计/五态/温度月轨迹/停机台账=所选窗真窗'
  1457. + ('(⚠ 判级矩阵正在按窗重算,下面系统卡暂为上一份口径,稍后自动刷新)' if sysmx_pending else '')))
  1458. # ---------- 本体网页面 (ontology 的网页体现: 机制链/决策台/机制库/对象浏览器; 只读, 复用 MCP 纯函数) ----------
  1459. def ontology_view():
  1460. from src.ontology import mcp_server as om
  1461. d = om.db()
  1462. from collections import Counter
  1463. stats = dict(Counter(v['type'] for v in d.values()))
  1464. mechs = [dict(id=o['id'], **{k: o['props'].get(k) for k in ('name', 'status', 'facets', 'source')},
  1465. affects=o.get('links', {}).get('affects', []))
  1466. for o in d.values() if o['type'] == 'FailureMechanism']
  1467. # 29# 机制链 (§2 规划补齐): 机制为中心的分层链
  1468. ev = [dict(id=i, **{k: d[i]['props'].get(k) for k in ('facet', 'claim_window', 'summary', 'source')})
  1469. for i in d['mechanism/WTG29/lub_starvation']['links']['evidencedBy']]
  1470. chain29 = dict(
  1471. mechanism=dict(id='mechanism/WTG29/lub_starvation', **d['mechanism/WTG29/lub_starvation']['props']),
  1472. evidence=ev,
  1473. verdict=dict(id='verdict/WTG29/TCM-16', **{k: d['verdict/WTG29/TCM-16']['props'].get(k)
  1474. for k in ('level', 'title', 'review_tier', 'claim_window')}),
  1475. scenario=dict(id='scenario/WTG29/repair_scheduling_v1',
  1476. **{k: d['scenario/WTG29/repair_scheduling_v1']['props'].get(k)
  1477. for k in ('key_insight', 'recommendation', 'scenarios')}),
  1478. action=dict(id='action/WTG29/repair_pump', **d['action/WTG29/repair_pump']['props']),
  1479. acceptance=dict(id='acceptance/WTG29/state', **d.get('acceptance/WTG29/state', {}).get('props', {})),
  1480. tree=dict(id='decision/WTG29/acceptance_tree', **d['decision/WTG29/acceptance_tree']['props']))
  1481. decisions = [dict(id=o['id'], **{k: o['props'].get(k) for k in ('decided_by', 'when', 'decision', 'status', 'outcome')})
  1482. for o in d.values() if o['id'].startswith('decision/user') or o['id'].startswith('decision/pending')]
  1483. roll = d.get('meta/roll_state', {}).get('props', {})
  1484. constraints = ('C1 证据必带claim_window+观测面+源身份 · C2 判级必带审级+证据链接 · C3 独立源数按观测面去重禁按条数 · '
  1485. 'C4 判读三分(支持|排除|不可判, 不可判≠阴性) · C5 状态措辞窗相关必带窗 · C6 动作必带可逆级(L3须审级)')
  1486. weather = d.get('mwindow/rudong/climatology', {}).get('props', {}).get('monthly', {})
  1487. return dict(stats=stats, constraints=constraints, mechanisms=mechs, chain29=chain29,
  1488. decisions=decisions, roll=roll, weather=weather)
  1489. def ontology_list(otype, turbine, limit=60):
  1490. from src.ontology import mcp_server as om
  1491. return om.ont_query(type=otype or '', turbine=turbine or '', limit=int(limit)).get('data', [])
  1492. def ontology_obj(oid):
  1493. from src.ontology import mcp_server as om
  1494. r = om.ont_get(id=oid)
  1495. lk = om.ont_links(id=oid).get('data', {})
  1496. return dict(obj=r.get('data'), links=lk, err=r.get('error'))
  1497. # ---------- AI 问答 (P2 离线编排的网页入口; dsh + ontology MCP; 串行锁=一次一问, windcms五类错教训) ----------
  1498. CUR_WIN = ['2026年'] # 最近一次 fleet 查询的窗, 供报告助手复用
  1499. ASK = {'state': 'idle', 'q': '', 'model': '', 'answer': '', 'verify': None, 't0': 0, 'err': '',
  1500. 'step': '', 'steps': [], 'review': None, 'xrev': False, 'lang': 'zh'}
  1501. # step/steps: 推理进度 (229s 全程无反馈会被读成"给不出结果"); review: 另一模型的交叉审核结果
  1502. _ASK_STEP_KEY_EN = {
  1503. '启动': 'Starting',
  1504. '预取': 'Prefetch',
  1505. '推理': 'Reasoning',
  1506. '取证': 'Evidence retrieval',
  1507. '成文': 'Drafting',
  1508. '校闸': 'Grounding gate',
  1509. '交叉审核': 'Cross-review',
  1510. '完成': 'Done',
  1511. '失败': 'Failed',
  1512. }
  1513. def _ask_step_text(k, d='', lang='zh'):
  1514. k = '' if k is None else str(k)
  1515. d = '' if d is None else str(d)
  1516. if lang != 'en':
  1517. return f'{k}· {d}' if d else k
  1518. key = _ASK_STEP_KEY_EN.get(k, k)
  1519. detail = d.strip()
  1520. m = re.search(r'第\s*(\d+\s*/\s*\d+)\s*轮', detail)
  1521. if m:
  1522. detail = 'round ' + m.group(1).replace(' ', '')
  1523. else:
  1524. m = re.search(r'(\d{1,2})\s*一站式证据包', detail)
  1525. if m:
  1526. alias = 'focus unit'
  1527. try:
  1528. from src.windscada.deid import unitize_answer
  1529. alias = unitize_answer(f"WTG{int(m.group(1)):02d}")
  1530. except Exception:
  1531. pass
  1532. detail = f'{alias} evidence pack'
  1533. elif '闭环' in detail and '证据' in detail:
  1534. n = ''.join(re.findall(r'\d+', detail))
  1535. detail = f'closure and trend evidence: {n} records' if n else 'closure and trend evidence'
  1536. elif '全场' in detail and '判级' in detail:
  1537. n = ''.join(re.findall(r'\d+', detail))
  1538. detail = f'fleet ratings: {n} units' if n else 'fleet ratings'
  1539. elif '换模型' in detail or '复核' in detail:
  1540. detail = 'reviewing with another model'
  1541. elif re.search(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]', detail):
  1542. detail = ''
  1543. if re.search(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]', key):
  1544. key = 'Working'
  1545. if re.search(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]', detail):
  1546. detail = ''
  1547. return f'{key} · {detail}' if detail else key
  1548. # 模型档位 (2026-08-28 用户裁: 去掉 qwen3:30b-a3b, 用 27B)。
  1549. # 每档标注适用场景与实测耗时 — 让人按"这个问题值不值得等"来选, 而不是凭型号大小猜。
  1550. ASK_MODELS = {'qwen3:8b': 'deploy/dsh/model.qwen8b.patch.yml',
  1551. 'qwen3.8:27b': 'deploy/dsh/model.qwen27b.patch.yml',
  1552. # 2026-09-06 用户裁 "加档": 部署包写的是 "本机 Ollama 的 DeepSeek 模型", 机上实有 r1 14b/32b 却选不到.
  1553. # 本地档一律走 fast_agent 快路径 (Ollama 直连), 不走 dsh, 故 patch 为 None; r1 不支持 tools 时快路径自动退化为预取直答 (见 fast_agent._chat).
  1554. 'deepseek-r1:14b': None, 'deepseek-r1:32b': None,
  1555. 'qwen3:30b-a3b': None, # 2026-09-07: 8B 校闸未过的自动升档目标 (30B 参数 / 3B 激活, 单轮 ~20 s)
  1556. 'deepseek-v4-pro': None}
  1557. # ★这张表原来只有本地模型时代的三档, **不含实际部署的 deepseek-chat / kimi-fast /
  1558. # qwen-plus** —— 于是 MODEL_SPEC[sel.value] 落空, v.warn 为空, 徽章就显示「零外发」。
  1559. # 云端模型对用户说"零外发"是**假保证**, 比时间写错严重得多 (2026-09-01 用户说"太慢"
  1560. # 时顺带查出来的)。补全 + 下面 mdlNote 的兜底改为"按外发处理", 宁可说重不可说轻。
  1561. MODEL_SPEC = {
  1562. 'qwen3:8b': dict(nm='轻量 8B', loc='本地', t='约 30 到 60 秒', use='默认档: 快速简答问答 (用户裁 2026-09-07)'),
  1563. 'qwen3.8:27b': dict(nm='标准 27B', loc='本地', t='约 2 到 4 分钟', use='复杂问题档: 多台横向比对与机制推断 (用户裁 2026-09-07)'),
  1564. 'qwen3:30b-a3b': dict(nm='快 30B (a3b)', loc='本地', t='约 20 到 60 秒', use='8B 校闸未过时的自动升档目标; 也可直接选'),
  1565. 'deepseek-r1:14b': dict(nm='本机 DeepSeek 14B', loc='本地', t='约 1 到 3 分钟', use='交叉审核审核档 (作答请优先 qwen 27B; 用户裁 2026-09-07)'),
  1566. 'deepseek-r1:32b': dict(nm='本机 DeepSeek 32B', loc='本地', t='约 3 到 8 分钟', use='备用: 更强更慢, 引用格式不稳 (约三成概率不出文)'),
  1567. 'deepseek-chat': dict(nm='DeepSeek', loc='云端', t='约 3 到 10 秒', use='默认档, 状态查询与机制推断',
  1568. warn='内容会外发到模型厂商, 场名与台号已去标识化'),
  1569. 'kimi-fast': dict(nm='Kimi', loc='云端', t='约 5 到 20 秒', use='长文归纳',
  1570. warn='内容会外发到模型厂商, 场名与台号已去标识化'),
  1571. 'qwen-plus': dict(nm='通义千问', loc='云端', t='约 5 到 20 秒', use='备用云端档',
  1572. warn='内容会外发到模型厂商, 场名与台号已去标识化'),
  1573. 'deepseek-v4-pro': dict(nm='云端 DS', loc='云端', t='约 2 到 6 分钟', use='复杂推理与交叉审核',
  1574. warn='内容会外发到模型厂商, 场名与台号已去标识化'),
  1575. }
  1576. def _warm(model):
  1577. # 预热+常驻: 空载请求把模型拉进内存并 keep_alive 30m (与 dsh 冷启并行, 消掉 18GB 级冷加载)
  1578. try:
  1579. import urllib.request
  1580. urllib.request.urlopen(urllib.request.Request(
  1581. 'http://127.0.0.1:11434/api/generate', method='POST',
  1582. data=json.dumps(dict(model=model, prompt='', keep_alive='30m')).encode(),
  1583. headers={'Content-Type': 'application/json'}), timeout=120).read()
  1584. except Exception:
  1585. pass
  1586. def _ask_worker(q, model, lang='zh'):
  1587. import subprocess, threading, time as _t
  1588. out = _P.ont() / 'ask_last.out'
  1589. try:
  1590. if MODEL_SPEC.get(model, {}).get('loc') == '本地': # 本地档 (qwen3 / deepseek-r1) 走直连快路径; 原按 'qwen' 前缀硬判, 加 r1 档后改按档表 loc
  1591. # 本地模型走直连快路径 (fast_agent: ollama chat+tools 同进程, 砍 dsh/node/MCP 三层;
  1592. # 实测 8b 116s→19s; 同一套工具与闸, 服务端强制双闸). deepseek 云端仍走 dsh 完整编排.
  1593. threading.Thread(target=_warm, args=(model,), daemon=True).start()
  1594. sys.path.insert(0, str(pathlib.Path('.').resolve()))
  1595. sys.path.insert(0, str(pathlib.Path('scripts').resolve()))
  1596. from src.ontology.fast_agent import answer_escalate as _fast # 2026-09-07: 8B 拦了自动升 a3b 重答 (用户令)
  1597. def _on_step(k, d=''):
  1598. ASK['step'] = _ask_step_text(k, d, lang)
  1599. ASK.setdefault('steps', []).append(ASK['step'])
  1600. def _on_partial(txt):
  1601. # 先快后准 (用户裁 2026-09-07): 30 s 内先出初答 (契约直出), 状态 partial, 前端继续轮询; 27B 出来后整段替换
  1602. ASK.update(answer=txt, state='partial', partial=True)
  1603. r = _fast(q, model, on_step=_on_step, lang=lang, on_partial=_on_partial)
  1604. _esc = f" · 升档 {r['escalated_from']}→{r['model']}" if r.get('escalated_from') else ''
  1605. ans = r['answer'] + f"\n\n({r['gate']} · {r['rounds']}轮 · 直连快路径{_esc})"
  1606. out.write_text(ans, encoding='utf-8') # 答案是中文: 缺 encoding 在中文 Windows 上按 GBK 写, 可能崩
  1607. from ontology_p2_verify import verify as _v0
  1608. ASK.update(answer=ans, verify=_v0(ans, lang=lang), partial=False, refined_from=r.get('escalated_from'))
  1609. # 交叉审核: 换一个本地模型复核同一份证据 (同模型自审 = 自己给自己判卷)
  1610. if ASK.get('xrev'):
  1611. _on_step('交叉审核', '换模型复核中')
  1612. from src.ontology.fast_agent import cross_review as _xr
  1613. # 审核档 = DeepSeek r1:14b (用户裁 2026-09-07); 作答本身是 DeepSeek 时换 qwen 27B 审 —— 始终跨家族 (同家族自审 = 自己判卷)
  1614. _rm = 'deepseek-r1:14b' if not model.startswith('deepseek') else 'qwen3.8:27b'
  1615. ASK['review'] = _xr(q, r['answer'], r.get('facts') or [], model=_rm)
  1616. ASK.update(state='done')
  1617. return
  1618. if False:
  1619. q = '/no_think ' + q # (快路径内已带 /no_think; dsh 路径仅 deepseek)
  1620. cmd = ['deploy/dsh/dsh_run.sh', '--profile', 'headless',
  1621. '--patch', 'deploy/dsh/headless.patch.ontology.yml']
  1622. mp = ASK_MODELS.get(model)
  1623. if mp:
  1624. cmd += ['--patch', mp]
  1625. if lang == 'en':
  1626. cmd.append(q + ' Answer in English only. End every conclusion sentence with [object/id]. Finally run claim_check.')
  1627. else:
  1628. cmd.append(q + ' 每个结论句末尾用[对象id]标注来源。最后调用claim_check。')
  1629. # 2026-09-16: 走 src.proc.run —— 本函数跑在 detail 服务进程里 (无可见控制台), 裸 spawn
  1630. # 会让 Windows 给这条"云端档问答"新建可见控制台窗口 (本地档不走这里)。
  1631. from src import proc as _proc
  1632. r = _proc.run(cmd, capture_output=True, text=True, timeout=600)
  1633. ans = r.stdout.strip() or ('(空输出) stderr: ' + r.stderr[-500:])
  1634. out.write_text(ans, encoding='utf-8')
  1635. sys.path.insert(0, str(pathlib.Path('scripts').resolve()))
  1636. from ontology_p2_verify import verify as _v
  1637. ASK.update(answer=ans, verify=_v(ans, lang=lang), state='done')
  1638. except Exception as e:
  1639. _msg = str(e)
  1640. if 'Connection refused' in _msg or 'Errno 61' in _msg or '11434' in _msg:
  1641. _msg = '本机模型未启动 (Ollama 127.0.0.1:11434 拒连); 离线版不转云, 请先启动 Ollama' # 2026-09-07 真停实测: 原样抛 urlopen 错误给用户
  1642. ASK.update(state='error', err=_msg[:300])
  1643. # ── 报告定制助手 (2026-08-28 用户令: 留一个互动交互窗, 报告按定制, 离线模型支持) ──
  1644. # 两件事: ①听懂"给集团看的月报, 重点讲设备问题"这类话 → 选出该放哪些块
  1645. # ②基于**真实数字**写一段总结叙述 — 为图表补充文字结论, 供汇报使用。
  1646. # 防编造: 数字只许用下面这份摘要里的, 生成后过 grounding 闸复核, 越界即标注。
  1647. RPT_BLOCK_KEYS = ['kpi', 'water', 'emon', 'blame', 'ram', 'parts', 'attrib',
  1648. 'pareto', 'quad', 'frate', 'units', 'chain', 'loop', 'plan', 'caliber']
  1649. RPT_SYS = ('你是风电场报告编排助手。根据用户要求做两件事, 严格按格式输出, 不要解释:\n'
  1650. 'BLOCKS: 用逗号分隔的块名 (只能从给定清单里选, 按报告顺序排)\n'
  1651. 'TEXT: 一段中文总结 (150~300 字, 可分 2~3 句一组)\n'
  1652. '铁律: TEXT 里出现的每个数字都必须来自"可用数据"里, 不许自己算不许估计; '
  1653. '没有依据的话就不写。不要写套话, 不要写"综上所述"。')
  1654. def rpt_compose(need, canvas, model='qwen3:8b'):
  1655. import time as _t
  1656. fl = fleet_view(CUR_WIN[0] if CUR_WIN else '2026年')
  1657. E = (fl.get('control') or {}).get('energy') or {}
  1658. R = fl.get('rel') or {}
  1659. M = ((fl.get('m8') or {}).get('mtbf')) or {}
  1660. F = fl.get('faults') or {}
  1661. BD = ((fl.get('fus') or {}).get('链盘')) or {}
  1662. facts = [
  1663. f"统计期 {fl.get('win')}; 38 台 4.0MW",
  1664. f"上网电量 {E.get('act')} MWh; 理论可发 {E.get('theo')}; 损失 {E.get('loss')} MWh; 损失率 {E.get('loss_pct')}%",
  1665. f"等效满发 {E.get('eflh')} h; 时间可用率 {(fl.get('control') or {}).get('avail')}%",
  1666. f"总停机 {R.get('总停机h')} h = 设备类 {R.get('设备类停机h')} + 外部类 {R.get('外部类停机h')}",
  1667. f"平均停机间隔 {M.get('MTBF_h')} h; 停机 {M.get('停机事件数')} 起; 单次平均 {M.get('MDT_h')} h",
  1668. f"报警 {F.get('total')} 条 / {F.get('n_codes')} 个码",
  1669. f"需行动机组 {len(BD.get('rows') or [])} 台; 卡点 {BD.get('stuck')}",
  1670. # 占比直接给出: 不给模型就得自己算, 算对了也会被接地闸判为"未接地数字"(实测 31.4/46.6)
  1671. f"损失构成: " + '; '.join(
  1672. f"{x['k']} {x['loss']} MWh (占损失 {round(x['loss'] / max(E.get('loss') or 1, 1) * 100, 1)}%)"
  1673. for x in (E.get('items') or [])),
  1674. f"停机 {R.get('总停机h')} h 中设备类占 {round((R.get('设备类停机h') or 0) / max(R.get('总停机h') or 1, 1) * 100, 1)}%",
  1675. f"端到端闭环 {len(BD.get('closed') or [])} 例",
  1676. ]
  1677. prompt = (f"可用数据:\n" + '\n'.join('- ' + x for x in facts) +
  1678. f"\n\n可选块 (只能从这里选): {', '.join(RPT_BLOCK_KEYS)}"
  1679. f"\n块的含义: kpi=一级指标 water=能量瀑布 emon=逐月发电 blame=损失分账 ram=可靠性象限 "
  1680. f"parts=部件停机 attrib=归因覆盖 pareto=故障帕累托 quad=故障四象限 frate=月度故障率 "
  1681. f"units=重点机组 chain=决策链 loop=闭环实证 plan=下期建议 caliber=口径边界"
  1682. f"\n\n当前画布: {', '.join(canvas) if canvas else '(空)'}"
  1683. f"\n用户要求: {need}")
  1684. import urllib.request as _ur # 顶部只 import urllib.parse, urllib.request 需显式导入
  1685. body = dict(model=model, messages=[dict(role='system', content=RPT_SYS),
  1686. dict(role='user', content='/no_think ' + prompt)],
  1687. stream=False, keep_alive='30m', options=dict(num_ctx=8192, num_predict=800))
  1688. t0 = _t.time()
  1689. try:
  1690. req = _ur.Request('http://127.0.0.1:11434/api/chat', data=json.dumps(body).encode(),
  1691. headers={'Content-Type': 'application/json'})
  1692. with _ur.urlopen(req, timeout=300) as r:
  1693. txt = (json.loads(r.read())['message'].get('content') or '')
  1694. except Exception as e:
  1695. return dict(err=f'本地模型不可达: {str(e)[:120]}')
  1696. txt = re.sub(r'<think>.*?</think>', '', txt, flags=re.S).strip()
  1697. mb = re.search(r'BLOCKS\s*[::]\s*(.+)', txt)
  1698. mt = re.search(r'TEXT\s*[::]\s*(.+)', txt, re.S)
  1699. blocks = [b.strip() for b in re.split(r'[,,\s]+', mb.group(1))] if mb else []
  1700. blocks = [b for b in blocks if b in RPT_BLOCK_KEYS]
  1701. narrative = (mt.group(1).strip() if mt else '').strip()
  1702. # 接地闸: 叙述里的数字必须在 facts 里出现 (同 fast_agent 的 grounding 口径)
  1703. bad = []
  1704. try:
  1705. from src.windscada.audit import grounding as _gr
  1706. ok, b = _gr(narrative, '\n'.join(facts))
  1707. bad = (b or {}).get('numbers') or []
  1708. except Exception:
  1709. pass
  1710. return dict(blocks=blocks, narrative=narrative, model=model,
  1711. secs=round(_t.time() - t0, 1), ungrounded=bad, raw=txt[:900])
  1712. ASK_CAT_HINTS = {
  1713. '设备类': '(设备状态类: 从本体的机组档案、对象查询与机制检索三条路取证)',
  1714. '检修排程决策': '(检修排程类: 用 ont_scenario/ont_get 取排期沙盘与验收树; 建议级动作注明待人裁)',
  1715. '损失分析': '(损失分析类: 用 ont_loss/ont_scenario 取证; 电量kWh为主, 金额必带电价区间0.391~0.85口径; 限电类损失≠设备责任)',
  1716. '安全风险预测': '(风险预测类: 用 ont_risk/ont_mechanism_search 取证; 区分升级中/稳定高位/盲区, 未列≠正常, 不可判≠阴性)',
  1717. }
  1718. ASK_CAT_HINTS_EN = {
  1719. '设备类': '(Equipment state: use unit profiles, object queries and mechanism search for evidence)',
  1720. '检修排程决策': '(Maintenance scheduling: use scenarios and acceptance trees; mark advisory actions as pending human decision)',
  1721. '损失分析': '(Loss analysis: use loss accounts and scenarios; energy in kWh/MWh, monetary value must carry the tariff-range caveat)',
  1722. '安全风险预测': '(Risk prediction: distinguish escalating, stable-high and blind-spot cases; no listing is not proof of normality)',
  1723. }
  1724. def ask_start(q, model, cat='', xrev=False, lang='zh', display_q=''):
  1725. import threading, time as _t
  1726. if not q.strip():
  1727. return dict(err='问题为空' if lang != 'en' else 'The question is empty')
  1728. if ASK['state'] in ('running', 'partial'): # partial = 初答已出、27B 复核中, 仍占槽
  1729. return dict(err='已有问题在推理中 (串行锁, 一次一问)', state=ASK['state'])
  1730. if model not in ASK_MODELS:
  1731. return dict(err=f'未知模型 {model}; 可选 {list(ASK_MODELS)}')
  1732. hints = ASK_CAT_HINTS_EN if lang == 'en' else ASK_CAT_HINTS
  1733. q2 = (hints.get(cat, '') + ' ' + q).strip()
  1734. if lang == 'en':
  1735. try:
  1736. from src.windscada.deid import ununitize_question
  1737. q2 = ununitize_question(q2)
  1738. except Exception as e:
  1739. print('[ask] 代号反解失败: %s' % e, file=sys.stderr)
  1740. ASK.update(state='running', q=display_q or q, model=model, answer='', verify=None, err='', t0=_t.time(),
  1741. step='启动' if lang != 'en' else 'Starting', steps=[], review=None, lang=lang)
  1742. ASK['xrev'] = bool(xrev)
  1743. threading.Thread(target=_ask_worker, args=(q2, model, lang), daemon=True).start()
  1744. return dict(state='running', model=model)
  1745. def ask_status():
  1746. import time as _t
  1747. d = dict(ASK)
  1748. d['elapsed'] = int(_t.time() - ASK['t0']) if ASK['t0'] else 0
  1749. return d
  1750. # ---------- 台内即时查询 ----------
  1751. _RAW_CACHE = {}
  1752. def adhoc_query(t, ch, win, agg):
  1753. from src.windscada.data import load_10min, contracted_cols
  1754. _load()
  1755. key = t
  1756. with LOCK:
  1757. if key not in _RAW_CACHE:
  1758. if len(_RAW_CACHE) > 6: _RAW_CACHE.clear()
  1759. try:
  1760. _RAW_CACHE[key] = load_10min(t, CFG, groups=['A.功率', 'A.风况', 'A.转速', 'A.状态', 'B.变桨', 'B.温度NBM', 'B.偏航', 'B.润滑液压'])
  1761. except Exception as e:
  1762. # 2026-09-11 场景① 逮: 原始件不在时这里原来直接把异常抛给 HTTP 处理器 → 前端拿到 500,
  1763. # 面板只显示"服务器错误", 人看不出是"没放数据"还是"程序坏了"。改为**结构化的无数据**回答:
  1764. # 与成功路径同一个形状 (kind/title/unit/months/series/note), 前端按正常渲染即可显示原因。
  1765. src = CFG.get('src_10min')
  1766. return dict(kind='multiline', title=f'{t} {i18n.ch_cn(ch)}', unit='', months=[], series=[],
  1767. err='no_source',
  1768. note=f'无原始件: 本机应在 {src}\\{t}.csv (即 data\\raw\\<场站名称>\\scada_10min\\)'
  1769. f' — 放入后重跑 scripts\\rebuild_from_raw.py --scada')
  1770. d = _RAW_CACHE[key]
  1771. if ch not in d.columns: return dict(err=f'通道 {ch} 不在契约内')
  1772. ms = months_of(win)
  1773. dd = d[d.ts.dt.to_period('M').astype(str).isin(ms)]
  1774. if agg == 'daily':
  1775. g = dd.groupby(dd.ts.dt.date)[ch].median()
  1776. xs = [str(x) for x in g.index]
  1777. else:
  1778. g = dd.groupby(dd.ts.dt.to_period('M').astype(str))[ch].median() if agg == 'med' else \
  1779. dd.groupby([dd.ts.dt.to_period('M').astype(str), dd.ts.dt.date])[ch].sum().groupby(level=0).mean()
  1780. xs = list(g.index)
  1781. agg_cn = {'med': '月中位', 'sum': '月duty(秒/日)', 'daily': '逐日中位'}.get(agg, agg)
  1782. fig = dict(kind='multiline', title=f'{t} {i18n.ch_cn(ch)} ({agg_cn}, {win})', unit='', months=xs,
  1783. series=[dict(name=i18n.ch_cn(ch, with_tag=False), vals=[None if v != v else round(float(v), 3) for v in g])], note='台内即时查询')
  1784. # fleet 带 (若月仓有)
  1785. for store, col in ((_CACHE['tm'], 'med'), (_CACHE['duty'], 'val')):
  1786. if store is None or agg == 'daily': continue
  1787. gg = store[store.channel == ch]
  1788. if len(gg):
  1789. piv = gg.pivot_table(index='month', columns='turbine', values=col).reindex(xs)
  1790. fig = dict(kind='band', title=fig['title'], unit='', months=xs,
  1791. self=[None if v != v else round(float(v), 3) for v in piv.get(t, pd.Series(index=xs, dtype=float))],
  1792. med=[None if v != v else round(float(v), 3) for v in piv.median(axis=1)],
  1793. q1=[None if v != v else round(float(v), 3) for v in piv.quantile(.25, axis=1)],
  1794. q3=[None if v != v else round(float(v), 3) for v in piv.quantile(.75, axis=1)], note='灰带=全场四分位 (月仓)')
  1795. break
  1796. return fig
  1797. def channel_list():
  1798. from src.windscada.data import contract
  1799. c = contract(CFG)
  1800. out = {}
  1801. for g, cols in c['groups'].items():
  1802. out[g] = [dict(code=k, cn=i18n.ch_cn(k, with_tag=False) + ' · ' + k) for k, v in cols.items() if v['liveness'] == '活']
  1803. return out
  1804. # ---------- HTML ----------
  1805. from src.windscada.design import CSS as BASE_CSS
  1806. CHART_JS = """
  1807. // 设备状态色 = 观澜语义色单源 (2026-08-28 用户裁: 观澜色块体系更好, 保留; windcms 那边改过来)
  1808. // 对照 windcms 现值见 design.py 的 --st-* (迁移期保留, 供振动线对齐用)
  1809. const C={'优秀':'var(--ok)','良好':'var(--info)','报警':'var(--warn)','危险':'var(--bad)','不可判':'var(--unknown)','监测不可用':'var(--unknown)','无监测':'var(--unknown)'};
  1810. // 传动链运动简图 (2026-08-28 用户令"纯图形": 弃方块标签, 用工程简图语言 —
  1811. // 轴=横线 · 齿轮=竖矩形(啮合处相切) · 轴承=剖面符号 · 行星级=齿圈+行星架 · 发电机=定子/转子;
  1812. // 只有命中部件着色并标名, 其余全灰 = 一眼看出"链条哪一环出问题")
  1813. const baBar=(pre,post,col)=>{
  1814. const M=Math.max(pre,post,7), W=120, sc2=v=>Math.max(2,v/M*(W-4));
  1815. return `<svg class="dt" viewBox="0 0 ${W} 26" style="width:${W}px;height:26px">
  1816. <rect x="2" y="2" width="${sc2(pre).toFixed(1)}" height="9" rx="2" fill="var(--muted)" fill-opacity="0.55"/>
  1817. <rect x="2" y="15" width="${sc2(post).toFixed(1)}" height="9" rx="2" fill="${col}" fill-opacity="0.9"/>
  1818. </svg>`};
  1819. const esc=t=>String(t==null?'':t).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
  1820. // 温度偏差条: 0 为中线的双向条 (方向即符号, 长度即幅度, 色即判级) + 数字并存
  1821. const devBar=(ch,dev,vd,raw)=>{
  1822. if(dev==null||dev===undefined) return `<span class="small">${esc(raw).slice(0,30)}</span>`;
  1823. const M=6, W=104, mid=W/2, k=Math.max(-1,Math.min(1,dev/M)), bw=Math.abs(k)*(mid-8);
  1824. // 只有真越档(|dev|>=3K)才上语义色, 其余淡灰 — 避免整列彩条抢注意力
  1825. const strong = Math.abs(dev) >= 3;
  1826. const col = !strong ? 'var(--muted)' : (/候选/.test(vd||'')?'var(--warn)':/记基线/.test(vd||'')?'var(--info)':/不可判|返服/.test(vd||'')?'var(--unknown)':'var(--muted)');
  1827. const over=Math.abs(dev)>M;
  1828. return `<div title="${esc(raw)}" style="display:flex;align-items:center;gap:7px">
  1829. <svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px;flex:none">
  1830. <line x1="4" y1="9" x2="${W-4}" y2="9" stroke="var(--line)" stroke-width="1" stroke-opacity="0.7"/>
  1831. <line x1="${mid}" y1="1" x2="${mid}" y2="17" stroke="var(--muted)" stroke-width="1" stroke-opacity="0.6"/>
  1832. <rect x="${dev>=0?mid:mid-bw}" y="4" width="${Math.max(bw,1.5)}" height="10" rx="2" fill="${col}" fill-opacity="0.9"/>
  1833. ${over?`<path d="M${dev>0?W-5:5},9 l${dev>0?-5:5},-4 v8 z" fill="${col}"/>`:''}
  1834. </svg>
  1835. <b class="small" style="font-variant-numeric:tabular-nums;color:${col}">${dev>0?'+':''}${dev}K</b></div>
  1836. <div class="small" style="color:var(--muted);margin-top:1px">${esc(ch||'')}</div>`};
  1837. // 证据窗时间条: 固定 2026-01→09 轴, 块=claim 窗覆盖段 (跨行可比, 一眼看窗对不对得上)
  1838. const winBar=(spans,raw)=>{
  1839. if(!spans||!spans.length) return `<span class="small" title="${esc(raw)}">未标窗</span>`;
  1840. const T0=Date.parse('2026-01-01'), T1=Date.parse('2026-09-01'), W=118;
  1841. const px=d=>{const v=Date.parse(d); return 3+Math.max(0,Math.min(1,(v-T0)/(T1-T0)))*(W-6)};
  1842. const blocks=spans.map(([a,b])=>{const x1=px(a), x2=px(b);
  1843. return `<rect x="${x1.toFixed(1)}" y="4" width="${Math.max(x2-x1,2.5).toFixed(1)}" height="10" rx="2" fill="var(--accent)" fill-opacity="0.8"/>`}).join('');
  1844. return `<div title="${esc(raw)}"><svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px">
  1845. <rect x="3" y="4" width="${W-6}" height="10" rx="2" fill="var(--line)" fill-opacity="0.5"/>${blocks}</svg>
  1846. <div class="small" style="color:var(--muted)">${spans.length} 个窗段</div></div>`};
  1847. // 油液时效胶囊: 绿=近共时可作现时判据 / 灰=stale 只作回测锚
  1848. const oilPill=(last,fresh,raw)=>`<div title="${esc(raw)}" style="display:flex;align-items:center;gap:6px">
  1849. <i style="width:9px;height:9px;border-radius:50%;flex:none;background:${fresh?'var(--ok)':'var(--unknown)'}"></i>
  1850. <span class="small" style="font-variant-numeric:tabular-nums">${last||'无样'}</span></div>
  1851. <div class="small" style="color:var(--muted)">${last?(fresh?'近共时':'stale &gt;12 个月'):'—'}</div>`;
  1852. // 术语去黑话 (2026-08-28 用户令"翻译·审核黑话"): 内部判级词/口径缩写 → 现场话, 括注保留原词以便对账
  1853. const JARGON=[[/正样本/g,'健康对照台'],[/可降(?![级])/g,'可降级观察'],[/记基线观察/g,'记基线·下轮复核'],
  1854. [/CLAIM窗|claim窗/g,'证据窗'],[/位次([0-9]+)[/]([0-9]+)/g,'全场第$1名(共$2台)'],[/未列 [((]≠正常[^))]*[))]/g,'未收录 (≠已排除异常)'],
  1855. [/温度候选[(]定向查[)]/g,'温度偏高·待定向排查'],[/最大偏/g,'偏差最大通道'],[/单次([0-9]+)s/g,'单次循环 $1 秒'],
  1856. [/窗内无样/g,'本窗未取样'],[/仲裁输入/g,'待仲裁输入'],[/无新证/g,'本轮无新证据'],[/降观察/g,'降为观察'],
  1857. [/弱同向新证/g,'弱同向新证据'],[/传感器项/g,'测量链(仪器)问题'],[/份样/g,' 份油样'],[/NBM/g,'基线模型(NBM)']];
  1858. // 表述规范单源 = windcms agent.py 的业主口径注解 (2026-08-28 用户令: 等级评价和表述一定要参照 windcms)。
  1859. // 规范原文: 状态等级打头(优秀=未见异常保持常规监视 / 良好=轻微偏离记录观察 / 报警=存在需现场核实或处置的异常 /
  1860. // 危险=异常严重建议尽快停机检查 / 不可判=数据不足或监测中断); 证据状态放括号里说可信度
  1861. // (候选=待现场核实 / 准定论·预警=基本确认 / 候选·新发=新出现待核实 / 候选·记基线=已记为基线下窗观察 /
  1862. // 参考=仅记录 / INSUFFICIENT=数据不足 / 撤回=已取消)。
  1863. // ★不自造措辞: 之前我译的"待现场核实/观察即可"虽通顺但偏离了 windcms 词表, 已撤。
  1864. const ST_NOTE = {'优秀':'未见异常, 保持常规监视', '良好':'轻微偏离, 记录观察',
  1865. '报警':'存在需现场核实或处置的异常', '危险':'异常严重, 建议尽快停机检查', '不可判':'数据不足或监测中断'};
  1866. const EV_NOTE = {'候选':'待现场核实', '准定论·预警':'基本确认', '候选·新发':'新出现待核实',
  1867. '候选·记基线':'已记为基线, 下窗观察', '参考':'仅记录', 'INSUFFICIENT':'数据不足',
  1868. '定论':'已定案', '正常':'未见异常', '撤回':'已取消'};
  1869. const plain = t => String(t || ''); // 判级词一律用 windcms 原词, 注解走 ST_NOTE/EV_NOTE
  1870. // 振动技术速记 → 人话 (2026-08-28 用户令: 黑话改掉)。只翻术语不动数字, 原文进悬停。
  1871. // 顺序敏感: 长词先于短词 (DE/NDE 要先于 DE), 否则会被短词切碎。
  1872. const VIBJARGON = [
  1873. [/DE[/]NDE/g, '驱动端与非驱动端'], [/GenDE/g, '发电机驱动端'], [/GenNDE/g, '发电机非驱动端'],
  1874. [/HS_gen2?/g, '高速轴发电机侧'], [/HS_rot/g, '高速轴转子侧'], [/IMS/g, '中间轴'],
  1875. [/(?<![A-Za-z])NDE(?![A-Za-z])/g, '非驱动端'], [/(?<![A-Za-z])DE(?![A-Za-z])/g, '驱动端'],
  1876. // 复合词先于单词: 原文"内圈BPFI"若先翻 BPFI 会变成"内圈内圈特征频率"
  1877. [/内圈BPFI/g, '内圈特征频率'], [/外圈BPFO/g, '外圈特征频率'],
  1878. [/BPFI/g, '内圈特征频率'], [/BPFO/g, '外圈特征频率'], [/FTF/g, '保持架特征频率'], [/BSF/g, '滚动体特征频率'],
  1879. [/TD_1R/g, '时域单转指标'], [/Peak/g, '冲击峰值'], [/Rms/gi, '有效值'],
  1880. [/([0-9]+(?:[.][0-9]+)?)[ ]*阶/g, '$1 倍轴频'], [/[(]o([0-9]+(?:[.][0-9]+)?)[)]/g, '(轴频 $1 倍)'],
  1881. [/×p90/g, '×全场90分位'], [/×fleet/g, '×全场中位'],
  1882. [/六窗/g, '六个观测窗'], [/([0-9])窗/g, '$1 个观测窗'],
  1883. [/宽带/g, '宽频带总量'], [/台阶/g, '阶跃上升'], [/缓升/g, '缓慢上升'], [/回落/g, '回落至'],
  1884. [/全净|六窗全净/g, '各窗均干净'], [/清洁/g, '干净'], [/回归/g, '回到常态'],
  1885. // 替换文本本身不得含后续规则会再匹配的词 (原写'见族主台'会被下一条的 /族/ 二次替换)
  1886. [/同族/g, '同批次共性 (见该批主台)'], [/族第([^ ]+)台/g, '同批次第$1台'], [/族/g, '批次共性组'],
  1887. [/gain异常/g, '测点增益异常'], [/gain/g, '测点增益'],
  1888. [/[(]销[)]/g, '(已销案)'], [/梯度/g, '沿链梯度最高点'],
  1889. [/周期主导/g, '周期成分主导'], [/急性事件/g, '突发事件'], [/慢性/g, '长期'],
  1890. [/主异常在/g, '主要异常位于'],
  1891. ];
  1892. const vibPlain = t => { let x = String(t || ''); VIBJARGON.forEach(([a, b]) => x = x.replace(a, b)); return x; };
  1893. const zh=t=>{let x=String(t==null?'':t); JARGON.forEach(([a,b])=>x=x.replace(a,b)); return x};
  1894. // 族分布堆叠条 (报警列)
  1895. // 族分布条 (2026-08-28 用户逮"故障多少长度都一样"):
  1896. // 条总长 = 该台条数/全场最大 → 跨行可比 (原来段宽按台内占比算, 总和恒 100% 所以每行一样长, 量的信息全丢);
  1897. // 段内比例 = 族构成。总长封顶 118px, 最短 6px 保证可见。
  1898. const famBar=(fam,n,maxN)=>{
  1899. if(!n) return `<span class="small" style="color:var(--muted)">窗内无报警</span>`;
  1900. const CO={'齿轮箱':'#8A6D3B','发电机':'#4A6FA5','主轴承':'#2E7D6B','变桨叶片':'#4A6FA5','偏航':'#2E7D6B','测风':'#7C5295','其他':'#5A6470'};
  1901. const W=118, w=Math.max(6, Math.round(n/Math.max(maxN||n,1)*W));
  1902. return `<div title="${n} 条 · 全场最多 ${maxN||n} 条" style="display:flex;height:9px;border-radius:3px;overflow:hidden;width:${w}px">`
  1903. +fam.map(f=>`<i title="${f.k} ${f.n}" style="width:${(f.n/n*100).toFixed(1)}%;background:${CO[f.k]||'#5A6470'}"></i>`).join('')
  1904. +`</div><div class="small" style="color:var(--muted);margin-top:3px">${fam.slice(0,2).map(f=>f.k+' '+f.n).join(' · ')}</div>`};
  1905. const PART2SYS=p=>/主轴承|主轴/.test(p)?'主轴承':/齿轮|行星|中间|中速|高速/.test(p)?'齿轮箱':/发电机/.test(p)?'发电机':p;
  1906. // ────────────────────────────────────────────────────────────────────────
  1907. // 报表汇报端口 (2026-08-28 用户令: 按客户汇报管理要求做定制化报表端口, 一级显示, ontology 支持)
  1908. //
  1909. // 范式不是自创的 — 国内风电场上报集团的月度运行分析报告结构是固定的:
  1910. // 发电完成 → 设备运行 → 故障分析 → 限电情况 → 检修执行 → 重点机组 → 下期计划。
  1911. // 端口做三件事: ①选范式与周期 ②勾章节(定制化) ③选受众(决定脱敏级别)。
  1912. // "一级显示" = 顶部六个核心指标用大字直出, 明细下沉到章节; 领导只看第一屏也拿得到结论。
  1913. // 每个数字带口径与来源, 结论可回溯到本体对象 — 这是它区别于"手搓 PPT"的地方。
  1914. // ────────────────────────────────────────────────────────────────────────
  1915. // 报表画布 (2026-08-28 用户令: "像画布一样让客户选, 不是放太多信息")
  1916. //
  1917. // 改法: 从"满配 + 关掉不要的"翻成"空画布 + 挑要的"。前者默认给一大堆再让人做减法,
  1918. // 客户看到的是负担; 后者每加一块都是一次主动选择, 报告长度天然由客户控制。
  1919. // 组件粒度也从"章"降到"块" — 一张图/一张表就是一块, 挑的时候看得见自己在放什么。
  1920. // 范式(月报/周报/专题/年度) 退化为**预设组合**: 点一下把一组块填进画布, 之后照样能加减调序。
  1921. // 组件按主题分组 (2026-08-28 用户"交互界面设计一下"): 15 块平铺成两行时,
  1922. // 客户要在一堆同质按钮里找, 且原生 title 弹出会遮住旁边两个按钮 (截图逮)。
  1923. // 改为按报告的叙事顺序分五组, 说明走固定说明区不浮动。
  1924. const RPT_GROUPS = [['发电', 'gen'], ['可靠性', 'rel'], ['故障', 'flt'], ['机组', 'unit'], ['收尾', 'end']];
  1925. const RPT_BLOCKS = {
  1926. kpi: {g:'gen', nm:'一级指标', ic:'▦', d:'六个核心数: 上网电量 / 等效满发 / 可用率 / 损失率 / 停机间隔 / 需行动台'},
  1927. water: {g:'gen', nm:'能量瀑布', ic:'▤', d:'理论可发 → 逐段扣损 → 实际上网, 用于定位电量流向'},
  1928. emon: {g:'gen', nm:'逐月发电表', ic:'▥', d:'逐月上网 / 损失 / 损失率 / 等效满发, 含数据覆盖率'},
  1929. blame: {g:'gen', nm:'损失责任分账', ic:'◧', d:'交易调度侧 / 设备侧 / 运行侧 三段占比 — 回答"谁的责任"'},
  1930. ram: {g:'rel', nm:'可靠性象限', ic:'◈', d:'MTBF × MDT 部件象限, 分出"频发易修"与"罕发难修", 处置方向相反'},
  1931. parts: {g:'rel', nm:'部件停机表', ic:'▤', d:'逐部件停机时长 / 事件数 / MTBF / MDT / 影响台数'},
  1932. attrib: {g:'rel', nm:'归因覆盖', ic:'▬', d:'已归因 vs 无记录停机的占比 — 无记录不等于没有故障'},
  1933. pareto: {g:'flt', nm:'故障帕累托', ic:'▮', d:'按条数降序 + 累计 80% 线, 找关键少数'},
  1934. quad: {g:'flt', nm:'故障四象限', ic:'◇', d:'条数 × 单次时长, 区分重复触发与实际停机 — 按条数排序会误定优先级'},
  1935. frate: {g:'flt', nm:'月度故障率', ic:'▪', d:'按天归一的日均条数, 数据不满月单独标注'},
  1936. units: {g:'unit', nm:'重点机组', ic:'☰', d:'需跟踪台的部件 / 证据源 / 六步进度 / 卡在哪一步'},
  1937. chain: {g:'unit', nm:'决策链进度', ic:'⛓', d:'证据→机制→判级→排期→动作→验收 六步的瓶颈分布'},
  1938. loop: {g:'unit', nm:'闭环实证', ic:'✓', d:'检出 → 换件 → 复测回落的历史例证, 证明这条链是通的'},
  1939. plan: {g:'end', nm:'下期建议', ic:'✎', d:'由数据推出的行动项, 不是套话'},
  1940. caliber:{g:'end', nm:'口径与边界', ic:'⚑', d:'统计期 / 残月 / 工单窗 / 判级性质 — 建议常驻, 没有边界的数字堆不能作汇报依据'},
  1941. narr: {g:'end', nm:'运行总结', ic:'✍', d:'本地模型基于实测数据生成的运行总结 — 为图表补充文字结论, 供汇报使用'},
  1942. };
  1943. const RPT_PRESETS = {
  1944. month: {nm:'月度运行分析', blocks:['kpi','water','emon','blame','ram','pareto','units','plan','caliber']},
  1945. week: {nm:'周报', blocks:['kpi','frate','units','plan','caliber']},
  1946. topic: {nm:'设备专题', blocks:['units','chain','ram','loop','caliber']},
  1947. year: {nm:'年度总结', blocks:['kpi','water','emon','ram','loop','caliber']},
  1948. blank: {nm:'空白画布', blocks:[]},
  1949. };
  1950. function rptInit(){
  1951. if(!window.RPTAUD) window.RPTAUD='in';
  1952. // 默认空画布 (2026-08-28 用户令"都去掉, 只剩选项栏"): 打开只有选项栏, 一块都不预填。
  1953. // 起手给几块仍是替客户做决定 — 画布的意思就是空着等人放东西。
  1954. if(!window.RPTCANVAS) window.RPTCANVAS=[];
  1955. }
  1956. // 报告定制交互 (2026-08-28 用户令: 留一个互动交互窗, 报告按定制, 离线模型支持)。
  1957. // 模型做两件事: 按需求挑组件 + 据实测摘要写总结。数字全部来自后端摘要, 生成后过接地闸;
  1958. // 模型自己算的派生值(如占比求和)会被标出来让人核 — 算得对也要标, 因为核不了的数字不能进报告。
  1959. // ★下拉原来写死两个**本地**模型 (qwen3:8b / qwen3.8:27b), 而云端部署上一个都没配;
  1960. // /api/rpt_compose 在云端的问答微服务里也不存在 (只有 ask / ask_status / ask_models)。
  1961. // 于是这个按钮在生产环境是死的: 点了拿回 404 的 {"err":"not found"}, 显示成 "not found"。
  1962. // 改为按**实际可用**填充: 有编排接口才启用, 模型清单与问答助手同源 (/api/ask_models)。
  1963. // 交付件不该摆一个点了就报错的按钮。
  1964. // ★不能叫 rptInit —— 上面 L1528 已经有一个同名函数 (初始化 RPTAUD / RPTCANVAS)。
  1965. // 函数声明后者覆盖前者, 于是 RPTAUD 永远初始化不了, 渲染直接 ReferenceError,
  1966. // 整个汇报定制面板消失。命名撞车只在运行时才炸, 语法闸看不见。
  1967. // ★模型下拉必须按**实际配了密钥的**填, 不能把 MODEL_SPEC 全列出来 ——
  1968. // MODEL_SPEC 是"档位说明表"(含本地档), 不是"可用清单"。全列出来并默认选第一个
  1969. // (qwen3:8b, 本地档) 的后果: 用户一提问就报「模型 qwen3:8b 未配置密钥」。
  1970. // 云主机 2 核 1GB, 本地模型本来就跑不起来。可用清单唯一来源 = /api/ask_models。
  1971. async function fillModels(selId){
  1972. const sel=document.getElementById(selId); if(!sel) return {};
  1973. let models={};
  1974. try{ models=(await (await fetch('/api/ask_models')).json()).models||{} }catch(e){}
  1975. const ks=Object.keys(models);
  1976. sel.innerHTML = ks.length
  1977. ? ks.map(k=>{const v=MODEL_SPEC[k]||{}; return `<option value="${k}">${v.nm||k}${v.loc?' · '+v.loc:''}${v.t?' · '+v.t:''}</option>`}).join('')
  1978. : '<option value="">无可用模型</option>';
  1979. return models;
  1980. }
  1981. async function askModelInit(){
  1982. await fillModels('askm');
  1983. if(typeof mdlNote==='function') mdlNote();
  1984. }
  1985. async function rptModelInit(){
  1986. const sel=document.getElementById('rptmdl'), btn=document.getElementById('rptgo'),
  1987. out=document.getElementById('rptout');
  1988. if(!sel) return;
  1989. let hasCompose=false;
  1990. const models=await fillModels('rptmdl');
  1991. // 编排已移植上云 (2026-09-01), 故改回**真探测** —— 拦截器已把 rpt_compose 放行到
  1992. // 真网络, 探得到 200 就启用, 离线打开探不到就禁用并说明。
  1993. try{ const r=await fetch('/api/rpt_compose?probe=1'); hasCompose = !!(r&&r.ok) }catch(e){ hasCompose=false }
  1994. const ks=Object.keys(models);
  1995. // ★这句原文是「本地模型…数据不出本地」—— 选 DeepSeek 时数据当然出本地。
  1996. // 与徽章同一判据: 按选中档位的 loc 给话, 不写死。
  1997. const hint=document.getElementById('rpthint');
  1998. const syncHint=()=>{ if(!hint) return;
  1999. const v=MODEL_SPEC[sel.value]||{};
  2000. hint.innerHTML='模型依据需求选取组件, 并基于实测数据生成运行总结'
  2001. + (v.loc==='本地' ? '; 数据不出本地'
  2002. : '; <span style="color:var(--warn)">内容会外发到模型厂商</span>'); };
  2003. sel.addEventListener('change', syncHint); syncHint();
  2004. if(!hasCompose||!ks.length){
  2005. if(btn){ btn.disabled=true; btn.style.opacity='.5'; btn.style.cursor='not-allowed' }
  2006. if(out) out.innerHTML = !ks.length ? '本部署未配置任何模型密钥, 编排不可用'
  2007. : '本部署未启用编排接口, 画布仍可手动拼装';
  2008. }
  2009. }
  2010. async function rptCompose(){
  2011. const need=(document.getElementById('rptneed')||{}).value||'';
  2012. const mdl=(document.getElementById('rptmdl')||{}).value||'qwen3:8b';
  2013. const out=document.getElementById('rptout');
  2014. if(!need.trim()){ out.innerHTML='先输入报告要求'; return }
  2015. const _sp=MODEL_SPEC[mdl]||{}, _loc=(_sp.loc==='本地');
  2016. out.innerHTML='<span style="color:var(--accent)">模型编排中…</span> 挑组件 + 写总结'
  2017. + (_loc ? ', 全程零外发' : ', <span style="color:var(--warn)">内容会外发到模型厂商</span>');
  2018. let d;
  2019. try{ d=await (await fetch(`/api/rpt_compose?need=${encodeURIComponent(need)}&canvas=${encodeURIComponent(RPTCANVAS.join(','))}&model=${encodeURIComponent(mdl)}`)).json(); }
  2020. catch(e){ out.innerHTML=`<span style="color:var(--bad)">生成失败: ${e.message}</span>`; return }
  2021. if(d.err){ out.innerHTML=`<span style="color:var(--bad)">${d.err}</span>`; return }
  2022. window.RPTNARR={text:d.narrative, model:d.model, ungrounded:d.ungrounded||[]};
  2023. const bl=(d.blocks||[]).filter(b=>RPT_BLOCKS[b]);
  2024. if(bl.length) window.RPTCANVAS = (d.narrative? ['narr'] : []).concat(bl);
  2025. else if(d.narrative && !RPTCANVAS.includes('narr')) RPTCANVAS.unshift('narr');
  2026. out.innerHTML=`已按需求编排 <b>${bl.length}</b> 块${d.narrative?' + 一段运行总结':''} ·
  2027. ${d.model} 用时 ${d.secs}s${(d.ungrounded||[]).length?` · <span style="color:var(--warn)">${d.ungrounded.length} 个数字待核</span>`:' · 数字全部接地'}`;
  2028. render();
  2029. setTimeout(()=>{const el=document.getElementById('rptout'); if(el&&!el.innerHTML) el.innerHTML='';},50);
  2030. }
  2031. function rptExport(fmt){
  2032. // 导出走后端生成真 .docx/.xlsx: 图表块转成对应数据表, 信息不丢且可核对。
  2033. // 上报版沿用界面同一套脱敏规则, 不需要导出后再手工处理。
  2034. if(!RPTCANVAS.length){ alert('画布是空的 — 先从组件库选内容'); return }
  2035. const n=(window.RPTNARR&&RPTNARR.text)?RPTNARR.text:'';
  2036. const win=(window.D&&D.win)||'2026年';
  2037. const url=`/api/rpt_export?fmt=${fmt}&blocks=${encodeURIComponent(RPTCANVAS.join(','))}`
  2038. +`&aud=${RPTAUD}&win=${encodeURIComponent(win)}&narr=${encodeURIComponent(n.slice(0,4000))}`;
  2039. const a=document.createElement('a'); a.href=url; a.download=''; document.body.appendChild(a); a.click(); a.remove();
  2040. }
  2041. function rptHint(k){
  2042. // 固定说明区而非浮动 tooltip: 原生 title 弹出会盖住相邻按钮 (用户截图逮),
  2043. // 且鼠标一移就消失, 读不完。放在组件库下方定位不动。
  2044. const el=document.getElementById('rpthint'); if(!el) return;
  2045. const v=k?RPT_BLOCKS[k]:null;
  2046. el.innerHTML = v ? `<b style="color:var(--ink)">${v.ic} ${v.nm}</b> — ${v.d}`
  2047. : '悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序';
  2048. }
  2049. function rptPreset(k){ window.RPTCANVAS=RPT_PRESETS[k].blocks.slice(); render(); }
  2050. function rptAdd(k){ if(!RPTCANVAS.includes(k)){ RPTCANVAS.push(k); render(); } }
  2051. function rptDel(k){ const i=RPTCANVAS.indexOf(k); if(i>=0){ RPTCANVAS.splice(i,1); render(); } }
  2052. function rptMove(k,dir){
  2053. const i=RPTCANVAS.indexOf(k), j=i+dir;
  2054. if(i<0||j<0||j>=RPTCANVAS.length) return;
  2055. [RPTCANVAS[i],RPTCANVAS[j]]=[RPTCANVAS[j],RPTCANVAS[i]]; render();
  2056. }
  2057. function rptK(v,u,lab,note,cls){
  2058. return `<div class="kpi ${cls||''}"><b>${v}<span> ${u}</span></b>
  2059. <span>${lab}<br><span style="opacity:.8">${note||''}</span></span></div>`;
  2060. }
  2061. function rptBlock(k, d){
  2062. const E=(d.control&&d.control.energy)||{}, em=E.monthly||[], R=(d.rel&&!d.rel.err)?d.rel:null,
  2063. M=(d.m8&&!d.m8.err)?d.m8.mtbf:null, F=d.faults||{}, FU=d.fus||{}, BD=(FU.链盘)||{};
  2064. const wrap=(t,b,src)=>`<div class="card mb"><h3>${t}</h3>${b}
  2065. ${src?`<p class="small" style="color:var(--muted);margin-top:8px;padding-top:7px;border-top:1px solid var(--line-2)">口径: ${src}</p>`:''}</div>`;
  2066. if(k==='kpi'){
  2067. const last=em[em.length-1]||{}, prev=em[em.length-2]||{};
  2068. const cmpOK=(last.cov||0)>=0.5 && (prev.cov||0)>=0.5;
  2069. const dl=(a,b)=>b?((a-b)/b*100):0;
  2070. return `<div class="kpis" style="margin-bottom:14px">
  2071. ${rptK((E.act||0).toLocaleString(),'MWh','上网电量',`理论可发 ${(E.theo||0).toLocaleString()} | ${cmpOK?`环比 ${dl(last.act,prev.act)>=0?'+':''}${dl(last.act,prev.act).toFixed(0)}%`:'环比不可比(末月不满)'}`)}
  2072. ${rptK((E.eflh||0).toLocaleString(),'h','等效满发小时',`年化约 ${Math.round((E.eflh||0)/Math.max(em.filter(x=>x.cov>=0.5).length,1)*12).toLocaleString()} h`)}
  2073. ${rptK(d.control&&d.control.avail,'%','时间可用率','不含调度令与低风待机',(d.control&&d.control.avail>=95)?'ok':'warn')}
  2074. ${rptK(E.loss_pct,'%','损失率',`${(E.loss||0).toLocaleString()} MWh`,(E.loss_pct>=15)?'warn':'')}
  2075. ${rptK(M?M.MTBF_h:'—','h','平均停机间隔',`停机 ${M?M.停机事件数:'—'} 起 · 单次均 ${M?M.MDT_h:'—'}h`)}
  2076. ${rptK((BD.rows||[]).length,'台','需行动机组',`定论 ${(FU.kpi||{}).定论||0} · 预警 ${(FU.kpi||{}).预警||0} · 候选 ${(FU.kpi||{}).候选观察||0}`,(BD.rows||[]).length>20?'warn':'')}</div>`;
  2077. }
  2078. if(k==='water') return wrap('发电完成情况', waterfall(E)+
  2079. `<p class="small" style="color:var(--muted);margin-top:6px">理论可发 ${(E.theo||0).toLocaleString()} = 上网 ${(E.act||0).toLocaleString()} + 损失 ${(E.loss||0).toLocaleString()} MWh (${E.loss_pct}%)
  2080. | <span title="不是物理判据: 正常数据下必然通过。它的作用是当损失被高估到不可能时(台账数字虚高曾把损失写成超过全停上限)立刻拦下">量级自检</span>: 理论 ${E.cap_ok?'低于':'≥'} 全额定上限 ${(E.cap||0).toLocaleString()} MWh ${E.cap_ok?'✔':'✗ 数字不可信'}</p>`,
  2081. 'SCADA 10min 工况态分解; 损失=同工况理论出力与实测之差。量级自检为防虚高的下限闸, 不是风资源判据');
  2082. if(k==='emon') return wrap('逐月发电', em.length?`<table><tr><th>月</th><th class="num">上网 MWh</th><th class="num">损失 MWh</th><th class="num">损失率</th><th class="num">等效满发 h</th><th>覆盖</th></tr>
  2083. ${em.map(x=>`<tr><td>${x.m}</td><td class="num">${x.act.toLocaleString()}</td><td class="num">${x.loss.toLocaleString()}</td><td class="num">${x.loss_pct}%</td><td class="num">${x.eflh}</td>
  2084. <td class="small" style="color:${x.cov<0.5?'var(--warn)':'var(--muted)'}">${(x.cov*100).toFixed(0)}%${x.cov<0.5?' 不满月':''}</td></tr>`).join('')}</table>`:'—',
  2085. '覆盖率<50% 的月不参与环比');
  2086. if(k==='blame') return wrap('损失责任分账', blameBar(E)+
  2087. `<p class="small" style="color:var(--muted);margin-top:8px">进设备考核的只有"故障与检修停机"; 限电属交易与调度侧。
  2088. <br><b>限电不是"低风时段收到的空令"</b>: 限电·绑定时段平均出力 1.435 MW, 正常发电时段 1.412 MW, 比值 1.02 —
  2089. 风况相同而出力被压, 说明限电是真压制; 但"限电命令期·功率未被压制"那一档平均出力仅 0.724 MW(18% 额定), 确属低风时段收令。</p>`,
  2090. '工况态分解 + 限电命令面绑定判别; 平均出力=实发÷台时, 用作风资源代理量');
  2091. if(k==='ram') return R?wrap('可靠性 × 可维修性', ramChart(R.表)+
  2092. `<p class="small" style="color:var(--muted);margin-top:6px">设备类 ${R.设备类停机h}h / 外部类 ${R.外部类停机h}h (电网·风况·运维操作不计设备可靠性)</p>`,
  2093. 'SCADA ≥1h 停机段, 不含调度令; 海上 MDT 含交通窗口'):'';
  2094. if(k==='parts') return R?wrap('部件停机', `<table><tr><th>部件</th><th class="num">停机h</th><th class="num">事件</th><th class="num">MTBF h</th><th class="num">MDT h</th><th class="num">影响台</th></tr>
  2095. ${R.表.slice(0,9).map(x=>`<tr><td class="small">${x.部件}${x.外部?' (外部)':''}</td><td class="num">${x.停机时长h}</td><td class="num">${x.事件数}</td>
  2096. <td class="num">${x.MTBF_h||'—'}</td><td class="num">${x.MDT_h}</td><td class="num">${x.影响台数}</td></tr>`).join('')}</table>`,
  2097. '归因=段起点±30min 首发码'):'';
  2098. if(k==='attrib') return R?wrap('归因覆盖', attribBar(R.表,R.总停机h)+
  2099. `<p class="small" style="color:var(--muted);margin-top:6px">"无记录停机"不是没有故障, 是没归上因。</p>`,''):'';
  2100. if(k==='pareto') return wrap('故障帕累托', paretoChart(F.pareto_n||[],' 条',''), '报警事件表');
  2101. if(k==='quad') return wrap('故障模式四象限', quadChart(F.quad||[])+
  2102. `<p class="small" style="color:var(--muted);margin-top:6px">右下角报警码属重复触发, 不应按条数排定优先级。</p>`,'纵界=10min(一个SCADA采样格)');
  2103. if(k==='frate') return wrap('月度故障率', rateChart(F.monthly||{months:[],vals:[]})+
  2104. `<p class="small" style="color:var(--muted);margin-top:6px">柱高=日均条数, 不是月总数。</p>`,'不满月按天归一');
  2105. if(k==='units'){
  2106. const rows=(BD.rows||[]).slice(0,14);
  2107. return wrap('重点机组与处置', `<p class="small" style="color:var(--muted);margin-bottom:8px">需跟踪 ${(BD.rows||[]).length} 台 / 38 | 瓶颈 ${Object.entries(BD.stuck||{}).map(([a,b])=>`<b>${b} 台</b>卡在「${a}」`).join(' · ')}</p>
  2108. <table><tr><th style="width:52px">机组</th><th style="width:64px">状态</th><th>部件与证据</th><th style="width:56px">进度</th><th style="width:62px">卡在</th></tr>
  2109. ${rows.map(r=>`<tr><td><b>${r.t.replace('WTG','')}#</b></td><td>${chip(r.ostate||'—')}</td>
  2110. <td class="small" style="color:var(--muted)">${r.part&&r.part.indexOf('未收录')<0?`<b style="color:var(--ink)">${r.part}</b> · `:''}${(r.srcs||[]).join('/')||'—'}${r.rising?` · <span style="color:var(--bad)">在升 ${r.rising.ratio}×</span>`:''}</td>
  2111. <td class="num small">${r.done}/6</td><td class="small" style="color:var(--warn)">${r.stuck||'全通'}</td></tr>`).join('')}</table>`,
  2112. '四源融合判级');
  2113. }
  2114. if(k==='chain') return wrap('决策链进度', `<p class="small" style="color:var(--muted)">六步: 证据 → 机制 → 判级 → 排期 → 动作 → 验收。
  2115. 当前 ${Object.entries(BD.stuck||{}).map(([a,b])=>`${b} 台卡在「${a}」`).join(', ')}。</p>`,
  2116. '「动作」一列不可判 — ' + (BD.wo_bound||''));
  2117. if(k==='loop'){
  2118. const cl=BD.closed||[];
  2119. return wrap('端到端闭环实证', `<table><tr><th>机组</th><th>部件</th><th>换件日期</th><th class="num">换前</th><th class="num">换后</th><th class="num">比值</th></tr>
  2120. ${cl.map(x=>`<tr><td><b>${x.turbine.replace('WTG','')}#</b></td><td class="small">${x.component}</td><td class="small">${x.replace_date}</td>
  2121. <td class="num">${x.pre}</td><td class="num">${x.post}</td><td class="num" style="color:var(--ok)"><b>${x.ratio}×</b></td></tr>`).join('')}</table>
  2122. <p class="small" style="color:var(--muted);margin-top:6px">这几台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复。</p>`,
  2123. '振动标量前后对比');
  2124. }
  2125. if(k==='plan') return wrap('下期计划与建议', `<ol style="margin:0 0 0 20px;line-height:1.85">
  2126. <li><b>补机制定性</b> — ${(BD.stuck||{}).机制||0} 台停在"判了级但根因未定", 是当前最大单一瓶颈</li>
  2127. <li><b>回收 25~26 工单</b> — 台账止 2024-11, 本期检修执行与闭环无法自动判定</li>
  2128. <li><b>油液采样节奏</b> — 四源里油液长期 stale, 只能作回测锚</li>
  2129. <li><b>限电侧</b> — 损失六成在交易与调度侧, 设备侧改善空间有限</li></ol>`,
  2130. '建议级 — 排期与动作须经审批');
  2131. if(k==='narr'){
  2132. const N=window.RPTNARR;
  2133. if(!N||!N.text) return wrap('运行总结','<p class="small" style="color:var(--muted)">还没有生成 — 在上面的交互窗输入报告要求。</p>','');
  2134. return wrap('运行总结', `<p style="line-height:1.9;margin:0">${N.text.replace(/</g,'&lt;').split(String.fromCharCode(10)).join('<br>')}</p>
  2135. ${(N.ungrounded||[]).length?`<p class="small" style="color:var(--warn);margin-top:8px">
  2136. ⚠ 以下数值由模型自行推算, 不在原始数据摘要中, 引用前须核对: ${N.ungrounded.join(' / ')}</p>`:''}`,
  2137. `本地模型 ${N.model} 生成 · 数字取自系统实测摘要 · 接地闸${(N.ungrounded||[]).length?'报 '+N.ungrounded.length+' 处待核':'全通过'}`);
  2138. }
  2139. if(k==='caliber'){
  2140. const part=(F.partial||[]);
  2141. return wrap('口径与数据边界', `<ul style="margin:0 0 0 20px;line-height:1.85">
  2142. <li><b>统计期</b> ${d.win}${part.length?` — ${part.join('/')} 数据不满月(覆盖 ${Math.round((F.cov_min||0)*100)}%), 月度已按天归一`:''}</li>
  2143. <li><b>可用率与损失率分母不同</b> 可用率分母剔除低风待机与调度令(问"该发时能不能发"),
  2144. 损失率分母含全部时段(问"理论能发多少"); 同一低风时段在可用率里不计时间、在损失率里计入基数 —
  2145. 这是两个指标的设计差异不是错, 但两者不可直接相乘或互推。低风待机对损失的贡献仅 118 MWh(0.5%)</li>
  2146. <li><b>停机</b> 可靠性口径 ≥1h 段, 电量账为逐条工况态 — 两个数都对, 不可相减</li>
  2147. <li><b>工单</b> 台账 2020~2024-11, 本报告不对本期检修执行下结论</li>
  2148. <li><b>判级</b> 由振动/温度/润滑/油液四源融合得出; 行动等级由综合报告出具</li>
  2149. ${RPTAUD==='up'?'<li><b>脱敏</b> 上报版隐去判据门槛与特征频率</li>':''}</ul>`,'');
  2150. }
  2151. return '';
  2152. }
  2153. function renderReport(d){
  2154. rptInit();
  2155. const FU=d.fus||{};
  2156. let h=`<div class="eyebrow">汇报定制 · 画布</div>`;
  2157. // ── 画布控制条: 预设 / 组件库 / 受众 / 打印 ──
  2158. h+=`<div class="card mb noprint">
  2159. <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
  2160. <span class="small" style="font-weight:600">预设</span>
  2161. ${Object.entries(RPT_PRESETS).map(([k,v])=>`<button onclick="rptPreset('${k}')" style="cursor:pointer;padding:5px 13px;border-radius:15px;
  2162. border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">${v.nm}</button>`).join('')}
  2163. <span style="flex:1"></span>
  2164. <select onchange="window.RPTAUD=this.value;render()" style="padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:12.5px">
  2165. <option value="in"${RPTAUD==='in'?' selected':''}>场站内部 (全量)</option>
  2166. <option value="up"${RPTAUD==='up'?' selected':''}>上报集团 (脱敏)</option></select>
  2167. <button onclick="window.print()" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--accent);background:var(--panel);color:var(--accent);font-weight:600;font-size:12.5px">打印 / 存 PDF</button>
  2168. <button onclick="rptExport('docx')" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">导出 Word</button>
  2169. <button onclick="rptExport('xlsx')" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">导出 Excel</button>
  2170. ${RPTAUD==='up'?'<span class="small" style="color:var(--warn);flex-basis:100%">上报版 — 判据门槛与特征频率已脱敏</span>':''}
  2171. </div>
  2172. <div class="small" style="font-weight:600;margin-bottom:7px">组件库
  2173. <span style="font-weight:400;color:var(--muted)">点击加入报告, 再次点击移出 · 画布里已有 <b>${RPTCANVAS.length}</b> 块</span></div>
  2174. <div style="display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center">
  2175. ${RPT_GROUPS.map(([gn,gk])=>`
  2176. <div class="small" style="color:var(--muted);text-align:right;white-space:nowrap">${gn}</div>
  2177. <div style="display:flex;gap:6px;flex-wrap:wrap">
  2178. ${Object.entries(RPT_BLOCKS).filter(([,v])=>v.g===gk).map(([k,v])=>{const on=RPTCANVAS.includes(k);
  2179. return `<button onmouseenter="rptHint('${k}')" onmouseleave="rptHint()" onfocus="rptHint('${k}')"
  2180. onclick="rpt${on?'Del':'Add'}('${k}')" style="cursor:pointer;padding:5px 11px;border-radius:7px;
  2181. border:1px solid ${on?'var(--accent)':'var(--line)'};background:${on?'var(--accent)':'var(--panel)'};
  2182. color:${on?'#fff':'var(--ink)'};font-size:12.5px;opacity:${on?1:0.78}">${v.ic} ${v.nm}${on?' ✓':''}</button>`}).join('')}
  2183. </div>`).join('')}
  2184. </div>
  2185. <div id="rpthint" class="small" style="color:var(--muted);margin-top:9px;padding-top:8px;border-top:1px solid var(--line-2);min-height:19px">
  2186. 悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序</div>
  2187. <div style="margin-top:11px;padding-top:10px;border-top:1px solid var(--line-2)">
  2188. <div class="small" style="font-weight:600;margin-bottom:6px">输入报告要求
  2189. <span id="rpthint" style="font-weight:400;color:var(--muted)">模型依据需求选取组件, 并基于实测数据生成运行总结</span></div>
  2190. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
  2191. <input id="rptneed" placeholder="例如: 上报集团月报, 侧重设备可靠性与重点机组"
  2192. onkeydown="if(event.key==='Enter')rptCompose()"
  2193. style="flex:1 1 300px;min-width:220px;padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:13px">
  2194. <select id="rptmdl" style="padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:12.5px"></select>
  2195. <button id="rptgo" onclick="rptCompose()" style="cursor:pointer;padding:7px 18px;border-radius:7px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:600;font-size:13px">生成</button>
  2196. </div>
  2197. <div id="rptout" class="small" style="color:var(--muted);margin-top:7px"></div></div></div>`;
  2198. setTimeout(rptModelInit, 60);
  2199. if(!RPTCANVAS.length)
  2200. return h+`<div class="paper" style="text-align:center;padding:56px 40px">
  2201. <div class="small" style="color:var(--muted)">文档预览窗 — 从上面的组件库点选内容, 此处显示报告预览</div></div>`;
  2202. // 报表头卡撤 (2026-08-28 用户令): 场名与统计期页眉已有, 数据源列表与"结论可回溯"声明
  2203. // 属口径块的内容 — 摆在报告最上面是重复且抢戏。打印时的抬头由下面这行轻量署名承担。
  2204. // ── 文档预览窗 (2026-08-28 用户令: 生成的文档能预览才打印) ──
  2205. // 画布内容包进"一页纸": 有抬头、有边界、有页脚, 所见即将打印出来的样子。
  2206. const _asof=(FU.数据时点||'').slice(0,10);
  2207. h+=`<div class="paper"><div class="paper-hd">
  2208. <div><div class="paper-ti">如东海上风电场 · 运行分析报告</div>
  2209. <div class="small" style="color:var(--muted);margin-top:3px">统计期 ${d.win} | 38 × SWT-4.0-130${RPTAUD==='up'?' | 上报版':''}</div></div>
  2210. <div class="small" style="color:var(--muted);text-align:right">${RPTCANVAS.length} 个章节</div></div>`;
  2211. RPTCANVAS.forEach((k,i)=>{
  2212. const v=RPT_BLOCKS[k]; if(!v) return;
  2213. // 控制条移到块**上方**独立一行 — 原来绝对定位压在卡右上角, 挡住 KPI 数字与图例 (用户截图逮)
  2214. const bb=(t,fn,dis)=>`<button title="${t}" onclick="${fn}" ${dis?'disabled':''} style="cursor:pointer;padding:1px 8px;border-radius:5px;
  2215. border:1px solid var(--line);background:transparent;color:var(--muted);font-size:11px;opacity:${dis?0.3:0.7}">${t}</button>`;
  2216. h+=`<div class="noprint" style="display:flex;gap:3px;justify-content:flex-end;margin-bottom:-6px;position:relative;z-index:2">
  2217. ${bb('↑',`rptMove('${k}',-1)`,i===0)}${bb('↓',`rptMove('${k}',1)`,i===RPTCANVAS.length-1)}${bb('×',`rptDel('${k}')`,false)}
  2218. </div>${rptBlock(k,d)}`;
  2219. });
  2220. h+=`<div class="paper-ft">
  2221. <span>数据源 SCADA 10min · 报警事件 · 工单台账 · CMS 振动 · 油液化验${_asof?` | 出件 ${_asof}`:''}</span>
  2222. <span>结论可回溯至本体对象${RPTAUD==='up'?' | 判据门槛与特征频率已脱敏':''}</span></div></div>`;
  2223. return h;
  2224. }
  2225. // 决策链浮窗 (2026-08-28 用户令: 29号点开应该是决策链的具体信息的浮窗)。
  2226. // 盘上一行只给"卡在哪", 点开给这一台的六步全貌: 每步达没达、依据是什么、为什么卡住。
  2227. // 29# 另有本体链(证据清单/机制/四情景/验收树), 其余台给四源证据明细。
  2228. function popChain(t){
  2229. const BD=(D&&D.fus&&D.fus.链盘)||{}; const r=(BD.rows||[]).find(x=>x.t===t); if(!r) return;
  2230. const STC={done:'var(--ok)',open:'var(--warn)',unknown:'var(--muted)'};
  2231. const STT={done:'已达',open:'未达',unknown:'不可判'};
  2232. const num='①②③④⑤⑥';
  2233. // ★列宽按语言取。原来写死 44/52/96px, 是照**两个汉字**量的; 英文 "Verification" /
  2234. // "Not evaluable" 塞不下 ⇒ 标签被截成 "Mechanis"/"Scheduli"/"Verificati", chip 压到
  2235. // 下一列上 (用户截图实逮)。中文页取原值, 一个像素不动。
  2236. const EN=(typeof window!=='undefined'&&window.__EN), W=EN?[92,104,132]:[44,52,96];
  2237. const steps=r.steps.map((x,i)=>{
  2238. const cur=(x.k===r.stuck);
  2239. return `<div style="display:flex;gap:10px;padding:9px 11px;border-radius:8px;margin-bottom:5px;align-items:flex-start;
  2240. border:1px solid ${cur?STC[x.st]:'var(--line)'};background:${cur?'var(--bg)':'transparent'}">
  2241. <div style="flex:none;width:20px;color:var(--muted);font-size:13px">${num[i]}</div>
  2242. <div style="flex:none;width:${W[0]}px;font-weight:600;font-size:12.5px">${x.k}</div>
  2243. <div style="flex:none;width:${W[1]}px"><span class="badge" style="border-color:${STC[x.st]};color:${STC[x.st]};white-space:nowrap">${STT[x.st]}</span></div>
  2244. <div style="flex:none;width:${W[2]}px;font-size:12px">${x.v}</div>
  2245. <div class="small" style="color:var(--muted);flex:1;line-height:1.5">${x.d}${cur?` <b style="color:${STC[x.st]}">← 卡在这里</b>`:''}</div></div>`;}).join('');
  2246. // 与后端 _tbi 同口径: 多行台取最严重那行, 否则浮窗四源卡会指向另一个部件
  2247. const SEV={bad:0,warn:1,note:2,stale:3,ok:4,unlisted:5};
  2248. const F=(D&&D.fus)||{};
  2249. const frow=(F.表||[]).filter(x=>x.turbine===t).sort((a,b)=>(SEV[a.级]??9)-(SEV[b.级]??9))[0];
  2250. let mini='';
  2251. if(frow){
  2252. mini=`<div class="pop-mini">
  2253. <div><b>振动结论</b><span class="badge" style="border-color:${LVC2(frow.级)};color:${LVC2(frow.级)}">${(frow.振动结论||'').slice(0,26)}</span>
  2254. <div class="small" style="color:var(--muted);margin-top:4px">${frow.部件||''}</div></div>
  2255. <div><b>证据窗</b>${winBar(frow.窗区间,frow.claim_window)}</div>
  2256. <div><b>温度轴 (fleet相对)</b>${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}</div>
  2257. <div><b>油液时效</b>${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}</div></div>`;
  2258. }
  2259. let ext='';
  2260. const C=(typeof ONT!=='undefined'&&ONT)?ONT.chain29:null;
  2261. if(t==='WTG29'&&C){
  2262. const ev=(C.evidence||[]).map(e=>`<div class="small" style="color:var(--muted);padding:4px 0;border-bottom:1px solid var(--line-2)">
  2263. <b style="color:var(--ink)">${e.facet||e.id||''}</b>
  2264. <span style="color:var(--muted)"> 窗 ${e.claim_window||'—'}</span>
  2265. <div style="line-height:1.55;margin-top:2px">${(e.summary||'').toString().slice(0,150)}</div></div>`).join('');
  2266. const sc=(C.scenario&&C.scenario.scenarios)||[];
  2267. ext=`<div style="margin-top:12px">
  2268. <div class="small" style="font-weight:600;margin-bottom:5px">① 证据清单 · ${(C.evidence||[]).length} 条 / ${C.mechanism.independent_source_count} 个观测面</div>
  2269. ${ev}
  2270. <div class="small" style="color:var(--muted);margin-top:5px">四个观测面是<b>同一条机制链的四个面</b>, 不算四个独立源 — 加权时不可当多源印证。</div>
  2271. <div class="small" style="font-weight:600;margin:12px 0 5px">② 机制 · ${C.mechanism.name||''}</div>
  2272. <div class="small" style="color:var(--muted)">${C.mechanism.note||''}</div>
  2273. <div class="small" style="font-weight:600;margin:12px 0 5px">④ 排期沙盘 · ${sc.length} 个情景</div>
  2274. <table style="margin-top:2px"><tr><th>情景</th><th>窗</th><th>作业级</th><th>停机损失</th></tr>
  2275. ${sc.map(x=>`<tr><td class="small"><b>${x.id}</b></td><td class="small">${x['修理窗']||'—'}</td>
  2276. <td class="small" style="color:var(--muted)">${(x['作业级']||'').slice(0,20)}</td>
  2277. <td class="small" style="color:var(--muted)">${((x['停机损失']||{})['电费口径']||'—').slice(0,22)}</td></tr>`).join('')}</table>
  2278. <div class="small" style="font-weight:600;margin:12px 0 5px">⑥ 验收 · ${C.acceptance.state||'待执行'}</div>
  2279. <div class="small" style="color:var(--muted);line-height:1.55">${(C.acceptance.note||'').slice(0,200)}</div></div>`;
  2280. }
  2281. document.body.insertAdjacentHTML('beforeend',
  2282. `<div class="pop-mask" id="popm" onclick="if(event.target.id==='popm')closePop()"><div class="pop-box">
  2283. <div class="pop-hd"><b>${t.replace('WTG','')}# 决策链</b>
  2284. ${chip(r.ostate||'—')}<span class="small" style="color:var(--muted)">走到 ${r.done}/6 · 卡在「${r.stuck||'全通'}」</span>
  2285. <span class="pop-x" onclick="closePop()">×</span></div>
  2286. <div class="pop-bd">
  2287. <p class="small" style="color:var(--muted);margin-bottom:10px">从发现异常到修完验收的六步。
  2288. <b>下一步:</b> ${r.next||'—'}</p>
  2289. ${steps}${mini}${ext}
  2290. <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
  2291. <a class="badge" href="/turbine/${t}">该台完整问题页 →</a></div>
  2292. </div></div></div>`);
  2293. }
  2294. async function popPart(t,part){
  2295. const sys=PART2SYS(part), win=(D&&D.win)||'2026年';
  2296. document.body.insertAdjacentHTML('beforeend',
  2297. `<div class="pop-mask" id="popm" onclick="if(event.target.id==='popm')closePop()"><div class="pop-box">
  2298. <div class="pop-hd"><b>${t} · ${part}</b><span class="small" style="color:var(--muted)">载入中…</span>
  2299. <span class="pop-x" onclick="closePop()">×</span></div><div class="pop-bd" id="popbd"></div></div></div>`);
  2300. let figs=[], p0=null;
  2301. try{ const r=await fetch(`/api/problem?t=${t}&sys=${encodeURIComponent(sys)}&win=${encodeURIComponent(win)}`);
  2302. const d=await r.json(); p0=(d.problems||[])[0]; figs=(p0&&p0.figs)||[]; }catch(e){}
  2303. const F=(D&&D.fus)||{}, frow=(F.表||[]).filter(x=>x.turbine===t&&PART2SYS(x.部件)===sys)[0];
  2304. const curves=figs.filter(f=>f.kind!=='text'&&f.kind!=='vib');
  2305. const mini=[];
  2306. if(frow){
  2307. mini.push(`<div><b>振动结论</b><span class="badge" style="border-color:${LVC2(frow.级)};color:${LVC2(frow.级)}">${frow.振动结论}</span>
  2308. <div class="small" style="color:var(--muted);margin-top:4px">${frow.部件}</div></div>`);
  2309. mini.push(`<div><b>证据窗</b>${winBar(frow.窗区间,frow.claim_window)}</div>`);
  2310. mini.push(`<div><b>温度轴 (fleet相对)</b>${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}</div>`);
  2311. mini.push(`<div><b>油液时效</b>${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}</div>`);
  2312. }
  2313. if(p0) mini.unshift(`<div><b>系统判级</b>${chip(p0['状态']||'—')}<div class="small" style="color:var(--muted);margin-top:4px">${(p0['依据']||'').slice(0,70)}</div></div>`);
  2314. const hd=document.querySelector('#popm .pop-hd span.small');
  2315. if(hd) hd.outerHTML=`<span class="small" style="color:var(--muted)">${sys} · 窗 ${win}</span>`;
  2316. document.getElementById('popbd').innerHTML=
  2317. `<div style="margin-bottom:10px">${dtChainStates({[part.replace(/[((].*/,'')]:LVC2(p0&&p0['状态']==='报警'?'bad':'warn')},260,null)}</div>
  2318. <div class="pop-mini">${mini.join('')}</div>`
  2319. +(curves.length?curves.slice(0,2).map(f=>`<div style="margin-bottom:10px">${drawFig(f)}</div>`).join('')
  2320. :`<p class="small" style="color:var(--muted)">该部件在本窗无 SCADA 曲线 (振动谱级证据走 windcms)</p>`)
  2321. +`<p class="small" style="margin-top:8px"><a class="tl" href="/problem/${t}/${encodeURIComponent(sys)}">完整问题页 (全部曲线与工单史) →</a>
  2322. &nbsp;·&nbsp;<a class="tl" href="/turbine/${t}">本台总览 →</a></p>`;
  2323. }
  2324. // 三个场景各自独立提交与展示 (2026-08-28 用户令"都单独放在一个输入窗")。
  2325. // 原来共用一个输入框 + 一个结果区, 切场景就把上一个结果冲掉 —
  2326. // 而实际用法是"先查报警码怎么修, 顺手看这个系统的预防任务和故障树", 三者要并存对照。
  2327. if(!window.ASSTCACHE3) window.ASSTCACHE3={};
  2328. async function asstGo(mode){
  2329. const m=mode||ASSTMODE;
  2330. const out=document.getElementById('asstOut_'+m); if(!out) return;
  2331. const el=document.getElementById('asstIn_'+m); const v=el?el.value.trim():'';
  2332. out.innerHTML=`<div class="card"><p class="small">查询中…</p></div>`;
  2333. const qs={fault:`kind=fault&code=${encodeURIComponent(v)}`,
  2334. prev:`kind=prev&system=${encodeURIComponent(v||'齿轮箱')}`,
  2335. tree:`kind=tree&system=${encodeURIComponent(v||'齿轮箱')}`}[m];
  2336. let d;
  2337. try{ d=await (await fetch('/api/ont_chain?'+qs)).json(); }
  2338. catch(e){ out.innerHTML=`<div class="card"><p class="small" style="color:var(--bad)">查询失败: ${e.message}</p></div>`; return }
  2339. out.innerHTML = (m==='fault' && d.data) ? faultCards(d.data)
  2340. : (m==='tree' && d.data) ? treeCards(d.data)
  2341. : `<div class="card"><pre class="small" style="white-space:pre-wrap;margin:0;line-height:1.75">${(d.text||'').replace(/</g,'&lt;')}</pre></div>`;
  2342. window.ASSTCACHE3[m]=out.innerHTML;
  2343. }
  2344. function treeCards(x){
  2345. // 故障树两层: 失效模式(果) ← causedBy ← 部件级原因(因); 条长=实证量, 步骤=手册原文
  2346. const nodes = x.nodes || [];
  2347. if(!nodes.length) return `<div class="card"><p class="small">该系统无失效模式记录</p></div>`;
  2348. const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10));
  2349. let h = `<div class="card mb"><h3>${x.system} 故障树 · ${nodes.length} 个失效模式</h3>
  2350. <p class="small" style="color:var(--muted)">一层=系统×失效类(归纳自本场码表与工单) · 二层=手册排查方向归一+工单原因计数;
  2351. 条长按实证量(报警条数 + 工单×10), 因果来自手册与工单原文非推断, 树深两层不外推。</p></div>`;
  2352. nodes.forEach(n => {
  2353. const w = Math.max(3, (n.n_alarm + n.n_wo * 10) / maxE * 100);
  2354. const col = n.n_wo >= 5 ? 'var(--bad)' : n.n_alarm >= 100 ? 'var(--warn)' : 'var(--info)';
  2355. h += `<div class="card mb"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
  2356. <div style="flex:1;min-width:200px"><b>${n.name}</b>
  2357. <div style="height:7px;border-radius:3px;background:${col};width:${w.toFixed(0)}%;margin-top:5px;max-width:100%"></div>
  2358. <div class="small" style="color:var(--muted);margin-top:4px">本场报警 ${n.n_alarm} 条 · 工单 ${n.n_wo} 条${n.codes.length?` · 征兆码 ${n.codes.join(', ')}`:''}</div>
  2359. </div></div>`;
  2360. if(n.causes && n.causes.length){
  2361. h += `<div style="margin-top:10px;padding-left:14px;border-left:2px dashed var(--line)">
  2362. <div class="small" style="color:var(--muted);margin-bottom:6px">← 可能原因 (手册排查方向 + 工单实证)</div>
  2363. <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">`
  2364. + n.causes.map(c => `<div style="border:1px solid var(--line);border-radius:8px;padding:10px 12px">
  2365. <div><b class="small">${c.name}</b>
  2366. <span class="small" style="color:var(--muted)"> 手册 ${c.n_ref} 次 · 工单 ${c.n_wo} 次 · 涉码 ${c.n_codes}</span></div>
  2367. ${(c.steps||[]).map(st => `<div class="small" style="color:var(--muted);margin-top:5px;line-height:1.6">· ${st}</div>`).join('')}
  2368. </div>`).join('') + `</div></div>`;
  2369. } else {
  2370. h += `<p class="small" style="color:var(--muted);margin-top:8px">(手册无该类排查条目 — 原因层待补)</p>`;
  2371. }
  2372. h += `</div>`;
  2373. });
  2374. return h;
  2375. }
  2376. function maintCards(md){
  2377. // 数据维护面: 两层分开 (混在一起会让"机理被数据覆盖" — 更新频率与责任方完全不同)
  2378. const rows = md.rows || [];
  2379. if(md.error) return `<div class="card"><p class="small" style="color:var(--bad)">${md.error}</p></div>`;
  2380. const LAYER = [['数据','随运营滚动 — 新数据到位后跑摄入, 本体与判级自动刷新','var(--accent)'],
  2381. ['机理','相对稳定 — 换厂商资料或现场反馈修正时才动; 它是判据的地基, 变更须过审','var(--warn)']];
  2382. let h = `<div class="card mb"><h3>数据维护面 · ${rows.length} 个源</h3>
  2383. <p class="small" style="color:var(--muted)">每个源给出: 当前覆盖 / 末次更新 / 约定位置 / 摄入命令 / 更新频率 / 责任方。
  2384. 要补数据就把新文件放到"约定位置"再跑"摄入命令"。</p></div>`;
  2385. LAYER.forEach(([lv, note, col]) => {
  2386. const rs = rows.filter(r => r.层 === lv);
  2387. if(!rs.length) return;
  2388. h += `<div class="card mb" style="border-left:3px solid ${col}"><h3>${lv}${LAYER_SFX} (${rs.length})</h3>
  2389. <p class="small" style="color:var(--muted)">${note}</p>
  2390. <table style="margin-top:10px"><tr><th style="width:20%">源</th><th style="width:22%">当前覆盖</th>
  2391. <th style="width:14%">末次更新</th><th style="width:10%">频率</th><th style="width:10%">责任</th><th>补数据怎么做</th></tr>
  2392. ${rs.map(r => `<tr>
  2393. <td class="small"><b>${r.存在 ? '' : '✗ '}${r.名}</b></td>
  2394. <td class="small" style="font-variant-numeric:tabular-nums">${r.覆盖}${r.条数 ? ` <span style="color:var(--muted)">(${r.条数.toLocaleString()} 条)</span>` : ''}</td>
  2395. <td class="small" style="color:var(--muted)">${r.末次更新 || '—'}</td>
  2396. <td class="small">${r.频率}</td><td class="small">${r.责任}</td>
  2397. <td class="small"><div style="color:var(--muted)">放到: <code>${r.位置}</code></div>
  2398. <div style="margin-top:3px">跑: <code>${r.摄入}</code></div>
  2399. <div style="margin-top:3px;color:var(--muted)">${r.说明}</div></td></tr>`).join('')}</table></div>`;
  2400. });
  2401. return h;
  2402. }
  2403. function faultCards(x){
  2404. // 抢修链结构化渲染: 码身份 → 失效模式 → 分步处置(卡片) → 历史工单 → 技术依据
  2405. const sev = x.n_site>=1000?'var(--bad)':x.n_site>=100?'var(--warn)':'var(--muted)';
  2406. let h=`<div class="card mb"><div style="display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap">
  2407. ${x.system?partGlyph(x.system,'var(--accent)'):''}
  2408. <div style="flex:1;min-width:220px"><h3 style="margin:0">${x.code} · ${x.name}</h3>
  2409. <p class="small" style="color:var(--muted);margin-top:5px">
  2410. 系统 ${x.system||'未归属'} · 本场 <b style="color:${sev}">${x.n_site}</b> 条${x.alarm_type?` · 告警类型 ${x.alarm_type}`:''}</p></div></div>`;
  2411. if(x.failmodes&&x.failmodes.length)
  2412. h+=`<div style="margin-top:10px"><b class="small" style="color:var(--muted)">指向的失效模式</b>
  2413. ${x.failmodes.map(f=>`<div class="small" style="margin-top:5px">· ${f.name} <span style="color:var(--muted)">(该类本场报警 ${f.n_alarm} 条 / 工单 ${f.n_wo} 条)</span></div>`).join('')}</div>`;
  2414. h+=`</div>`;
  2415. if(x.steps&&x.steps.length){
  2416. h+=`<div class="card mb"><h3>现场处置 ${x.steps.length} 步 <span class="small" style="font-weight:400;color:var(--muted)">(${x.wi_source||''})</span></h3>
  2417. <p class="small" style="color:var(--muted)">节点部件=手册原文元件位号(确定); 判定规范=实机参数表(2024-10-21 导出)按物理量匹配的<b>候选判据</b>,
  2418. 非厂商绑定 — 现场以铭牌与图纸复核; 参数 ID 与告警码是不同命名空间, 未按同号关联。</p>
  2419. <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;margin-top:10px">`
  2420. +x.steps.map(st=>`<div style="border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:11px 13px">
  2421. <div class="small" style="color:var(--accent);font-weight:600">第 ${st.序} 步${st.类别?` · ${st.类别}`:''}${st.qty?` · 量: ${st.qty}`:''}</div>
  2422. <div style="margin-top:5px;line-height:1.65">${st.步骤}</div>
  2423. ${(st.tags&&st.tags.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">节点部件</b>
  2424. ${st.tags.map(t=>`<code style="background:var(--panel-2);border:1px solid var(--line);border-radius:4px;padding:1px 6px;margin-right:5px">${t}</code>`).join('')}</div>`:''}
  2425. ${(st.nodes&&st.nodes.length)?`<div class="small" style="margin-top:7px">
  2426. <b style="color:var(--muted)">应检节点 · ${st.circuit||''} (手册此步未给位号 → 按回路构成展开, 逐点查)</b>
  2427. <table style="margin-top:3px"><tr><th style="width:38%">节点</th><th>查什么</th></tr>
  2428. ${st.nodes.map(n=>`<tr><td class="small">${n.name}</td><td class="small">${n.check}</td></tr>`).join('')}</table>
  2429. ${(st.known_tags&&st.known_tags.length)?`<div style="color:var(--muted);margin-top:4px">该系统手册出现过的位号 (对图纸时参考):
  2430. ${st.known_tags.map(k=>`<code style="margin-right:5px">${k.tag}</code>`).join('')}</div>`:''}
  2431. </div>`:''}
  2432. ${(st.specs&&st.specs.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">判定规范 (实机参数表)</b>
  2433. <table style="margin-top:3px"><tr><th style="width:64px">参数号</th><th>名称</th><th style="width:74px" class="num">整定值</th><th style="width:96px" class="num">允许范围</th></tr>
  2434. ${st.specs.map(sp=>`<tr><td class="small">${sp.no}</td><td class="small">${sp.text}</td>
  2435. <td class="num small">${sp.val!=null?sp.val+(sp.unit||''):'—'}</td>
  2436. <td class="num small">${sp.lo!=null?`${sp.lo} ~ ${sp.hi}`:'—'}</td></tr>`).join('')}</table></div>`:''}
  2437. ${(st.drawings&&st.drawings.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">对应图纸</b>
  2438. ${st.drawings.map(dw=>`<div style="color:var(--muted);margin-top:2px">· ${dw.name}</div>`).join('')}</div>`:''}
  2439. </div>`).join('')
  2440. +`</div></div>`;
  2441. } else {
  2442. h+=`<div class="card mb"><p class="small">本码无手册处置条目 (手册覆盖本场 161/322 码) — 需查图纸或联系厂商支持。</p></div>`;
  2443. }
  2444. if(x.workorders&&x.workorders.length)
  2445. h+=`<div class="card mb"><h3>现场实际怎么修的 (${x.system} 历史工单 ${x.wo_total} 条, 台账 2020~2024)</h3>
  2446. <table style="margin-top:8px"><tr><th style="width:92px">日期</th><th style="width:64px">机组</th><th>故障</th><th style="width:110px">处置</th></tr>
  2447. ${x.workorders.map(w=>`<tr><td class="small">${w.date}</td><td class="small">${w.turbine.replace('WTG','')}#</td>
  2448. <td class="small">${w.name}</td><td class="small">${w.action} ${w.part}</td></tr>`).join('')}</table>
  2449. <p class="small" style="color:var(--muted);margin-top:6px">工单窗止 2024-11-21; 2025-26 现场未提供 ⇒ "0 条"是记录缺失非未修过。</p></div>`;
  2450. // 技术依据卡撤 (2026-08-28 用户令): 按文件名关键词匹配出来的文档与本次故障常不相关
  2451. // (液压压力偏高 → 推出"单叶片吊具培训资料"/"轮毂起吊图纸"), 列出来是噪音不是依据。
  2452. // ont_doc_search API 保留, 需要时按码精确查, 不再在抢修链里自动铺开。
  2453. return h;
  2454. }
  2455. async function ontChain(kind){
  2456. const out=document.getElementById('ocOut'); if(!out) return;
  2457. out.style.display='block'; out.textContent='查询中…';
  2458. const qs={fault:()=>`kind=fault&code=${encodeURIComponent((document.getElementById('ocCode')||{}).value||'')}`,
  2459. prev:()=>`kind=prev&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
  2460. tree:()=>`kind=tree&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
  2461. plan:()=>'kind=plan&top=12',
  2462. doc:()=>`kind=doc&keyword=${encodeURIComponent((document.getElementById('ocDoc')||{}).value||'')}`}[kind];
  2463. try{ const r=await fetch('/api/ont_chain?'+qs()); const d=await r.json();
  2464. out.textContent=d.text||JSON.stringify(d); }
  2465. catch(e){ out.textContent='查询失败: '+e.message }
  2466. }
  2467. function closePop(){const m=document.getElementById('popm'); if(m)m.remove()}
  2468. const LVC2=lv=>({bad:'var(--bad)',warn:'var(--warn)',ok:'var(--ok)',meta:'var(--muted)',note:'var(--info)'}[lv]||'var(--warn)');
  2469. document.addEventListener('keydown',e=>{if(e.key==='Escape')closePop()});
  2470. // 状态链条 (per 节点各自着色版; windcms 结果层用: 四部件等级一次性画在传动链上, 替代四列 chips)
  2471. const dtChainStates=(cmap,W,T2)=>{
  2472. const w=W||208, Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
  2473. const zone=(x,w2,dash)=>`<rect x="${x}" y="5" width="${w2}" height="30" rx="6" fill="var(--muted)" fill-opacity="0.13"
  2474. stroke="${LN}" stroke-opacity="0.5" ${dash?'stroke-dasharray="3 2"':''}/>`;
  2475. const node=(x,key,kind)=>{
  2476. const col=cmap[key]||null, k=!!col, r=k?6.6:5;
  2477. const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
  2478. return `<line x1="${(x+Math.cos(t)*r).toFixed(1)}" y1="${(Y+Math.sin(t)*r).toFixed(1)}" x2="${(x+Math.cos(t)*(r+2.6)).toFixed(1)}" y2="${(Y+Math.sin(t)*(r+2.6)).toFixed(1)}" stroke="${col||MU}" stroke-width="${k?1.7:1.2}"/>`}).join(''):'';
  2479. const core=`<circle cx="${x}" cy="${Y}" r="${r}" fill="${col||PN}" fill-opacity="${k?0.9:1}" stroke="${col||MU}" stroke-opacity="${k?1:0.8}" stroke-width="${k?2:1.3}"/>`
  2480. +(kind==='b'?`<circle cx="${x}" cy="${Y}" r="${(r*0.4).toFixed(1)}" fill="${k?'#fff':MU}" fill-opacity="${k?0.85:0.7}"/>`:teeth);
  2481. return (k&&T2)?`<g class="dt-hot" onclick="event.stopPropagation();popPart('${T2}','${key}')"><title>${key} — 点看曲线与四源微视图</title>
  2482. <circle cx="${x}" cy="${Y}" r="${r+7}" fill="transparent"/>${core}</g>`:core};
  2483. return `<svg class="dt" viewBox="0 0 320 40" style="display:block;width:${w}px;max-width:100%;height:auto">
  2484. ${zone(28,74)}${zone(112,90,1)}${zone(212,96)}
  2485. <line x1="20" y1="${Y}" x2="304" y2="${Y}" stroke="${LN}" stroke-width="3" stroke-linecap="round"/>
  2486. <path d="M20,13 L11,4 M20,27 L11,36 M20,${Y} L9,${Y}" stroke="${MU}" stroke-width="1.6" fill="none"/>
  2487. ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')}
  2488. ${node(133,'齿轮箱','g')}${node(157,'齿轮箱','g')}${node(181,'齿轮箱','g')}
  2489. ${node(236,'发电机','b')}${node(282,'发电机','b')}
  2490. </svg>`};
  2491. // 证据强度阶梯 (确诊4格/准定论3/候选2/参考1/INSUFFICIENT 0虚框) — 文字分级换成可比的阶梯
  2492. const evLadder=t=>{
  2493. const T=String(t||''), n=/确诊/.test(T)?4:/准定论|定论/.test(T)?3:/候选/.test(T)?2:/参考/.test(T)?1:0;
  2494. const col=n>=4?'var(--bad)':n===3?'var(--warn)':n===2?'var(--info)':n===1?'var(--muted)':'var(--unknown)';
  2495. const cells=[0,1,2,3].map(i=>`<rect x="${2+i*15}" y="${12-i*3}" width="11" height="${6+i*3}" rx="1.5"
  2496. fill="${i<n?col:'none'}" fill-opacity="0.9" stroke="${col}" stroke-opacity="${i<n?1:0.4}" stroke-width="1"
  2497. ${i>=n?'stroke-dasharray="2 1.5"':''}/>`).join('');
  2498. return `<div title="${esc(T)}" style="display:flex;align-items:center;gap:7px">
  2499. <svg class="dt" viewBox="0 0 62 20" style="width:62px;height:20px;flex:none">${cells}</svg>
  2500. <span class="small" style="color:${col}">${esc(T)}</span></div>`};
  2501. // 部件微缩符号 (矩阵/列表行内用): 与解剖图同一套图形语言的 24px 版 —
  2502. // 齿轮箱=行星轮系 · 发电机=定子转子 · 主轴承=滚子剖面 · 变桨=桨叶轴承 · 偏航=齿圈驱动
  2503. function partIcon(name, col){
  2504. const c = col || 'currentColor', n = String(name || '');
  2505. const W = 30, H = 26;
  2506. // 尺寸走 inline style (全局 svg{width:100%} 会吃掉 width 属性 — 同一个坑第三次: dtSVG/台页/此处)
  2507. const wrap = body => `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${n}"
  2508. style="width:${W}px;height:${H}px;min-width:${W}px;vertical-align:-6px;margin-right:6px;flex:none;display:inline-block"
  2509. ><g fill="none" stroke="${c}" stroke-width="1.3">${body}</g></svg>`;
  2510. if(/齿轮箱|行星|中间轴|中速|高速/.test(n))
  2511. return wrap(`<circle cx="15" cy="13" r="10.5"/><circle cx="15" cy="13" r="3.4"/>
  2512. <circle cx="15" cy="5.5" r="3"/><circle cx="8.5" cy="17" r="3"/><circle cx="21.5" cy="17" r="3"/>
  2513. <path d="M15,13 L15,5.5 M15,13 L8.5,17 M15,13 L21.5,17" stroke-width="0.9" stroke-opacity="0.75"/>`);
  2514. if(/发电机|定子|转子|DE|NDE/.test(n))
  2515. return wrap(`<rect x="4" y="6" width="22" height="14" rx="2.5"/>
  2516. <path d="M4,10 h22 M4,16 h22" stroke-opacity="0.45"/>
  2517. <rect x="9" y="10" width="12" height="6" rx="1" fill="${c}" fill-opacity="0.18" stroke="none"/>
  2518. <path d="M1,13 h3 M26,13 h3" stroke-width="1.6"/>`);
  2519. if(/主轴承|主轴/.test(n)){
  2520. // 前后两个轴承都画出来 (2026-08-28 用户令), 命中的那个着色加粗, 另一个留灰 —
  2521. // 左=前(轮毂侧) 右=后(齿轮箱侧), 与传动链图同一朝向; 未指明前后则两个都着色
  2522. const hasF = /前/.test(n), hasR = /后/.test(n), both = !hasF && !hasR;
  2523. const brg = (x, on) => {
  2524. const st = on ? c : 'var(--muted)', w = on ? 1.7 : 1.05, op = on ? 1 : 0.55;
  2525. return `<g stroke="${st}" stroke-width="${w}" stroke-opacity="${op}">
  2526. <rect x="${x}" y="5.5" width="16" height="4" rx="1"/><rect x="${x}" y="16.5" width="16" height="4" rx="1"/>
  2527. <circle cx="${x + 4.5}" cy="13" r="2.3"/><circle cx="${x + 11.5}" cy="13" r="2.3"/></g>`;
  2528. };
  2529. return `<svg viewBox="0 0 46 26" role="img" aria-label="${n}"
  2530. style="width:46px;height:26px;min-width:46px;vertical-align:-6px;margin-right:6px;flex:none;display:inline-block">
  2531. <g fill="none"><path d="M2,13 h42" stroke="var(--muted)" stroke-width="2.2" stroke-opacity="0.35"/>
  2532. ${brg(4, hasF || both)}${brg(26, hasR || both)}</g></svg>`;
  2533. }
  2534. if(/变桨|桨|叶片|叶根|轮毂/.test(n))
  2535. return wrap(`<circle cx="15" cy="13" r="7.5"/><circle cx="15" cy="13" r="4"/>
  2536. <path d="M15,5.5 q7,-3 11,1 M15,20.5 q7,3 11,-1 M7.5,13 q-6,0 -6.5,-4" stroke-width="1.1" stroke-opacity="0.8"/>`);
  2537. if(/偏航|解缆|扭缆|对风/.test(n))
  2538. return wrap(`<circle cx="15" cy="15" r="9"/><circle cx="15" cy="15" r="5.5" stroke-dasharray="2 2"/>
  2539. ${[0,72,144,216,288].map(a=>{const r=(a-90)*Math.PI/180;
  2540. return `<circle cx="${(15+Math.cos(r)*9).toFixed(1)}" cy="${(15+Math.sin(r)*9).toFixed(1)}" r="1.7"/>`}).join('')}`);
  2541. if(/变流|变频|逆变/.test(n))
  2542. return wrap(`<rect x="5" y="6" width="20" height="14" rx="2"/><path d="M11,10 l-3,4 h5 l-3,4" stroke-width="1.2"/>
  2543. <path d="M18,10 v8 M21.5,10 v8" stroke-opacity="0.6"/>`);
  2544. if(/塔架|塔筒|基础|法兰/.test(n))
  2545. return wrap(`<path d="M11,4 h8 l3,17 h-14 z"/><path d="M9,14 h12" stroke-opacity="0.5"/>
  2546. <path d="M5,21 h20" stroke-width="1.8"/>`);
  2547. return wrap(`<circle cx="15" cy="13" r="8" stroke-dasharray="3 2.5"/>`);
  2548. }
  2549. // 部件小图 (按部件截取: 只画命中的那一段, 不画整条链; 比八节点链条形象 — 带座体/齿形/散热筋等外形特征)
  2550. function partGlyph(name, col, solid){
  2551. // solid=true 仅用于报警/危险级 — 良好/优秀级用描边不填充, 否则密集表格里满屏深色块
  2552. // (2026-08-28 用户逮'满屏黑化'; 对应 skill 原则: boldness 只花在一处, 其余安静)
  2553. const c = col || 'currentColor', n = String(name || ''), MU = 'var(--muted)';
  2554. const svg = (w, body) => `<svg viewBox="0 0 ${w} 38" role="img" aria-label="${n}"
  2555. style="width:${w}px;height:38px;min-width:${w}px;vertical-align:-11px;flex:none;display:block">
  2556. <g fill="none" stroke-linejoin="round">${body}</g></svg>`;
  2557. if(/主轴承|主轴/.test(n)){
  2558. const F = /前/.test(n), R = /后/.test(n), both = !F && !R;
  2559. // 带座体的轴承: 座体梯形 + 外圈 + 滚子 + 内圈, 轴穿过
  2560. const unit = (x, on) => {
  2561. const st = on ? c : MU, w = on ? 1.7 : 1, op = on ? 1 : 0.5;
  2562. return `<g stroke="${st}" stroke-width="${w}" stroke-opacity="${op}">
  2563. <path d="M${x-2},32 h26 l-3,-8 h-20 z" fill="${on&&solid?c:'none'}" fill-opacity="${on&&solid?0.14:0}"/>
  2564. <rect x="${x+1}" y="7" width="18" height="4" rx="1"/>
  2565. <rect x="${x+1}" y="25" width="18" height="4" rx="1"/>
  2566. <rect x="${x+1}" y="12.5" width="18" height="2.6" rx="0.8" stroke-opacity="${op*0.7}"/>
  2567. <rect x="${x+1}" y="21" width="18" height="2.6" rx="0.8" stroke-opacity="${op*0.7}"/>
  2568. <circle cx="${x+5}" cy="18" r="2.6"/><circle cx="${x+10}" cy="18" r="2.6"/><circle cx="${x+15}" cy="18" r="2.6"/></g>`;
  2569. };
  2570. return svg(96, `<path d="M2,18 h92" stroke="${MU}" stroke-width="3" stroke-opacity="0.3" stroke-linecap="round"/>
  2571. ${unit(8, F||both)}${unit(56, R||both)}
  2572. <text x="19" y="6" text-anchor="middle" font-size="8" fill="${F||both?c:MU}" stroke="none" opacity="${F||both?1:0.6}">前</text>
  2573. <text x="67" y="6" text-anchor="middle" font-size="8" fill="${R||both?c:MU}" stroke="none" opacity="${R||both?1:0.6}">后</text>`);
  2574. }
  2575. if(/齿轮箱|行星|中间轴|中速|高速/.test(n)){
  2576. const P = /行星/.test(n), M = /中间|中速/.test(n), H = /高速/.test(n), all = !P && !M && !H;
  2577. const gear = (cx, cy, r, teeth, on) => {
  2578. const st = on ? c : MU, sw = on ? 1.6 : 1, op = on ? 1 : 0.45;
  2579. let t = '';
  2580. for(let i=0;i<teeth;i++){const a=i*2*Math.PI/teeth;
  2581. t += `<line x1="${(cx+Math.cos(a)*r).toFixed(1)}" y1="${(cy+Math.sin(a)*r).toFixed(1)}"
  2582. x2="${(cx+Math.cos(a)*(r+2.4)).toFixed(1)}" y2="${(cy+Math.sin(a)*(r+2.4)).toFixed(1)}"/>`}
  2583. return `<g stroke="${st}" stroke-width="${sw}" stroke-opacity="${op}"><circle cx="${cx}" cy="${cy}" r="${r}"/>${t}
  2584. <circle cx="${cx}" cy="${cy}" r="${(r*0.3).toFixed(1)}"/></g>`;
  2585. };
  2586. return svg(104, `<path d="M3,10 h98 v22 q0,4 -4,4 h-90 q-4,0 -4,-4 z" stroke="${MU}" stroke-opacity="0.45" stroke-dasharray="3 2"/>
  2587. ${gear(22, 21, 9, 12, P||all)}${gear(22, 21, 3.4, 0, P||all)}
  2588. ${gear(55, 18, 7.5, 10, M||all)}${gear(80, 25, 5.5, 8, H||all)}
  2589. <path d="M31,21 h16 M62,20 h11" stroke="${MU}" stroke-width="1.6" stroke-opacity="0.45"/>`);
  2590. }
  2591. if(/发电机|定子|转子|DE|NDE/.test(n)){
  2592. const D = /(?<!N)DE/.test(n) && !/NDE/.test(n), ND = /NDE/.test(n), all = !D && !ND;
  2593. const end = (x, on) => `<g stroke="${on?c:MU}" stroke-width="${on?1.7:1}" stroke-opacity="${on?1:0.5}">
  2594. <rect x="${x}" y="12" width="9" height="14" rx="2"/><circle cx="${x+4.5}" cy="19" r="2.6"/></g>`;
  2595. return svg(96, `<g stroke="${c}" stroke-width="1.3">
  2596. <rect x="18" y="8" width="60" height="22" rx="3"/></g>
  2597. <g stroke="${MU}" stroke-opacity="0.5" stroke-width="0.9">
  2598. ${[26,34,42,50,58,66].map(x=>`<line x1="${x}" y1="8" x2="${x}" y2="30"/>`).join('')}</g>
  2599. <rect x="30" y="14" width="36" height="10" rx="2" stroke="${c}" stroke-width="1.2" fill="${c}" fill-opacity="${solid?0.14:0.04}"/>
  2600. <path d="M4,19 h14 M78,19 h14" stroke="${MU}" stroke-width="2.4" stroke-opacity="0.35" stroke-linecap="round"/>
  2601. ${end(8, D||all)}${end(79, ND||all)}
  2602. <text x="12" y="6" text-anchor="middle" font-size="8" fill="${D||all?c:MU}" stroke="none" opacity="${D||all?1:0.6}">DE</text>
  2603. <text x="84" y="6" text-anchor="middle" font-size="8" fill="${ND||all?c:MU}" stroke="none" opacity="${ND||all?1:0.6}">NDE</text>`);
  2604. }
  2605. if(/变桨|桨|叶片|叶根|轮毂/.test(n))
  2606. return svg(80, `<g stroke="${c}" stroke-width="1.5">
  2607. <circle cx="26" cy="19" r="12"/><circle cx="26" cy="19" r="7"/>
  2608. ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
  2609. return `<circle cx="${(26+Math.cos(r)*9.6).toFixed(1)}" cy="${(19+Math.sin(r)*9.6).toFixed(1)}" r="2.2"/>`}).join('')}</g>
  2610. <g stroke="${MU}" stroke-width="4" stroke-opacity="0.32" stroke-linecap="round">
  2611. <path d="M38,19 q22,-3 36,-11"/><path d="M38,19 q22,3 36,11"/></g>`);
  2612. if(/偏航|解缆|扭缆|对风/.test(n))
  2613. return svg(80, `<g stroke="${c}" stroke-width="1.5"><circle cx="34" cy="21" r="13"/>
  2614. ${Array.from({length:14},(_,i)=>{const a=i*2*Math.PI/14;
  2615. return `<line x1="${(34+Math.cos(a)*13).toFixed(1)}" y1="${(21+Math.sin(a)*13).toFixed(1)}"
  2616. x2="${(34+Math.cos(a)*15.6).toFixed(1)}" y2="${(21+Math.sin(a)*15.6).toFixed(1)}"/>`}).join('')}</g>
  2617. <g stroke="${MU}" stroke-width="1.2" stroke-opacity="0.6">
  2618. <circle cx="60" cy="14" r="5"/><circle cx="60" cy="28" r="5"/></g>
  2619. <path d="M18,6 h32" stroke="${MU}" stroke-width="2" stroke-opacity="0.4"/>`);
  2620. return svg(60, `<circle cx="30" cy="19" r="11" stroke="${MU}" stroke-dasharray="3 2.5" stroke-opacity="0.6"/>`);
  2621. }
  2622. // ══ 部件解剖图 (2026-08-28 用户令"部件UI图像化认真对待") ══
  2623. // 原则: 画机制不画名字 — 每张图展示该系统的介质/动力如何流经部件, 箭头带口径标注,
  2624. // 命中部件按状态着色; 一图一论点。与传动链简图分工: 传动链=链上哪一环, 解剖图=环内怎么坏的。
  2625. const ANAT_C = st => ({'报警':'var(--bad)','危险':'var(--bad)','预警':'var(--warn)','良好':'var(--info)',
  2626. '优秀':'var(--ok)','不可判':'var(--unknown)'}[st] || 'var(--muted)');
  2627. function anatMainBearing(hit, states){
  2628. // 主轴承集中润滑链: 泵 →(管路)→ 分配器 →(柱塞往复=出脂计量)→ 前/后轴承
  2629. // 画的是"脂怎么到轴承", 29# 的失效点在泵本体 (柱塞单次时长拖长=打不满)
  2630. const c = k => ANAT_C((states||{})[k]);
  2631. const on = k => (hit||'').includes(k);
  2632. const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
  2633. return `<figure style="margin:0"><svg viewBox="0 0 660 210" role="img" width="660"
  2634. aria-label="主轴承集中润滑链: 润滑泵经管路到分配器, 分配器柱塞往复把脂定量送到前后主轴承"
  2635. style="max-width:100%;height:auto;display:block;margin:0 auto">
  2636. <g fill="none" stroke="currentColor" stroke-width="1.4" font-family="inherit">
  2637. <!-- 脂桶+泵 -->
  2638. <rect x="18" y="60" width="54" height="66" rx="4" stroke="${hl('泵')}" stroke-width="${on('泵')?2.4:1.4}"/>
  2639. <path d="M28,70 h34 M28,80 h34 M28,90 h34" stroke-opacity="0.35"/>
  2640. <text x="45" y="142" text-anchor="middle" font-size="11" fill="currentColor" stroke="none">脂桶</text>
  2641. <circle cx="104" cy="93" r="17" stroke="${hl('泵')}" stroke-width="${on('泵')?2.4:1.4}"/>
  2642. <path d="M97,86 l14,7 -14,7 z" fill="${hl('泵')}" stroke="none"/>
  2643. <text x="104" y="128" text-anchor="middle" font-size="11" fill="${hl('泵')}" stroke="none">润滑泵</text>
  2644. ${on('泵') ? '<text x="104" y="62" text-anchor="middle" font-size="10" fill="var(--bad)" stroke="none">本体失效</text>' : ''}
  2645. <!-- 管路 -->
  2646. <path d="M121,93 h58" marker-end="url(#aH)"/>
  2647. <text x="150" y="86" text-anchor="middle" font-size="10" fill="currentColor" stroke="none" stroke-opacity="0">主管路</text>
  2648. <!-- 分配器 -->
  2649. <rect x="182" y="66" width="66" height="54" rx="4" stroke="${hl('分配器')}"/>
  2650. <path d="M196,80 v26 M215,80 v26 M234,80 v26" stroke-opacity="0.55"/>
  2651. <text x="215" y="134" text-anchor="middle" font-size="11" fill="currentColor" stroke="none">分配器</text>
  2652. <text x="215" y="60" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">柱塞往复=出脂计量</text>
  2653. <!-- 支路到前后轴承 -->
  2654. <path d="M248,80 h48 q12,0 12,-12 v-14" marker-end="url(#aH)"/>
  2655. <path d="M248,106 h48 q12,0 12,12 v14" marker-end="url(#aH)"/>
  2656. <text x="286" y="70" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">前支路</text>
  2657. <text x="286" y="140" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">后支路</text>
  2658. <!-- 前后轴承 (剖面) -->
  2659. ${[['主轴承前', 34], ['主轴承后', 132]].map(([k, y]) => `
  2660. <g stroke="${c(k)}" stroke-width="${(states||{})[k]==='报警'?2.4:1.4}">
  2661. <rect x="330" y="${y}" width="86" height="16" rx="2"/>
  2662. <rect x="330" y="${y+28}" width="86" height="16" rx="2"/>
  2663. <circle cx="352" cy="${y+22}" r="5"/><circle cx="373" cy="${y+22}" r="5"/><circle cx="394" cy="${y+22}" r="5"/>
  2664. </g>
  2665. <text x="373" y="${y+58}" text-anchor="middle" font-size="11" fill="${c(k)}" stroke="none">${k}</text>`).join('')}
  2666. <!-- 主轴穿过 -->
  2667. <path d="M300,105 h180" stroke-width="7" stroke-opacity="0.25" stroke-linecap="round"/>
  2668. <!-- 观测面标注 -->
  2669. <path d="M215,120 v42" stroke-dasharray="3 3" stroke-opacity="0.6"/>
  2670. <text x="215" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">因侧: 柱塞计数/单次时长</text>
  2671. <path d="M373,${(states||{})['主轴承后']?'190':'190'} v-14" stroke-dasharray="3 3" stroke-opacity="0.6"/>
  2672. <text x="470" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">果侧: 轴承温度 · 振动冲击</text>
  2673. <defs><marker id="aH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
  2674. <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
  2675. </g></svg>
  2676. <figcaption class="small" style="color:var(--muted);margin-top:6px">
  2677. 脂路: 泵 → 主管路 → 分配器(柱塞定量) → 前/后支路 → 轴承。因侧看柱塞行为, 果侧看轴承温度与冲击 —
  2678. 两侧不是两个独立源, 是同一条机制链的两端。</figcaption></figure>`;
  2679. }
  2680. function anatGearbox(hit, states){
  2681. // 齿轮箱: 一级行星 + 两级平行轴 + 润滑/冷却回路 (油从箱底经泵/滤/冷却器回喷淋)
  2682. const c = k => ANAT_C((states||{})[k]);
  2683. const on = k => (hit||'').includes(k);
  2684. const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
  2685. return `<figure style="margin:0"><svg viewBox="0 0 660 250" role="img" width="660"
  2686. aria-label="齿轮箱: 一级行星加两级平行轴传动, 下方为润滑油路(泵-滤芯-冷却器-喷淋)"
  2687. style="max-width:100%;height:auto;display:block;margin:0 auto">
  2688. <g fill="none" stroke="currentColor" stroke-width="1.4">
  2689. <rect x="24" y="18" width="500" height="128" rx="8" stroke-dasharray="5 4" stroke-opacity="0.5"/>
  2690. <text x="274" y="14" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none" opacity="0.7">齿轮箱壳体 (i=119.752)</text>
  2691. <!-- 行星级 -->
  2692. <g stroke="${hl('行星')}" stroke-width="${on('行星')?2.2:1.4}">
  2693. <circle cx="98" cy="82" r="46"/><circle cx="98" cy="82" r="15"/>
  2694. <circle cx="98" cy="46" r="14"/><circle cx="67" cy="100" r="14"/><circle cx="129" cy="100" r="14"/>
  2695. <path d="M98,82 L98,46 M98,82 L67,100 M98,82 L129,100" stroke-width="1"/>
  2696. </g>
  2697. <text x="98" y="146" text-anchor="middle" font-size="11" fill="${hl('行星')}" stroke="none">一级行星</text>
  2698. <path d="M144,82 h40" marker-end="url(#gH)"/>
  2699. <text x="164" y="74" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">中速</text>
  2700. <!-- 平行轴两级 -->
  2701. <g stroke="${hl('中间')}" stroke-width="${on('中间')?2.2:1.4}">
  2702. <rect x="188" y="52" width="16" height="60" rx="3"/><rect x="188" y="26" width="16" height="26" rx="3"/></g>
  2703. <text x="196" y="146" text-anchor="middle" font-size="11" fill="${hl('中间')}" stroke="none">中间轴</text>
  2704. <g stroke="${hl('高速')}" stroke-width="${on('高速')?2.2:1.4}">
  2705. <rect x="244" y="26" width="16" height="52" rx="3"/><rect x="244" y="78" width="16" height="30" rx="3"/></g>
  2706. <text x="252" y="146" text-anchor="middle" font-size="11" fill="${hl('高速')}" stroke="none">高速轴</text>
  2707. <path d="M268,93 h56" marker-end="url(#gH)"/>
  2708. <text x="296" y="86" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">→ 发电机</text>
  2709. <!-- 油路 -->
  2710. <path d="M60,146 v34 h420 v-34" stroke-opacity="0.8"/>
  2711. <circle cx="180" cy="180" r="13" stroke="${hl('油泵')}"/><path d="M174,174 l12,6 -12,6 z" fill="${hl('油泵')}" stroke="none"/>
  2712. <text x="180" y="208" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">油泵</text>
  2713. <rect x="242" y="168" width="26" height="24" rx="2" stroke="${hl('滤芯')}"/>
  2714. <path d="M246,172 l18,16 M264,172 l-18,16" stroke-width="0.9" stroke-opacity="0.6"/>
  2715. <text x="255" y="208" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">滤芯</text>
  2716. <rect x="322" y="166" width="40" height="28" rx="3" stroke="${hl('冷却')}"/>
  2717. <path d="M330,170 v20 M340,170 v20 M350,170 v20" stroke-width="0.9" stroke-opacity="0.6"/>
  2718. <text x="342" y="208" text-anchor="middle" font-size="10" fill="${hl('冷却')}" stroke="none">冷却器</text>
  2719. <path d="M410,180 v-20" marker-end="url(#gH)"/>
  2720. <text x="446" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">喷淋回箱</text>
  2721. <!-- 观测面 -->
  2722. <text x="560" y="60" font-size="10" fill="currentColor" stroke="none">观测面:</text>
  2723. <text x="560" y="78" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 振动 (各级测点)</text>
  2724. <text x="560" y="94" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 轴承/油温 ΔT</text>
  2725. <text x="560" y="110" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 滤芯压差码</text>
  2726. <text x="560" y="126" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 油样铁谱</text>
  2727. <defs><marker id="gH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
  2728. <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
  2729. </g></svg>
  2730. <figcaption class="small" style="color:var(--muted);margin-top:6px">
  2731. 传动: 行星级(输入) → 中间轴 → 高速轴(输出)。油路: 箱底 → 泵 → 滤芯 → 冷却器 → 喷淋回箱 —
  2732. 过热类失效沿油路查(冷却/滤芯), 磨损类沿齿轮线查(振动/铁谱)。</figcaption></figure>`;
  2733. }
  2734. function anatPitch(hit, states){
  2735. // 变桨: 蓄能器储压 → 比例阀 → 油缸推动变桨轴承 → 桨叶; 滑环供电与信号
  2736. const c = k => ANAT_C((states||{})[k]);
  2737. const on = k => (hit||'').includes(k);
  2738. const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
  2739. return `<figure style="margin:0"><svg viewBox="0 0 660 230" role="img" width="660"
  2740. aria-label="变桨系统: 蓄能器储压经比例阀推动油缸, 油缸驱动变桨轴承转动桨叶; 滑环传电与信号"
  2741. style="max-width:100%;height:auto;display:block;margin:0 auto">
  2742. <g fill="none" stroke="currentColor" stroke-width="1.4">
  2743. <!-- 滑环 -->
  2744. <g stroke="${hl('滑环')}"><rect x="20" y="86" width="30" height="50" rx="4"/>
  2745. <path d="M20,96 h30 M20,110 h30 M20,124 h30" stroke-opacity="0.5"/></g>
  2746. <text x="35" y="152" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none">滑环</text>
  2747. <path d="M50,111 h34" marker-end="url(#pH)"/>
  2748. <text x="67" y="104" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">电/信号</text>
  2749. <!-- 蓄能器 -->
  2750. <g stroke="${hl('蓄能')}" stroke-width="${on('蓄能')?2.2:1.4}">
  2751. <rect x="88" y="70" width="40" height="82" rx="20"/>
  2752. <path d="M88,104 h40" stroke-dasharray="3 2"/></g>
  2753. <text x="108" y="166" text-anchor="middle" font-size="10.5" fill="${hl('蓄能')}" stroke="none">蓄能器</text>
  2754. <text x="108" y="62" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">氮气预充</text>
  2755. <path d="M128,111 h36" marker-end="url(#pH)"/>
  2756. <!-- 比例阀 -->
  2757. <g stroke="${hl('阀')}"><rect x="166" y="92" width="34" height="38" rx="3"/>
  2758. <path d="M166,111 h34 M183,92 v38" stroke-opacity="0.5"/></g>
  2759. <text x="183" y="146" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none">比例阀</text>
  2760. <path d="M200,111 h34" marker-end="url(#pH)"/>
  2761. <text x="217" y="104" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">压力</text>
  2762. <!-- 油缸 -->
  2763. <g stroke="${hl('油缸')}" stroke-width="${on('油缸')?2.2:1.4}">
  2764. <rect x="236" y="94" width="86" height="34" rx="3"/>
  2765. <rect x="246" y="100" width="30" height="22" rx="2" fill="currentColor" fill-opacity="0.14"/>
  2766. <path d="M276,111 h66"/></g>
  2767. <text x="279" y="146" text-anchor="middle" font-size="10.5" fill="${hl('油缸')}" stroke="none">变桨油缸</text>
  2768. <!-- 变桨轴承 + 桨叶 -->
  2769. <g stroke="${hl('轴承')}" stroke-width="${on('轴承')?2.2:1.4}">
  2770. <circle cx="392" cy="111" r="34"/><circle cx="392" cy="111" r="24"/>
  2771. ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
  2772. return `<circle cx="${(392+Math.cos(r)*29).toFixed(1)}" cy="${(111+Math.sin(r)*29).toFixed(1)}" r="3.4"/>`}).join('')}</g>
  2773. <text x="392" y="164" text-anchor="middle" font-size="10.5" fill="${hl('轴承')}" stroke="none">变桨轴承</text>
  2774. <path d="M426,111 q46,-4 74,-40" stroke-width="5" stroke-opacity="0.3" stroke-linecap="round"/>
  2775. <path d="M426,111 q46,4 74,40" stroke-width="5" stroke-opacity="0.3" stroke-linecap="round"/>
  2776. <text x="520" y="76" font-size="10.5" fill="currentColor" stroke="none">桨叶 A/B/C</text>
  2777. <!-- 观测面 -->
  2778. <path d="M108,152 v30 h284 v-18" stroke-dasharray="3 3" stroke-opacity="0.55"/>
  2779. <text x="250" y="196" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">
  2780. 观测: 蓄能带宽/停机泄压 · 桨距跟踪偏差 · 三叶零位一致性 · 轴承润滑脂</text>
  2781. <defs><marker id="pH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
  2782. <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
  2783. </g></svg>
  2784. <figcaption class="small" style="color:var(--muted);margin-top:6px">
  2785. 动力: 蓄能器储压 → 比例阀 → 油缸 → 变桨轴承 → 桨叶。压力侧失效看蓄能带宽与泄压率,
  2786. 执行侧看桨距跟踪偏差, 三叶不一致指向单支路。</figcaption></figure>`;
  2787. }
  2788. const ANAT = {'主轴承': anatMainBearing, '齿轮箱': anatGearbox, '变桨': anatPitch};
  2789. // 链条缩略版 (表格用: 列宽有限, 全剖面被挤成49px不可读) — 同一图形语言的 sparkline。
  2790. // 2026-08-28 用户"更清楚": 八个一样的圆点分不出段落 → 节点形状语义化 (轴承=双环 · 齿轮=带齿圆)
  2791. // + 三段分区底纹 (主轴 / 齿轮箱 / 发电机), 命中环放大填色+外发光, 仍零文字。
  2792. const dtMini=(comp,sev,W,T2)=>{
  2793. const c=String(comp||''), w=W||208;
  2794. const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
  2795. '行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
  2796. '发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
  2797. const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
  2798. const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
  2799. const Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
  2800. const zone=(x,w2,dash)=>`<rect x="${x}" y="5" width="${w2}" height="30" rx="6" fill="var(--muted)" fill-opacity="0.13"
  2801. stroke="${dash&&gball?sev:LN}" stroke-opacity="${dash&&gball?0.9:0.5}" ${dash?'stroke-dasharray="3 2"':''}/>`;
  2802. // 轴承节点 = 双环 (外圈+滚道); 齿轮节点 = 圆+四齿
  2803. const node=(x,n,kind)=>{
  2804. const k=on(n), col=k?sev:MU, r=k?7:5.4;
  2805. const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
  2806. return `<line x1="${(x+Math.cos(t)*r).toFixed(1)}" y1="${(Y+Math.sin(t)*r).toFixed(1)}" x2="${(x+Math.cos(t)*(r+2.6)).toFixed(1)}" y2="${(Y+Math.sin(t)*(r+2.6)).toFixed(1)}" stroke="${col}" stroke-width="${k?1.8:1.2}"/>`}).join(''):'';
  2807. const core=(k?`<circle class="dt-halo" cx="${x}" cy="${Y}" r="${r+6}" fill="${sev}"/>`:'')
  2808. +`<circle cx="${x}" cy="${Y}" r="${r}" fill="${k?sev:PN}" fill-opacity="${k?0.9:1}" stroke="${col}" stroke-opacity="${k?1:0.85}" stroke-width="${k?2.2:1.4}"/>`
  2809. +(kind==='b'?`<circle cx="${x}" cy="${Y}" r="${(r*0.42).toFixed(1)}" fill="${k?'#fff':MU}" fill-opacity="${k?0.85:0.7}" stroke="none"/>`:teeth);
  2810. return (k&&T2)?`<g class="dt-hot" onclick="event.stopPropagation();popPart('${T2}','${n}')"><title>${n} — 点看曲线与四源微视图</title>
  2811. <circle cx="${x}" cy="${Y}" r="${r+7}" fill="transparent"/>${core}</g>`:core;
  2812. };
  2813. return `<svg class="dt" viewBox="0 0 320 40" style="display:block;width:${w}px;max-width:100%;height:auto">
  2814. ${zone(28,74)}${zone(112,90,1)}${zone(212,96)}
  2815. <line x1="20" y1="${Y}" x2="304" y2="${Y}" stroke="${LN}" stroke-width="3" stroke-linecap="round"/>
  2816. <path d="M20,13 L11,4 M20,27 L11,36 M20,${Y} L9,${Y}" stroke="${MU}" stroke-width="1.6" fill="none"/>
  2817. ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')}
  2818. ${node(133,'行星','g')}${node(157,'中间','g')}${node(181,'高速','g')}
  2819. ${node(236,'发电机DE','b')}${node(282,'发电机NDE','b')}
  2820. </svg>`};
  2821. const dtSVG=(comp,sev,W)=>{
  2822. const c=String(comp||''), w=W||660;
  2823. const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
  2824. '行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
  2825. '发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
  2826. const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
  2827. const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
  2828. const LN='var(--line)', MU='var(--muted)', PN='var(--panel)';
  2829. const K=660/w, FS=(11*K).toFixed(1), SW=(1.5*K).toFixed(2); // 缩放补偿: 显示字号/线宽与图宽无关
  2830. // 部件绘制器: 命中→光晕+sev描边加粗+标名; 未命中→灰描边无字 (纯图形: 只有出问题的那一环有字)
  2831. const part=(n,label,lx,ly,body,halo)=>{
  2832. const k=on(n), col=k?sev:MU;
  2833. return (k&&halo?`<rect class="dt-halo" x="${halo[0]}" y="${halo[1]}" width="${halo[2]}" height="${halo[3]}" rx="8" fill="${sev}"/>`:'')
  2834. +`<g stroke="${col}" stroke-width="${k?SW:0.9*K}" fill="${PN}" fill-opacity="1">${body}</g>`
  2835. +(k?`<text x="${lx}" y="${ly}" text-anchor="middle" font-size="${FS}" font-weight="600" fill="${sev}">${label}</text>`:'');
  2836. };
  2837. // 轴承剖面符号 (上下外圈+滚子+内圈)
  2838. const brg=(x,n,label,ly)=>part(n,label,x,ly,
  2839. `<rect x="${x-17}" y="47" width="34" height="7" rx="1.5"/><rect x="${x-17}" y="96" width="34" height="7" rx="1.5"/>`+
  2840. [-11,0,11].map(d=>`<circle cx="${x+d}" cy="60" r="4.2"/><circle cx="${x+d}" cy="90" r="4.2"/>`).join('')+
  2841. `<rect x="${x-17}" y="66" width="34" height="4" rx="1"/><rect x="${x-17}" y="80" width="34" height="4" rx="1"/>`,[x-24,40,48,70]);
  2842. // 齿轮 = 竖矩形 + 齿纹
  2843. const gear=(x,cy,D,n,label,ly)=>part(n,label,x+6,ly,
  2844. `<rect x="${x}" y="${cy-D/2}" width="13" height="${D}" rx="2"/>`+
  2845. Array.from({length:Math.max(3,Math.round(D/9))},(_,i)=>{const yy=cy-D/2+4+i*(D-8)/Math.max(2,Math.round(D/9)-1);
  2846. return `<line x1="${x}" y1="${yy}" x2="${x+13}" y2="${yy}" stroke-width="${0.7*K}" stroke-opacity="0.55"/>`}).join(''),[x-7,cy-D/2-7,27,D+14]);
  2847. const shaft=(x1,x2,y,th)=>`<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${LN}" stroke-width="${th||9}" stroke-linecap="round"/>`;
  2848. return `<svg class="dt" viewBox="0 0 660 150" style="display:block;width:${w}px;max-width:100%;height:auto;margin:6px auto">
  2849. <!-- 轮毂+叶根 -->
  2850. <g stroke="${MU}" fill="${PN}"><path d="M22,52 L48,62 L48,88 L22,98 Z" stroke-width="1"/>
  2851. <line x1="22" y1="75" x2="6" y2="75" stroke-width="3"/><line x1="24" y1="60" x2="10" y2="34" stroke-width="3"/>
  2852. <line x1="24" y1="90" x2="10" y2="116" stroke-width="3"/></g>
  2853. ${shaft(46,206,75,12)}
  2854. ${brg(88,'主轴承前','主轴承 前',126)}${brg(154,'主轴承后','主轴承 后',126)}
  2855. <!-- 齿轮箱壳 -->
  2856. <rect x="196" y="16" width="242" height="118" rx="7" fill="none"
  2857. stroke="${gball?sev:LN}" stroke-width="${gball?1.6:1}" stroke-dasharray="4 3" stroke-opacity="${gball?0.9:0.6}"/>
  2858. <!-- 一级行星: 齿圈(上下)+行星轮+太阳轮+行星架 -->
  2859. ${part('行星','行星级',236,146,
  2860. `<rect x="210" y="24" width="52" height="8" rx="2"/><rect x="210" y="118" width="52" height="8" rx="2"/>
  2861. <rect x="228" y="38" width="14" height="26" rx="2"/><rect x="228" y="86" width="14" height="26" rx="2"/>
  2862. <rect x="228" y="62" width="14" height="26" rx="2"/>
  2863. <path d="M210,75 L228,51 M210,75 L228,99" stroke-width="${1.4*K}" fill="none"/>`,[203,17,66,116])}
  2864. ${shaft(256,306,75,7)}
  2865. <!-- 二级 中速→中间轴 (节点相切) -->
  2866. ${gear(300,75,46,'中间','中间轴',146)}
  2867. ${gear(300,37,30,'中间','',0)}
  2868. ${shaft(306,352,37,6)}
  2869. <!-- 三级 中间轴→高速轴 -->
  2870. ${gear(348,37,50,'高速','高速轴',146)}
  2871. ${gear(348,75,26,'高速','',0)}
  2872. ${shaft(360,438,75,8)}
  2873. <!-- 联轴器 -->
  2874. <g stroke="${MU}" fill="${PN}"><rect x="438" y="62" width="6" height="26" rx="1"/><rect x="452" y="62" width="6" height="26" rx="1"/>
  2875. <line x1="444" y1="75" x2="452" y2="75" stroke-width="2"/></g>
  2876. <!-- 发电机: 壳+定子+转子+两端轴承 -->
  2877. <rect x="462" y="34" width="184" height="82" rx="7" fill="none" stroke="${LN}" stroke-dasharray="4 3" stroke-opacity="0.6"/>
  2878. <g stroke="${MU}" fill="${PN}"><rect x="486" y="42" width="136" height="12" rx="2"/><rect x="486" y="96" width="136" height="12" rx="2"/>
  2879. <rect x="492" y="60" width="124" height="30" rx="3" fill-opacity="0.35"/></g>
  2880. ${shaft(430,652,75,8)}
  2881. ${part('发电机DE','发电机 DE',478,146,`<rect x="470" y="56" width="16" height="12" rx="2"/><rect x="470" y="82" width="16" height="12" rx="2"/>
  2882. <circle cx="478" cy="64" r="3.4"/><circle cx="478" cy="86" r="3.4"/>`,[464,48,28,54])}
  2883. ${part('发电机NDE','发电机 NDE',628,146,`<rect x="620" y="56" width="16" height="12" rx="2"/><rect x="620" y="82" width="16" height="12" rx="2"/>
  2884. <circle cx="628" cy="64" r="3.4"/><circle cx="628" cy="86" r="3.4"/>`,[614,48,28,54])}
  2885. </svg>`};
  2886. function chip(s){return `<span class="chip" style="--c:${C[s]||'#9aa4b0'}">${s}</span>`}
  2887. function sc(v,lo,hi,a,b){return hi>lo? a+(b-a)*(v-lo)/(hi-lo) : (a+b)/2}
  2888. // 图幅分级 (2026-08-28 用户令"根据系统和分类要合理大小"):
  2889. // L=整行 (传动链/振动标量/功率曲线 — 需要横向空间, 但图身封顶 880/760 不随容器无限拉伸)
  2890. // M=常规格 (月度趋势/计数条 — 两张并排可比)
  2891. const FIGW = {vib: 880, vibbar: 900, xy: 760, band: 620, bar: 620, line: 620};
  2892. const isL = f => f.size === 'L' || ['vib', 'vibbar', 'xy'].includes(f.kind);
  2893. const figWrap = (f, inner, note) => `<div class="card"${isL(f) ? ' style="grid-column:1/-1"' : ''}>
  2894. <h3>${f.title || ''}</h3><div style="max-width:${FIGW[f.kind] || 620}px;margin:0 auto">${inner}</div>
  2895. ${note ? `<p class="small" style="margin-top:6px;color:var(--muted)">${note}</p>` : ''}</div>`;
  2896. // 故障模式四象限 (经典可靠性分析)。x=发生条数(对数) y=单次中位时长(对数);
  2897. // 右下=高频短时(抖动/刷屏, 调参或屏蔽) 左上=低频长时(硬故障, 备件/检修)
  2898. // 右上=高频长时(重点治理) 左下=低频短时(本底)。点面积=影响台数, 分离单台刷屏与批次共性。
  2899. function quadChart(items){
  2900. const it=(items||[]).filter(x=>x&&x.n>0&&x.med>0);
  2901. if(it.length<3) return '<p class="small">窗内码数不足, 不作四象限</p>';
  2902. const W=720,H=330,ML=58,MR=16,MB=44,MT=16;
  2903. const lx=x=>Math.log10(x), xs=it.map(d=>lx(d.n)), ys=it.map(d=>lx(d.med));
  2904. const x0=Math.min(...xs)-0.15,x1=Math.max(...xs)+0.15,y0=Math.min(...ys)-0.15,y1=Math.max(...ys)+0.15;
  2905. const PX=v=>ML+(lx(v)-x0)/(x1-x0)*(W-ML-MR), PY=v=>H-MB-(lx(v)-y0)/(y1-y0)*(H-MT-MB);
  2906. // 分界: 条数中位 与 单次时长 600s(10min, 一个SCADA采样格) — 时长界是物理刻度不是分位数
  2907. const srt=[...it].map(d=>d.n).sort((a,b)=>a-b), nmid=srt[Math.floor(srt.length/2)];
  2908. const cx=PX(nmid), cy=PY(600);
  2909. const mt=Math.max(...it.map(d=>d.nt));
  2910. const pts=it.map(d=>{
  2911. const r=3+Math.sqrt(d.nt/mt)*7, hot=(d.n>=nmid&&d.med>=600);
  2912. return `<circle cx="${PX(d.n).toFixed(1)}" cy="${PY(d.med).toFixed(1)}" r="${r.toFixed(1)}"
  2913. fill="${hot?'var(--bad)':'var(--accent)'}" fill-opacity="0.5" stroke="${hot?'var(--bad)':'var(--accent)'}" stroke-width="1">
  2914. <title>${d.code} ${d.k}\u000A${d.n} 条 · 单次中位 ${d.med}s · 累计 ${d.h}h · ${d.nt} 台</title></circle>`;}).join('');
  2915. const lab=it.filter(d=>d.n>=nmid||d.med>=600).slice(0,8).map(d=>
  2916. `<text x="${(PX(d.n)+9).toFixed(1)}" y="${(PY(d.med)+3).toFixed(1)}" font-size="9" fill="var(--muted)">${d.code}</text>`).join('');
  2917. const qn=(x,y,t,sub)=>`<text x="${x}" y="${y}" font-size="10" fill="var(--muted)" opacity="0.85">${t}</text>
  2918. <text x="${x}" y="${y+12}" font-size="8.5" fill="var(--muted)" opacity="0.6">${sub}</text>`;
  2919. const tick=v=>v>=3600?(v/3600)+'h':(v>=60?(v/60)+'min':v+'s');
  2920. const yt=[10,60,600,3600,36000].filter(v=>lx(v)>=y0&&lx(v)<=y1).map(v=>
  2921. `<line x1="${ML}" y1="${PY(v).toFixed(1)}" x2="${W-MR}" y2="${PY(v).toFixed(1)}" stroke="var(--line)" stroke-opacity="0.4"/>
  2922. <text x="${ML-6}" y="${(PY(v)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${tick(v)}</text>`).join('');
  2923. const xt=[1,10,100,1000,10000].filter(v=>lx(v)>=x0&&lx(v)<=x1).map(v=>
  2924. `<text x="${PX(v).toFixed(1)}" y="${H-MB+14}" font-size="9" fill="var(--muted)" text-anchor="middle">${v}</text>`).join('');
  2925. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  2926. ${yt}${xt}
  2927. <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H-MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.5"/>
  2928. <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W-MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.5"/>
  2929. ${qn(ML+6,MT+12,'低频长时 · 硬故障','备件与检修窗')}
  2930. ${qn(W-MR-96,MT+12,'高频长时 · 重点治理','优先派工')}
  2931. ${qn(ML+6,H-MB-18,'低频短时 · 本底','不单独处置')}
  2932. ${qn(W-MR-96,H-MB-18,'高频短时 · 重复触发','调整参数或屏蔽, 非硬件故障')}
  2933. ${pts}${lab}
  2934. <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-6}" font-size="9.5" fill="var(--muted)" text-anchor="middle">发生条数 (对数)</text>
  2935. <text x="14" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle" transform="rotate(-90 14 ${(MT+(H-MB-MT)/2).toFixed(1)})">单次中位时长 (对数)</text>
  2936. </svg>`;
  2937. }
  2938. // 月度故障率图 (2026-08-28 审核逮的反向误读)。
  2939. // 原图画"月度条数", 末月 1371 条看着是全窗最低=在改善; 但 2026-07 只有 6 天数据(覆盖 19.4%),
  2940. // 按日均归一后是 228 条/日 = 全窗第二高, 是 6 月(75)的 3 倍 — 方向完全相反。
  2941. // ⇒ 残月绝不能与整月并列比较。柱=日均(可比), 灰斜纹=数据不满月, 数字标总条数(原始事实)。
  2942. function rateChart(mo){
  2943. const ms=mo.months, vs=mo.vals, cov=mo.cov||ms.map(()=>1), pd=mo.per_day||[];
  2944. const W=720,H=250,ML=48,MR=14,MB=52,MT=18, n=ms.length;
  2945. const mx=Math.max(...pd.filter(x=>x!=null),1), bw=(W-ML-MR)/n;
  2946. let bars='', lab='';
  2947. ms.forEach((m,i)=>{
  2948. const v=pd[i], part=cov[i]<0.5;
  2949. if(v==null) return;
  2950. const hh=v/mx*(H-MT-MB), bx=ML+i*bw+bw*0.16, by=H-MB-hh;
  2951. bars+=`<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw*0.68).toFixed(1)}" height="${hh.toFixed(1)}" rx="2"
  2952. fill="${part?'var(--muted)':'var(--accent)'}" fill-opacity="${part?0.32:0.8}"
  2953. stroke="${part?'var(--warn)':'none'}" stroke-dasharray="${part?'3 2':''}">
  2954. <title>${m}: ${v} 条/日 (总 ${vs[i]} 条, 数据覆盖 ${(cov[i]*100).toFixed(0)}%)</title></rect>`;
  2955. lab+=`<text x="${(bx+bw*0.34).toFixed(1)}" y="${(by-4).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="middle">${v}</text>`
  2956. +`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+13}" font-size="9" fill="var(--muted)" text-anchor="middle">${m.slice(5)}月</text>`
  2957. +`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+25}" font-size="8" fill="var(--muted)" opacity="0.65" text-anchor="middle">${vs[i]}条</text>`
  2958. +(part?`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+37}" font-size="8" fill="var(--warn)" text-anchor="middle">仅${Math.round(cov[i]*30)}天</text>`:'');
  2959. });
  2960. const yt=[0,.5,1].map(f=>`<text x="${ML-6}" y="${(H-MB-f*(H-MT-MB)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${Math.round(mx*f)}</text>`).join('');
  2961. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  2962. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  2963. <line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H-MB}" stroke="var(--line)"/>
  2964. ${bars}${lab}${yt}
  2965. <text x="14" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle"
  2966. transform="rotate(-90 14 ${(MT+(H-MB-MT)/2).toFixed(1)})">条/日</text></svg>`;
  2967. }
  2968. // 能量账瀑布 (经典发电量分析)。四行工况表回答不了业主唯一关心的问题: 电去哪了。
  2969. // 左=理论可发, 中间逐段扣损(按损失降序), 右=实际上网; 段高即电量, 一眼看出谁是大头。
  2970. // 物理闸: 理论可发不得超 台数×额定×窗时长 (能量守恒, memory ledger-loss-numbers-need-physical-cap-check)。
  2971. const LOSSPLAIN = {
  2972. '限电·绑定':'限电 (命令与实际功率对得上)',
  2973. '限电命令·未绑定':'限电命令期 · 功率未被压制',
  2974. '停机(调度令)':'调度令停机',
  2975. '停机':'故障与检修停机',
  2976. '正常发电':'正常发电时的欠发',
  2977. '低风待机':'低风待机'};
  2978. function waterfall(e){
  2979. if(!e||!e.theo) return '';
  2980. const W=720,H=300,ML=8,MR=8,MT=30,MB=64, items=(e.items||[]).filter(x=>x.loss>0);
  2981. const cols=items.length+2, cw=(W-ML-MR)/cols, sc=(H-MT-MB)/e.theo;
  2982. let run=e.theo, h='', i=0;
  2983. const bar=(idx,y,hh,fill,op)=>`<rect x="${(ML+idx*cw+cw*0.14).toFixed(1)}" y="${y.toFixed(1)}"
  2984. width="${(cw*0.72).toFixed(1)}" height="${Math.max(hh,1.2).toFixed(1)}" rx="2" fill="${fill}" fill-opacity="${op}"/>`;
  2985. const lab=(idx,y,t,v,c)=>`<text x="${(ML+idx*cw+cw*0.5).toFixed(1)}" y="${(y-6).toFixed(1)}" font-size="10"
  2986. fill="${c||'var(--fg)'}" text-anchor="middle" font-weight="600">${v}</text>`
  2987. +`<text x="${(ML+idx*cw+cw*0.5).toFixed(1)}" y="${H-MB+14}" font-size="8.5" fill="var(--muted)" text-anchor="middle">${t}</text>`;
  2988. h+=bar(0,H-MB-e.theo*sc,e.theo*sc,'var(--info)',0.45)
  2989. +lab(0,H-MB-e.theo*sc,'理论可发',Math.round(e.theo).toLocaleString());
  2990. items.forEach(x=>{ i++;
  2991. const top=H-MB-run*sc, hh=x.loss*sc; run-=x.loss;
  2992. const nm=LOSSPLAIN[x.k]||x.k;
  2993. h+=bar(i,top,hh,'var(--bad)',0.55)
  2994. +`<line x1="${(ML+(i-1)*cw+cw*0.86).toFixed(1)}" y1="${top.toFixed(1)}" x2="${(ML+i*cw+cw*0.14).toFixed(1)}" y2="${top.toFixed(1)}"
  2995. stroke="var(--muted)" stroke-dasharray="2 2" stroke-opacity="0.6"/>`
  2996. +lab(i,top,nm.length>10?nm.slice(0,9)+'…':nm,'−'+Math.round(x.loss).toLocaleString(),'var(--bad)')
  2997. +`<title>${nm}: 损失 ${x.loss.toLocaleString()} MWh, 占理论 ${(x.loss/e.theo*100).toFixed(1)}%, 时长 ${x.h.toLocaleString()}h</title>`;
  2998. });
  2999. h+=bar(cols-1,H-MB-e.act*sc,e.act*sc,'var(--ok)',0.6)
  3000. +lab(cols-1,H-MB-e.act*sc,'实际上网',Math.round(e.act).toLocaleString(),'var(--ok)');
  3001. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3002. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  3003. <text x="${ML}" y="14" font-size="9.5" fill="var(--muted)">MWh</text>${h}</svg>`;
  3004. }
  3005. // 可靠性-可维修性象限 (经典 RAM 分析, 2026-08-28 用户令: 停机事件化与可靠性可视化)。
  3006. // x=MTBF 平均无故障间隔(可靠性), y=MDT 平均单次停机时长(可维修性), 点面积=累计停机时长。
  3007. // 四象限的处置方向完全不同 — 表格按"停机时长"排序把这层结构整个抹掉了:
  3008. // 左上 常坏且难修 → 最高优先级 左下 频发易修 → 查根因(治本, 换件无用)
  3009. // 右上 罕发难修 → 备件与作业窗 右下 少发好修 → 维持
  3010. function ramChart(tb){
  3011. const it=(tb||[]).filter(x=>x.MTBF_h&&x.MDT_h);
  3012. if(it.length<3) return '<p class="small">部件数不足, 不作象限</p>';
  3013. const W=720,H=340,ML=56,MR=112,MB=48,MT=16;
  3014. const lg=v=>Math.log10(v), xs=it.map(d=>lg(d.MTBF_h)), ys=it.map(d=>d.MDT_h);
  3015. const x0=Math.min(...xs)-0.08,x1=Math.max(...xs)+0.08,y0=0,y1=Math.max(...ys)*1.12;
  3016. const PX=v=>ML+(lg(v)-x0)/(x1-x0)*(W-ML-MR), PY=v=>H-MB-(v-y0)/(y1-y0)*(H-MT-MB);
  3017. const md=a=>{const z=[...a].sort((p,q)=>p-q);return z[Math.floor(z.length/2)]};
  3018. const cx=PX(md(it.map(d=>d.MTBF_h))), cy=PY(md(ys));
  3019. const mh=Math.max(...it.map(d=>d.停机时长h));
  3020. const pts=it.map(d=>{
  3021. const r=4+Math.sqrt(d.停机时长h/mh)*13;
  3022. const ext=d.外部, hot=(d.MTBF_h<md(it.map(v=>v.MTBF_h))&&d.MDT_h>=md(ys));
  3023. const c=ext?'var(--muted)':(hot?'var(--bad)':'var(--accent)');
  3024. return `<circle cx="${PX(d.MTBF_h).toFixed(1)}" cy="${PY(d.MDT_h).toFixed(1)}" r="${r.toFixed(1)}"
  3025. fill="${c}" fill-opacity="${ext?0.3:0.42}" stroke="${c}" stroke-width="1.2"
  3026. stroke-dasharray="${ext?'3 2':''}"><title>${d.部件}: MTBF ${d.MTBF_h}h · MDT ${d.MDT_h}h · 停机 ${d.停机时长h}h · ${d.事件数}起 · ${d.影响台数}台${ext?' · 外部类不计设备可靠性':''}</title></circle>
  3027. <text x="${(PX(d.MTBF_h)+r+4).toFixed(1)}" y="${(PY(d.MDT_h)+3.5).toFixed(1)}" font-size="9.5"
  3028. fill="${ext?'var(--muted)':'currentColor'}">${d.部件.slice(0,7)}</text>`;}).join('');
  3029. const qn=(x,y,a,t,sub)=>`<text x="${x}" y="${y}" font-size="10" fill="var(--muted)" text-anchor="${a}" opacity="0.9">${t}</text>
  3030. <text x="${x}" y="${y+12}" font-size="8.5" fill="var(--muted)" text-anchor="${a}" opacity="0.62">${sub}</text>`;
  3031. const xt=[1000,2000,5000,10000,20000].filter(v=>lg(v)>=x0&&lg(v)<=x1).map(v=>
  3032. `<line x1="${PX(v).toFixed(1)}" y1="${MT}" x2="${PX(v).toFixed(1)}" y2="${H-MB}" stroke="var(--line)" stroke-opacity="0.3"/>
  3033. <text x="${PX(v).toFixed(1)}" y="${H-MB+14}" font-size="9" fill="var(--muted)" text-anchor="middle">${v>=1000?(v/1000)+'k':v}</text>`).join('');
  3034. const yt=[0,5,10,15,20].filter(v=>v<=y1).map(v=>
  3035. `<text x="${ML-6}" y="${(PY(v)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${v}</text>`).join('');
  3036. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3037. ${xt}${yt}
  3038. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  3039. <line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H-MB}" stroke="var(--line)"/>
  3040. <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H-MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.45"/>
  3041. <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W-MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.45"/>
  3042. ${qn(ML+6,MT+12,'start','常坏且难修','最高优先级')}
  3043. ${qn(W-MR-6,MT+12,'end','罕发难修','备件与作业窗')}
  3044. ${qn(ML+6,H-MB-16,'start','频发易修','查根因, 换件无用')}
  3045. ${qn(W-MR-6,H-MB-16,'end','少发好修','维持')}
  3046. ${pts}
  3047. <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-8}" font-size="9.5" fill="var(--muted)" text-anchor="middle">平均无故障间隔 MTBF (h, 对数) → 越右越可靠</text>
  3048. <text x="13" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle"
  3049. transform="rotate(-90 13 ${(MT+(H-MB-MT)/2).toFixed(1)})">单次停机时长 MDT (h) → 越上越难修</text>
  3050. <circle cx="${W-MR+16}" cy="${MT+40}" r="5" fill="var(--accent)" fill-opacity="0.42" stroke="var(--accent)"/>
  3051. <text x="${W-MR+26}" y="${MT+44}" font-size="9" fill="var(--muted)">设备类</text>
  3052. <circle cx="${W-MR+16}" cy="${MT+58}" r="5" fill="var(--muted)" fill-opacity="0.3" stroke="var(--muted)" stroke-dasharray="3 2"/>
  3053. <text x="${W-MR+26}" y="${MT+62}" font-size="9" fill="var(--muted)">外部类</text>
  3054. <text x="${W-MR+6}" y="${MT+84}" font-size="8.5" fill="var(--muted)">圆面积 = 累计停机时长</text>
  3055. <text x="${W-MR+6}" y="${MT+98}" font-size="8.5" fill="var(--muted)">虚线 = 本场中位分界</text>
  3056. </svg>`;
  3057. }
  3058. // 归因覆盖率条: "无记录停机" 是最大单项却因不给 MTBF 而在可靠性排序里隐身 (规划 P1-2)
  3059. function attribBar(tb, tot){
  3060. const nore=(tb||[]).find(x=>x.部件==='无记录停机');
  3061. const unk=nore?nore.停机时长h:0, un2=(tb||[]).filter(x=>x.部件==='未归类').reduce((a,b)=>a+b.停机时长h,0);
  3062. const ok=tot-unk-un2, W=720,H=54;
  3063. const seg=(x,w,c,op)=>`<rect x="${x.toFixed(1)}" y="14" width="${Math.max(w,1).toFixed(1)}" height="22" rx="3" fill="${c}" fill-opacity="${op}"/>`;
  3064. const w1=ok/tot*W, w2=un2/tot*W, w3=unk/tot*W;
  3065. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3066. ${seg(0,w1,'var(--accent)',0.55)}${seg(w1,w2,'var(--warn)',0.5)}${seg(w1+w2,w3,'var(--bad)',0.5)}
  3067. <text x="4" y="10" font-size="9" fill="var(--muted)">已归因到部件 ${Math.round(ok)}h (${(ok/tot*100).toFixed(0)}%)</text>
  3068. <text x="${(w1+w2+w3-2).toFixed(1)}" y="10" font-size="9" fill="var(--bad)" text-anchor="end">无记录 ${Math.round(unk)}h (${(unk/tot*100).toFixed(0)}%)</text>
  3069. <text x="${(w1+w2/2).toFixed(1)}" y="50" font-size="9" fill="var(--warn)" text-anchor="middle">未归类 ${Math.round(un2)}h</text>
  3070. </svg>`;
  3071. }
  3072. // 失败必须响亮 (2026-08-28 全系统盘点逮): 后端 m8/m9/rel/fus 四块都是
  3073. // `except: X = {err: ...}`, 前端一律 `&&!d.X.err` 直接跳过渲染 —— 界面看不出"这块没算出来",
  3074. // 看起来只是"这一页没这个内容"。reliability 的 bool 位取反 bug 就是这样活下来的。
  3075. // ⇒ 出错时改为渲染一张醒目失败卡: 说清哪块没了、意味着什么缺失、错在哪。
  3076. const BLOCKDESC = {
  3077. rel: ['部件停机贡献与可靠性', '本页看不到部件级 MTBF/MDT 与停机归因'],
  3078. m8: ['停机事件化与故障季节性', '故障统计页缺停机归因与季节形态'],
  3079. m9: ['控制参数一致性', '发电性能页缺封顶分组与桨距调度差'],
  3080. fus: ['振动四源融合', '振动融合页缺逐台判级矩阵'],
  3081. };
  3082. function blockErr(key, err){
  3083. const d = BLOCKDESC[key] || [key, ''];
  3084. return `<div class="card mb" style="border-left:3px solid var(--bad)">
  3085. <h3 style="color:var(--bad)">⚠ ${d[0]} — 未算出</h3>
  3086. <p class="small" style="margin-bottom:6px">${d[1]}。<b>非"本窗无数据", 属计算失败</b> — 下方数字不完整, 不要据此下结论。</p>
  3087. <pre class="small" style="white-space:pre-wrap;color:var(--muted);background:var(--bg);padding:8px 10px;border-radius:6px;margin:0">${String(err).slice(0,300)}</pre></div>`;
  3088. }
  3089. // 逐页窗声明 (2026-08-28 审核逮的交互欺骗)。
  3090. // 顶部窗选择器对"发电性能"完全无效 — 那 19 张图是预计算产物, 窗在生成时钉死为 2025-07~2026-01;
  3091. // 用户切到"2026年"图纹丝不动却毫无提示。"控制策略"曾更糟(工况态跟随+参数件固定窗并列),
  3092. // 两个窗并列在同一页。窗对不齐 = claim窗≠证据窗, 是判据失效的头号来源, 必须写在脸上。
  3093. const WINSPEC = {
  3094. '发电性能': {mode:'fixed', win:'2025-07 ~ 2025-12',
  3095. why:'限电前干净窗 — 2026 起交易策略主导, 曲线与控制参数含策略性少发不可作设备判据。本页含特性曲线与控制参数一致性两件, 同窗'},
  3096. // 2026-08-28 用户裁: 控制参数件(固定窗)迁入发电性能, "电量算账"只留跟随窗内容
  3097. // → 每页只有一个窗制, 混合窗从根上消掉 (原来是本系统唯一的双窗页)
  3098. };
  3099. function winBanner(tab, curWin, extra){
  3100. const w = WINSPEC[tab];
  3101. if(!w) return `<div class="winb"><b>时间窗</b> ${curWin} · 本页全部数字随上方窗切换${extra?' | '+extra:''}</div>`;
  3102. const c = w.mode==='fixed' ? 'var(--warn)' : 'var(--info)';
  3103. const tag = w.mode==='fixed' ? '固定窗 · 不随上方切换' : '本页含两个窗';
  3104. return `<div class="winb" style="border-left-color:${c}">
  3105. <b style="color:${c}">${tag}</b> ${w.win}
  3106. <div class="small" style="color:var(--muted);margin-top:3px">${w.why}${extra?' | '+extra:''}</div></div>`;
  3107. }
  3108. // 控制参数三图 (2026-08-28 用户令: 控制参数一致性要图形, 在发电性能展示)。
  3109. // 原来是三段密文字, 关键结论"离散成组≠连续分布"完全看不出来 — 而这正是判"参数级差异"的依据。
  3110. // A 封顶参数数轴: 台数落在几个离散位置上 = 配置/版本差异; 若是磨损或工况会是连续散布。
  3111. // B 桨距同档差: 零中线偏差条, 长度即幅度, 与全场中位同尺比。
  3112. // C 尺子巡检: 极差/σ 比 — 38 台正态抽样的极差期望约 3.7σ, 明显超出即分布非单峰(佐证 A 的分组)。
  3113. function capAxis(P, W9){
  3114. const W=500, H=158, ML=54, MR=34;
  3115. const row=(y, spec, unit, label)=>{
  3116. const gs=Object.entries(spec.分组).map(([k,v])=>({v:parseFloat(k), n:v, raw:k}));
  3117. if(!gs.length) return '';
  3118. const lo=Math.min(...gs.map(g=>g.v)), hi=Math.max(...gs.map(g=>g.v));
  3119. const pad=(hi-lo)*0.22||1, x0=lo-pad, x1=hi+pad;
  3120. const PX=v=>ML+(v-x0)/(x1-x0)*(W-ML-MR);
  3121. const mn=Math.max(...gs.map(g=>g.n));
  3122. const dots=gs.map(g=>{const r=5+Math.sqrt(g.n/mn)*9;
  3123. return `<circle cx="${PX(g.v).toFixed(1)}" cy="${y}" r="${r.toFixed(1)}" fill="var(--accent)" fill-opacity="0.42" stroke="var(--accent)"/>
  3124. <text x="${PX(g.v).toFixed(1)}" y="${y-r-5}" font-size="9" text-anchor="middle" fill="currentColor">${g.raw}</text>
  3125. <text x="${PX(g.v).toFixed(1)}" y="${y+3.5}" font-size="9" text-anchor="middle" fill="var(--panel)" font-weight="600">${g.n}</text>`}).join('');
  3126. const med=spec['中位'];
  3127. return `<text x="${ML-6}" y="${y+3}" font-size="9.5" text-anchor="end" fill="var(--muted)">${label}</text>
  3128. <line x1="${ML}" y1="${y}" x2="${W-MR}" y2="${y}" stroke="var(--line)"/>
  3129. <line x1="${PX(med).toFixed(1)}" y1="${y-24}" x2="${PX(med).toFixed(1)}" y2="${y+18}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.55"/>
  3130. <text x="${PX(med).toFixed(1)}" y="${y+28}" font-size="8.5" text-anchor="middle" fill="var(--muted)">中位 ${med}</text>
  3131. ${dots}`;
  3132. };
  3133. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3134. ${row(44, P, 'kW', '满发封顶')}
  3135. ${row(120, W9, 'rpm', '转速封顶')}
  3136. <text x="${W-MR}" y="12" font-size="8.5" text-anchor="end" fill="var(--muted)">圆内数字=台数, 圆面积∝台数</text></svg>`;
  3137. }
  3138. function betaBar(b){
  3139. const outs=(b.离群||[]), med=b['全场同档差中位'];
  3140. const W=500, H=34+outs.length*26, ML=52, MR=58;
  3141. const mx=Math.max(med, ...outs.map(o=>o.平均偏))*1.18||1;
  3142. const PX=v=>ML+v/mx*(W-ML-MR);
  3143. const rows=outs.map((o,i)=>{const y=30+i*26;
  3144. return `<text x="${ML-6}" y="${y+10}" font-size="9.5" text-anchor="end" fill="currentColor">${o.t.replace('WTG','')}#</text>
  3145. <rect x="${ML}" y="${y+1}" width="${(PX(o.平均偏)-ML).toFixed(1)}" height="13" rx="2" fill="var(--warn)" fill-opacity="0.62"/>
  3146. <text x="${(PX(o.平均偏)+5).toFixed(1)}" y="${y+11}" font-size="9" fill="var(--warn)">${o.平均偏}°</text>`}).join('');
  3147. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3148. <line x1="${PX(med).toFixed(1)}" y1="18" x2="${PX(med).toFixed(1)}" y2="${H-4}" stroke="var(--ok)" stroke-dasharray="3 3"/>
  3149. <text x="${PX(med).toFixed(1)}" y="12" font-size="8.5" fill="var(--ok)">全场中位 ${med}°</text>
  3150. <line x1="${ML}" y1="18" x2="${ML}" y2="${H-4}" stroke="var(--line)"/>
  3151. ${rows}</svg>`;
  3152. }
  3153. function rulerBar(rl){
  3154. const ks=Object.entries(rl); const W=500, H=26+ks.length*30, ML=88, MR=76;
  3155. const NORM=3.7; // 38 台正态抽样极差期望 ≈ 3.7σ
  3156. const mxr=Math.max(NORM*1.35, ...ks.map(([,v])=>v.极差/v.σ));
  3157. const PX=v=>ML+v/mxr*(W-ML-MR);
  3158. const rows=ks.map(([k,v],i)=>{const y=22+i*30, ratio=v.极差/v.σ, wide=ratio>NORM;
  3159. return `<text x="${ML-6}" y="${y+11}" font-size="9.5" text-anchor="end" fill="currentColor">${k}</text>
  3160. <rect x="${ML}" y="${y+2}" width="${(PX(ratio)-ML).toFixed(1)}" height="12" rx="2"
  3161. fill="${wide?'var(--warn)':'var(--accent)'}" fill-opacity="0.55"/>
  3162. <text x="${(PX(ratio)+5).toFixed(1)}" y="${y+11}" font-size="9" fill="var(--muted)">${ratio.toFixed(1)}σ σ=${v.σ}</text>`}).join('');
  3163. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3164. <line x1="${PX(NORM).toFixed(1)}" y1="14" x2="${PX(NORM).toFixed(1)}" y2="${H-4}" stroke="var(--ok)" stroke-dasharray="3 3"/>
  3165. <text x="${PX(NORM).toFixed(1)}" y="10" font-size="8.5" fill="var(--ok)" text-anchor="middle">正态期望 3.7σ</text>
  3166. ${rows}</svg>`;
  3167. }
  3168. // 责任分账 (2026-08-28 用户令: 电量算账优化显示)。
  3169. // 瀑布回答"电量流向", 但业主真正要问的下一句是"谁的责任" — 原来只在一段小字里写了个百分比。
  3170. // 三分账: 交易与调度侧(限电两项+调度令停机) / 设备侧(故障与检修停机) / 运行侧(正常发电欠发+低风)。
  3171. function blameBar(e){
  3172. const cat = k => (k.indexOf('限电')>=0 || k.indexOf('调度')>=0) ? 0 : (k==='停机' ? 1 : 2);
  3173. const NM = ['交易与调度侧','设备侧','运行侧'];
  3174. const NOTE = ['限电与调度令, 不进设备责任','故障与检修停机, 唯一进设备考核','正常发电欠发与低风待机'];
  3175. const CL = ['var(--info)','var(--bad)','var(--muted)'];
  3176. const g = [0,0,0];
  3177. (e.items||[]).forEach(x => { g[cat(x.k)] += x.loss; });
  3178. const tot = g.reduce((a,b)=>a+b,0) || 1;
  3179. const W=500, H=44;
  3180. let x=0, seg='';
  3181. g.forEach((v,i)=>{ const w=v/tot*W;
  3182. seg += `<rect x="${x.toFixed(1)}" y="12" width="${Math.max(w,1).toFixed(1)}" height="26" rx="3" fill="${CL[i]}" fill-opacity="0.55"><title>${NM[i]}: ${Math.round(v).toLocaleString()} MWh</title></rect>`
  3183. + (w>46 ? `<text x="${(x+w/2).toFixed(1)}" y="29" font-size="10.5" fill="currentColor" text-anchor="middle" font-weight="600">${(v/tot*100).toFixed(0)}%</text>` : '');
  3184. x += w; });
  3185. // 图例走 HTML 而非 SVG text: SVG 里估算中文字宽不准, 三段说明会互相压字 (实测重叠)
  3186. const leg = g.map((v,i)=>`<div style="display:flex;align-items:baseline;gap:7px;margin-top:5px">
  3187. <i style="flex:none;width:9px;height:9px;border-radius:2px;background:${CL[i]};opacity:.55"></i>
  3188. <span style="font-weight:600">${NM[i]}</span>
  3189. <span class="cnt">${Math.round(v).toLocaleString()} MWh</span>
  3190. <span class="small" style="color:var(--muted)">${NOTE[i]}</span></div>`).join('');
  3191. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3192. <text x="0" y="8" font-size="9" fill="var(--muted)">损失 ${Math.round(tot).toLocaleString()} MWh 按责任归属</text>
  3193. ${seg}</svg>${leg}`;
  3194. }
  3195. // 帕累托图 (2026-08-28 用户令: 故障统计按经典分析展示)。
  3196. // 经典做法 = 条形按降序 + 右轴累计百分比折线 + 80% 参考线 → 一眼看出"关键少数";
  3197. // 原来只有两列排序表, 看不出前几项就占了大头。
  3198. function paretoChart(items, unit, note){
  3199. const it = (items || []).filter(x => x && x.v > 0);
  3200. if(!it.length) return '<p class="small">窗内无数据</p>';
  3201. // 尺寸 (2026-08-28 排布优化): 原 720x260 + 旋转长标签, 两列并排时被缩到 0.60 倍 = 字高 6.5px 不可读。
  3202. // 改紧凑版 520x236: x 轴只标码号(水平, 不旋转 → 底部留白 86→40), 全名进悬停与图注前三行。
  3203. const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14;
  3204. const n = it.length, tot = it.reduce((a, x) => a + x.v, 0);
  3205. const mx = Math.max(...it.map(x => x.v));
  3206. const bw = (W - ML - MR) / n;
  3207. const short = k => { const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
  3208. let cum = 0, bars = '', line = [], dots = '', k80 = null;
  3209. it.forEach((x, i) => {
  3210. const hh = (x.v / mx) * (H - MT - MB);
  3211. const bx = ML + i * bw + bw * 0.14, by = H - MB - hh;
  3212. bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * 0.72).toFixed(1)}" height="${hh.toFixed(1)}"
  3213. rx="2" fill="var(--accent)" fill-opacity="${i < 3 ? 0.85 : 0.45}"><title>${x.k}: ${x.v}${unit}</title></rect>`;
  3214. cum += x.v;
  3215. const pct = cum / tot;
  3216. if(k80 === null && pct >= 0.8) k80 = i + 1;
  3217. const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB);
  3218. line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
  3219. dots += `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="var(--warn)"><title>${x.k} · 累计 ${(pct*100).toFixed(0)}%</title></circle>`;
  3220. });
  3221. const y80 = MT + 0.2 * (H - MT - MB);
  3222. const xt = it.map((x, i) => `<text x="${(ML + i*bw + bw/2).toFixed(1)}" y="${H - MB + 13}" font-size="9"
  3223. fill="var(--muted)" text-anchor="middle">${short(x.k)}</text>`).join('');
  3224. const yt = [0, .5, 1].map(f => `<text x="${ML-5}" y="${(H-MB-(f*(H-MT-MB))+3).toFixed(1)}" font-size="9"
  3225. fill="var(--muted)" text-anchor="end">${Math.round(mx*f).toLocaleString()}</text>`).join('')
  3226. + [0, .5, 1].map(f => `<text x="${W-MR+5}" y="${(MT+(1-f)*(H-MT-MB)+3).toFixed(1)}" font-size="9"
  3227. fill="var(--warn)">${Math.round(f*100)}%</text>`).join('');
  3228. const top3 = it.slice(0, 3).map(x => `<b>${short(x.k)}</b> ${String(x.k).replace(/^[0-9]+ */, '').slice(0, 13)}`).join(' · ');
  3229. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  3230. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  3231. <line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H-MB}" stroke="var(--line)"/>
  3232. ${bars}
  3233. <line x1="${ML}" y1="${y80.toFixed(1)}" x2="${W-MR}" y2="${y80.toFixed(1)}" stroke="var(--warn)"
  3234. stroke-dasharray="4 3" stroke-opacity="0.7"/>
  3235. <text x="${W-MR-3}" y="${(y80-4).toFixed(1)}" font-size="9" fill="var(--warn)" text-anchor="end">累计 80%</text>
  3236. <polyline points="${line.join(' ')}" fill="none" stroke="var(--warn)" stroke-width="1.5"/>${dots}
  3237. ${xt}${yt}
  3238. <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-3}" font-size="8.5" fill="var(--muted)" text-anchor="middle">报警码 (悬停看全名)</text></svg>
  3239. <p class="small" style="color:var(--muted);margin-top:5px;line-height:1.6">
  3240. ${k80 ? `<b>关键少数: 前 ${k80} 项占 80%</b> (共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit})` : `共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit}`}
  3241. <br>前三: ${top3}${note ? '<br>' + note : ''}</p>`;
  3242. }
  3243. // ── 风场阵列 (2026-08-31 用户令: 风机与环境形象要用视觉体现) ──────────────
  3244. // 视觉语言与推介页 hero 同源: 细线几何 + stroke-opacity 造景深, 不写实。
  3245. // 它同时是**信息图**不只是插画 —— 每台按其最严重系统状态着色, 全场健康分布一眼可见。
  3246. // 快照版总览原本只有一句"内嵌页不可用", 这里正好补上。
  3247. // ── 运维成熟度与四判据 (2026-08-31) ───────────────────────────────
  3248. // 为什么放在检修决策页顶端: 这四条是"系统会主动拦下来"的判据, 不是又一张统计图。
  3249. // 那三个问题(复发/共性/记录断层)在本场存在五年没被发现, 不是没人懂,
  3250. // 是没有任何环节会主动报出来。
  3251. // 本体里的文本用 **x** 标重点 (那是给人读源文件时用的)。直接插进 HTML 会把星号
  3252. // 原样印在客户交付页上。这里只转粗体, 不引入 markdown 解析器 —— 需求就这一条。
  3253. // 用字符类 [*] 而非反斜杠转义 —— CHART_JS 是**非 raw** 的 Python 三引号串,
  3254. // 反斜杠在这里会被 Python 当转义处理: 反斜杠加星号触发 SyntaxWarning, 反斜杠加 n
  3255. // 直接变成真换行, 把单行注释劈成两行 —— 第二行不再是注释, 当场语法错、页面白屏。
  3256. // (写这条注释时我为了举例又写了一次那个转义, 于是当场复现了它描述的 bug,
  3257. // 被成品页语法闸逮到。所以: 这个字符串里能不写反斜杠就不写, 举例也别写。)
  3258. // 层级后缀单独成常量 —— 原来写成 `${lv}层` 时那个 `层` 是模板里的**字面量孤字**,
  3259. // 数据侧的 `机理` 译成了 Domain model 而它没译, 页面上就是 'Domain model层'。
  3260. // 提成常量后它是一个可查表的完整词。
  3261. const LAYER_SFX = '层';
  3262. const TIMES_SFX = '倍';
  3263. const mdB = s => String(s == null ? '' : s).replace(/[*][*]([^*]+)[*][*]/g, '<b>$1</b>');
  3264. // 快照版总览面板: 用已固化的数据汇总, 而不是道歉。
  3265. // 逐系统两个键: 裸名 = 有问题的机组, `·良好` = 判良好的机组 —— 汇总时要分开数。
  3266. function snapOverview(d){
  3267. const SY = d.systems || {};
  3268. const rows = [];
  3269. Object.keys(SY).forEach(function(k){
  3270. if (k.indexOf('·') >= 0) return; // 只遍历裸名, 良好那半单独取
  3271. const bad = (SY[k] || []).length;
  3272. const good = (SY[k + '·良好'] || []).length;
  3273. if (bad + good) rows.push({sys: k, bad: bad, good: good});
  3274. });
  3275. rows.sort(function(a, b){ return b.bad - a.bad; });
  3276. const K = d.kpi || {};
  3277. const M = (d.m8 && d.m8.mtbf) || {};
  3278. let h = '<div class="card mb"><div class="eyebrow">Where to look</div>';
  3279. h += '<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
  3280. + '本页为固化快照: 上方每个标签页的数据都已随页面打包, 断网也能看。'
  3281. + '下表按系统汇总当前状态, 点系统名进该系统的机组清单。</div>';
  3282. if (rows.length){
  3283. h += '<table class="tbl"><thead><tr><th>系统</th><th>需跟踪</th><th>判良好</th>'
  3284. + '<th>占全场</th></tr></thead><tbody>';
  3285. rows.forEach(function(r){
  3286. const tot = r.bad + r.good;
  3287. // 用 data 属性 + 委托事件, **不写嵌套引号** —— CHART_JS 是非 raw 的 Python
  3288. // 三引号串, 里面的反斜杠转义会被 Python 吃掉, 反引号/单引号一嵌套就断串。
  3289. // (今天第三次栽在这条上, 每次都是成品页语法闸拦下来的。)
  3290. h += '<tr><td><a class="tl" href="#" data-gotab="部件问题">'
  3291. + r.sys + '</a></td>'
  3292. + '<td class="num"><b style="color:' + (r.bad ? 'var(--warn)' : 'var(--muted)') + '">'
  3293. + r.bad + '</b></td><td class="num">' + r.good + '</td>'
  3294. + '<td class="num small">' + tot + '</td></tr>';
  3295. });
  3296. h += '</tbody></table>';
  3297. }
  3298. h += '<div class="kpis" style="margin-top:12px">'
  3299. + '<div class="kpi"><b>' + (K['报警台'] != null ? K['报警台'] : '—') + '</b>'
  3300. + '<span>机组带报警级判定</span></div>'
  3301. + '<div class="kpi"><b>' + (K['关注台'] != null ? K['关注台'] : '—') + '</b>'
  3302. + '<span>机组在跟踪清单上</span></div>'
  3303. + '<div class="kpi"><b>' + (M['停机事件数'] != null ? M['停机事件数'] : '—') + '</b>'
  3304. + '<span>停机事件 (本窗)</span></div></div>';
  3305. h += '<div style="font-size:12px;color:var(--muted);margin-top:14px;line-height:1.75">'
  3306. + '<b>各屏回答什么</b><br>'
  3307. + '电量算账 — 电去哪了, 每一度归到哪个原因<br>'
  3308. + '部件问题 — 哪台哪系统该管, 依据是什么<br>'
  3309. + '振动融合分析 — 振动与 SCADA 对上没有<br>'
  3310. + '发电性能 — 曲线偏了没有, 偏在哪一段<br>'
  3311. + '故障统计 — 报警的关键少数与可靠性象限<br>'
  3312. + '检修决策 — 数据本身可不可信, 运维体系差在哪</div>';
  3313. h += '</div>';
  3314. return h;
  3315. }
  3316. // 总览表里的系统名跳到对应标签页。委托绑定, 只挂一次。
  3317. document.addEventListener('click', function(e){
  3318. var a = e.target && e.target.closest ? e.target.closest('a[data-gotab]') : null;
  3319. if (!a) return;
  3320. e.preventDefault();
  3321. TAB = a.getAttribute('data-gotab');
  3322. render();
  3323. });
  3324. function dqFindings(d){
  3325. // 数据可信度 —— 系统自查出的数据质量与口径问题。
  3326. // 放在判据面板之前: 先说"这些数字可不可信", 再说"数字说明什么"。
  3327. // 一个只报结论不报数据缺陷的系统, 是在把风险留给读者。
  3328. if(!d) return '<div class="card">数据可信度加载中…</div>';
  3329. if(d.err) return '<div class="card">数据可信度: '+d.err+'</div>';
  3330. const KIND={data_quality:['数据质量','var(--bad)'], caliber:['口径','var(--warn)'],
  3331. system_coverage:['覆盖','var(--accent)'], tier_trend:['结构趋势','var(--ok)']};
  3332. const GR={established:['定论','var(--bad)'], candidate:['候选','var(--warn)'],
  3333. reference:['参考','var(--muted)']};
  3334. let h='<div class="card"><div class="eyebrow">数据可信度 · 系统自查发现</div>';
  3335. h+='<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
  3336. +'先回答"这些数字可不可信", 再谈数字说明什么。以下每条都是在做分析的过程中被判据拦下来的, '
  3337. +'不是事后补的说明。</div>';
  3338. (d.rows||[]).forEach(r=>{
  3339. const k=KIND[r.kind]||['其他','var(--muted)'];
  3340. const g=GR[r.grade]||[r.grade,'var(--muted)'];
  3341. h+=`<div style="border:1px solid var(--line);border-left:3px solid ${k[1]};border-radius:var(--r);
  3342. padding:12px 14px;margin-bottom:10px;background:var(--panel-2)">`;
  3343. h+=`<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;margin-bottom:6px">
  3344. <span style="font-size:11px;padding:1px 8px;border-radius:10px;background:${k[1]};color:#fff">${k[0]}</span>
  3345. <span style="font-size:11px;color:${g[1]};border:1px solid ${g[1]};padding:0 6px;border-radius:10px">${g[0]}</span>
  3346. <span style="font-size:11px;color:var(--muted);font-family:var(--num)">${r.claim_window||''}</span>
  3347. </div>`;
  3348. h+=`<div style="font-size:13px;font-weight:600;line-height:1.5;margin-bottom:6px">${mdB(r.name)}</div>`;
  3349. if((r.evidence||[]).length){
  3350. h+='<ul style="margin:4px 0 6px 16px;padding:0;font-size:12px;color:var(--ink);line-height:1.65">';
  3351. (r.evidence||[]).slice(0,4).forEach(e=>{h+=`<li>${mdB(e)}</li>`});
  3352. h+='</ul>';
  3353. }
  3354. if(r.impact) h+=`<div style="font-size:12px;color:var(--ok);line-height:1.6;margin-top:4px">
  3355. <b>判别 / 处置</b> ${mdB(r.impact)}</div>`;
  3356. if(r.got_wrong) h+=`<div style="font-size:12px;color:var(--warn);line-height:1.6;margin-top:6px;
  3357. padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--line)">
  3358. <b>首版判错</b> ${mdB(r.got_wrong)}</div>`;
  3359. // 数据质量标志 + 稳健性复算: 削弱结论的证据, 与证据同级显示
  3360. if(r.quality_flag) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
  3361. padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--warn)">
  3362. <b style="color:var(--warn)">记录质量</b> ${mdB(r.quality_flag)}</div>`;
  3363. if(r.robust) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
  3364. <b>稳健性复算</b> ${mdB(r.robust)}</div>`;
  3365. if(r.basis) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
  3366. <b>分类依据</b> ${mdB(r.basis)}</div>`;
  3367. if(r.sensitivity) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
  3368. padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--accent)">
  3369. <b style="color:var(--accent)">敏感性检验</b> ${mdB(r.sensitivity)}</div>`;
  3370. if(r.age_basis) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
  3371. <b>场龄口径</b> ${mdB(r.age_basis)}</div>`;
  3372. // ★边界从 11px/muted 的脚注提到 12px/正文色 (2026-08-31 独立审逮):
  3373. // 它**限定结论成立的范围**, 不是补充说明。原样式让它成为卡片里最小最淡的元素,
  3374. // 排在一串证据数字之后 —— 读者读完数字就走了, 限定语等于没写。
  3375. if(r.caveat) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
  3376. padding:6px 10px;background:var(--panel);border-radius:6px;border-left:3px solid var(--muted)">
  3377. <b>结论边界</b> ${mdB(r.caveat)}</div>`;
  3378. h+='</div>';
  3379. });
  3380. h+='</div>';
  3381. return h;
  3382. }
  3383. function maintFw(d){
  3384. // 运维管理体系三维骨架 — 架构取自一套跨厂商通用的培训编号体系, 实证取自本场五年工单。
  3385. // 放在四判据之后: 判据回答"哪台该管", 本面板回答"这个场的运维能力结构长什么样"。
  3386. // 配色一律走 CSS 变量 —— 页面有明暗两套 :root, 硬编码色在另一套下不可读。
  3387. if(!d) return '<div class="card">运维体系加载中…</div>';
  3388. if(d.err) return '<div class="card">运维体系: '+d.err+'</div>';
  3389. const c=d.coverage||{}, g=d.gaps||{};
  3390. const tierName={common:'通用 · 换机型仍有效', platform:'机型专属 · 绑定当前机型', basic:'技术基础', eng:'工程改造'};
  3391. const tierCol={common:'var(--accent)', platform:'var(--warn)', basic:'var(--muted)', eng:'var(--unknown)'};
  3392. let h='<div class="card"><div class="eyebrow">运维管理体系 · 系统 × 阶段 × 记录</div>';
  3393. // ★跨厂商借用必须在**用它的地方**声明 (2026-08-31 双票独立命中)。
  3394. // ★这套码是**借来的跨厂商分类法**, 不点任何厂商名 (点了等于用否定把机型漏回去)。
  3395. // 声明写在别的页面等于没声明: 读者是在这一屏做判断的。
  3396. h+='<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
  3397. +'三位系统码分两层 (3xx 跨机型通用 / 4xx 机型专属), 判据与缺口取自本场五年工单实证。'
  3398. +'<b style="color:var(--warn)">口径声明</b>: '
  3399. // ★整句不嵌 <b> —— 中间插标签会把这段切成多个 PH 片段, 逐片查表再拼回去,
  3400. // 结果是"arenot"这种缺空格拼接 + 某一片查不到就留中文 (实测两种都发生了)。
  3401. // 要强调就整句强调, 别在句中加标记。整段一个 key, 查得到就整段换, 查不到就整段留。
  3402. // ★整句放在**一个**字符串字面量里。apply() 跑在**源码文本**上, 拆成多个字面量拼接时,
  3403. // 源码里它们被换行加 + 号隔开 ⇒ PH 切出的段与渲染后看到的整句**不是同一个东西**,
  3404. // (注意: CHART_JS 是非 raw 的 Python 三引号串, 注释里写反斜杠-n 会变成**真换行**,
  3405. // 把 // 注释劈成两行 —— 第二行不再是注释, 直接语法错。本行原来就踩了这个坑。)
  3406. // 我按渲染后的整句注册 key, 页面上却按源码分段查表 → 全部 miss。
  3407. // (实测踩到: 助手 --segments 报"命中", 页面上仍是中文。验证跑在渲染后, 翻译跑在源码上。)
  3408. +'三位码仅用于能力分级与培训投资参照。部件到系统码的映射由本项目建立、未经确认, 因此结构趋势比绝对占比可靠。</div>';
  3409. // ① 覆盖率 — 本轮最实的一条发现
  3410. h+='<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">';
  3411. h+=`<div style="flex:1;min-width:180px;padding:12px 14px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid var(--bad)">
  3412. <div style="font-size:28px;font-weight:600;color:var(--bad);font-family:var(--num)">${c.system_pct}%</div>
  3413. <div style="font-size:12px;color:var(--muted);margin-top:2px">工单可归属到系统<br><span style="font-family:var(--num)">${c.mapped} / ${c.n_orders}</span></div></div>`;
  3414. h+=`<div style="flex:1;min-width:180px;padding:12px 14px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid var(--warn)">
  3415. <div style="font-size:28px;font-weight:600;color:var(--warn);font-family:var(--num)">${c.part_field_pct}%</div>
  3416. <div style="font-size:12px;color:var(--muted);margin-top:2px">工单填了部件字段<br><span style="font-family:var(--num)">${c.with_part} / ${c.n_orders}</span></div></div>`;
  3417. h+=`<div style="flex:2.2;min-width:280px;padding:12px 14px;border-radius:var(--r);background:var(--accent-soft);border:1px solid var(--line);border-left:3px solid var(--ok)">
  3418. <div style="font-size:12px;color:var(--ok);font-weight:600;margin-bottom:4px">怎么补</div>
  3419. <div style="font-size:12px;color:var(--ink);line-height:1.65">工单模板增加系统码必填项 (31 个系统码下拉), 此后每条工单按构造自动归属。
  3420. <div style="color:var(--muted);margin-top:4px">归属率低是模板缺字段, 不是维修没做 — 后者要贵得多。</div></div></div>`;
  3421. h+='</div>';
  3422. // ② 通用 vs 机型专属 — 决定培训投资与合同边界
  3423. h+='<div style="font-size:13px;font-weight:600;margin:16px 0 4px;color:var(--ink)">作业量结构 · 通用能力 vs 机型专属</div>';
  3424. h+='<div style="font-size:12px;color:var(--muted);margin-bottom:8px">通用能力换机型仍成立, 属场站资产; 机型专属绑定当前机队, 更适合合同覆盖</div>';
  3425. const byT=g.by_tier||{}, tks=Object.keys(byT).sort((a,b)=>byT[b].jobs-byT[a].jobs);
  3426. h+='<div style="display:flex;height:24px;border-radius:4px;overflow:hidden;margin-bottom:6px;border:1px solid var(--line)">';
  3427. tks.forEach(t=>{h+=`<div style="width:${byT[t].share}%;background:${tierCol[t]||'var(--muted)'};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-family:var(--num)">${byT[t].share}%</div>`;});
  3428. h+='</div><div style="font-size:11px;color:var(--muted);margin-bottom:14px">';
  3429. tks.forEach(t=>{h+=`<span style="margin-right:16px"><span style="display:inline-block;width:9px;height:9px;background:${tierCol[t]||'var(--muted)'};border-radius:2px;margin-right:5px;vertical-align:middle"></span>${tierName[t]||t} · <span style="font-family:var(--num)">${byT[t].jobs}</span> 次</span>`;});
  3430. h+='</div>';
  3431. // ③ 系统作业量排行
  3432. h+='<div style="font-size:13px;font-weight:600;margin:16px 0 6px;color:var(--ink)">按系统码的作业量</div>';
  3433. h+='<table class="tbl"><tr><th>码</th><th>系统</th><th>类别</th><th>作业次数</th><th>台数</th><th>次/台</th></tr>';
  3434. (d.systems||[]).slice(0,10).forEach(r=>{
  3435. h+=`<tr><td style="font-family:var(--num)">${r.code}</td><td>${r.zh}<div style="font-size:11px;color:var(--muted)">${r.en}</div></td>
  3436. <td style="color:${tierCol[r.tier]||'var(--muted)'};font-size:12px">${tierName[r.tier]||r.tier}</td>
  3437. <td style="font-family:var(--num)">${r.jobs}</td><td style="font-family:var(--num)">${r.n_units}</td><td style="font-family:var(--num)">${r.per_unit}</td></tr>`;});
  3438. h+='</table>';
  3439. // ④ 阶段 × 出口闸 × 记录
  3440. h+='<div style="font-size:13px;font-weight:600;margin:18px 0 4px;color:var(--ink)">六个运维阶段与各自的放行条件</div>';
  3441. h+='<div style="font-size:12px;color:var(--muted);margin-bottom:8px">放行条件 = 不满足就不该进下一阶段; 记录 = 该阶段必须留下的凭据</div>';
  3442. h+='<table class="tbl"><tr><th style="width:15%">阶段</th><th style="width:38%">放行条件</th><th>必备记录</th></tr>';
  3443. (d.stages||[]).forEach(st=>{
  3444. const rn=(st.records||[]).map(r=>((d.records||{})[r]||{}).zh||r)
  3445. .map(x=>`<span style="display:inline-block;padding:1px 7px;margin:1px 3px 1px 0;border-radius:10px;background:var(--panel-2);border:1px solid var(--line);font-size:11px">${x}</span>`).join('');
  3446. // ★英文侧 zh 已被 _proj 投影成英文 (裸 zh/en 成对的处理), 两行会显示成一样的字 ——
  3447. // 相同就只出一行。出口闸那列同理: 页面原读 st.gate_zh, 而投影后字段名是 gate,
  3448. // 于是**整列 undefined** —— 而"出口闸"正是这张表最要紧的一列 (无闸的阶段划分只是标签)。
  3449. h+=`<tr><td><span style="font-family:var(--num);color:var(--muted)">${st.seq}</span> ${st.zh||st.en||''}
  3450. ${(st.en&&st.en!==st.zh)?`<div style="font-size:11px;color:var(--muted)">${st.en}</div>`:''}</td>
  3451. <td style="font-size:12px;line-height:1.5">${st.gate||st.gate_zh||''}</td><td>${rn}</td></tr>`;});
  3452. h+='</table>';
  3453. // ⑤ 未映射 — 不猜归属
  3454. const um=(g.unmapped_top||[]);
  3455. if(um.length){
  3456. h+='<div style="font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6">未映射到系统码的部件 (不猜归属, 留待补映射表): '
  3457. + um.slice(0,10).map(x=>`${x[0]}<span style="font-family:var(--num)">×${x[1]}</span>`).join('、')+'</div>';}
  3458. h+='</div>';
  3459. return h;
  3460. }
  3461. function maintStd(d){
  3462. if(!d) return '<div class="card"><p class="small">载入中…</p></div>';
  3463. if(d.err) return `<div class="card"><p class="small" style="color:var(--bad)">${d.err}</p></div>`;
  3464. const S=d.score||{}; let h='';
  3465. const dims=(S.dims||[]).map(x=>{
  3466. // ★"不可评估"要在**出现的地方**就自明 —— 用户读到这四个字问"什么意思",
  3467. // 说明把解释放在上一行的口径说明里不够。它不是"做得差", 是"没法判":
  3468. // 工单模板里没有记录该证据的位置, 所以既不能记功也不能记过。
  3469. if(!x.evaluable) return `<div class="kpi"><b style="color:var(--muted)">—</b>
  3470. <span>${x.dim} · <b style="color:var(--warn)">不可评估</b>
  3471. <span style="color:var(--muted)">(工单里没有记录这项证据的字段, 所以既不能算做到、也不能算没做到 &mdash; 不是 0 分, 是无法判断)</span><br>
  3472. <span style="color:var(--muted)">${mdB(x.detail)}</span></span></div>`;
  3473. const pct=Math.round(x.score/x.full*100);
  3474. const cls=pct>=70?'ok':pct>=40?'':'bad';
  3475. return `<div class="kpi ${cls}"><b>${x.score}<span style="font-size:14px;color:var(--muted)">/${x.full}</span></b>
  3476. <span>${x.dim}<br><span style="color:var(--muted)">${mdB(x.detail)}</span></span></div>`;
  3477. }).join('');
  3478. // 两个分并排、各自带标签; 口径与理由见 src/windscada/maint/standards.py。
  3479. // (页面源码随交付件一起发出去, 内部审核讨论不留在这里。)
  3480. const _en=(typeof window!=='undefined'&&window.__EN);
  3481. const twoScore=(lab,val,ev,n,tone)=>`<div style="flex:1;min-width:190px;padding:12px 14px;
  3482. border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid ${tone}">
  3483. <div style="font-size:12px;color:var(--muted)">${lab||''}</div>
  3484. <div style="font-size:30px;font-weight:600;font-family:var(--num);color:${tone};line-height:1.15">
  3485. ${val!=null?val:'—'}<span style="font-size:14px;color:var(--muted);font-weight:400">/100</span></div>
  3486. <div style="font-size:11.5px;color:var(--muted);margin-top:2px">${ev} / ${n} ${_en?'dimensions measurable':'维可测'}</div></div>`;
  3487. h+=`<div class="card mb"><h3>${_en?'Maintenance scorecard':'运维评分卡'}
  3488. <span class="badge" style="margin-left:10px">${S.stage_label||('第 '+S.stage+' 阶')} · ${S.stage_name}</span></h3>
  3489. <div style="display:flex;gap:12px;flex-wrap:wrap;margin:4px 0 12px">
  3490. ${twoScore(S.record_label,S.record_score,S.record_evaluable,S.record_dims,'var(--bad)')}
  3491. ${twoScore(S.exec_label,S.exec_score,S.exec_evaluable,S.exec_dims,'var(--ok)')}
  3492. </div>
  3493. ${S.score_basis?`<p class="small" style="color:var(--muted);margin:0 2px 10px;line-height:1.6">${mdB(S.score_basis)}</p>`:''}
  3494. <div class="kpis">${dims}</div>
  3495. <p class="small" style="color:var(--warn);margin:8px 2px 0">${mdB(S.caveat)}</p>
  3496. ${S.model_source?`<p class="small" style="color:var(--muted);margin:6px 2px 0;line-height:1.6">
  3497. <b>评分方法来源</b> ${mdB(S.model_source)}</p>`:''}
  3498. ${S.bottleneck?`<p class="small" style="margin:4px 2px 0">当前瓶颈: <b>${S.bottleneck}</b> — 先补这一项, 其余维度的提升都受它限制。</p>`:''}</div>`;
  3499. // R1 复发
  3500. const R=(d.recurrence||[]).slice(0,10);
  3501. if(R.length) h+=`<div class="card mb"><h3>复发 · ${d.recurrence.length} 组</h3>
  3502. <p class="small" style="color:var(--muted)">同一机组同一部件多次更换 — 换件未解决产生它的原因。按作业日去重计数, 非工单条数。</p>
  3503. <table class="tbl"><thead><tr><th>机组</th><th>部件</th><th>作业次数</th><th>首次</th><th>末次</th><th>最短间隔</th></tr></thead><tbody>
  3504. ${R.map(x=>`<tr><td>${x.turbine}</td><td>${x.part}</td><td><b>${x.n_jobs}</b></td>
  3505. <td>${x.first}</td><td>${x.last}</td><td>${x.min_gap_months} 月</td></tr>`).join('')}
  3506. </tbody></table></div>`;
  3507. // R2 批次候选
  3508. const B=(d.batch||[]).slice(0,8);
  3509. if(B.length) h+=`<div class="card mb"><h3>共性候选 · ${d.batch.length} 项</h3>
  3510. <p class="small" style="color:var(--warn)">仅候选。分布形态无法区分 批次 / 共模应力 / 耗损期 — 三者形态相似而处置方向完全不同(追索赔 / 改运行条件 / 排寿命更换)。定性需物料证据。</p>
  3511. <table class="tbl"><thead><tr><th>部件</th><th>涉及台数</th><th>作业次数</th><th>每台</th><th>缺什么才能定性</th></tr></thead><tbody>
  3512. ${B.map(x=>`<tr><td>${x.part}</td><td>${x.n_units}</td><td>${x.n_jobs}</td>
  3513. <td>${x.per_unit}</td><td class="small" style="color:var(--muted)">${x.needs}</td></tr>`).join('')}
  3514. </tbody></table></div>`;
  3515. // R4 断层
  3516. const D=d.discontinuity||{};
  3517. if((D.breaks||[]).length) h+=`<div class="card mb"><h3>记录断层</h3>
  3518. <p class="small">${D.note}</p>
  3519. ${D.breaks.map(b=>`<p class="small"><b>${b.before} → ${b.after}</b>: ${b.n_before} → ${b.n_after} 条 (<b style="color:var(--bad)">${b.ratio}×</b>) — 机组不会一年之间变坏这么多, 变的是记录制度。</p>`).join('')}
  3520. </div>`;
  3521. return h;
  3522. }
  3523. function farmScene(d){
  3524. const RANK={'危险':4,'报警':3,'不可判':2,'良好':1,'优秀':0};
  3525. const COL={'危险':'var(--bad)','报警':'var(--bad)','不可判':'var(--unknown)',
  3526. '良好':'var(--ok)','优秀':'var(--st-exc)'};
  3527. const S={};
  3528. Object.entries((d&&d.systems)||{}).forEach(([sys,rows])=>(rows||[]).forEach(r=>{
  3529. if(!r||!r.t) return;
  3530. const st=String(r.st||'').split('·')[0].trim();
  3531. if(!(st in RANK)) return;
  3532. if(!(r.t in S) || RANK[st] > RANK[S[r.t]]) S[r.t]=st;
  3533. }));
  3534. // 补全: 预烤 key 里有全场台号; 在线模式下退回已知台集合
  3535. let all=Object.keys(S);
  3536. if(typeof window.__SNAP__!=='undefined'){
  3537. const ks=new Set();
  3538. Object.keys(window.__SNAP__).forEach(k=>{const m=/[?&]t=([A-Za-z0-9]+)/.exec(k); if(m)ks.add(m[1])});
  3539. if(ks.size>all.length) all=[...ks];
  3540. }
  3541. all=all.sort();
  3542. if(!all.length) return '';
  3543. const n=all.length, W=980, H=190, m=30;
  3544. const per=Math.ceil(n/2), gap=(W-m*2)/(per-1||1);
  3545. let g='';
  3546. all.forEach((t,i)=>{
  3547. const row=i<per?0:1, idx=i<per?i:i-per;
  3548. const x=m+idx*gap+(row?gap/2:0);
  3549. const base=row?150:126, hh=row?30:38, op=row?.55:.92, bw=row?12:15;
  3550. const st=S[t]||'优秀', c=COL[st]||'var(--unknown)';
  3551. const hub=base-hh;
  3552. g+=`<g opacity="${op}"><title>${t} · ${st}</title>`
  3553. +`<path d="M${x} ${base} L${x} ${hub}" stroke="${c}" stroke-width="${row?1:1.3}" fill="none" stroke-linecap="round"/>`
  3554. +`<path d="M${x} ${hub} L${x} ${hub-bw}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
  3555. +`<path d="M${x} ${hub} L${(x+bw*0.87).toFixed(1)} ${(hub+bw*0.5).toFixed(1)}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
  3556. +`<path d="M${x} ${hub} L${(x-bw*0.87).toFixed(1)} ${(hub+bw*0.5).toFixed(1)}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
  3557. +`<circle cx="${x}" cy="${hub}" r="${row?1.5:1.9}" fill="${c}"/></g>`;
  3558. });
  3559. const cnt={};
  3560. Object.values(S).forEach(v=>cnt[v]=(cnt[v]||0)+1);
  3561. const legend=['危险','报警','不可判','良好','优秀'].filter(k=>cnt[k])
  3562. .map(k=>`<span style="display:inline-flex;align-items:center;gap:5px">
  3563. <i style="width:7px;height:7px;border-radius:50%;background:${COL[k]};display:inline-block"></i>${k} ${cnt[k]}</span>`).join('');
  3564. return `<div class="card mb"><svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block"
  3565. role="img" aria-label="Fleet layout: each turbine coloured by its most severe system state">
  3566. <defs><linearGradient id="fsea" x1="0" y1="0" x2="0" y2="1">
  3567. <stop offset="0" stop-color="var(--accent)" stop-opacity=".14"/>
  3568. <stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
  3569. <path d="M0 150 H${W}" stroke="var(--line)" stroke-width="1" fill="none"/>
  3570. <path d="M0 126 H${W}" stroke="var(--line)" stroke-width="1" fill="none" opacity=".5"/>
  3571. <rect x="0" y="150" width="${W}" height="${H-150}" fill="url(#fsea)"/>
  3572. ${g}</svg>
  3573. <p class="small" style="color:var(--muted);display:flex;gap:16px;flex-wrap:wrap;margin:10px 2px 0">
  3574. <b style="color:var(--ink)">全场态势</b>${legend}
  3575. <span style="margin-left:auto">按各台最严重系统状态着色 · 悬停看台号</span></p></div>`;
  3576. }
  3577. function drawFig(f){
  3578. if(f.kind==='vib'){return figWrap(f, dtSVG(f.comp||f.text||'',f.sev||'var(--warn)',880), (f.text||'').replace(/</g,'&lt;'))}
  3579. if(f.kind==='text'){return `<div class="card"><h3>${f.title||''}</h3><p class="small">${(f.text||'').replace(/</g,'&lt;')}</p></div>`}
  3580. if(f.kind==='vibbar'){
  3581. // 倍中位条 (1× 为基准竖线; 绝对值量级差 3 个数量级时按绝对值画条会全部顶满 — 速度有效值 0.001 实例)
  3582. const rows=f.rows||[]; if(!rows.length) return '';
  3583. const sig=v=>{const a=Math.abs(v); return a>=100?v.toFixed(0):a>=10?v.toFixed(1):a>=1?v.toFixed(2):a>=0.01?v.toFixed(3):v.toExponential(1)};
  3584. const byM={}; rows.forEach(r=>{(byM[r.指标]=byM[r.指标]||[]).push(r)});
  3585. const LO=0.5, HI=2.5, W2=190, xr=v=>((Math.max(LO,Math.min(HI,v))-LO)/(HI-LO))*W2;
  3586. let inner=`<div class="small" style="color:var(--muted);margin-bottom:8px">条为本台与全场中位之比, 竖线取 1× 基准, 右端封顶 2.5×; 色按 z (稳健离群度): 绿&lt;2 · 橙2~3 · 红≥3</div>`;
  3587. for(const [mk,rs] of Object.entries(byM)){
  3588. inner+=`<div style="margin-bottom:9px;overflow-x:auto"><b class="small" style="color:var(--muted)">${mk}</b><table style="width:100%;margin-top:2px">`;
  3589. rs.forEach(r=>{
  3590. const v=r.倍中位||1, col=Math.abs(r.z)>=3?'var(--bad)':Math.abs(r.z)>=2?'var(--warn)':'var(--ok)';
  3591. const x1=xr(1), xv=xr(v), left=Math.min(x1,xv), wid=Math.max(Math.abs(xv-x1),1.5);
  3592. inner+=`<tr><td class="small" style="width:104px">${r.测点}</td>
  3593. <td style="width:${W2+10}px"><svg class="dt" viewBox="0 0 ${W2} 16" style="width:${W2}px;height:16px">
  3594. <line x1="0" y1="8" x2="${W2}" y2="8" stroke="var(--line)" stroke-width="1"/>
  3595. <line x1="${x1}" y1="1" x2="${x1}" y2="15" stroke="var(--muted)" stroke-width="1"/>
  3596. <rect x="${left}" y="4" width="${wid}" height="8" rx="2" fill="${col}" fill-opacity="0.9"/>
  3597. ${v>HI?`<path d="M${W2-1},8 l-6,-4 v8 z" fill="${col}"/>`:''}</svg></td>
  3598. <td class="num small" style="width:60px;color:${col};font-weight:600">${r.倍中位!=null?r.倍中位+'×':'—'}</td>
  3599. <td class="num small" style="width:80px">${sig(r.本台)}</td>
  3600. <td class="num small" style="width:78px;color:var(--muted)">中位 ${sig(r.全场中位)}</td>
  3601. <td class="num small" style="width:56px;color:${Math.abs(r.z)>=3?'var(--bad)':'var(--muted)'}">z ${r.z}</td>
  3602. <td class="num small" style="width:64px;color:${r.六窗比>1.15?'var(--warn)':'var(--muted)'}">${r.六窗比!=null?'趋势'+r.六窗比+'×':'趋势—'}</td></tr>`;
  3603. });
  3604. inner+='</table></div>';
  3605. }
  3606. return figWrap(f, inner, f.note);
  3607. }
  3608. const W=620,H=250,ML=52,MB=34;
  3609. let xs=f.months||f.x, n=xs.length; if(!n) return '';
  3610. const X=i=>sc(i,0,Math.max(n-1,1),ML,W-12);
  3611. let all=[];
  3612. if(f.kind==='band'){all=[...(f.self||[]),...(f.med||[]),...(f.q1||[]),...(f.q3||[])]}
  3613. else if(f.kind==='bars'){all=[0,...(f.vals||[])]}
  3614. else if(f.kind==='xy'){all=[...(f.self||[]),...(f.med||[])]}
  3615. else {(f.series||[]).forEach(s=>all.push(...s.vals))}
  3616. (f.thresholds||[]).forEach(t=>all.push(t.v));
  3617. all=all.filter(v=>v!==null&&v===v); if(!all.length) return '';
  3618. let lo=Math.min(...all),hi=Math.max(...all); if(lo===hi){lo-=1;hi+=1} const pad=(hi-lo)*0.08; lo-=pad; hi+=pad;
  3619. const Y=v=>sc(v,lo,hi,H-MB,14);
  3620. const line=(vals,st)=>{let p=vals.map((v,i)=>v===null||v!==v?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`);
  3621. let segs=[],cur=[]; p.forEach(q=>{if(q)cur.push(q);else{if(cur.length)segs.push(cur);cur=[]}}); if(cur.length)segs.push(cur);
  3622. return segs.map(sg=>`<polyline points="${sg.join(' ')}" style="fill:none;${st}"/>`).join('')}
  3623. let body='';
  3624. if(f.kind==='band'&&f.q1){
  3625. let up=f.q3.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean);
  3626. let dn=f.q1.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean).reverse();
  3627. if(up.length&&dn.length) body+=`<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="#cbd5e1" fill-opacity="0.45"/>`;
  3628. }
  3629. if(f.kind==='bars'){const bw=Math.max(3,(W-ML-20)/n*0.6);
  3630. body+=f.vals.map((v,i)=>`<rect x="${(X(i)-bw/2).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}" height="${(Y(Math.max(lo,0))-Y(v)).toFixed(1)}" fill="#0f4c75" fill-opacity="0.75"/>`).join('');}
  3631. if(f.med) body+=line(f.med,'stroke:#64748b;stroke-width:1.7');
  3632. if(f.self) body+=line(f.self,'stroke:#c0392b;stroke-width:2.1')+
  3633. f.self.map((v,i)=>v===null||v!==v?'':`<circle cx="${X(i)}" cy="${Y(v)}" r="2.4" fill="var(--bad)"/>`).join('');
  3634. const cols=['#0f4c75','#ef6c00','#0e6e66','#7c5295'];
  3635. (f.series||[]).forEach((s,k)=>{body+=line(s.vals,`stroke:${cols[k%4]};stroke-width:1.8`)});
  3636. (f.thresholds||[]).forEach(t=>{body+=`<line x1="${ML}" x2="${W-12}" y1="${Y(t.v)}" y2="${Y(t.v)}" stroke="var(--muted)" stroke-dasharray="5 4"/>`+
  3637. (t.label?`<text x="${ML+6}" y="${Y(t.v)-4}" font-size="10" fill="var(--muted)">${t.label}</text>`:'')});
  3638. const step=Math.max(1,Math.ceil(n/8));
  3639. let xt=''; for(let i=0;i<n;i+=step) xt+=`<text x="${X(i)}" y="${H-10}" font-size="10" fill="var(--muted)" text-anchor="middle">${String(xs[i]).includes('-')?String(xs[i]).slice(2,7):String(xs[i])}</text>`;
  3640. let yt=''; for(let k=0;k<5;k++){const v=lo+(hi-lo)*k/4; yt+=`<text x="${ML-6}" y="${Y(v)+3}" font-size="10" fill="var(--muted)" text-anchor="end">${v.toFixed(Math.abs(hi)<10?1:0)}</text>`+
  3641. `<line x1="${ML}" x2="${W-12}" y1="${Y(v)}" y2="${Y(v)}" stroke="var(--line-2)"/>`}
  3642. if(f.band&&f.band.q1){const q1=f.band.q1,q3=f.band.q3;
  3643. let up='',dn='';
  3644. for(let i=0;i<n;i++){if(q3[i]!=null)up+=`${X(i)},${Y(q3[i])} `}
  3645. for(let i=n-1;i>=0;i--){if(q1[i]!=null)dn+=`${X(i)},${Y(q1[i])} `}
  3646. body=`<polygon points="${up}${dn}" fill="var(--unknown)" fill-opacity="0.16"/>`+body}
  3647. if(f.xlab) body+=`<text x="${(ML+W)/2}" y="${H-1}" font-size="10" fill="var(--unknown)" text-anchor="middle">${f.xlab}</text>`;
  3648. let leg = f.kind==='band' ? `<text x="${W-190}" y="12" font-size="10" fill="var(--bad)">— 本台</text><text x="${W-130}" y="12" font-size="10" fill="var(--muted)">— 全场中位</text><text x="${W-60}" y="12" font-size="10" fill="var(--unknown)">▧ 四分位</text>` :
  3649. ((f.series&&f.series.length<=2)?(()=>{const n2=f.series.length,gap=Math.min(118,Math.floor((W-ML-20)/n2));
  3650. return f.series.map((s,k)=>`<text x="${W-12-(n2-1-k)*gap}" y="12" font-size="10" fill="${cols[k%4]}" text-anchor="end">— ${s.name}</text>`).join('')})():'');
  3651. const legendHtml=(f.series&&f.series.length>2)?`<div class="legend">`+f.series.map((s,k)=>
  3652. `<span class="lgi"><i style="background:${cols[k%4]}"></i>${s.name}</span>`).join('')+`</div>`:'';
  3653. // 统一走 figWrap: 图身按类别封顶宽度 (曲线760/月度620), 不再随容器拉伸成巨图
  3654. return figWrap(f, `<svg viewBox="0 0 ${W} ${H}">${yt}${body}${xt}${leg}</svg>`+legendHtml,
  3655. f.note||'').replace('<h3>'+(f.title||''), '<h3>'+(f.title||'')+(f.unit?' ('+f.unit+')':''));
  3656. }
  3657. function ordersHtml(o){
  3658. if(!o) return '';
  3659. let h=`<div class="card mb"><h3>工单处理史 · 该系统 (处理 ${o.n} 次${o.窗?'; 窗 '+o.窗:''})</h3>`;
  3660. if(o.n===0){h+=`<p class="small">该窗内无工单记录。${o.note||''}</p></div>`; return h}
  3661. const acts=Object.entries(o.动作统计||{}).map(([k,v])=>k+'×'+v).join(' / ');
  3662. if(acts) h+=`<p class="small" style="margin-bottom:6px">处置动作统计: <b>${acts}</b></p>`;
  3663. h+=`<table><tr><th style="width:92px">日期</th><th>故障/工作</th><th>排查发现</th><th>处置</th><th>类别</th></tr>`;
  3664. o.rows.forEach(r=>{h+=`<tr><td class="num" style="text-align:left">${r.日期}</td><td class="small">${r.故障}</td><td class="small">${r.排查&&r.排查!=='nan'?r.排查:'—'}</td><td class="small">${r.处置||'—'}</td><td class="small">${r.类别||''}</td></tr>`});
  3665. h+=`</table><p class="small">${o.note||''}</p></div>`;
  3666. return h;
  3667. }
  3668. let ONT=null;
  3669. async function loadDbase(){
  3670. const el=document.getElementById('dbase'); if(!el) return;
  3671. if(!ONT) ONT=await (await fetch('/api/ontology')).json();
  3672. const C=ONT.chain29; let h='';
  3673. // ① 机制链横图 (决策的证据底座, 图形化)
  3674. const node=(label,val,color)=>`<div style="min-width:120px;flex:1;text-align:center">
  3675. <div style="border:1.5px solid ${color};border-radius:10px;padding:8px;background:var(--panel)">
  3676. <div class="small" style="color:var(--muted);letter-spacing:.05em">${label}</div>
  3677. <div style="font-weight:600;font-size:13.5px;margin-top:4px">${val}</div></div></div>`;
  3678. const arrow=`<div style="align-self:center;color:var(--muted);font-size:16px;padding:0 4px">→</div>`;
  3679. // 每格加一行"这一格回答什么" (2026-08-28 用户问"什么意思" — 六个词现场看不出是干嘛的)
  3680. const node2=(t,v,c,q)=>`<div style="flex:none;min-width:132px;border:1px solid ${c};border-radius:10px;padding:9px 12px;text-align:center">
  3681. <div class="small" style="color:var(--muted)">${t}</div>
  3682. <div style="font-weight:600;margin:4px 0 3px">${v}</div>
  3683. <div class="small" style="color:var(--muted);line-height:1.45">${q}</div></div>`;
  3684. // 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用, 我没看太懂")。
  3685. // 原来是 29# 一台的静态六格 — 打开它做不了任何决定, 是展示牌不是工作面。
  3686. // 改为: 全部需跟踪台各走到第几步 / 卡在哪一步 / 下一步具体做什么 / 点台号进该台。
  3687. // 这样它回答的是"今天该管谁、卡在哪、谁去做什么", 而不是"29# 的故事是怎样的"。
  3688. const BD=(window.D&&D.fus&&D.fus.链盘)?D.fus.链盘:(D.fus&&D.fus.链盘);
  3689. if(BD&&BD.rows&&BD.rows.length){
  3690. const SK=Object.entries(BD.stuck||{}).sort((a,b)=>b[1]-a[1]);
  3691. const tot=BD.rows.length;
  3692. const cell=st=>st==='done'?`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;background:var(--accent);opacity:.8"></i>`
  3693. :st==='unknown'?`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;border:1.5px dashed var(--muted);opacity:.75"></i>`
  3694. :`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;border:1.5px solid var(--line)"></i>`;
  3695. h+=`<div class="card mb"><h3>决策链进度盘 · ${tot} 台需跟踪</h3>
  3696. <p class="small" style="color:var(--muted);margin-bottom:10px">
  3697. 一台机组从发现异常到修完验收要走六步。这张盘不讲某一台的故事, 用于确认<b>当前需处置机组、各自所处环节、后续动作</b>。
  3698. <span style="color:var(--accent)">■</span> 已达 ·
  3699. <span style="color:var(--muted)">▢ 虚线</span> 数据不足不可判 ·
  3700. <span style="color:var(--muted)">▢ 实线</span> 未达</p>
  3701. <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px;
  3702. padding:9px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--line)">
  3703. <b class="small">瓶颈</b>${SK.map(([k,n])=>`<span class="small">
  3704. <b style="color:${n/tot>0.5?'var(--bad)':'var(--warn)'}">${n} 台</b> 卡在「${k}」</span>`).join('<span class="small" style="color:var(--muted)">·</span>')}
  3705. ${SK.length&&SK[0][1]/tot>0.5?`<span class="small" style="color:var(--muted)">— 瓶颈集中在一步, 不是"再多看点数据"能解的</span>`:''}
  3706. <div style="flex-basis:100%;height:0"></div>
  3707. ${SK.map(([k,n])=>{const r=BD.rows.find(x=>x.stuck===k);
  3708. /* 值为空就整块不出 —— 原来无条件渲染, 没 next 的两个环节在页面上留下
  3709. "「机制」24 台的下一步:" 后面空着的悬空标签 (中英文版同样露)。 */
  3710. return (r&&r.next)?`<div class="small" style="color:var(--muted);margin-top:3px">
  3711. <b>「${k}」${n} 台的下一步:</b> ${r.next}</div>`:''}).join('')}</div>
  3712. <table><tr><th style="width:52px">机组</th><th style="width:64px">状态</th>
  3713. <th style="width:126px">六步进度</th><th style="width:70px">卡在</th><th>本台情况</th></tr>`;
  3714. BD.rows.forEach(r=>{
  3715. const ris=r.rising;
  3716. h+=`<tr><td><a class="tl" href="javascript:void(0)" onclick="popChain('${r.t}')" title="点开看这一台的六步决策链"><b>${r.t.replace('WTG','')}#</b></a>
  3717. ${ris?`<span class="small" style="color:var(--bad)" title="${ris.component} 末三窗/早三窗 ${ris.ratio}×">↑${ris.ratio}×</span>`:''}</td>
  3718. <td>${chip(r.ostate||'—')}</td>
  3719. <td style="white-space:nowrap">${r.steps.map((x,i)=>`<span title="${i+1} ${x.k}: ${x.v} — ${x.d}" style="margin-right:3px">${cell(x.st)}</span>`).join('')}
  3720. <span class="cnt">${r.done}/6</span></td>
  3721. <td class="small" style="color:${r.stuck_kind==='unknown'?'var(--muted)':'var(--warn)'}">${r.stuck||'全通'}${r.stuck_kind==='unknown'?' <span title="非未执行, 系统无法判定">?</span>':''}</td>
  3722. <td class="small" style="color:var(--muted)">${r.part&&r.part.indexOf('未收录')<0?`<b style="color:var(--ink)">${r.part}</b> · `:''}证据 ${(r.srcs||[]).join('/')||'—'}
  3723. ${ris?` · <span style="color:var(--bad)">${ris.component}末三窗/早三窗 ${ris.ratio}×${ris.工单可对===false?', 待现场/工单确认':''}</span>`:''}
  3724. ${r.hist_loop?` · <span style="color:var(--ok)">${r.hist_loop.replace_date} 有过闭环 ${r.hist_loop.ratio}×</span>`:''}</td></tr>`;});
  3725. h+=`</table>
  3726. <p class="small" style="color:var(--muted);margin-top:8px">
  3727. <b>「动作」一列全场都是不可判</b>, 不是偷懒: ${BD.wo_bound}, 而判级窗在 2026 —
  3728. 台账里的历史工单不可能是针对本次问题的, 用"有没有工单"判已派工会造出假的完成态。要填这一列须向现场要 25~26 工单。</p>`;
  3729. if(BD.closed&&BD.closed.length){
  3730. h+=`<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--line)">
  3731. <div class="small" style="font-weight:600;margin-bottom:5px">已走完全程并退出盘面 · ${BD.closed.length} 台 (端到端闭环实证)</div>
  3732. ${BD.closed.map(x=>`<div class="small" style="color:var(--muted)">
  3733. <a class="tl" href="/turbine/${x.turbine}"><b>${x.turbine.replace('WTG','')}#</b></a>
  3734. ${x.component}${x.title?' · '+x.title:''} ${x.replace_date} 换件 →
  3735. 振动 ${x.pre} 降至 ${x.post} = <b style="color:var(--ok)">${x.ratio}×</b></div>`).join('')}
  3736. <div class="small" style="color:var(--muted);margin-top:5px">
  3737. 这三台正是算法当初的检出台 ⇒ <b>故障前能看见、换件后能看见恢复</b>, 这条链是通的; 闭环成功的台会从盘面消失。</div></div>`;
  3738. }
  3739. h+=`</div>`;
  3740. }
  3741. // ② 四情景损失对比 (log 尺度条形; 数值解析电费口径区间取中值)
  3742. const sc=C.scenario.scenarios||[];
  3743. const parseWan=t=>{const m=/([0-9.]+)~([0-9.]+) 万元/.exec(t||'');return m?(+m[1]+ +m[2])/2:null};
  3744. const rows=sc.map(x=>{
  3745. let v=parseWan(x.停机损失&&x.停机损失.电费口径), extra=0, lab=x.id;
  3746. if(x.停机损失&&x.停机损失.大部件成本){extra=350; lab+=' (+大部件成本200-500万)'; // 用 - 不用 ~ : ~ 不在 PH 续接字符类里, 会把整串切断
  3747. }
  3748. return {lab, v:(v||0)+extra, base:v||0, extra};});
  3749. if(rows.length){
  3750. const mx=Math.max(...rows.map(r=>r.v));
  3751. h+=`<div class="card mb"><h3>排期沙盘 · 四情景损失对比 (万元, 电价区间中值; 建议级待人裁)</h3>`;
  3752. rows.forEach(r=>{
  3753. const w=Math.max(3,Math.round(Math.log10(1+r.v)/Math.log10(1+mx)*320));
  3754. const col=r.extra?'var(--bad)':(r.lab.startsWith('A')?'var(--ok)':'var(--warn)');
  3755. h+=`<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
  3756. <div class="small" style="width:170px;text-align:right">${r.lab}</div>
  3757. <div style="height:16px;width:${w}px;background:${col};border-radius:4px;opacity:.85"></div>
  3758. <div class="small" style="font-variant-numeric:tabular-nums"><b>${r.v>=100?r.v.toFixed(0):r.v.toFixed(1)}</b> 万元</div></div>`});
  3759. h+=`<p class="small">条长=log 尺度 (跨两个数量级); D 含大部件成本中值 350 万 (grade D 弱源)。早修成本被限电稀释, 晚修成本被窗口与船期放大。</p></div>`;
  3760. }
  3761. // ③ 天气窗热力条 (近12月, CTV/吊装 双行)
  3762. const W=ONT.weather||{}; const ms=Object.keys(W).sort().slice(-12);
  3763. if(ms.length){
  3764. const cell=(v,d)=>{const r=v/d; const bg=r>0.9?'var(--ok)':r>0.7?'var(--warn)':'var(--bad)';
  3765. return `<td style="text-align:center;padding:4px"><div style="background:${bg};opacity:${0.25+r*0.6};border-radius:4px;padding:4px 0;font-size:11.5px;font-variant-numeric:tabular-nums">${v}</div></td>`};
  3766. h+=`<div class="card mb"><h3>海上作业天气窗 · 近12月可作业日 (公开再分析统计, 非派工依据)</h3>
  3767. <div style="overflow-x:auto"><table><tr><th style="width:90px"></th>${ms.map(m=>`<th class="small" style="text-align:center">${m.slice(2)}</th>`).join('')}</tr>`;
  3768. h+=`<tr><td class="small">CTV人员转运</td>${ms.map(m=>cell(W[m]['CTV人员转运']||0,W[m]['天数']||30)).join('')}</tr>`;
  3769. h+=`<tr><td class="small">吊装作业</td>${ms.map(m=>cell(W[m]['吊装作业']||0,W[m]['天数']||30)).join('')}</tr>`;
  3770. h+=`</table></div><p class="small">CTV 级小修全年窗宽; 吊装级受台风季约束, 色深表示可作业占比。限值取行业常规 (CTV Hs≤1.5m∧风速≤12m/s, 吊装 1.2/8), 非本场合同值。</p></div>`;
  3771. }
  3772. el.innerHTML=h;
  3773. }
  3774. async function renderOntology(){
  3775. const root=document.getElementById('root');
  3776. if(!ONT){
  3777. root.innerHTML='<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;margin-top:9px"></div></div>';
  3778. ONT=await (await fetch('/api/ontology')).json();
  3779. }
  3780. const O=ONT; let h='';
  3781. h+=`<div class="eyebrow">语义本体 · 对象/机制/决策/约束 (Palantir 思路轻量自建; 判读纪律=写入闸, 违反写不进库)</div>`;
  3782. h+=`<div class="card mb" style="border-left:3px solid var(--accent)"><b>六条写入约束 (库级保证)</b><p class="small">${O.constraints}</p></div>`;
  3783. h+=`<div class="kpis">`+Object.entries(O.stats).map(([k,v])=>`<div class="kpi"><b>${v}</b><span>${k}</span></div>`).join('')+`</div>`;
  3784. const C=O.chain29;
  3785. h+=`<div class="card mb"><h3>机制链视图 · 29# 范例 (证据 → 机制 → 判级 → 沙盘 → 动作 → 验收)</h3>
  3786. <div style="display:flex;gap:10px;overflow-x:auto;padding:6px 0">`;
  3787. h+=`<div style="min-width:240px;flex:1"><p class="small"><b>① 证据 ×${C.evidence.length} (${C.mechanism.independent_source_count||4} 观测面)</b></p>`;
  3788. C.evidence.forEach(e=>{h+=`<div class="small" style="border:1px solid var(--line);border-left:3px solid var(--info);border-radius:6px;padding:6px 8px;margin-bottom:6px"><b>${e.facet}</b> · 窗 ${e.claim_window}<br>${(e.summary||'').slice(0,90)}…<br><span style="color:var(--muted)">${e.id}</span></div>`});
  3789. h+=`</div>`;
  3790. const box=(t,color,inner)=>`<div style="min-width:210px;flex:1"><p class="small"><b>${t}</b></p><div class="small" style="border:1px solid var(--line);border-left:3px solid ${color};border-radius:6px;padding:8px">${inner}</div></div>`;
  3791. h+=box('② 机制','var(--warn)',`<b>${C.mechanism.name}</b><br>源数=观测面去重 ${C.mechanism.independent_source_count} (证据${C.evidence.length}条)<br><span style="color:var(--muted)">${C.mechanism.id}</span>`);
  3792. h+=box('③ 判级','var(--bad)',`<b>${C.verdict.level}</b> · 审级 ${C.verdict.review_tier}<br>${(C.verdict.title||'').slice(0,110)}…<br><span style="color:var(--muted)">${C.verdict.id}</span>`);
  3793. h+=box('④ 沙盘','var(--accent)',`${(C.scenario.recommendation||'').slice(0,130)}…<br><span style="color:var(--muted)">${C.scenario.id}</span>`);
  3794. h+=box('⑤ 动作','var(--warn)',`${(C.action.name||'').slice(0,90)}<br>闸: ${C.action.gate?C.action.gate.reversibility+' · '+C.action.gate.review_tier:''} · ${C.action.status||''}<br><span style="color:var(--muted)">${C.action.id}</span>`);
  3795. h+=box('⑥ 验收状态机','var(--ok)',`状态 <b>${C.acceptance.state||'—'}</b> 分支 ${C.acceptance.branch||'—'}<br>${(C.acceptance.note||'').slice(0,110)}<br><span style="color:var(--muted)">acceptance/WTG29/state</span>`);
  3796. h+=`</div><p class="small">判读纪律: 六个观测量=一条机制链 (C3 源数按观测面), 禁写多源印证; 判级只在 L1/审级产生, 本体转录带审级。</p></div>`;
  3797. h+=`<div class="card mb"><h3>失效机制库 (${O.mechanisms.length} 族)</h3><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px">`;
  3798. O.mechanisms.forEach(m=>{h+=`<div class="small" style="border:1px solid var(--line);border-radius:8px;padding:9px"><b>${m.name}</b><br>状态: ${m.status||''}<br>观测面: ${(m.facets||[]).join('/')}<br>涉及: ${(m.affects||[]).map(a=>a.split('/').pop()).join(', ')||'fleet'}<br><span style="color:var(--muted)">${m.id}</span></div>`});
  3799. h+=`</div></div>`;
  3800. h+=`<div class="card mb"><h3>决策台 (决策捕获 + 月度滚动)</h3><table><tr><th>决策</th><th>裁决人</th><th>时间</th><th>状态</th></tr>`;
  3801. O.decisions.forEach(x=>{const on=x.status==='pending_user';
  3802. h+=`<tr><td class="small">${(x.decision||'').slice(0,130)}</td><td>${x.decided_by}</td><td class="small">${x.when}</td>
  3803. <td><span class="badge" style="border-color:${on?'var(--warn)':'var(--ok)'};color:${on?'var(--warn)':'var(--ok)'}">${x.status}</span></td></tr>`});
  3804. h+=`</table><p class="small">月度滚动: 数据月 ${O.roll.data_month||'—'} · 上次动作 ${(O.roll.last_roll_actions||[]).join('; ')||'—'} · 变化 ${(O.roll.last_changes||[]).join('; ')||'—'}</p></div>`;
  3805. h+=`<div class="card mb"><h3>对象浏览器 (${Object.values(O.stats).reduce((a,b)=>a+b,0)} 对象)</h3>
  3806. <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
  3807. <select id="obType" style="padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink)"><option value="">全部类型</option>${Object.keys(O.stats).map(k=>`<option>${k}</option>`).join('')}</select>
  3808. <input id="obT" placeholder="台号 如 29" style="padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);width:110px">
  3809. <button onclick="obGo()" style="cursor:pointer;padding:8px 16px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff">查询</button>
  3810. </div><div id="obOut" class="small">选类型/台号后查询; 点结果行看对象全文与链接。</div></div>`;
  3811. root.innerHTML=h;
  3812. document.getElementById('foot').innerHTML='本体=只读语义层; 判级权在 L1+审级, 模型与网页均零判级权';
  3813. }
  3814. async function obGo(){
  3815. const t=document.getElementById('obType').value, tb=document.getElementById('obT').value;
  3816. const d=await (await fetch(`/api/ont_list?type=${encodeURIComponent(t)}&t=${encodeURIComponent(tb)}&limit=60`)).json();
  3817. const el=document.getElementById('obOut');
  3818. if(!d.rows||!d.rows.length){el.textContent='无结果';return}
  3819. el.innerHTML=`<table><tr><th>id</th><th>类型</th><th>要点</th></tr>`+d.rows.map(r=>
  3820. `<tr style="cursor:pointer" onclick="obOpen('${r.id}')"><td class="small">${r.id}</td><td>${r.type}</td><td class="small">${r.name||r.level||r.status||''} ${r.review_tier?'· '+r.review_tier:''}</td></tr>`).join('')+`</table><div id="obDetail"></div>`;
  3821. }
  3822. async function obOpen(id){
  3823. const d=await (await fetch('/api/ont_obj?id='+encodeURIComponent(id))).json();
  3824. const el=document.getElementById('obDetail'); if(!el) return;
  3825. el.innerHTML=`<pre style="white-space:pre-wrap;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px">${JSON.stringify(d,null,1).replace(/</g,'&lt;')}</pre>`;
  3826. }
  3827. // 模型档位 (2026-08-28 用户令: 模型切换的应用你设计好; 去 30B 用 27B)。
  3828. // 按"这个问题值不值得等"选, 而非凭型号大小猜 — 每档写清 在哪跑 / 要多久 / 适合什么。
  3829. // 云端档单独标"内容会外发", 因为那是一个需要用户知情的选择而不是性能选项。
  3830. // ★页面用的是**这一份** (JS), 上面 L1158 那份是 Python 的 —— 两份同名不同源。
  3831. // 我第一次只改了 Python 那份, 页面一点没变 (deepseek-chat 在成品件里 0 命中)。
  3832. // 同名双实现: 改一处不报错、不生效, 只能靠回查产物发现。
  3833. const MODEL_SPEC = {
  3834. 'qwen3:8b': {nm:'轻量 8B', loc:'本地', t:'约 30 到 60 秒', use:'默认档: 快速简答问答', warn:''},
  3835. 'qwen3.8:27b': {nm:'标准 27B', loc:'本地', t:'约 2 到 4 分钟', use:'复杂问题档: 多台横向比对与机制推断', warn:''},
  3836. 'qwen3:30b-a3b': {nm:'快 30B (a3b)', loc:'本地', t:'约 20 到 60 秒', use:'8B 成文校验未过时自动升档目标', warn:''},
  3837. 'deepseek-r1:14b': {nm:'本机 DeepSeek 14B', loc:'本地', t:'约 1 到 3 分钟', use:'交叉审核审核档 (作答请优先 27B)', warn:''},
  3838. 'deepseek-r1:32b': {nm:'本机 DeepSeek 32B', loc:'本地', t:'约 3 到 8 分钟', use:'备用: 更强更慢, 引用格式不稳', warn:''},
  3839. 'deepseek-chat': {nm:'DeepSeek', loc:'云端', t:'约 3 到 10 秒', use:'默认档, 状态查询与机制推断', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  3840. 'kimi-fast': {nm:'Kimi', loc:'云端', t:'约 5 到 20 秒', use:'长文归纳', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  3841. 'qwen-plus': {nm:'通义千问', loc:'云端', t:'约 5 到 20 秒', use:'备用云端档', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  3842. 'deepseek-v4-pro': {nm:'云端 DS', loc:'云端', t:'约 2 到 6 分钟', use:'复杂推理与交叉审核', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  3843. };
  3844. function mdlNote(){
  3845. const el=document.getElementById('mdlnote'), sel=document.getElementById('askm');
  3846. if(!el||!sel) return;
  3847. const v=MODEL_SPEC[sel.value]||{};
  3848. // ★查不到档位时**不能**默认"零外发" —— 未知档只可能是新加的云端模型。宁可说重不说轻。
  3849. const note = v.warn ? `<span style="color:var(--warn)">${v.warn}</span>`
  3850. : (v.loc==='本地' ? '全程零外发' : '<span style="color:var(--warn)">未知档位, 按内容会外发处理</span>');
  3851. el.innerHTML=`${v.use||''} · ${note}`;
  3852. }
  3853. async function askGo(){
  3854. const q=document.getElementById('askq').value, m=document.getElementById('askm').value;
  3855. if(!q.trim()){document.getElementById('askout').textContent='问题为空';return}
  3856. const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({q,model:m,cat:window.ASKCAT||'',xrev:(document.getElementById('askxrev')||{}).checked||false})});
  3857. const d=await r.json();
  3858. if(d.err){document.getElementById('askout').textContent=d.err;return}
  3859. askPoll();
  3860. }
  3861. // ★这句提示原文是「本地直连约 45~250 秒 (复合问题更久; 全程零外发)」—— 那是本地模型
  3862. // 时期的文案, 两处都已失真 (2026-09-01 用户说"太慢", 实测才发现):
  3863. // ① 时间: 云端档实测 3~7 秒, 而提示写 45~250 秒 —— **是提示语在告诉用户它很慢**
  3864. // ② 「全程零外发」现在是**假的**: 云端档的问题会发到厂商 API。
  3865. // 向用户保证一件不成立的事, 比写慢了严重得多。
  3866. // 故改为按档给话, 本地档才说零外发。
  3867. function MDLNOTE(m){
  3868. // ★不用正则: CHART_JS 是**非 raw** 的 Python 三引号串, 反斜杠转义序列会被 Python
  3869. // 吃掉并报 SyntaxWarning/SyntaxError。本地档判别用朴素前缀/子串即可。
  3870. // (本会话第 4 次踩这个坑 —— 而这一次是在**写这条警告的注释里**踩的。)
  3871. const s0=String(m||'').toLowerCase();
  3872. const local = s0.indexOf(':')>=0 || ['qwen3','llama','gemma','deepseek-r1'].some(x=>s0.indexOf(x)===0);
  3873. return local ? '本地直连, 约 45 到 250 秒 (复合问题更久), 全程零外发'
  3874. : '云端模型, 通常 3 到 10 秒 (复合问题更久), 问题与检索到的证据会发往模型厂商';
  3875. }
  3876. async function askPoll(){
  3877. const el=document.getElementById('askout'); if(!el) return;
  3878. const r=await fetch('/api/ask_status'); const s=await r.json();
  3879. if((s.state==='running'||s.state==='partial')){
  3880. // 进度上屏 (2026-08-28 用户"刚发现给不出结果"): 原来只有一个秒数在跳, 本地模型一问 45~250s,
  3881. // 看不见它在干什么就会被读成卡死。现在报当前阶段(预取/推理第n轮/取证工具名/成文/校闸)与轨迹。
  3882. const st=(s.steps||[]);
  3883. el.innerHTML=`<b>推理中…</b> ${s.elapsed}s · <span style="color:var(--accent)">${s.step||'启动'}</span>
  3884. <div class="small" style="color:var(--muted);margin-top:5px">${s.model} · ${MDLNOTE(s.model)}</div>
  3885. ${st.length?`<div class="small" style="color:var(--muted);margin-top:4px">${st.slice(-6).join(' → ')}</div>`:''}
  3886. <div class="small" style="color:var(--muted);margin-top:4px">问题: ${(s.q||'').slice(0,90)}</div>`;
  3887. setTimeout(askPoll, 2000);
  3888. }else if(s.state==='done'){
  3889. const v=s.verify||{};
  3890. const badge=v.全通过?`<span class="badge" style="border-color:var(--ok);color:var(--ok)">溯源核验 ${v.引用id数}/${v.引用id数} 全真</span>`
  3891. :`<span class="badge" style="border-color:var(--warn);color:var(--warn)">溯源核验未全过 (引用${v.引用id数||0})</span>`;
  3892. const R=s.review;
  3893. const rv=R?`<div class="card mb" style="border-left:3px solid ${R.issues&&R.issues.length?'var(--warn)':'var(--ok)'};margin-top:10px">
  3894. <h3>交叉审核 · ${R.model} <span class="small" style="font-weight:400;color:${R.issues&&R.issues.length?'var(--warn)':'var(--ok)'}">${R.verdict}</span></h3>
  3895. <p class="small" style="color:var(--muted);margin-bottom:${R.issues&&R.issues.length?'8px':'0'}">
  3896. 由另一本地模型基于<b>同一份证据</b>复核上述答案。溯源核验仅校验数字与机组号是否越界, 本项校验结论是否有证据支撑。</p>
  3897. ${(R.issues||[]).map(i=>`<div class="small" style="padding:5px 0;border-top:1px solid var(--line-2)">
  3898. <span class="badge" style="border-color:var(--warn);color:var(--warn)">${i.type}</span>
  3899. <b>「${i.quote}」</b> <span style="color:var(--muted)">${i.why}</span></div>`).join('')}
  3900. ${R.err?`<p class="small" style="color:var(--bad)">审核未完成: ${R.err}</p>`:''}</div>`:'';
  3901. el.innerHTML=`<p>${badge} · 模型 ${s.model} · 用时 ${s.elapsed}s · ${(s.steps||[]).length} 步</p><pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px solid var(--line)">${(s.answer||'').replace(/</g,'&lt;')}</pre>${rv}`;
  3902. }else if(s.state==='error'){
  3903. el.textContent='出错: '+s.err;
  3904. }
  3905. }
  3906. function winSel(cur){
  3907. const base=['近30日','近90日','2026年','2026H1','2025H2','全程'];
  3908. const ms=(window.ALLMONTHS&&ALLMONTHS.length)?ALLMONTHS:[]; // 按月份过滤和选择 (用户令): 预设窗+逐月
  3909. let h=`<select id="win" onchange="reload(this.value)"><optgroup label="预设窗">`+
  3910. base.map(w=>`<option ${w===cur?'selected':''}>${w}</option>`).join('')+`</optgroup>`;
  3911. if(ms.length) h+=`<optgroup label="按月">`+ms.slice().reverse().map(m=>`<option ${m===cur?'selected':''}>${m}</option>`).join('')+`</optgroup>`;
  3912. else if(!base.includes(cur)) h+=`<option selected>${cur}</option>`; // 台页/问题页无月轴时保显当前月窗
  3913. return h+`</select>`}
  3914. """
  3915. PAGE_FLEET = """<!doctype html><html lang="zh" data-theme="light"><head><meta charset="utf-8"><script>(function(){try{var q=new URLSearchParams(location.search).get("theme");if(q){localStorage.setItem("windscada_theme",q)}var t=q||"light";document.documentElement.setAttribute("data-theme",t)}catch(e){}})();</script><meta name="viewport" content="width=device-width,initial-scale=1">
  3916. <title>观澜 · 风电智能分析决策</title><style>%CSS%</style></head><body>
  3917. <header><h1><s>观澜</s> 如东海上</h1><span id="winbox"></span>
  3918. </header>
  3919. <nav class="tabs" id="tabs"></nav>
  3920. <div id="root"><div class="card"><div class="skel" style="width:38%"></div><div class="skel" style="width:72%;margin-top:9px"></div><div class="skel" style="width:56%;margin-top:9px"></div></div></div>
  3921. <footer id="foot"></footer>
  3922. <script>%JS%
  3923. async function reload(win){
  3924. const r=await fetch('/api/fleet?win='+encodeURIComponent(win)); const d=await r.json();
  3925. if(d.all_months) window.ALLMONTHS=d.all_months;
  3926. document.getElementById('winbox').innerHTML=winSel(d.win);
  3927. D=d; renderTabs(); render();
  3928. syncOverviewWin();
  3929. }
  3930. function syncOverviewWin(){
  3931. // 总览静态页窗自适应 (2026-08-28 用户令): 把所选窗月列表推给 iframe, 页内重算 KPI/五态/限电/损失账
  3932. const f=document.getElementById('ovframe');
  3933. if(f&&D&&D.months) try{ f.contentWindow.postMessage({months:D.months, label:D.win}, '*') }catch(e){}
  3934. }
  3935. const TABS=['总览','电量算账','部件问题','振动融合分析','发电性能','故障统计','检修决策','检修助手','汇报定制','系统维护'] // 2026-08-28 用户指定顺序与命名; 分组标签撤 (10 个平铺, 分组反而多一层视觉层级); // 问题总览撤: 系统入口进总览, 点系统看详情 // 本体不设独立tab (用户裁: 检修决策页=ontology展现的窗口)
  3936. let CURVES=null;
  3937. let D=null, TAB='总览';
  3938. const TABGROUPS=null; // 分组已撤 (用户裁 2026-08-28)
  3939. function renderTabs(){
  3940. // 平铺 (2026-08-28 用户裁): 10 个标签直接排, 撤掉"状态/分析/决策/维护"分组标签 —
  3941. // 分组标签本身占一个视觉层级, 而 10 个中文标签本来就读得完, 分组是多余的一跳。
  3942. document.getElementById('tabs').innerHTML=TABS.map(t=>
  3943. `<button class="${t===TAB?'on':''}" onclick="TAB='${t}';renderTabs();render()">${t}</button>`).join('');
  3944. }
  3945. function render(){
  3946. const d=D; const k=d.kpi, m=(d.m8&&!d.m8.err)?d.m8.mtbf:null;
  3947. let h='';
  3948. // 九系统入口 + 系统详情 (2026-08-28 用户令: 取消问题总览, 每个系统点开看详情)
  3949. // 系统详情: 入口卡已随'两页顶端互换'移到可靠性概览, 条件不能再钉死总览 (2026-08-28 用户逮'点卡没反应')
  3950. if(window.SYSV&&(TAB==='部件问题'||TAB==='总览')){
  3951. const sname=window.SYSV, rows=(d.systems||{})[sname]||[], dist=(d.sysdist||{})[sname]||{};
  3952. const cnt=st=>dist[st]||0;
  3953. const al=rows.filter(r=>r.st==='报警'), un=rows.filter(r=>r.st==='不可判'), gd=(d.systems||{})[sname+'·良好']||[];
  3954. const N=Object.values(dist).reduce((x,y)=>x+y,0)||1;
  3955. h+=`<div class="card mb"><p class="small"><a class="tl" href="#" onclick="window.SYSV=null;render();return false">← 返回${TAB}</a></p>
  3956. <h3>${sname} · 全场 ${N} 台</h3>
  3957. <div style="display:flex;height:14px;border-radius:4px;overflow:hidden;margin:8px 0 4px">
  3958. ${[['报警','var(--bad)'],['不可判','var(--unknown)'],['良好','var(--info)'],['优秀','var(--ok)']]
  3959. .map(([k,c])=>{const n=cnt(k); return n?`<i title="${k} ${n}" style="width:${(n/N*100).toFixed(1)}%;background:${c}"></i>`:''}).join('')}
  3960. </div>
  3961. <div class="small" style="color:var(--muted)">报警 ${cnt('报警')} · 不可判 ${cnt('不可判')} · 良好 ${cnt('良好')} · 优秀 ${cnt('优秀')}</div></div>`;
  3962. // 部件解剖图: 该系统内部怎么构成、介质/动力怎么流 (与传动链简图分工: 链=哪一环, 解剖=环内怎么坏的)
  3963. if(ANAT[sname]){
  3964. const _hit = al.map(r=>r.why).join(' ');
  3965. h+=`<div class="card mb"><h3>${sname} 部件构成与失效路径</h3>${ANAT[sname](_hit, {})}</div>`;
  3966. }
  3967. const tbl=(title,list,col)=>list.length?`<div class="card mb"><h3>${title} (${list.length} 台)</h3>
  3968. <table><tr><th style="width:74px">机组</th><th>依据</th><th style="width:96px">详情</th></tr>
  3969. ${list.map(r=>`<tr><td><a class="tl" href="/turbine/${r.t}"><b>${r.t.replace('WTG','')}#</b></a></td>
  3970. <td class="small">${r.why}</td>
  3971. <td><a class="tl small" href="/problem/${r.t}/${encodeURIComponent(sname)}">该问题页 →</a></td></tr>`).join('')}</table></div>`:'';
  3972. h+=tbl('报警机组',al)+tbl('不可判 (监测/数据不足, ≠正常)',un);
  3973. if(gd.length) h+=`<details class="card mb"><summary style="cursor:pointer;font-weight:600;font-size:14px">良好 ${gd.length} 台 (轻微偏离, 周期观察 — 点开看逐台依据)</summary>
  3974. <table style="margin-top:10px"><tr><th style="width:74px">机组</th><th>依据</th><th style="width:96px">详情</th></tr>
  3975. ${gd.map(r=>`<tr><td><a class="tl" href="/turbine/${r.t}"><b>${r.t.replace('WTG','')}#</b></a></td><td class="small">${r.why}</td>
  3976. <td><a class="tl small" href="/problem/${r.t}/${encodeURIComponent(sname)}">该问题页 →</a></td></tr>`).join('')}</table></details>`;
  3977. if(!al.length&&!un.length&&!gd.length) h+=`<div class="card"><p class="small">本系统全场无非常态。</p></div>`;
  3978. document.getElementById('root').innerHTML=h; document.getElementById('foot').innerHTML=d.note; return;
  3979. }
  3980. if(TAB==='总览'){
  3981. // 总览顶部 = 可用率与损失总结 (2026-08-28 用户令: 与可靠性页最上端互换)
  3982. const LS={}; ((d.control&&d.control.states)||[]).forEach(x=>{LS[x.k]=x.loss});
  3983. const M8=(d.m8&&d.m8.mtbf)||null;
  3984. h+=`<div class="kpis">
  3985. <div class="kpi ${d.control&&d.control.avail>=95?'ok':'warn'}"><b>${d.control?d.control.avail:'—'}%</b><span>时间可用率(不含调度令) · ${d.win}</span></div>`;
  3986. if(M8) h+=`<div class="kpi"><b>${M8.停机总时长h}h</b><span>停机时长 | ${M8.停机事件数} 起 (${d.win})</span></div>
  3987. <div class="kpi"><b>${M8.MTBF_h}h</b><span>平均停机间隔MTBO | 剔除口径 ${M8.剔除口径.MTBF_h}h (非行业MTBF)</span></div>`;
  3988. if(LS['停机']!=null) h+=`<div class="kpi"><b>${Math.round(LS['停机']).toLocaleString()}</b><span>停机损失 MWh${LS['停机(调度令)']!=null?` (另调度令 ${Math.round(LS['停机(调度令)']).toLocaleString()})`:''}</span></div>`;
  3989. if(LS['限电·绑定']!=null) h+=`<div class="kpi"><b>${Math.round(LS['限电·绑定']).toLocaleString()}</b><span>限电·绑定损失 MWh</span></div>`;
  3990. h+=`</div>`;
  3991. // 快照模式下 /static/index.html 取不到 (它是服务端另一套页面), 会渲染出静态服务的 404 页;
  3992. // 快照里改为提示条, 并指向快照内已有的各分析页。
  3993. const _snap = (typeof window.__SNAP__!=='undefined');
  3994. // ★快照版的总览不能是一句"内嵌页不可用" (2026-09-01 用户: "over view 也打不开")。
  3995. // 它是访客落地的**第一屏**, 而原来那句等于开门就说"这里没东西"。
  3996. // 数据本来就在快照里 —— 逐系统机组、kpi、可靠性统计 —— 直接在这里汇总,
  3997. // 并给一张"哪一屏回答什么"的导览。服务端版仍走 iframe, 不受影响。
  3998. document.getElementById('root').innerHTML = h + (_snap
  3999. ? (farmScene(d) + snapOverview(d))
  4000. : `<iframe id="ovframe" src="/static/index.html" title="总览" onload="syncOverviewWin()"></iframe>`);
  4001. document.getElementById('foot').innerHTML=d.note; return;
  4002. }
  4003. if(TAB==='发电性能'){ renderCurves(); return; }
  4004. if(TAB==='本体'){ renderOntology(); return; }
  4005. if(TAB==='振动融合分析'&&d.fus&&!d.fus.err&&d.fus.kpi){
  4006. const K=d.fus.kpi; // 振动域分布卡·按钮化 (用户令: 点卡进入看该级具体台; FUSF=当前筛选级)
  4007. const btn=(f,cls,val,label)=>{const on=window.FUSF===f;
  4008. return `<div class="kpi ${cls}" role="button" tabindex="0" onclick="window.FUSF=window.FUSF==='${f}'?null:'${f}';window.FUSSUB='逐台情况';render()" style="cursor:pointer${on?';outline:2px solid var(--accent)':''}"><b>${val}</b><span>${label}${on?' · <b>筛选中,再点清除</b>':' · 点击查看'}</span></div>`};
  4009. h+=`<div class="kpis">`;
  4010. h+=btn('bad', K.定论>0?'bad':'ok', K.定论, `定论/报警级 (机制链定案 ${K.机制链} 台)`);
  4011. h+=btn('warn', K.预警>0?'warn':'ok', K.预警, '预警级机组');
  4012. h+=btn('note', '', K.候选观察, '候选/监视/观察级');
  4013. h+=btn('ok', 'ok', K.销案正常, '销案/无新证');
  4014. h+=btn('unlisted', '', `${K.收录台}<span style="font-size:13px;color:var(--muted)">/${K.全场}</span>`, `振动线收录台 (未列 ${K.未列} 台 ≠ 正常)`);
  4015. // (可检出系数KPI卡撤 — 2026-08-28 用户令: 能力边界取消, 模型计算隐藏)
  4016. h+=`</div>`;
  4017. // 数据时点 (用户令"以最新导入的报告日期为准, 并说明时间"): 判级转录自振动线, 有自己的出件日期
  4018. // 与证据窗末, 与本页所选时间窗无关 — 不标时点会让人以为它跟着窗走
  4019. if(d.fus.数据时点) h+=`<p class="small" style="color:var(--muted);margin:-4px 0 12px">
  4020. 判级数据: ${d.fus.数据时点} — 转录自振动线, <b>不随上方时间窗变</b>; 报警/工单/温度列才跟随所选窗。</p>`;
  4021. } else {
  4022. // (KPI 组已移至总览顶部 — 2026-08-28 用户令: 两页最上端互换)
  4023. }
  4024. if(TAB==='部件问题'){
  4025. // 九系统入口 (点开看该系统全场详情) — 替代原"问题总览"tab
  4026. const SD=d.sysdist||{};
  4027. h+=`<div class="eyebrow">按系统查看</div><div class="card mb"><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px">`
  4028. +Object.entries(SD).map(([s2,dist])=>{
  4029. const a=dist['报警']||0, u=dist['不可判']||0, N=Object.values(dist).reduce((x,y)=>x+y,0)||1;
  4030. const col=a>=8?'var(--bad)':a>=3?'var(--warn)':a?'var(--info)':'var(--ok)';
  4031. return `<div role="button" tabindex="0" onclick="window.SYSV='${s2}';render()" style="cursor:pointer;border:1px solid var(--line);border-left:3px solid ${col};border-radius:9px;padding:10px 12px"
  4032. onmouseover="this.style.borderColor='var(--accent)';this.style.borderLeftColor='${col}'" onmouseout="this.style.borderColor='var(--line)';this.style.borderLeftColor='${col}'">
  4033. <div style="font-weight:600;font-size:13px">${s2}</div>
  4034. <div style="display:flex;height:7px;border-radius:3px;overflow:hidden;margin:6px 0 5px">
  4035. ${[['报警','var(--bad)'],['不可判','var(--unknown)'],['良好','var(--info)'],['优秀','var(--ok)']]
  4036. .map(([k,c])=>{const n=dist[k]||0; return n?`<i title="${k} ${n}" style="width:${(n/N*100).toFixed(1)}%;background:${c}"></i>`:''}).join('')}
  4037. </div>
  4038. <div class="small" style="color:var(--muted)">报警 <b style="color:${col}">${a}</b>${u?` · 不可判 ${u}`:''} · 全场 ${N} 台</div></div>`}).join('')
  4039. +`</div><p class="small" style="margin-top:8px;color:var(--muted)">点系统卡 → 该系统全场机组与依据; 九系统 = 交付九分册一一对应; 叶片与叶根/塔架与基础无 SCADA 轴 (分册蓝本取 CMS/巡检)</p></div>`;
  4040. if(d.rel&&d.rel.err) h+=blockErr('rel', d.rel.err);
  4041. if(d.rel&&!d.rel.err){
  4042. const R=d.rel, tb=R.表, maxH=Math.max(...tb.map(r=>r.停机时长h));
  4043. h+=`<div class="eyebrow">部件可靠性 · ${R.窗}</div>`;
  4044. h+=`<div class="card mb"><h3>部件停机贡献与可靠性 <span class="small" style="font-weight:400;color:var(--muted)">总停机 ${R.总停机h}h = 设备类 ${R.设备类停机h} + 外部类 ${R.外部类停机h}</span></h3>
  4045. <p class="small" style="color:var(--muted);margin:-2px 0 8px">口径 ≥1h 停机段, 不含调度令 —
  4046. 与"电量算账"里的停机时长${(function(){var v=d.control.energy&&d.control.energy.items.filter(function(x){return x.k==='停机'}).map(function(x){return x.h})[0];return (v===undefined||v===null)?'':' '+v+'h'})()} 不同,
  4047. 差额是 <b>短于 1h 的停机段</b> (本页按段做可靠性指标须成段, 能量账按逐条工况态算电量): 两个数都对, 不可互相相减。</p>
  4048. <table><tr><th>部件</th><th class="barcell">停机时长</th><th class="num">事件</th><th class="num">MTBO h</th><th class="num">MDT h</th><th class="num">影响台</th><th>典型失效模式 (按时长)</th></tr>`;
  4049. tb.forEach(r=>{
  4050. const w=Math.round(r.停机时长h/maxH*72);
  4051. const sev=r.部件==='无记录停机'?'var(--unknown)':r.外部?'var(--info)':(r.占停机>=0.06?'var(--bad)':r.占停机>=0.03?'var(--warn)':'var(--ok)');
  4052. h+=`<tr><td><b>${r.部件}</b>${r.外部?' <span class="badge">外部</span>':''}</td>
  4053. <td class="barcell"><i class="bar" style="width:${w}px;--c:${sev}"></i><span class="cnt">${r.停机时长h}h · ${(r.占停机*100).toFixed(1)}%</span></td>
  4054. <td class="num">${r.事件数}</td><td class="num">${r.外部?'—':r.MTBF_h}</td><td class="num">${r.MDT_h}</td>
  4055. <td class="num">${r.影响台数}</td><td class="small">${r.典型失效模式||'—'}</td></tr>`});
  4056. h+=`</table><p class="small">${R.口径}</p></div>`;
  4057. }
  4058. if(d.fus&&d.fus.err) h+=blockErr('fus', d.fus.err);
  4059. if(d.fus&&!d.fus.err){
  4060. h+=`<div class="card mb"><p class="small">振动线结论与四源融合已移至独立板块 → <a class="tl" href="#" onclick="TAB='振动融合分析';render();return false"><b>振动融合</b></a> (${d.fus.表.length} 台次收录)</p></div>`;
  4061. }
  4062. h+=`<div class="eyebrow">多系统交叉机组</div>`;
  4063. h+=`<div class="card mb"><h3>关注机组 (≥2 系统交叉, ${d.watch.length} 台; 点机组号 → 该台独立页)</h3>
  4064. <table><tr><th>机组</th><th>系统数</th><th>命中系统</th><th>依据</th></tr>`;
  4065. d.watch.forEach(w=>{h+=`<tr><td><a class="tl" href="/turbine/${w.t}"><b>${w.t.replace('WTG','')}#</b></a></td><td class="num">${w.n}</td>
  4066. <td class="small">${w.syss.map(x=>`<a class="tl" href="/problem/${w.t}/${encodeURIComponent(x)}">${x}</a>`).join(' · ')}</td>
  4067. <td class="small">${w.why}</td></tr>`});
  4068. h+=`</table></div>`;
  4069. }
  4070. if(TAB==='振动融合分析'){
  4071. if(!d.fus||d.fus.err){ h+=`<div class="card"><p>融合面数据不可用: ${d.fus&&d.fus.err||'无数据'}</p></div>`; }
  4072. else{
  4073. const F=d.fus, CL={bad:'var(--bad)',warn:'var(--warn)',note:'var(--muted)',ok:'var(--ok)',unlisted:'var(--muted)',stale:'var(--muted)'};
  4074. if(!window.FUSSUB||window.FUSSUB==='能力边界') window.FUSSUB='融合矩阵'; // 旧持久值兜底
  4075. const SUBS=['融合矩阵','端到端闭环','逐台情况','报警工单','振动分析'];
  4076. h+=`<div class="eyebrow">振动与 SCADA 证据视图 · 展示振动结论、SCADA 指标、报警与工单线索</div>`;
  4077. h+=`<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">`+SUBS.map(s=>{
  4078. const on=s===FUSSUB;
  4079. return `<button onclick="window.FUSSUB='${s}';if('${s}'!=='逐台情况')window.FUSF=null;render()" style="cursor:pointer;padding:8px 16px;border-radius:20px;border:1px solid ${on?'var(--accent)':'var(--line)'};background:${on?'var(--accent)':'var(--panel)'};color:${on?'#fff':'var(--ink)'};font-size:13px;font-weight:${on?'600':'400'}">${s}</button>`}).join('')+`</div>`;
  4080. if(FUSSUB==='融合矩阵'){
  4081. h+=`<div class="card mb" style="border-left:3px solid var(--warn)"><b>两条判读纪律 (随板块常显)</b><p class="small">① 同一失效机制的多个观测面 ≠ 多源印证 — 融合禁按源数加权 (29# 柱塞/温度/振动稳态/振动瞬态 = 四个观测面一条机制链)。<br>② "没报警" ≠ "正常" — 在已知盲区上 (见"能力边界"页), 振动阴性不构成证据; 矩阵"未列"台同理。</p></div>`;
  4082. const spark=(hist,color)=>{ // 记忆轨迹: 旧窗=细线, 现窗=实心点
  4083. if(!hist||hist.filter(x=>x!=null).length<3) return '';
  4084. const vs=hist.filter(x=>x!=null), mn=Math.min(...vs), mx=Math.max(...vs), sp=(mx-mn)||1;
  4085. const W=84,H=22,n=hist.length;
  4086. let dpath='',last=null;
  4087. hist.forEach((v,i)=>{ if(v==null)return; const x=(i/(n-1))*W, y=H-2-((v-mn)/sp)*(H-4);
  4088. dpath+=(dpath?'L':'M')+x.toFixed(1)+' '+y.toFixed(1); last=[x,y]; });
  4089. return `<svg width="${W}" height="${H}" style="vertical-align:middle;margin-left:6px"><path d="${dpath}" fill="none" stroke="${color}" stroke-width="1.2" opacity="0.75"/><circle cx="${last[0]}" cy="${last[1]}" r="2.4" fill="${color}"/></svg>`;
  4090. };
  4091. h+=`<div class="card mb"><h3>全场融合矩阵 (38 台 × 6 个证据面)</h3>\n <p class="small" style="color:var(--muted)">各列取数窗口不同 (见列头) — 不同窗的两格同时发红不等于"同时发生", 不可当共时印证。</p>
  4092. <p class="small">每格右侧曲线 = 逐月记忆轨迹 (${(F.列窗.months||[]).length} 个月, ${(F.列窗.months||['?'])[0]}起; 实心点=现窗) — 现窗快照会丢形态, 轨迹才看得见"塌→回升→翻转"类机制签名。</p>`;
  4093. // 等宽固定布局 (用户令): 六证据列各 15.5%, 机组列 7%; 长口径收进列头副行与悬停
  4094. const shortWin=w=>{const t=zh(w||''); const m=t.match(/20[0-9][0-9]-[0-9][0-9](?:~20[0-9][0-9]-[0-9][0-9])?|至[ ]*20[0-9][0-9]-[0-9][0-9]|20[0-9][0-9]~20[0-9][0-9]/); return m?m[0]:t.slice(0,14)};
  4095. const th=(name,win,extra)=>`<th title="${esc(zh(win))}${extra?' · '+extra:''}"><div>${name}</div>
  4096. <div class="small" style="font-weight:400;color:var(--muted)">${shortWin(win)}${extra?' · '+extra:''}</div></th>`;
  4097. h+=`<div class="small" style="color:var(--muted);margin:6px 0 12px;line-height:2">
  4098. <div><b>格子颜色 = 设备状态 (业主口径, 与 windcms 报告同一套):</b>
  4099. ${['危险','报警','良好','优秀','不可判'].map(k=>
  4100. `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px"><i style="width:10px;height:10px;border-radius:2px;background:${C[k]||'#ccc'}"></i>${k}</span>`).join('')}</div>
  4101. <div><b>徽章 = 证据状态 (可信度, windcms 九枚举):</b>
  4102. ${['定论','准定论·预警','候选·新发','候选','候选·记基线','参考','INSUFFICIENT'].map(k=>
  4103. `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px"><i style="width:10px;height:10px;border-radius:2px;background:${(F.色标||{})[k]||'#ccc'}"></i>${k}</span>`).join('')}</div>
  4104. <div style="color:var(--muted)">两轴不可互相替代: "候选"说的是可信度不是设备好坏; "良好"说的是设备状态不是证据强度。设备状态只取自振动线的判级栏这一个来源。</div>
  4105. </div>`;
  4106. h+=`<table style="table-layout:fixed;width:100%"><colgroup><col style="width:7%">${'<col style="width:15.5%">'.repeat(6)}</colgroup>
  4107. <tr><th>机组</th>${th('振动',F.列窗.振动,'不随本窗')}${th('温度',F.列窗.温度)}${th('润滑柱塞',F.列窗.润滑,'只展示')}${th('油液',F.列窗.油液)}${th('报警',F.列窗.报警||'')}${th('工单',F.列窗.工单||'')}</tr>`;
  4108. const MAXAL = Math.max(1, ...F.矩阵.map(r=>(r.报警||{}).n||0));
  4109. const MAXWO = Math.max(1, ...F.矩阵.map(r=>(r.工单||{}).n||0));
  4110. F.矩阵.forEach(r=>{
  4111. // 各列只列与问题相关的 (2026-08-28 用户令"不要发散"): 该格非问题级 → 收敛成一词, 全文留悬停;
  4112. // 有问题才展开证据。矩阵是找问题的面, 不是数据倾倒面。
  4113. // 非问题格短词: 优先取 note 里的判断短语 (振动列已把"未收录"细化成
  4114. // 已评估无异常 / 数据不足·不可判 / 未评估 三类, 固定字典会把这个区分盖掉)
  4115. // 格内一律显示明确定级词, 不显示截断的原文 (2026-08-28 用户逮"评估报告 2026-08-2…"这类无意义截断)
  4116. const shortOf = (c) => {
  4117. if(c.ostate && c.ostate !== '不可判') return c.ostate; // 明确设备状态优先
  4118. if(/数据不足|不可判/.test(String(c.note))) return '数据不足';
  4119. if(/未评估/.test(String(c.note))) return '未评估';
  4120. if(/无样|未取样/.test(String(c.note))) return '未取样';
  4121. if(/样龄|stale/.test(String(c.note))) return '样龄过期';
  4122. return {ok:'正常', unlisted:'未收录', stale:'样龄过期'}[c.level] || '—';
  4123. };
  4124. // ★两套体系并存 (2026-08-28 用户令"参照 windcms 优秀/良好/报警/危险 级别体系"):
  4125. // ① 业主口径状态等级 (危险>报警>良好>优秀>不可判) = 设备状态轴 → 决定格子颜色 (C 表, 与九系统矩阵同色)
  4126. // ② 证据状态 九枚举 (定论/准定论·预警/候选/参考/INSUFFICIENT) = 可信度轴 → 作徽章挂在旁边 (windcms 色标)
  4127. // 两轴不可互相替代: "候选"是可信度不是设备好坏, "良好"是设备状态不是证据强度。
  4128. const WC = F.色标 || {};
  4129. const cell=(c,lim,icon,tid)=>{
  4130. const col = C[c.ostate] || WC[c.wlevel] || CL[c.level] || 'var(--muted)';
  4131. const full = vibPlain(zh(c.note));
  4132. const isProb = ['bad','warn','note'].includes(c.level);
  4133. if(!isProb) return `<td class="small tipw" data-tip="${esc(full)} — 设备状态 ${c.ostate||'?'}" style="border-left:3px solid ${col};color:var(--muted)">${shortOf(c)}</td>`;
  4134. const cut = full.length>(lim||46) ? full.slice(0,lim||46)+'…' : full;
  4135. const hot = ['报警','危险'].includes(c.ostate);
  4136. const ic = icon ? partGlyph(full.split(/[::]/)[0], hot ? col : 'var(--muted)', hot) : '';
  4137. // 图 + 判级短词; 全文走悬停浮层 (2026-08-28 用户令: 列宽有限, 长句在格里会被挤成竖排)
  4138. // 部件名已由图表达 ⇒ 格内文字只留判级词, 不重复部件名
  4139. const parts = full.split(/[::]/);
  4140. // 明确定级词打头, 后面才是简述; 截断的原文(如"评估报告 2026-08-2…")不作为格内主文案
  4141. const brief = (parts.length > 1 ? parts.slice(1).join(':') : full).trim();
  4142. const cleanBrief = /^[ ]*(评估报告|两口径差异|⚠)/.test(brief) || brief.length > 16 ? '' : brief;
  4143. // duo 徽章已给定级词 → 主文案只留简述, 无简述时才回退定级词 (避免同格重复两次)
  4144. const shortCut = cleanBrief || (c.ostate && c.ostate !== '不可判' ? '' : shortOf(c));
  4145. // 点部件图 → 浮窗 (问题说明 + 该部件小曲线 + 四源微视图); 悬停只给文字
  4146. const clk = (tid && ic) ? ` onclick="popPart('${tid}','${esc(parts[0]).replace(/'/g,'')}')" style="cursor:pointer"` : '';
  4147. // 设备状态 | 证据状态 同框 (用户令: 两个 UI 分开显示太乱)
  4148. const ev = (c.wlevel && c.wlevel !== '—' && c.wlevel !== '正常' && c.ostate)
  4149. ? `<span class="duo" style="--c:${col};margin-top:4px"><span class="st">${c.ostate}</span><span class="ev">${c.wlevel}</span></span>` : '';
  4150. return `<td class="small tipw" data-tip="${esc(full)} — 设备状态 ${c.ostate||'?'} / 证据状态 ${c.wlevel||'?'}"
  4151. style="border-left:3px solid ${col};overflow:hidden">
  4152. <div${clk}>${ic || ''}<div style="color:${col};font-weight:${['bad','warn'].includes(c.level)?600:400}">${shortCut}</div>${ev}</div>
  4153. ${spark(c.hist,c.level==='bad'?'var(--bad)':c.level==='warn'?'var(--warn)':'var(--muted)')}</td>`};
  4154. const al=r.报警||{n:0,fam:[]}, wo=r.工单||{n:0,acts:[]};
  4155. const alc=al.rank&&al.rank<=5?'var(--warn)':'var(--line)';
  4156. const bu=al.burst;
  4157. h+=`<tr><td><a class="tl" href="/turbine/${r.turbine}"><b>${r.turbine.replace('WTG','')}#</b></a></td>${cell(r.振动,46,true,r.turbine)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)}
  4158. <td class="${(al.items||[]).length?'tipw':''}" data-tip="${esc(((al.items||[]).map(x=>`${x.code} ${x.text} — ${x.n} 条 (${x.first}~${x.last})`).join('\\n')) + (bu?`\\n⚠ 单码 ${bu.code} 占 ${(bu.share*100).toFixed(0)}%, 中位 ${bu.med_s} 秒/次 — 条数被抖动灌满`:''))}" style="border-left:3px solid ${bu?'var(--unknown)':alc};white-space:pre-line">
  4159. <b class="small" style="font-variant-numeric:tabular-nums;color:${al.rank&&al.rank<=10?'var(--ink)':'var(--muted)'}">${al.n} 条</b>
  4160. ${al.rank?`<span class="small" style="color:var(--muted)"> · 第${al.rank}/${al.tot}</span>`:''}
  4161. ${bu?`<div class="small" style="color:var(--unknown)">⚠ ${(bu.share*100).toFixed(0)}% 是码${bu.code}抖动</div>`:''}
  4162. ${(al.rank&&al.rank<=10)||bu?famBar(al.fam,al.n,MAXAL):''}</td>
  4163. <td class="${(wo.items||[]).length?'tipw':''}" data-tip="${esc((wo.items||[]).map(x=>`${x.date} ${x.name} → ${x.act}${x.part?' '+x.part:''}`).join('\\n') || '窗内无传动链工单')}" style="min-width:110px;white-space:pre-line">
  4164. ${wo.n?`<b class="small">${wo.n} 单</b>${wo.last?`<span class="small" style="color:var(--muted)"> · ${wo.last}</span>`:''}
  4165. <div style="height:7px;border-radius:3px;background:var(--muted);opacity:.5;margin-top:4px;width:${Math.max(6,Math.round(wo.n/MAXWO*90))}px"></div>
  4166. <div class="small" style="color:var(--muted);margin-top:3px">${(wo.acts||[]).slice(0,2).map(a=>a.k+'×'+a.n).join(' · ')}</div>`
  4167. :`<span class="small" style="color:var(--muted)">—</span>`}</td></tr>`});
  4168. h+=`</table><p class="small">润滑柱塞各台设定本就不同 (全场单次循环 22~961 秒), 所以此列只展示不判级, 只有已定案的机制链才标色; 油液多数台末次取样距今超 12 个月, 此列看的是"样够不够新", 不代表当前状态。</p></div>`;
  4169. }
  4170. // (能力边界撤; 端到端闭环替入 — 2026-08-28 用户令: 回恢复的和不恢复的都列, 结果性实证非方法学)
  4171. if(FUSSUB==='端到端闭环'){
  4172. const LP=(F.能力&&F.能力.loop&&F.能力.loop['换件闭环 (检出→换件→恢复)'])?F.能力.loop:null;
  4173. if(!LP){ h+=`<div class="card mb"><p class="small">handoff 无端到端闭环数据</p></div>`; }
  4174. else {
  4175. const cl=LP['换件闭环 (检出→换件→恢复)']||[], up=LP['当前在升 (无工单可对)']||[], neg=LP['负面对照 (工单后持平)'];
  4176. h+=`<div class="card mb"><h3>维修后验证案例 (检出 → 换件 → 恢复)</h3>
  4177. <p class="small">有换件记录且同口径指标回落, 可作为检测链已闭合的案例; 指标仍在上升但缺少可匹配工单的机组, 需现场或台账确认后再关闭。</p>
  4178. <h3 style="margin-top:8px">已闭环 · 换件后恢复 (${cl.length} 例)</h3>
  4179. <table><tr><th>机组</th><th>部件</th><th>事项</th><th>换件日</th><th>换前→换后 (同标尺)</th><th>恢复比</th><th>判</th></tr>
  4180. ${cl.map(r=>`<tr><td><b>${r.turbine.replace('WTG','')}#</b></td><td style="min-width:220px">${dtMini(r.component+(r.title||''),'var(--ok)',null,r.turbine)}</td><td class="small">${r.title}</td><td>${r.replace_date}</td><td>${baBar(r.pre,r.post,'var(--ok)')}<span class="small" style="color:var(--muted)">${r.pre} → ${r.post}</span></td><td class="num"><b style="color:var(--ok)">${r.ratio}×</b></td><td><span class="chip" style="--c:var(--ok)">${r.verdict}</span></td></tr>`).join('')}</table>
  4181. <p class="small" style="margin-top:6px">负面对照: 工单后持平 ${neg} 例 (换件≠必然回落, 说明回落非巧合)。</p>
  4182. <h3 style="margin-top:14px">未闭环 · 指标上升待现场/工单确认 (${up.length} 台)</h3>
  4183. <table><tr><th>机组</th><th>部件</th><th>早期→近期 (同标尺)</th><th>倍比</th><th>状态</th></tr>
  4184. ${up.map(r=>`<tr><td><b>${r.turbine.replace('WTG','')}#</b></td><td style="min-width:220px">${dtMini(r.component,'var(--warn)',null,r.turbine)}</td><td>${baBar(r.early,r.late,'var(--warn)')}<span class="small" style="color:var(--muted)">${r.early} → ${r.late}</span></td><td class="num"><b style="color:var(--warn)">${r.ratio}×</b></td><td><span class="chip" style="--c:var(--warn)">上升待确认</span></td></tr>`).join('')}</table>
  4185. <p class="small" style="margin-top:8px;color:var(--muted)">数据边界: ${(LP['★数据边界']||'').split('**').join('')}</p>
  4186. </div>`;
  4187. }
  4188. }
  4189. if(FUSSUB==='逐台情况'){
  4190. const lv=v=>/定论|报警/.test(v)?'bad':/预警/.test(v)?'warn':/销案|无新证|保留/.test(v)?'ok':'note';
  4191. if(window.FUSF==='unlisted'){
  4192. const un=F.矩阵.filter(r=>r.振动.level==='unlisted');
  4193. h+=`<div class="card mb"><h3>未列机组 ${un.length} 台 (振动线未收录 — <b>未列 ≠ 正常</b>, 盲区见"能力边界"页) <a class="tl" href="#" onclick="window.FUSF=null;render();return false">×清除筛选</a></h3>
  4194. <table><tr><th>机组</th><th>温度轴现状</th><th>润滑柱塞现状</th></tr>`;
  4195. un.forEach(r=>{h+=`<tr><td><a class="tl" href="/turbine/${r.turbine}"><b>${r.turbine.replace('WTG','')}#</b></a></td><td class="small">${r.温度.note}</td><td class="small">${r.润滑.note}</td></tr>`});
  4196. h+=`</table></div>`;
  4197. } else {
  4198. let rows=F.表;
  4199. const FLABEL={bad:'定论/报警级',warn:'预警级',note:'候选/监视/观察级',ok:'销案/无新证'};
  4200. if(window.FUSF) rows=F.表.filter(r=>lv(r.振动结论)===FUSF);
  4201. h+=`<div class="card mb"><h3>逐台情况 (全场 ${new Set(F.表.map(x=>x.turbine)).size} 台 / ${rows.length}${window.FUSF?`/${F.表.length} 台次 · 筛选: ${FLABEL[FUSF]} <a class="tl" href="#" onclick="window.FUSF=null;render();return false">×清除</a>`:' 台次'}; 深入谱图/波形 → "振动分析"页)</h3>
  4202. <div class="card" style="border-left:3px solid var(--accent);margin-bottom:12px">
  4203. <b class="small">评价体系 (规范化定义 — 两轴并存, 不可互相替代)</b>
  4204. <table style="margin-top:8px"><tr><th style="width:34%">设备状态 (业主口径)</th><th style="width:34%">证据状态 (可信度)</th><th>含义</th></tr>
  4205. <tr><td>${chip('危险')}</td><td><span class="badge">定论</span></td><td class="small">异常严重, 建议尽快停机检查 <span style="color:var(--muted)">(证据: 已定案)</span></td></tr>
  4206. <tr><td>${chip('报警')}</td><td><span class="badge">准定论·预警 / 候选·新发</span></td><td class="small">存在需现场核实或处置的异常 <span style="color:var(--muted)">(证据: 基本确认 / 新出现待核实)</span></td></tr>
  4207. <tr><td>${chip('良好')}</td><td><span class="badge">候选 / 候选·记基线</span></td><td class="small">轻微偏离, 记录观察 <span style="color:var(--muted)">(证据: 待现场核实 / 已记为基线下窗观察)</span></td></tr>
  4208. <tr><td>${chip('优秀')}</td><td><span class="badge">参考 / 正常 / 撤回</span></td><td class="small">未见异常, 保持常规监视 <span style="color:var(--muted)">(证据: 仅记录 / 已取消)</span></td></tr>
  4209. <tr><td>${chip('不可判')}</td><td><span class="badge">INSUFFICIENT</span></td><td class="small">数据不足或监测中断 — 不等于正常 <span style="color:var(--muted)">(证据: 数据不足)</span></td></tr></table>
  4210. <p class="small" style="color:var(--muted);margin-top:7px">
  4211. 设备状态答"这台设备现在怎么样"(危险>报警>良好>优秀>不可判); 证据状态答"这个判断有多可信"(定论→INSUFFICIENT)。
  4212. 设备状态一律取自振动线的判级栏这一个来源(遇到未登记的定级词直接报错, 不静默当成"优秀")。
  4213. 本页判级为转录, 不产新判级; 标注为引用同族/谱线级/测量链/未映射的行<b>不作部件级结论消费</b>;
  4214. claim_window = 该结论的证据窗, 窗外状态该结论不背书。</p></div>
  4215. <table><tr><th>机组</th><th>部件</th><th>设备状态 / 振动结论</th><th>证据窗<span class='small' style='color:var(--muted);font-weight:400'> 26-01→09</span></th><th>振动信号</th><th>温度轴 <span class='small' style='color:var(--muted);font-weight:400'>±6K</span></th><th>油样</th></tr>`;
  4216. rows.forEach(r=>{
  4217. // 判级取后端单源列 (禁前端正则: '销案(定论)'含'定论'会被误染红 — 2026-08-28 同根第2例)
  4218. const LVC={bad:'var(--bad)',warn:'var(--warn)',ok:'var(--ok)',meta:'var(--muted)',note:'var(--info)'};
  4219. const sev=LVC[r.级]||'var(--info)';
  4220. h+=`<tr><td><a class="tl" href="/turbine/${r.turbine}"><b>${r.turbine.replace('WTG','')}#</b></a></td>
  4221. <td style="min-width:220px">${dtMini(r.部件,sev,null,r.turbine)}</td>
  4222. <td><span class="duo" style="--c:${C[r.设备状态]||'var(--muted)'}" title="${r.设备状态||'?'}: ${ST_NOTE[r.设备状态]||''} | 证据 ${r.证据状态||'?'}: ${EV_NOTE[r.证据状态]||''} | 振动线原文: ${esc(r.振动结论)}">
  4223. <span class="st">${r.设备状态||'—'}</span><span class="ev">${plain(r.振动结论)}</span></span></td>
  4224. <td style="min-width:130px">${winBar(r.窗区间,r.claim_window)}</td>
  4225. <td class="small tipw" data-tip="振动线原文: ${esc(r.振动信号)}">${vibPlain(r.振动信号)}</td><td style="min-width:158px">${devBar(r.温度通道,r.温度dev,r.温度判,r.温度轴)}</td>
  4226. <td style="min-width:112px">${oilPill(r.油末样,r.油新鲜,r.油样轴)}</td></tr>`});
  4227. h+=`</table></div>`;
  4228. }
  4229. }
  4230. if(FUSSUB==='报警工单'&&F.事件){
  4231. // 数据边界长条撤 (2026-08-28 用户令): 两源不重叠的完整说明太长且每页重复;
  4232. // 关键一句仍在工单卡("0 条=记录缺失非未修过")与汇报定制页第⑧章口径与数据边界。
  4233. h+=`<div class="card mb"><p class="small" style="color:var(--muted)"><b>时间窗 ${d.win}</b> · 顶部选择器可换</p></div>`;
  4234. for(const [comp,E] of Object.entries(F.事件)){
  4235. h+=`<div class="card mb"><h3>${comp} — 报警 ${E.报警.总条数} 条/${E.报警.台数} 台 · 工单 ${E.工单.总条数} 条/${E.工单.台数} 台 (窗内)</h3>`;
  4236. if(E.报警.总条数>0){
  4237. h+=`<table><tr><th>码</th><th>报警内容</th><th class="num">条数</th><th class="num">台数</th><th class="num">累计时长h</th></tr>`;
  4238. E.报警.码统计.forEach(c=>{h+=`<tr><td>${c.code}</td><td class="small">${c.text}</td><td class="num">${c.条数}</td><td class="num">${c.台数}</td><td class="num">${c.时长h}</td></tr>`});
  4239. h+=`</table><p class="small">台分布 top: ${Object.entries(E.报警.top台).map(([t,n])=>`<a class="tl" href="/turbine/${t}">${t.replace('WTG','')}#</a>×${n}`).join(' · ')}</p>`;
  4240. h+=`<details><summary class="small">最近 ${E.报警.近况.length} 条明细</summary><table><tr><th>机组</th><th>时间</th><th>码</th><th>内容</th><th class="num">时长min</th></tr>`;
  4241. E.报警.近况.forEach(r=>{h+=`<tr><td>${r.t.replace('WTG','')}#</td><td class="small">${r.on}</td><td>${r.code}</td><td class="small">${r.text}</td><td class="num">${r.dur_min??'—'}</td></tr>`});
  4242. h+=`</table></details>`;
  4243. } else h+=`<p class="small">窗内无该部件报警</p>`;
  4244. if(E.工单.总条数>0){
  4245. h+=`<table style="margin-top:8px"><tr><th>机组</th><th>日期</th><th>故障名称</th><th>处理</th><th>原因</th></tr>`;
  4246. E.工单.明细.forEach(r=>{h+=`<tr><td>${r.t.replace('WTG','')}#</td><td class="small">${r.date}</td><td class="small">${r.name}</td><td class="small">${r.act}</td><td class="small">${r.cause}</td></tr>`});
  4247. h+=`</table>`;
  4248. } else h+=`<p class="small">窗内无该部件工单 (工单数据止 2024-11, 2026 类窗必空 = 数据边界)</p>`;
  4249. h+=`</div>`;
  4250. }
  4251. }
  4252. if(FUSSUB==='振动分析'){
  4253. // 2026-08-28 用户令: 生接改融合 — 只显示结果(windcms 评估报告转录), 模型与计算隐藏, 分析功能留独立 cms
  4254. if(!window.VIBCMS) { fetch('/api/vibcms').then(r=>r.json()).then(d=>{window.VIBCMS=d; render();}); h+=`<div class="card mb"><p class="small">载入振动评估结果…</p></div>`; }
  4255. else if(VIBCMS.error){ h+=`<div class="card mb"><p class="small" style="color:var(--bad)">${VIBCMS.error}</p></div>`; }
  4256. else {
  4257. const V=VIBCMS;
  4258. const gc=st=>({'报警':'var(--bad)','危险':'var(--bad)','不可判':'#9aa4b0','良好':'var(--warn2,#8a8f3b)','优秀':'var(--ok)'}[st]||'var(--muted)');
  4259. const cp=st=>`<span class="chip" style="--c:${gc(st)}">${st}</span>`;
  4260. h+=`<div class="card mb"><h3>振动状态评估 (windcms 报告 ${V.date} 结果转录)</h3>
  4261. <p class="small">${V.overall||''} 状态管理判级, 不代表确诊数量; 判据/谱线/计算过程不在本页 — 深潜走底部独立系统。</p>
  4262. <h3 style="margin-top:10px">需行动机组 (报警/不可判)</h3>
  4263. <table><tr><th>机组</th><th>状态</th><th>状态焦点 <span class='small' style='font-weight:400;color:var(--muted)'>(传动链: 主承前·主承后·齿轮箱·发电机两端)</span></th><th>证据强度</th><th>行动</th></tr>
  4264. ${V.action.map(r=>{const cm={};String(r[2]).split(/[;;]/).forEach(seg=>{const m=seg.split(/[::]/);
  4265. if(m.length>1){const k=m[0].trim(); cm[k]=gc(m[1].trim())}});
  4266. return `<tr><td><b>${r[0]}</b></td><td>${cp(r[1])}</td><td style="min-width:220px" title="${esc(r[2])}">${dtChainStates(cm,null,r[0])}</td>
  4267. <td style="min-width:150px">${evLadder(r[3])}</td><td>${/P0/.test(r[4])?`<b style="color:var(--bad)">${r[4]}</b>`:r[4]}</td></tr>`}).join('')}</table>
  4268. <h3 style="margin-top:14px">38 台部件等级一览</h3>
  4269. <table><tr><th>机组</th><th>部件状态链 <span class='small' style='font-weight:400;color:var(--muted)'>(主承前·主承后·齿轮箱三级·发电机DE/NDE; 悬停看逐件等级)</span></th><th>综合</th><th>行动</th></tr>
  4270. ${V.grades.map(g=>`<tr><td><b>${g.t}</b></td>
  4271. <td style="min-width:220px" title="主轴承前 ${g.主轴承前} · 主轴承后 ${g.主轴承后} · 齿轮箱 ${g.齿轮箱} · 发电机 ${g.发电机}">${dtChainStates({'主轴承前':gc(g.主轴承前),'主轴承后':gc(g.主轴承后),'齿轮箱':gc(g.齿轮箱),'发电机':gc(g.发电机)},null,g.t)}</td>
  4272. <td>${cp(g.综合)}</td><td class="small">${g.行动}</td></tr>`).join('')}</table>
  4273. <div class="small" style="margin-top:12px;padding:10px;border:1px solid var(--line);border-radius:8px">
  4274. <b>口径说明</b> 本表=windcms 状态管理判级(含标量趋势与数据可判性); 融合矩阵/逐台结论=振动线定谳链转录(候选以上)。两口径差异台:
  4275. 仅本表列 ${V.diff.only_cms.join(', ')||'无'} (多为标量发作/数据不可判, 未到定谳链); 仅定谳链列 ${V.diff.only_handoff.join(', ')||'无'} (预警级在察, 状态管理未升)。
  4276. </div>
  4277. <p class="small" style="margin-top:10px;color:var(--muted)">谱图 · 波形 · 判据 · 工作台等分析功能在独立 windcms 系统 →
  4278. <a class="tl" href="http://localhost:8030/index.html" target="_blank">打开 windcms ↗</a> (未启动时 <a class="tl" href="/windcms/index.html" target="_blank">静态版</a>)</p>
  4279. </div>`;
  4280. }
  4281. }
  4282. }
  4283. }
  4284. if(TAB==='系统维护'){
  4285. h+=`<div class="eyebrow">系统维护 · 数据输入与更新端口</div>
  4286. <div id="mtOut"><div class="card"><p class="small">盘点中…</p></div></div>`;
  4287. document.getElementById('root').innerHTML=h;
  4288. document.getElementById('foot').innerHTML=d.note;
  4289. fetch('/api/maint_survey').then(r=>r.json()).then(md=>{
  4290. const el=document.getElementById('mtOut'); if(el) el.innerHTML=maintCards(md);
  4291. }).catch(e=>{const el=document.getElementById('mtOut'); if(el) el.innerHTML=`<div class="card"><p class="small" style="color:var(--bad)">盘点失败: ${e.message}</p></div>`});
  4292. return;
  4293. }
  4294. if(TAB==='检修助手'){
  4295. // 三窗并列 (2026-08-28 用户令): 抢修 / 预防性维护 / 故障树 各有自己的输入与结果,
  4296. // 不再共用一个框切换 — 实际用法是三者对照着看。
  4297. const SYSOPT=['变桨','偏航','主轴承','齿轮箱','发电机','变流器','塔架与基础','主控与传感网'];
  4298. // ★示例码原来写死 5 个, 其中 64000 在本体里 name 是字符串 "nan" (pandas NaN 漏成字符串,
  4299. // 555 个码里有 17 个是这样) —— 页面把一条垃圾记录当"常查"推荐, 点了必然空白。
  4300. // 改为**按本场实际计数**取: 只从 (本场出现过 ∧ 名字有效) 的 309 个里选。
  4301. // 写死清单迟早与数据脱节, 数据驱动才不会推荐自己答不上的东西。
  4302. const HOT=(window.__HOTCODES && window.__HOTCODES.length) ? window.__HOTCODES
  4303. : ['3225','3130','13902','10105','3224'];
  4304. const btn=m=>`<button onclick="asstGo('${m}')" style="cursor:pointer;flex:none;padding:7px 18px;border-radius:7px;
  4305. border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:600;font-size:13px">查询</button>`;
  4306. h+=`<div class="eyebrow">检修助手 · 手册、报警与工单证据查询</div>`;
  4307. h+=`<div class="card mb"><h3>抢修 <span class="small" style="font-weight:400;color:var(--muted)">报警码 → 失效模式 → 手册分步处置 → 历史工单</span></h3>
  4308. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
  4309. <input id="asstIn_fault" placeholder="报警码, 如 3225 / 3130 / 13902" value="${window.ASSTV||''}"
  4310. onkeydown="if(event.key==='Enter')asstGo('fault')"
  4311. style="flex:1 1 220px;min-width:180px;padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:13px">
  4312. ${btn('fault')}
  4313. <span class="small" style="color:var(--muted);flex:none">常查 ${HOT.map(c=>
  4314. `<a class="tl" href="#" onclick="document.getElementById('asstIn_fault').value='${c}';asstGo('fault');return false">${c}</a>`).join(' · ')}</span></div>
  4315. <div id="asstOut_fault" style="margin-top:10px"></div></div>`;
  4316. h+=`<div class="card mb"><h3>预防性维护 <span class="small" style="font-weight:400;color:var(--muted)">系统 → 失效模式 → 年度任务 → WI 步骤</span></h3>
  4317. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
  4318. <select id="asstIn_prev" style="flex:1 1 200px;min-width:170px;padding:7px 10px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:13px">
  4319. ${SYSOPT.map(x=>`<option>${x}</option>`).join('')}</select>
  4320. ${btn('prev')}</div>
  4321. <div id="asstOut_prev" style="margin-top:10px"></div></div>`;
  4322. h+=`<div class="card mb"><h3>故障树 <span class="small" style="font-weight:400;color:var(--muted)">系统失效模式按实证量排 + 部件级原因</span></h3>
  4323. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
  4324. <select id="asstIn_tree" style="flex:1 1 200px;min-width:170px;padding:7px 10px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:13px">
  4325. ${SYSOPT.map(x=>`<option>${x}</option>`).join('')}</select>
  4326. ${btn('tree')}</div>
  4327. <div id="asstOut_tree" style="margin-top:10px"></div></div>`;
  4328. document.getElementById('root').innerHTML=h;
  4329. document.getElementById('foot').innerHTML=d.note;
  4330. for(const m of ['fault','prev','tree']){
  4331. const c=(window.ASSTCACHE3||{})[m], el=document.getElementById('asstOut_'+m);
  4332. if(c&&el) el.innerHTML=c;
  4333. }
  4334. return;
  4335. }
  4336. if(TAB==='检修决策'){
  4337. if(!window.MAINTSTD){ fetch('/api/maint_std').then(r=>r.json()).then(d=>{window.MAINTSTD=d; render()}); }
  4338. h+=maintStd(window.MAINTSTD);
  4339. if(!window.MAINTFW){ fetch('/api/maint_framework').then(r=>r.json()).then(d=>{window.MAINTFW=d; render()}); }
  4340. h+=maintFw(window.MAINTFW);
  4341. if(!window.DQF){ fetch('/api/dq_findings').then(r=>r.json()).then(d=>{window.DQF=d; render()}); }
  4342. h+=dqFindings(window.DQF);
  4343. h+=`<div class="eyebrow">检修决策 · ontology 展现的窗口 (语义本体 + dsh 编排; 推理无界, 结论受闸)</div>`;
  4344. const CATS={
  4345. '设备类':{hint:'设备状态/机制/判级 — 从机组档案、对象查询与机制检索取证',
  4346. qs:['29号机组现在什么状态、为什么、该怎么办?','全场哪些机组有报警级或定论级判级?逐台列出并注明审级','失效机制库里有哪些机制族?各涉及哪些机组、状态如何?']},
  4347. '检修排程决策':{hint:'排期沙盘/天气窗/验收树 — ont_scenario/ont_get 取证',
  4348. qs:['29号修泵什么时候做最合适?对比各情景的损失与风险','未来几个月哪些月份适合安排CTV检修和吊装作业?依据历史可作业窗','29号修好之后按什么判据验收?三分支分别是什么条件?']},
  4349. '损失分析':{hint:'当前3个月损失/未来损失 — ont_loss/ont_scenario 取证, 金额带电价区间口径',
  4350. qs:['近3个月全场损失结构如何?分状态和分机组说, 电量与金额都给','29号如果拖到主轴承失效, 未来损失量级是多少?与现在修对比','限电常态化对检修机会成本有什么影响?']},
  4351. '安全风险预测':{hint:'风险敞口/劣化趋势/盲区 — ont_risk/ont_mechanism_search 取证, 未列≠正常',
  4352. qs:['当前哪些机组风险在上升需要重点盯?联动监视读数如何?','监测有哪些盲区?哪些"没报警"不等于正常?','29号轴承正在承受金属接触, 风险敞口怎么评估?']}};
  4353. h+=`<div id="dbase"><div class="card mb"><div class="skel" style="width:40%"></div><div class="skel" style="width:65%;margin-top:8px"></div></div></div>`;
  4354. setTimeout(loadDbase, 200);
  4355. if(!window.ASKCAT) window.ASKCAT='设备类';
  4356. h+=`<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">`+Object.keys(CATS).map(c=>{
  4357. const on=c===ASKCAT;
  4358. return `<button onclick="ASKCAT='${c}';render()" style="cursor:pointer;padding:8px 16px;border-radius:20px;border:1px solid ${on?'var(--accent)':'var(--line)'};background:${on?'var(--accent)':'var(--panel)'};color:${on?'#fff':'var(--ink)'};font-weight:${on?'600':'400'}">${c}</button>`}).join('')+`</div>`;
  4359. const cat=CATS[ASKCAT];
  4360. h+=`<div class="card mb"><h3>${ASKCAT} · 常用问题 (点即问, 或下方自由输入)</h3>
  4361. <p class="small">${cat.hint}。答案句句带[对象id]溯源+接地闸核验; 判级只转述本体原文带审级, 模型零判级权。</p>`;
  4362. cat.qs.forEach(q=>{h+=`<div class="small" style="cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-bottom:8px" onclick="document.getElementById('askq').value=this.textContent;askGo()">${q}</div>`});
  4363. h+=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0">
  4364. <input id="askq" type="text" placeholder="自由提问 (${ASKCAT})" style="flex:1;min-width:280px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink)">
  4365. <select id="askm" onchange="mdlNote()" style="padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink)">
  4366. <option value="">…</option>
  4367. </select>
  4368. <span id="mdlnote" class="small" style="color:var(--muted)"></span>
  4369. <label class="small" style="cursor:pointer;white-space:nowrap" title="由另一本地模型基于同一份证据复核; 同一模型自审无法构成独立校验">
  4370. <input type="checkbox" id="askxrev"> 交叉审核 <span style="color:var(--muted)">(DeepSeek 14B 互审, +约 1 分钟)</span></label>
  4371. <button onclick="askGo()" style="cursor:pointer;padding:8px 16px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:600">提问</button>
  4372. </div>
  4373. <div id="askout" class="small">空闲 — 点常用问题或输入后提问 (实测: 8b 单台问约 45 秒 / 复合问题约 50~90 秒, 27b/30b 约 2~4 分钟, deepseek 走 dsh 约 2~6 分钟; 串行一次一问; 本地模型零外发)。</div></div>`;
  4374. setTimeout(askPoll, 300); setTimeout(askModelInit, 320); // 填下拉后 askModelInit 内部会调 mdlNote
  4375. }
  4376. if(TAB==='故障统计'){
  4377. const _pt=(d.faults.partial||[]);
  4378. h+=`<div class="eyebrow">故障统计与可靠性</div>`
  4379. +winBanner('故障统计', d.win, _pt.length?`<span style="color:var(--warn)">窗内 ${_pt.join('/')} 数据不满月(覆盖 ${Math.round(d.faults.cov_min*100)}%), 月度图已按天归一</span>`:'');
  4380. }
  4381. if(TAB==='电量算账') h+=`<div class="eyebrow">电量算账</div>`+winBanner('电量算账', d.win, '');
  4382. if(TAB==='汇报定制'){ document.getElementById('root').innerHTML=renderReport(d);
  4383. document.getElementById('foot').innerHTML=d.note; return; }
  4384. h+=`<div class="grid">`;
  4385. if(TAB==='故障统计'){
  4386. // 排布 (2026-08-28 用户令: 界面优化排布)。原来 9 张卡一条龙堆着, 报警面与停机面混排,
  4387. // 且三张卡硬设 grid-column:1/-1 — 窄视口本来就单列, 全宽设置只在宽屏起反作用(挡住并排)。
  4388. // 现按经典可靠性报告顺序分两节: 报警面 总量→关键少数→结构→台级→季节; 停机面 结构→归因→台级。
  4389. // 只有"需要横向空间才读得出结构"的图保留全宽 (四象限散点 / RAM 象限), 其余交给 auto-fit:
  4390. // 窄屏自然单列(帕累托 720 viewBox 几乎 1:1), 宽屏自动两列。
  4391. const F2=d.faults, _pm=(F2.partial||[]);
  4392. const sec=(t,n)=>`<div class="eyebrow" style="grid-column:1/-1;margin-top:4px">${t}<span
  4393. style="text-transform:none;letter-spacing:0;font-weight:400;color:var(--muted)">${n}</span></div>`;
  4394. h+=sec('报警面', `${F2.total.toLocaleString()} 条 · ${F2.n_codes} 个码 · 窗 ${d.win}`);
  4395. h+=`<div class="card" style="grid-column:1/-1"><h3>月度故障率</h3>${rateChart(F2.monthly)}
  4396. <p class="small" style="color:var(--muted);margin-top:6px">
  4397. <b>柱高=日均条数, 不是月总数</b> — ${_pm.length?`本窗 ${_pm.join('/')} 数据不满月(灰虚线柱), 直接比月总数会读反`:'各月覆盖完整'}。
  4398. ${_pm.length?`实测: ${_pm[0]} 总数最低但日均 ${F2.monthly.per_day[F2.monthly.months.indexOf(_pm[0])]} 条/日 = 全窗前列。`:''}</p></div>`;
  4399. h+=`<div class="card"><h3>帕累托 · 按发生条数</h3>
  4400. ${paretoChart(F2.pareto_n, ' 条', '条数=发生频次, 不反映影响程度 — 高频短时报警码(信号抖动或重复触发)排序靠前')}</div>`;
  4401. h+=`<div class="card"><h3>帕累托 · 按持续时长</h3>
  4402. ${paretoChart(F2.pareto_d, ' h', '时长=闩锁口径(复位−激活), 只作排序参考不作停机时长')}</div>`;
  4403. h+=`<div class="card" style="grid-column:1/-1"><h3>故障模式四象限 · 条数 × 单次时长</h3>
  4404. ${quadChart(F2.quad)}
  4405. <p class="small" style="color:var(--muted);margin-top:6px">窗内共 ${F2.n_codes} 个码, 图上为条数前 ${F2.quad.length} 个。
  4406. ${_pm.length?`<span style="color:var(--warn)">窗含不满月 ${_pm.join('/')} (覆盖 ${Math.round(F2.cov_min*100)}%), 条数为原始累计未按天归一。</span> `:''}
  4407. 点面积表示影响台数, 大点为批次共性、小点为单台; 竖分界取条数中位, 横分界取 10min (一个SCADA采样格)。
  4408. <b>右下角的码不要按条数排优先级</b> — 属重复触发, 非硬件故障。上面两张帕累托对照看即此图的两条投影。</p></div>`;
  4409. const maxT=Math.max(1,...F2.per_t.map(x=>x.v));
  4410. h+=`<div class="card"><h3>台级分布 · 报警条数 top${F2.per_t.length}</h3>
  4411. <table><tr><th style="width:70px">机组</th><th class="barcell">条数</th><th class="num" style="width:80px">占全场</th></tr>
  4412. ${F2.per_t.map(x=>`<tr><td><a class="tl" href="/turbine/${x.k}"><b>${x.k.replace('WTG','')}#</b></a></td>
  4413. <td class="barcell"><i class="bar" style="width:${Math.round(x.v/maxT*90)}px;--c:${x.v/maxT>0.6?'var(--bad)':'var(--accent)'}"></i><span class="cnt">${x.v.toLocaleString()}</span></td>
  4414. <td class="num small">${(x.v/F2.total*100).toFixed(1)}%</td></tr>`).join('')}</table>
  4415. <p class="small" style="color:var(--muted);margin-top:6px">全场合计 ${F2.total.toLocaleString()} 条 · 单台占比高须先核查是否为单一报警码重复触发</p></div>`;
  4416. if(d.m8&&d.m8.err) h+=blockErr('m8', d.m8.err);
  4417. if(m) h+=drawFig({kind:'multiline',title:'故障季节性 · top 报警族 × 月',unit:'条/月',xlab:'月',
  4418. months:d.m8.seasonal[0].months, series:d.m8.seasonal.slice(0,4).map(x=>({name:x.name.slice(0,16),vals:x.vals})),
  4419. note:'全程 19 个月 (跨 2 冬 1 夏); 未做 χ² 显著性检验前为【参考级】, 只看形态不作季节归因'});
  4420. if(m){
  4421. const RT=(d.rel&&!d.rel.err)?d.rel:null;
  4422. h+=sec('停机面', `${m.停机总时长h?m.停机总时长h.toLocaleString():''}h · ${m.停机事件数||''} 起 · 口径 ≥1h 段, 不含调度令`);
  4423. if(RT) h+=`<div class="card" style="grid-column:1/-1"><h3>可靠性 × 可维修性 · 部件象限</h3>
  4424. ${ramChart(RT.表)}
  4425. <p class="small" style="color:var(--muted);margin-top:6px">
  4426. 按停机时长排序的表看不出这层结构: <b>同样 500h 的停机, "频发易修"要查根因、"罕发难修"要备件, 处置相反</b>。
  4427. 设备类停机 ${RT.设备类停机h}h / 外部类 ${RT.外部类停机h}h (外部=电网·风况·运维操作, 不计设备可靠性)。
  4428. 海上机组 MDT 普遍偏高是<b>交通窗口</b>决定的, 不等于维修本身慢 — 跨场比 MDT 须同为海上。</p>
  4429. <div style="margin-top:12px">${attribBar(RT.表, RT.总停机h)}</div>
  4430. <p class="small" style="color:var(--muted);margin-top:2px">
  4431. 归因覆盖: "无记录停机" ${(RT.表.find(x=>x.部件==='无记录停机')||{}).停机时长h||0}h 是全场最大单项,
  4432. 因不给 MTBF 而在上图中<b>不出现</b> — 它不是"没有故障", 是"没归上因"。
  4433. 口径为段起点±30min 首发码。<b>缺口成因待定</b>: 此前写的"98% 的段在前 72h 内有报警 ⇒ 长停远窗"
  4434. 不成立 — 前 72h 有报警不等于该报警与本段停机有因果 (双模审 2026-08-28 逮)。
  4435. 要定成因须改判"段起止是否落在某条报警的持续区间内", 未做之前只报缺口不报原因。</p></div>`;
  4436. h+=`<div class="card"><h3>停机归因帕累托</h3>
  4437. ${paretoChart(d.m8.stop_pareto.map(x=>({k:x.k.split(';')[0].slice(0,26), v:x.h})), ' h',
  4438. '按停机时长; 事件次数见悬停')}
  4439. <p class="small" style="color:var(--muted);margin-top:4px">${m.口径}</p></div>`;
  4440. h+=`<div class="card"><h3>台级停机 top${m.台级top.length}</h3>
  4441. <table><tr><th style="width:64px">机组</th><th class="barcell">停机时长</th><th class="num" style="width:56px">次数</th></tr>
  4442. ${(()=>{const mx=Math.max(...m.台级top.map(x=>x.时长h));return m.台级top.map(x=>`<tr>
  4443. <td><a class="tl" href="/turbine/${x.t}"><b>${x.t.replace('WTG','')}#</b></a></td>
  4444. <td class="barcell"><i class="bar" style="width:${Math.round(x.时长h/mx*92)}px;--c:${x.时长h/mx>0.6?'var(--bad)':'var(--accent)'}"></i><span class="cnt">${x.时长h}h</span></td>
  4445. <td class="num small">${x.次}</td></tr>`).join('')})()}</table>
  4446. <p class="small" style="color:var(--muted);margin-top:6px">停机时长高 ≠ 设备差 — 须先看归因里外部类占比 (调度令已剔除)</p></div>`;
  4447. }
  4448. }
  4449. if(TAB==='电量算账'){
  4450. const E=d.control.energy;
  4451. if(E) h+=`<div class="kpis" style="grid-column:1/-1;margin-bottom:0">
  4452. <div class="kpi"><b>${E.theo.toLocaleString()}<span> MWh</span></b><span>理论可发 (实际+损失)</span></div>
  4453. <div class="kpi ok"><b>${E.act.toLocaleString()}<span> MWh</span></b><span>实际上网 · 等效满发 ${E.eflh.toLocaleString()} h</span></div>
  4454. <div class="kpi ${E.loss_pct>=10?'warn':''}"><b>${E.loss_pct}<span>%</span></b><span>损失率 · ${E.loss.toLocaleString()} MWh</span></div>
  4455. <div class="kpi ${E.cap_ok?'ok':'bad'}"><b>${E.cap_ok?'通过':'不通过'}</b><span>能量守恒核 · 理论 ${E.cap_ok?'<':'≥'} 全额定上限 ${E.cap.toLocaleString()}</span></div></div>`;
  4456. if(E) h+=`<div class="card" style="grid-column:1/-1"><h3>电量平衡 <span class="small" style="font-weight:400;color:var(--muted)">窗 ${d.win}</span></h3>
  4457. ${waterfall(E)}</div>`;
  4458. if(E) h+=`<div class="card"><h3>损失责任分账</h3>${blameBar(E)}
  4459. <p class="small" style="color:var(--muted);margin-top:6px"><b>最大一笔是限电不是故障。</b>
  4460. 进设备考核的只有"故障与检修停机" ${Math.round((E.items.find(x=>x.k==='停机')||{}).loss||0).toLocaleString()} MWh;
  4461. 限电属交易与调度侧, 2026 起交易策略主导, 少发未必是弃风。</p></div>`;
  4462. h+=`<div class="card"><h3>工况态明细 <span class="small" style="font-weight:400;color:var(--muted)">窗 ${d.win}</span></h3>
  4463. <table><tr><th>工况态</th><th class="barcell">时长占比</th><th class="num" style="width:76px">损失MWh</th></tr>`;
  4464. const _mxp=Math.max(...d.control.states.map(x=>x.pct));
  4465. d.control.states.forEach(x=>{h+=`<tr><td class="small">${LOSSPLAIN[x.k]||x.k}</td>
  4466. <td class="barcell"><i class="bar" style="width:${Math.round(x.pct/_mxp*84)}px;--c:${x.k.indexOf('停机')>=0&&x.k.indexOf('调度')<0?'var(--bad)':'var(--accent)'}"></i><span class="cnt">${x.pct}% · ${Math.round(x.h).toLocaleString()}h</span></td>
  4467. <td class="num small">${Math.round(x.loss).toLocaleString()}</td></tr>`});
  4468. h+=`</table><p class="small" style="color:var(--muted)">${d.control.anchors}
  4469. <br>控制参数一致性与曲线欠发筛查在<b>固定干净窗</b>判, 已迁入「发电性能」页 — 本页只出当期电量账。</p></div>`;
  4470. }
  4471. h+=`</div>`;
  4472. document.getElementById('root').innerHTML=h;
  4473. document.getElementById('foot').innerHTML=d.note;
  4474. }
  4475. async function renderCurves(){
  4476. const root=document.getElementById('root');
  4477. if(!CURVES){root.innerHTML='<div class="card"><h3>特性曲线多镜头</h3><p class="small">正在计算七个镜头 (剥限电 · 逐档中位 · 同型机群残差)…</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>';
  4478. CURVES=await (await fetch('/api/curves')).json();}
  4479. const d=CURVES; let h='';
  4480. // 物理闸: 表格窄屏崩版 (2026-08-28 用户内部设计审逮) → 响应式三卡
  4481. const P=d.physics, L=P['λ叶尖速比'];
  4482. const pg=(name,crit,meas,verdict)=>`<div style="flex:1;min-width:220px;border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:10px;padding:12px 14px;background:var(--panel)">
  4483. <div style="font-weight:600">${name} <span class="small" style="color:var(--muted);font-weight:400">${crit}</span></div>
  4484. <div class="num" style="margin:6px 0;font-size:15px">${meas}</div>
  4485. <div class="small" style="color:var(--muted)">${verdict}</div></div>`;
  4486. // (第一性原理硬闸卡撤 — 2026-08-28 用户令: 数据分析原则不出现在交付界面; 校验仍在后端跑, 只是不展示)
  4487. h+=`<div class="eyebrow">特性曲线与控制参数</div>`+winBanner('发电性能','','');
  4488. // 控制参数一致性与曲线欠发候选自「控制策略」页迁入 (2026-08-28 用户裁): 三者同为固定干净窗,
  4489. // 放在跟随窗的电量页里会造成一页两窗 — 迁过来后两页各自单窗。
  4490. const _D=(typeof D!=='undefined'&&D)?D:null; // D 是块作用域变量, 不挂 window
  4491. const F=(_D&&_D.control)?_D.control:null, M9=(_D&&_D.m9&&!_D.m9.err)?_D.m9:null;
  4492. if(_D&&_D.m9&&_D.m9.err) h+=blockErr('m9', _D.m9.err);
  4493. if(M9){
  4494. const p9=M9['P封顶'],w9=M9['ω封顶'],b9=M9['β_schedule'];
  4495. h+=`<div class="card mb" style="grid-column:1/-1"><h3>控制参数一致性 <span class="small" style="font-weight:400;color:var(--muted)">窗 ${M9['窗']}</span></h3>
  4496. <div style="display:flex;flex-wrap:wrap;gap:18px">
  4497. <div style="flex:1 1 300px;min-width:280px">
  4498. <div class="small" style="font-weight:600;margin-bottom:4px">封顶参数落位</div>
  4499. ${capAxis(p9, w9)}
  4500. <p class="small" style="color:var(--muted);margin-top:4px"><b>台数落在几个离散位置上, 不是连成一片</b> —
  4501. 这是配置/版本差异的形状; 磨损或工况引起的会是连续散布。两轴各自成组且分组不同台 ⇒ 参数级差异, 成因须配置台账。</p>
  4502. </div>
  4503. <div style="flex:1 1 300px;min-width:280px">
  4504. <div class="small" style="font-weight:600;margin-bottom:4px">桨距调度同档差</div>
  4505. ${betaBar(b9)}
  4506. <p class="small" style="color:var(--muted);margin-top:4px">同一功率档下的桨角与全场中位之差。
  4507. ${b9.离群.map(x=>'<a class="tl" href="/turbine/'+x.t+'">'+x.t.replace('WTG','')+'#</a>').join(' / ')} 偏离达中位的
  4508. ${Math.round(Math.max(...b9.离群.map(x=>x.平均偏))/Math.max(b9.全场同档差中位,0.01))} ${TIMES_SFX} → ${b9.判}</p>
  4509. <div class="small" style="font-weight:600;margin:12px 0 4px">尺子巡检 · 极差 ÷ σ</div>
  4510. ${rulerBar(M9['尺子'])}
  4511. <p class="small" style="color:var(--muted);margin-top:4px">判据自身的分辨力。38 台正态抽样的极差期望约 3.7σ,
  4512. <b>明显超出即分布非单峰</b> — 与左图的分组互为印证; 落在期望附近的轴(额定桨角)则是连续分布。</p>
  4513. </div>
  4514. </div></div>`;
  4515. }
  4516. if(F&&F.curve){
  4517. h+=`<div class="card mb"><h3>曲线欠发筛查 <span class="small" style="font-weight:400;color:var(--muted)">${F.curve.note} · σ=${F.sigma||F.curve.sigma}%</span></h3>
  4518. <p class="small">${F.curve.cands.map(c=>'<a class="tl" href="/turbine/'+c.t+'">'+c.t.replace('WTG','')+'#</a> '+c.dev+'% '+c.判).join(' | ')||'无候选'}</p></div>`;
  4519. }
  4520. h+=`<div class="grid">`; d.figs.forEach(f=>{h+=drawFig(f)}); h+=`</div>`;
  4521. root.innerHTML=h;
  4522. document.getElementById('foot').innerHTML=d.note;
  4523. }
  4524. reload('2026年');
  4525. </script></body></html>"""
  4526. PAGE_PROBLEM = """<!doctype html><html lang="zh" data-theme="light"><head><meta charset="utf-8"><script>(function(){try{var q=new URLSearchParams(location.search).get("theme");if(q){localStorage.setItem("windscada_theme",q)}var t=q||"light";document.documentElement.setAttribute("data-theme",t)}catch(e){}})();</script><meta name="viewport" content="width=device-width,initial-scale=1">
  4527. <title>%T% %SYS% · windscada</title><style>%CSS%</style></head><body>
  4528. <header><h1>%T% · %SYS%</h1><span id="winbox"></span>
  4529. <a class="badge" href="/">← 总览</a><a class="badge" href="/turbine/%T%">本台全部问题</a></header>
  4530. <div id="one">加载中…</div>
  4531. <footer>单问题视图: 只呈现本台该系统的判据与证据图, 不带出其他系统</footer>
  4532. <script>%JS%
  4533. const T='%T%', SYS='%SYS%';
  4534. async function reload(win){
  4535. const r=await fetch('/api/problem?t='+T+'&sys='+encodeURIComponent(SYS)+'&win='+encodeURIComponent(win)); const d=await r.json();
  4536. document.getElementById('winbox').innerHTML=winSel(win);
  4537. let h='';
  4538. if(!d.problems.length){h='<div class="card"><p class="empty">该系统在判级窗内无非常态。</p></div>'}
  4539. d.problems.forEach(p=>{
  4540. h+=`<div class="card mb"><h3>${p.sys} ${chip(p.状态)}</h3><p style="margin-bottom:8px">${p.依据}</p></div>`;
  4541. if(p.figs.length){h+=`<div class="grid">`; p.figs.forEach(f=>{h+=drawFig(f)}); h+=`</div>`}
  4542. h+=ordersHtml(p.orders);
  4543. });
  4544. document.getElementById('one').innerHTML=h;
  4545. }
  4546. reload('2026年');
  4547. </script></body></html>"""
  4548. PAGE_TURBINE = """<!doctype html><html lang="zh" data-theme="light"><head><meta charset="utf-8"><script>(function(){try{var q=new URLSearchParams(location.search).get("theme");if(q){localStorage.setItem("windscada_theme",q)}var t=q||"light";document.documentElement.setAttribute("data-theme",t)}catch(e){}})();</script><meta name="viewport" content="width=device-width,initial-scale=1">
  4549. <title>%T% · windscada</title><style>%CSS%</style></head><body>
  4550. <header><h1>%T% <a href="/">← 工作台</a></h1><span id="winbox"></span><span class="badge">只显示有问题的系统; 无问题不出图</span></header>
  4551. <div id="probs">加载中…</div>
  4552. <div class="card" style="margin-top:14px"><h3>台内即时查询 (任一契约通道 × 时间窗 × 聚合)</h3>
  4553. <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
  4554. <select id="qch" style="max-width:340px"></select>
  4555. <select id="qagg"><option value="med">月中位</option><option value="dutyd">月duty(秒/日)</option><option value="daily">逐日中位</option></select>
  4556. <button onclick="runQ()">出图</button><span class="small" id="qmsg"></span></div>
  4557. <div id="qfig"></div></div>
  4558. <footer>边界: 叶片/塔架无SCADA轴 | 对风误差=wrap(风向−机舱位置) vane口径; 死区/速率需≤30s数据=INSUFFICIENT | 判级窗: 变桨/偏航/蓄能/温度判级=近90日固定, 曲线=2025H2; 图内月序=所选时间窗 | P级归综合报告线</footer>
  4559. <script>%JS%
  4560. const T='%T%';let WIN='2026年';
  4561. async function reload(win){WIN=win;
  4562. const r=await fetch('/api/turbine?t='+T+'&win='+encodeURIComponent(win)); const d=await r.json();
  4563. document.getElementById('winbox').innerHTML=winSel(win);
  4564. let h='';
  4565. if(!d.problems.length) h='<div class="card"><p class="empty">各系统在判级窗内均无非常态 —— 可用下方即时查询查看任意契约通道。</p></div>';
  4566. d.problems.forEach(p=>{
  4567. h+=`<div class="card mb"><h3>${p.sys} ${chip(p.状态)}</h3><p class="small" style="margin-bottom:8px">${p.依据}</p>`;
  4568. if(p.figs.length){h+=`<div class="grid">`; p.figs.forEach(f=>{h+=drawFig(f)}); h+=`</div>`}
  4569. h+=`</div>`+ordersHtml(p.orders)});
  4570. document.getElementById('probs').innerHTML=h;
  4571. }
  4572. async function loadCh(){const r=await fetch('/api/channels');const d=await r.json();
  4573. let o='';for(const [g,cols] of Object.entries(d)){o+=`<optgroup label="${g}">`+cols.map(c=>`<option value="${c.code}">${c.cn}</option>`).join('')+`</optgroup>`}
  4574. document.getElementById('qch').innerHTML=o}
  4575. async function runQ(){document.getElementById('qmsg').textContent='计算中…';
  4576. const ch=document.getElementById('qch').value, agg=document.getElementById('qagg').value;
  4577. const r=await fetch(`/api/query?t=${T}&ch=${encodeURIComponent(ch)}&win=${encodeURIComponent(WIN)}&agg=${agg}`);
  4578. const f=await r.json(); document.getElementById('qmsg').textContent=f.err||'';
  4579. document.getElementById('qfig').innerHTML=f.err?'':drawFig(f)}
  4580. reload(WIN);loadCh();
  4581. </script></body></html>"""
  4582. class H(BaseHTTPRequestHandler):
  4583. def log_message(self, *a): pass
  4584. def _send(self, body, ctype='text/html; charset=utf-8', code=200):
  4585. if 'text/html' in ctype and not self.path.startswith('/v2'): # 2026-09-06 用户批: /v2 = detail_style_baseline, 不注入 unified-review 样式/导航条 (基线令牌须等于门户)
  4586. from src.windscada.design import GUANLAN_REVIEW_CSS
  4587. text = body.decode('utf-8') if isinstance(body, bytes) else body
  4588. text = text.replace('</header>', '</header><nav class="action-path" aria-label="页面操作"><a href="http://127.0.0.1:18084/观澜_如东样板_门户_单文件.html#index" target="_top">← 返回观澜门户</a><a href="/">分析总览 / 重新选择</a></nav>', 1)
  4589. if '</head>' in text:
  4590. body = text.replace('</head>', '<style id="guanlan-unified-review-v1">' + GUANLAN_REVIEW_CSS + '</style></head>', 1)
  4591. b = body.encode('utf-8') if isinstance(body, str) else body
  4592. self.send_response(code)
  4593. self.send_header('Content-Type', ctype)
  4594. self.send_header('Content-Length', str(len(b)))
  4595. self.end_headers()
  4596. self.wfile.write(b)
  4597. def do_POST(self):
  4598. try:
  4599. u = urllib.parse.urlparse(self.path)
  4600. n = int(self.headers.get('Content-Length', 0))
  4601. body = json.loads(self.rfile.read(n) or b'{}')
  4602. if u.path == '/api/ask':
  4603. self._send(_jdump(ask_start(body.get('q', ''), body.get('model', 'qwen3:8b'), body.get('cat', ''),
  4604. body.get('xrev', False), body.get('lang', 'zh'), body.get('display_q', '')),
  4605. ensure_ascii=False), 'application/json; charset=utf-8')
  4606. else:
  4607. self._send('404', code=404)
  4608. except Exception as e:
  4609. self._send(_jdump(dict(err=str(e)[:300]), ensure_ascii=False), 'application/json; charset=utf-8', 500)
  4610. def do_GET(self):
  4611. try:
  4612. u = urllib.parse.urlparse(self.path)
  4613. # encoding='utf-8' 显式给定 (默认 latin-1 会把中文参数解成乱码 — "齿轮箱"→"齿轮箱",
  4614. # 浏览器 encodeURIComponent 路径侥幸正常, curl/直传中文即崩; 2026-08-28 四链接口实逮)
  4615. q = dict(urllib.parse.parse_qsl(u.query, encoding='utf-8', errors='replace'))
  4616. _REQ.lang = q.get('lang', '') # 本请求的语言, _jdump 出口据此投影文本层
  4617. _REQ.keep_orig = q.get('keep') == '1' # v2: 译不出留原文, 不写 [translation pending]
  4618. win = q.get('win', '2026年')
  4619. if u.path == '/':
  4620. _pg = PAGE_FLEET.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS)
  4621. self._send(_en_page(_pg))
  4622. elif u.path == '/v2':
  4623. # 新前端 (2026-09-03 用户裁"完全重新做一套, 在原来的基础上"): 同一套 /api/*,
  4624. # 表现层双语原生 —— 不经 _en_page/ui_en 那三层事后翻译。迁移期与 / 并存,
  4625. # 页签逐个搬完后再切默认。
  4626. from src.windscada.ui import build as _v2
  4627. self._send(_v2.render('en' if q.get('lang') == 'en' else 'zh'))
  4628. elif u.path == '/v2/snapshot':
  4629. # 新版单文件快照 (静态站用): 接口响应烤进页面, 拦截器实时优先快照兜底。
  4630. # 取数函数与在线 API 同一实现; 英文走同一套 _redact → _proj 投影 (keep=1 留原文)。
  4631. from src.windscada.ui import snapshot as _snap
  4632. from src.ontology import mcp_server as _omc
  4633. import json as _j3, pathlib as _p3
  4634. def _codes():
  4635. db = _j3.loads(_p3.Path(_omc.OBJ_PATH).read_text(encoding='utf-8'))
  4636. return sorted(k.split('/')[-1] for k in db if k.startswith('alarmcode/'))
  4637. _ctx = dict(fleet_view=fleet_view, curve_view=curve_view, vibcms_results=vibcms_results,
  4638. survey=__import__('src.ontology.maintenance', fromlist=['survey']).survey,
  4639. ont=_omc, alarm_codes=_codes, turbine_problems=turbine_problems,
  4640. single_problem=single_problem, _redact=_redact, _jclean=_jclean, _proj=_proj, _REQ=_REQ)
  4641. _v2_lang = 'en' if q.get('lang') == 'en' else 'zh'
  4642. _html, _rep = _snap.bake(_v2_lang, _ctx,
  4643. peer=q.get('peer', ''), noindex=q.get('noindex') == '1',
  4644. slim=(q.get('slim') != '0' if _v2_lang == 'en' else False))
  4645. print('[v2 snapshot] %s' % _j3.dumps(_rep, ensure_ascii=False)[:600], flush=True)
  4646. self._send(_html)
  4647. elif u.path.startswith('/turbine/'):
  4648. t = u.path.rsplit('/', 1)[1]
  4649. _pg = PAGE_TURBINE.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS).replace('%T%', t)
  4650. self._send(_en_page(_pg))
  4651. elif u.path == '/api/maint_survey':
  4652. try:
  4653. from src.ontology.maintenance import survey as _sv
  4654. r = dict(rows=_sv())
  4655. except Exception as e:
  4656. r = dict(rows=[], error=f'维护面盘点失败: {e}'[:200])
  4657. self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8')
  4658. elif u.path == '/api/ont_chain':
  4659. try:
  4660. from src.ontology import mcp_server as _om
  4661. kind = q.get('kind', 'fault')
  4662. fn = {'fault': lambda: _om.ont_chain_fault(code=q.get('code', '')),
  4663. 'prev': lambda: _om.ont_chain_preventive(system=q.get('system', '')),
  4664. 'tree': lambda: _om.ont_fault_tree(system=q.get('system', '')),
  4665. 'plan': lambda: _om.ont_maint_plan(top=q.get('top', 10)),
  4666. 'doc': lambda: _om.ont_doc_search(keyword=q.get('keyword', ''), kind=q.get('dkind', ''))}
  4667. r = fn[kind]() if kind in fn else dict(text=f'未知链 {kind}', error=True)
  4668. except Exception as e:
  4669. r = dict(text=f'本体查询失败: {e}'[:200], error=True)
  4670. self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8')
  4671. elif u.path == '/api/maint_std':
  4672. # 运维标准四判据 + 成熟度评分 (docs/如东运维标准化设计.md)
  4673. try:
  4674. import json as _j, pathlib as _p
  4675. from src.windscada.maint import standards as _st
  4676. _db = _j.loads(_P.objects_json()
  4677. .read_text(encoding='utf-8'))
  4678. _wo = [(v.get('props') or {}) for k, v in _db.items()
  4679. if k.startswith('workorder/')]
  4680. _r = _st.run_all(_wo)
  4681. _r['score'] = _st.score(_wo, _r)
  4682. except Exception as e:
  4683. _r = dict(err=f'运维标准判据失败: {e}'[:200])
  4684. self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8')
  4685. elif u.path == '/api/maint_framework':
  4686. # 运维管理体系 —— 与快照预烤**同一实现** (maint_framework_view)
  4687. try:
  4688. _r = maint_framework_view()
  4689. except Exception as e:
  4690. _r = dict(err=f'运维体系读取失败: {e}'[:200])
  4691. self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8')
  4692. elif u.path == '/api/dq_findings':
  4693. # 数据可信度发现 —— 与快照预烤**同一实现** (dq_findings_view)
  4694. try:
  4695. _r = dq_findings_view()
  4696. except Exception as e:
  4697. _r = dict(rows=[], err=f'数据可信度读取失败: {e}'[:200])
  4698. self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8')
  4699. elif u.path == '/api/vibcms':
  4700. self._send(_jdump(vibcms_results(), ensure_ascii=False), 'application/json; charset=utf-8')
  4701. elif u.path == '/api/curves':
  4702. # ★2026-09-21: 接 win(按所选时间窗重算;未命中就起后台线程 + 回 building,页面轮询)
  4703. self._send(_jdump(curve_view(q.get('win') or '2026年'), ensure_ascii=False),
  4704. 'application/json; charset=utf-8')
  4705. elif u.path == '/api/fleet':
  4706. CUR_WIN[0] = win
  4707. self._send(_jdump(fleet_view(win), ensure_ascii=False), 'application/json; charset=utf-8')
  4708. elif u.path.startswith('/problem/'):
  4709. import urllib.parse as _up
  4710. _, _, t, sysn = u.path.split('/', 3)
  4711. self._send(PAGE_PROBLEM.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS)
  4712. .replace('%T%', t).replace('%SYS%', _up.unquote(sysn)))
  4713. elif u.path == '/api/problem':
  4714. self._send(_jdump(dict(problems=single_problem(q['t'], q['sys'], win)), ensure_ascii=False), 'application/json; charset=utf-8')
  4715. elif u.path == '/api/turbine':
  4716. self._send(_jdump(dict(problems=turbine_problems(q['t'], win)), ensure_ascii=False), 'application/json; charset=utf-8')
  4717. elif u.path == '/api/query':
  4718. agg = q.get('agg', 'med')
  4719. self._send(json.dumps(adhoc_query(q['t'], q['ch'], win, 'sum' if agg == 'dutyd' else agg), ensure_ascii=False), 'application/json; charset=utf-8')
  4720. elif u.path == '/api/snapshot':
  4721. _lang = urllib.parse.parse_qs(u.query).get('lang', [''])[0]
  4722. # 单文件快照 (2026-08-28 用户: 我怎么发给别人)。
  4723. # 把各窗 fleet / curves / ontology / 维护盘点全部内联进 HTML, 并在页内改写
  4724. # window.fetch 直接返回内联数据 —— 前端代码一行不改, 收件人双击即开, 不需要
  4725. # 装 Python、不需要连内网、断网也能看。数据按当前受众设置脱敏后再内联。
  4726. try:
  4727. _wins = ['2026年', '2026H1', '2025H2', '全程']
  4728. snap, _skip = {}, []
  4729. def _nk(path, **kw):
  4730. """快照 key 归一化: 参数**解码后**排序拼接。
  4731. 必须与页内 window.__NK 同语义 —— 直接用 URL 字符串做 key 会被
  4732. encodeURIComponent 与 urllib quote 的编码差异 (! * ' ( ) 一编一不编)
  4733. 和参数顺序打掉, 而这种失效是**静默**的: 命中不了就退到
  4734. "快照版不含此功能", 看起来像功能没做, 不像 key 错了。
  4735. """
  4736. a = sorted('%s=%s' % (k, v) for k, v in kw.items() if v not in (None, ''))
  4737. return path + ('?' + '&'.join(a) if a else '')
  4738. def _bake(key, fn):
  4739. """烤一个 API 响应。失败**记名**不静默 —— 守护步骤失败必须响亮。
  4740. ★2026-08-31: 此前直接 _redact(_jclean(...)), **绕过 _jdump 里的语言投影**,
  4741. 于是 ?lang=en 的快照内联的仍是中文数据 (实测 57 万→11 万中文只是
  4742. 页面文案变了, 数据没变)。这里补上同一套投影。
  4743. """
  4744. try:
  4745. # ★顺序是 **_redact 先, _proj 后**, 不要"为了与在线同序"去调它:
  4746. # 曲线图注等模板的 key 是照**脱敏后**的文本登记的 (含 ▪ 掩码),
  4747. # 先投影的话字符串里还是真实数字, 模板全部对不上, 整批退回
  4748. # [translation pending]。我按"同序"改过一次, 当场被 parity 闸逮住。
  4749. # 在线路径没有这个问题是因为脱敏在页面层统一做, 不在数据层。
  4750. _v = _redact(_jclean(fn()))
  4751. if _lang and _lang != 'zh':
  4752. _v = _proj(_v, _lang)
  4753. snap[key] = _v
  4754. return True
  4755. except Exception as e:
  4756. _skip.append('%s: %s' % (key, str(e)[:60]))
  4757. return False
  4758. for _w in _wins:
  4759. _bake(_nk('/api/fleet', win=_w), lambda w=_w: fleet_view(w))
  4760. _bake('/api/curves', curve_view)
  4761. _bake('/api/ontology', ontology_view)
  4762. # ★后加的面板接口也要预烤 —— 漏了会在离线快照里空掉。
  4763. # 新增接口时必须回到这里补一行, 否则快照静默少一块。
  4764. def _maint_std():
  4765. import json as _j2, pathlib as _p2
  4766. from src.windscada.maint import standards as _st2
  4767. _db2 = _j2.loads(_P.objects_json()
  4768. .read_text(encoding='utf-8'))
  4769. _wo2 = [(v.get('props') or {}) for k, v in _db2.items()
  4770. if k.startswith('workorder/')]
  4771. _r2 = _st2.run_all(_wo2)
  4772. _r2['score'] = _st2.score(_wo2, _r2)
  4773. return _r2
  4774. _maint_fw = maint_framework_view # ★与在线 API 同一实现
  4775. _dq = dq_findings_view # ★与在线 API 同一实现
  4776. _bake('/api/maint_std', _maint_std)
  4777. _bake('/api/maint_framework', _maint_fw)
  4778. _bake('/api/dq_findings', _dq)
  4779. _bake('/api/maint_survey',
  4780. lambda: dict(rows=__import__('src.ontology.maintenance',
  4781. fromlist=['survey']).survey()))
  4782. # ── 扩展预烤 (2026-08-30 用户"完整展示") ───────────────────────
  4783. # 原先只烤上面 4 个, 另外 7 个接口在离线版里是死的: 部件问题下钻、
  4784. # 本体对象/清单/链、振动详情点开全是"快照版不含此功能" —— 页面能渲染
  4785. # 掩盖了功能不可用, 我此前按"十页可交互"汇报是说过头了。
  4786. # ask / rpt_compose 要现跑模型, 不烤 (页面另给在线提示), 其余全量枚举。
  4787. _bake('/api/vibcms', vibcms_results)
  4788. # 离线桩: 快照里没有服务端跑模型, 所以这条**有意**与在线不同。
  4789. # note 双写 —— 靠翻译层会得到 [translation pending], 而这是给读者看的一句话。
  4790. _bake('/api/ask_status', lambda: dict(
  4791. offline=True, models=[],
  4792. note_zh='离线版: 模型问答需连接系统',
  4793. note_en='Offline copy: the model Q&A needs a live connection to the system'))
  4794. # 部件问题: (机组, 系统) 从各窗 systems 展开, 每个窗各烤一份
  4795. _pairs = set()
  4796. for _w in _wins:
  4797. _fv = snap.get(_nk('/api/fleet', win=_w)) or {}
  4798. for _sysn, _rows in (_fv.get('systems') or {}).items():
  4799. for _r in (_rows or []):
  4800. if isinstance(_r, dict) and _r.get('t'):
  4801. _pairs.add((_r['t'], _sysn))
  4802. # ★系统名两种形态都要烤 (2026-09-01 实逮"点开是空浮层")。
  4803. # systems 的分组键带**等级后缀** (`塔架与基础·良好`), 而页面上
  4804. # `/problem/<t>/<sys>` 链接发出的是**裸系统名** (`塔架与基础`) ——
  4805. # 于是 key 差一截, 取不到数, 浮层开了但里面是"本窗无记录"。
  4806. # **这种失效最难发现: 浮层确实弹出来了, 看着像"这台这系统没数据",
  4807. # 而不像"key 错了"。** 两种形态都烤成同一份数据即可。
  4808. for _t, _sy in sorted(_pairs):
  4809. _forms = {_sy}
  4810. if '·' in _sy:
  4811. _forms.add(_sy.split('·')[0])
  4812. for _w in _wins:
  4813. for _f in sorted(_forms):
  4814. # ★每种形态烤**它自己**的数据。上一版这里捕获的是带等级的 _sy,
  4815. # 于是裸名 key 拿到的是带等级那份 —— 实测差出 0 vs 1 条,
  4816. # **被对拍闸逮到**。lambda 默认参数必须捕获循环变量 _f 本身。
  4817. _bake(_nk('/api/problem', t=_t, sys=_f, win=_w),
  4818. lambda t=_t, sy=_f, w=_w: dict(problems=single_problem(t, sy, w)))
  4819. # 整机视图: 页面上机组号链到 /turbine/<t>, 弹层要用
  4820. for _t in sorted({t for t, _ in _pairs}):
  4821. for _w in _wins:
  4822. _bake(_nk('/api/turbine', t=_t, win=_w),
  4823. lambda t=_t, w=_w: dict(problems=turbine_problems(t, w)))
  4824. # 本体清单 + 每个对象详情 (清单里出现的 id 才可能被点开)
  4825. _oids = set()
  4826. for _ty in sorted((ontology_view().get('stats') or {})):
  4827. _k = _nk('/api/ont_list', type=_ty, limit=60)
  4828. if _bake(_k, lambda ty=_ty: dict(rows=ontology_list(ty, '', 60))):
  4829. for _row in (snap[_k].get('rows') or []):
  4830. if isinstance(_row, dict) and _row.get('id'):
  4831. _oids.add(_row['id'])
  4832. for _oid in sorted(_oids):
  4833. _bake(_nk('/api/ont_obj', id=_oid), lambda i=_oid: ontology_obj(i))
  4834. # 本体链: kind 五种, 参数取页面下拉的实际取值域
  4835. try:
  4836. from src.ontology import mcp_server as _omc
  4837. _bake(_nk('/api/ont_chain', kind='plan', top='12'),
  4838. lambda: _omc.ont_maint_plan(top=12))
  4839. _syslist = sorted({sy for _, sy in _pairs}) or ['齿轮箱']
  4840. for _sy in _syslist:
  4841. _bake(_nk('/api/ont_chain', kind='prev', system=_sy),
  4842. lambda sy=_sy: _omc.ont_chain_preventive(system=sy))
  4843. _bake(_nk('/api/ont_chain', kind='tree', system=_sy),
  4844. lambda sy=_sy: _omc.ont_fault_tree(system=sy))
  4845. # ★原来码源是**另一处烤好的** /api/ont_list?limit=60 的行, 于是全场 555 个码
  4846. # 只进了按码号排序的前 60 个; 而页面上"点一下就查"的示例码
  4847. # (3225/64000/64001/4110/13121) 恰好都不在那 60 个里
  4848. # ⇒ **页面在推荐自己答不上的码**, 点下去整条抢修链没反应。
  4849. # "排序后取前 N"的裁剪最容易把真正要用的挡在外面。
  4850. # ★改为**直接从本体读**, 不再依赖别处烤没烤、烤了多少 ——
  4851. # 跨模块借数据是这次截断的根因, 借来的上限管不着。
  4852. # (中途踩坑: 我先写成 _omc.ont_list(...), 而 mcp_server 根本没有这个函数,
  4853. # AttributeError 被外层 try 吞掉 ⇒ 整段 fault 一条都没烤, 还静默。)
  4854. import json as _js
  4855. _db = _js.loads(pathlib.Path(_omc.OBJ_PATH).read_text(encoding='utf-8'))
  4856. _codes = sorted(k.split('/')[-1] for k in _db
  4857. if k.startswith('alarmcode/'))
  4858. for _c in [c for c in _codes if c]:
  4859. _bake(_nk('/api/ont_chain', kind='fault', code=_c),
  4860. lambda c=_c: _omc.ont_chain_fault(code=c))
  4861. except Exception as _e:
  4862. _skip.append('ont_chain: %s' % str(_e)[:60])
  4863. if _skip:
  4864. print('[snapshot] 未烤成 %d 项: %s' % (len(_skip), '; '.join(_skip[:6])),
  4865. file=sys.stderr)
  4866. print('[snapshot] 预烤 %d 键 (%s)' % (
  4867. len(snap), ', '.join(sorted({k.split('?')[0] for k in snap}))), file=sys.stderr)
  4868. if _lang and _lang != 'zh':
  4869. # ★key 统一在**全部烤完之后**加 lang: 英文页的 fetch 会自动追加
  4870. # ?lang=en, 预烤 key 不带就永远对不上 (miss 是静默的, 退到 err 提示,
  4871. # 看着像功能没做)。
  4872. # ★不能在 _bake 内部改 —— 后续代码还要用原 key 从 snap 取值,
  4873. # 改了就 KeyError (本次实测踩到)。
  4874. # ★存双 key: 带 lang 与不带。页面首帧的 fetch 可能早于 fetch patch
  4875. # 生效, 那一发不带 lang —— 只存带 lang 的会 miss 一次 (实测首屏 fleet)。
  4876. #
  4877. # ★★ 加 lang 必须走**同一个归一化器**, 不能字符串拼接 (2026-09-01 实逮)。
  4878. # __NK 把参数**按字母排序**, 所以正确形态是 `?lang=en&sys=…&t=…&win=…`;
  4879. # 而拼接得到的是 `?sys=…&t=…&win=…&lang=en` —— 差一个顺序, 全部 miss。
  4880. # 后果极隐蔽: 十个标签页照常显示 (它们命中的是**不带 lang** 的那份),
  4881. # 只有**点开下钻**才走带 lang 的 key ⇒ 浮层弹出来却是"本窗无记录",
  4882. # 看着像"这台这系统没数据", 不像 key 错了。用户报的"有些点不开"就是它。
  4883. def _with_lang(k):
  4884. base, _, q = k.partition('?')
  4885. kv = dict(x.split('=', 1) for x in q.split('&') if '=' in x) if q else {}
  4886. kv['lang'] = _lang
  4887. return base + '?' + '&'.join(sorted('%s=%s' % (a, b) for a, b in kv.items()))
  4888. snap = dict(snap, **{_with_lang(k): v for k, v in snap.items()})
  4889. page = PAGE_FLEET.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS)
  4890. if _lang and _lang != 'zh':
  4891. # 快照页面本身也走英文化 (文案表 + 显示层脚本), 并改 lang 属性 ——
  4892. # 此前只投影了 API 路径, 快照页漏了, 导出的 index.html 一直是 lang="zh"
  4893. page = _en_page(page).replace('<html lang="zh"', '<html lang="%s"' % _lang, 1)
  4894. inject = ('<script>window.__SNAP__='
  4895. + json.dumps(snap, ensure_ascii=False, allow_nan=False) + ';\n'
  4896. '/* 快照模式: 拦截 fetch 从内联数据取。key 走归一化(参数解码后排序)而非 */'
  4897. '/* 原样 URL —— 编码差异(encodeURIComponent 不编 !*\'() 而 quote 编)与参数 */'
  4898. '/* 顺序都会让字符串比对落空, 且落空是静默的: 退到 err 提示, 看着像功能没做。 */'
  4899. 'window.__NK=function(u){var s=String(u),i=s.indexOf("?");if(i<0)return s;'
  4900. 'var p=s.slice(0,i),a=s.slice(i+1).split("&").filter(Boolean).map(function(kv){'
  4901. 'var j=kv.indexOf("=");if(j<0)return kv;var k=kv.slice(0,j),v=kv.slice(j+1);'
  4902. 'try{v=decodeURIComponent(v.replace(/\\+/g," "))}catch(e){}'
  4903. 'return v===""?null:k+"="+v}).filter(function(x){return x!==null}).sort();'
  4904. 'return p+(a.length?"?"+a.join("&"):"")};'
  4905. 'window.__HIT={ok:0,miss:[]};window.__RF=window.fetch;'
  4906. # ★没烤的窗按钮直接摘掉: 让用户点一个必然空白的选项, 比少给几个窗更糟。
  4907. # 默认窗取**已烤窗里最新的那个**, 供上面的回落候选用。
  4908. 'window.__WINS=(function(){var s={};Object.keys(window.__SNAP__).forEach('
  4909. 'function(k){var m=/[?&]win=([^&]*)/.exec(k);if(m)s[decodeURIComponent(m[1])]=1});'
  4910. 'return Object.keys(s)})();'
  4911. 'window.__DEFWIN=window.__WINS[0]||"";'
  4912. 'window.__WINLABEL=' + json.dumps(
  4913. 'Latest completed build' if _lang == 'en'
  4914. else '最新一次完整分析') + ';'
  4915. # ★快照里**去掉窗选择器**, 换成固定标签 (2026-09-01 用户令
  4916. # "设定不随时间窗动, 就显示最新的")。
  4917. # 理由: 选择器给约 20 个窗而只烤了 4 个, 选中其余任何一个都空白;
  4918. # 而这 4 个窗的内容**完全一样** (fus 40 行 / 需跟踪 76 台次 逐窗相同),
  4919. # 所以留着选择器只会让人以为能切、切了还空。
  4920. # ★先前那版是"摘掉没烤的 option" —— 摘过头了: 选项显示文本已英文化
  4921. # ("2026 full year"), 而 __WINS 是中文键, 匹配不上就把有效的也摘了。
  4922. # **文本匹配在译过的 UI 上天然不可靠**, 所以改为整体替换而非逐项筛。
  4923. 'window.__PINWIN=function(){try{'
  4924. 'var sel=document.querySelector("select#win");if(!sel)return;'
  4925. 'var lab=document.createElement("span");lab.id="winpin";'
  4926. 'lab.style.cssText="font-size:12.5px;color:var(--muted);padding:5px 9px;'
  4927. 'border:1px solid var(--line);border-radius:7px;background:var(--panel)";'
  4928. 'lab.textContent=window.__WINLABEL||"Latest build";'
  4929. 'lab.title="This snapshot is fixed to the latest completed build; '
  4930. 'the panels below do not vary by window.";'
  4931. 'sel.parentNode.replaceChild(lab,sel)}catch(e){}};'
  4932. 'try{new MutationObserver(function(){window.__PINWIN()})'
  4933. '.observe(document.documentElement,{childList:true,subtree:true})}catch(e){}'
  4934. 'document.addEventListener("DOMContentLoaded",function(){'
  4935. 'setTimeout(window.__PINWIN,300)});'
  4936. # 问答接口不能被离线拦截: 云上 nginx /api/ 后面有真服务在跑,
  4937. # 其余接口才走预烤。放行名单写死, 免得日后新增接口误穿。
  4938. 'window.fetch=function(u,o){var k=window.__NK(u);'
  4939. 'if(/^\\/api\\/(ask|ask_status|ask_models|rpt_compose|rpt_export)\\b/.test(String(u)))return window.__RF(u,o);'
  4940. # ★第 4 个候选: 把 win 换成默认窗 (2026-09-01 用户令"不随时间窗动, 就显示最新的")。
  4941. # 窗选择器给了约 20 个选项而快照只烤了 4 个 —— 选中其余任何一个
  4942. # 都会整页空白 (表现为"该系统全场无异常"、振动页空), 看着像没数据,
  4943. # 实则是没烤。这些面板的内容本就不随窗变 (四个窗 fus 完全一样),
  4944. # 所以回落到默认窗是**正确答案**而不是遮丑。
  4945. # ★同 src/windscada/ui/snapshot.py 的坑: 这里不能 encodeURIComponent —— k 与
  4946. # __SNAP__ 键都是原文(未编码)中文, 编码后拼出的键谁都不匹配, 兜底静默失效
  4947. # (2026-09-03 实逮: 换窗点浮层报"本窗无记录", 默认窗其实有数据)。
  4948. 'var c=[k,k.replace(/&?win=[^&]*/,"").replace(/\\?$/,""),String(u).split("?")[0],'
  4949. 'k.replace(/win=[^&]*/,"win="+(window.__DEFWIN||""))];'
  4950. 'for(var i=0;i<c.length;i++){var d=window.__SNAP__[c[i]];'
  4951. 'if(d!==undefined){window.__HIT.ok++;return Promise.resolve({ok:true,'
  4952. 'json:function(){return Promise.resolve(d)}})}}'
  4953. 'if(window.__HIT.miss.indexOf(k)<0)window.__HIT.miss.push(k);'
  4954. 'return Promise.resolve({ok:true,json:function(){return Promise.resolve('
  4955. '{err:"离线版不含此功能 (模型问答与报告生成需连接系统)"})}})};'
  4956. '</script>')
  4957. # ★英文版改走**源码级生成**, 不再注入运行时翻译 hook
  4958. # (2026-08-31 用户令: 不要打补丁式翻译, 整个系统重新生成一遍)。
  4959. # hook 的本质是给中文系统套一层英文 —— 只译写入 DOM 的那一刻,
  4960. # JS 逻辑与 JSON 键仍是中文, 长尾补不完且源码里满是中文。
  4961. # 改为在页面拼好后整页替换 (见本函数末尾与 i18n_en.to_english_source)。
  4962. # ★中文版完全不受影响: 不带 lang=en 时这条分支不执行, 国内照常用。
  4963. # ★ 单文件版的下钻: 页面上 74 个链接指向 /problem/<t>/<sys> 与 /turbine/<t>,
  4964. # 那是服务端独立页面 —— 快照里点了就是死链 (404/白页)。此前"十页可交互"
  4965. # 的说法没覆盖这一层。改法: 拦截这两类链接, 用已预烤的同一份数据在页内
  4966. # 开浮层, 图表复用共享的 drawFig, 与在线版看到的是同一批图。
  4967. # ★注入块里的文案要**自己**按语言选: 它加在 _en_page 之后,
  4968. # 英文化那一遍已经跑完了, 这里的中文字面量永远不会被翻译。
  4969. # 实逮: 英文页浮层里弹出"本窗无记录"。
  4970. # ('状态'/'依据' 是 JSON 键, '年' 是窗名数据值 —— 都不能动。)
  4971. inject += ('<script>(function(){var M=null;'
  4972. 'function esc(s){return String(s==null?"":s).replace(/[&<>]/g,function(c){'
  4973. 'return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c]})}'
  4974. 'window.__CLOSEM=function(){if(M)M.style.display="none"};'
  4975. 'function box(){if(M)return M;M=document.createElement("div");'
  4976. 'M.style.cssText="position:fixed;left:0;top:0;right:0;bottom:0;z-index:9999;'
  4977. 'background:rgba(0,0,0,.6);overflow:auto";'
  4978. 'M.onclick=function(e){if(e.target===M)window.__CLOSEM()};'
  4979. 'document.body.appendChild(M);return M}'
  4980. 'async function open2(t,sys){'
  4981. 'var w=(window.D&&window.D.win)||"2026年";'
  4982. 'var u=sys?("/api/problem?t="+t+"&sys="+encodeURIComponent(sys)+"&win="+encodeURIComponent(w))'
  4983. ':("/api/turbine?t="+t+"&win="+encodeURIComponent(w));'
  4984. 'var d={};try{d=await (await fetch(u)).json()}catch(e){}'
  4985. 'var ps=(d&&d.problems)||[];'
  4986. 'var h=\'<div style="max-width:1120px;margin:26px auto;background:var(--card,#111a24);\''
  4987. '+\'border:1px solid var(--line,#22303d);border-radius:10px;padding:18px 20px">\''
  4988. '+\'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px">\''
  4989. '+"<h3 style=\'margin:0 0 4px\'>"+esc(t)+(sys?" · "+esc(sys):"")+"</h3>"'
  4990. '+\'<button onclick="window.__CLOSEM()" style="background:none;border:1px solid \''
  4991. '+\'var(--line,#22303d);color:inherit;border-radius:6px;padding:2px 9px;cursor:pointer">×</button></div>\';'
  4992. 'if(!ps.length)h+=\'<p class="small">' + ('No records in this window' if _lang == 'en' else '本窗无记录') + '</p>\';'
  4993. 'ps.forEach(function(p){h+=\'<div class="card mb"><b>\'+esc(p.sys)+\'</b> · \'+esc(p["状态"])'
  4994. '+\'<p class="small">\'+esc(p["依据"])+\'</p>\';'
  4995. 'if(p.figs&&p.figs.length&&typeof drawFig==="function"){h+=\'<div class="grid">\';'
  4996. 'p.figs.forEach(function(f){try{h+=drawFig(f)}catch(e){}});h+="</div>"}'
  4997. 'h+="</div>"});'
  4998. 'h+="</div>";var b=box();b.innerHTML=h;b.style.display="block";b.scrollTop=0}'
  4999. 'document.addEventListener("click",function(e){'
  5000. 'var a=e.target&&e.target.closest?e.target.closest(\'a[href^="/problem/"],a[href^="/turbine/"]\'):null;'
  5001. 'if(!a)return;e.preventDefault();e.stopPropagation();'
  5002. 'var q=a.getAttribute("href").split("/").filter(Boolean);'
  5003. 'open2(decodeURIComponent(q[1]||""),q[0]==="problem"?decodeURIComponent(q[2]||""):"")},true);'
  5004. 'document.addEventListener("keydown",function(e){if(e.key==="Escape")window.__CLOSEM()});'
  5005. '})();</script>')
  5006. page = page.replace('<script>%JS%', inject + '<script>%JS%') if '%JS%' in page else page.replace('</head>', inject + '</head>')
  5007. if inject not in page:
  5008. page = page.replace('</head>', inject + '</head>')
  5009. # ★这里**曾经**调 i18n_en.to_english_source(page) 做逐词替换。
  5010. # 它跑在 inject 之后, 作用在含 __SNAP__ JSON 的整页上, 把预烤 key 里的
  5011. # 中文吃掉: win=2026年 → win=2026, win=全程 → win=full window,
  5012. # 于是页面按原 key 取数全部 miss —— **而 miss 是静默的**, 退到 err 提示,
  5013. # 看着像功能没做。_PROT 保护不了 JSON 里的 key。
  5014. # 页面英文化已在前面由 _en_page (整段短语表 + NEVER 数据值保护) 完成,
  5015. # 这里不再需要第二遍, 已删。
  5016. # ★成品页语法闸 —— 校验**最终字节**, 不是中间态 (2026-08-31)。
  5017. # _en_page 里也有一道, 但它跑在 value_script 与 __SNAP__ 注入**之前**,
  5018. # 护不住后注入的脚本。而快照是要上云的成品, 坏了就是白屏,
  5019. # 且白屏时"可见中文=0"会让所有自查指标全绿 —— 最坏的一种失败。
  5020. try:
  5021. from src.windscada import ui_en as _uie
  5022. _nchk = 0
  5023. for _m in re.finditer(r'<script([^>]*)>(.*?)</script>', page, re.S):
  5024. if 'application/json' in _m.group(1) or not _m.group(2).strip():
  5025. continue
  5026. _uie.check_js(_m.group(2), label='快照脚本#%d' % (_nchk + 1))
  5027. _nchk += 1
  5028. print('[snapshot] 成品页语法闸: %d 个脚本全部通过 node --check' % _nchk,
  5029. file=sys.stderr)
  5030. if _nchk == 0:
  5031. print('[snapshot] 🔴 语法闸切出 0 个脚本 —— **本次未校验**, 非通过',
  5032. file=sys.stderr)
  5033. except Exception as _e:
  5034. print('[snapshot] 🔴 成品页语法闸失败: %s' % _e, file=sys.stderr)
  5035. raise
  5036. if _lang == 'en':
  5037. _n1 = len(re.findall(r'[\u4e00-\u9fff]', page))
  5038. print('[snapshot] 英文快照: 页面残留中文 %d 字 (含内联数据)' % _n1,
  5039. file=sys.stderr)
  5040. # ★脱敏必须是**最后一步**, 且只对上云那一版做 (2026-09-01 用户令)。
  5041. # 中文快照是内部工作副本, 要保留真实场名/机型, 不脱敏。
  5042. # 英文快照是要发到公网的, 而它此前带着 rudong×366 / Siemens×194 /
  5043. # SWT×89 —— **同时页面上写着 "site, owner and OEM removed"**,
  5044. # 产物与自己的声明不符, 这比不声明更糟。
  5045. # 放最后: 对象 id 与引用它的地方必须换成同一个词, 只能整体做。
  5046. if _lang == 'en':
  5047. from src.windscada import deid_public as _dp
  5048. page = _dp.scrub_or_die(page, label='英文快照 (上云件)')
  5049. print('[snapshot] 脱敏回扫: 干净 (rudong/OEM/机型/省市/业主/坐标 全零)',
  5050. file=sys.stderr)
  5051. fn = (f'Guanlan_Snapshot_{CUR_WIN[0]}_EN.html' if _lang == 'en'
  5052. else f'观澜快照_{CUR_WIN[0]}_{"上报版" if not INTERNAL else "内部版"}.html')
  5053. data = page.encode('utf-8')
  5054. self.send_response(200)
  5055. self.send_header('Content-Type', 'text/html; charset=utf-8')
  5056. self.send_header('Content-Length', str(len(data)))
  5057. self.send_header('Content-Disposition',
  5058. "attachment; filename*=UTF-8''" + urllib.parse.quote(fn))
  5059. self.end_headers()
  5060. self.wfile.write(data)
  5061. except Exception as e:
  5062. import traceback
  5063. self._send(_jdump(dict(err=str(e)[:200], tb=traceback.format_exc()[-500:]),
  5064. ensure_ascii=False), 'application/json; charset=utf-8', 500)
  5065. elif u.path == '/api/rpt_export':
  5066. # 导出 Word/Excel (2026-08-28 用户令: 导出 demo)。图表块转数据表, 不放位图 —
  5067. # Word 里的位图既不能编辑也无法核对, 上报集团要的是能核的数字。
  5068. q = urllib.parse.parse_qs(u.query)
  5069. fmt = q.get('fmt', ['docx'])[0]
  5070. blocks = [x for x in q.get('blocks', [''])[0].split(',') if x]
  5071. aud = q.get('aud', ['in'])[0]
  5072. w = q.get('win', ['2026年'])[0]
  5073. narr = q.get('narr', [''])[0] or None
  5074. try:
  5075. from src.windscada import report_export as _rx
  5076. fl = fleet_view(w)
  5077. if aud == 'up': # 上报版走同一套脱敏规则
  5078. fl = _redact(_jclean(fl))
  5079. data = (_rx.to_xlsx if fmt == 'xlsx' else _rx.to_docx)(fl, blocks, w, aud, narr)
  5080. ext = 'xlsx' if fmt == 'xlsx' else 'docx'
  5081. mime = ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' if fmt == 'xlsx'
  5082. else 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
  5083. fn = f'运行分析报告_{w}_{"上报版" if aud == "up" else "内部版"}.{ext}'
  5084. self.send_response(200)
  5085. self.send_header('Content-Type', mime)
  5086. self.send_header('Content-Length', str(len(data)))
  5087. self.send_header('Content-Disposition',
  5088. "attachment; filename*=UTF-8''" + urllib.parse.quote(fn))
  5089. self.end_headers()
  5090. self.wfile.write(data)
  5091. except Exception as e:
  5092. import traceback
  5093. self._send(_jdump(dict(err=str(e)[:200], tb=traceback.format_exc()[-400:]),
  5094. ensure_ascii=False), 'application/json; charset=utf-8', 500)
  5095. elif u.path in ('/api/facts', '/api/facts/claim'):
  5096. # 结构化事实契约 (用户批 2026-09-06 "8033 /api 切到契约生成"): 只读 derived/ 产物, 不算数不改写;
  5097. # 生成器 = scripts/guanlan_facts_contract.py render, 契约 sha 随体返回供前端/CI 对拍.
  5098. import json as _json
  5099. _dd = _P.guanlan() / 'derived'
  5100. try:
  5101. if u.path == '/api/facts':
  5102. _body = _dd.joinpath('detail_cards.json').read_text(encoding='utf-8')
  5103. else:
  5104. _cid = urllib.parse.parse_qs(u.query).get('id', [''])[0]
  5105. _qa = _json.loads(_dd.joinpath('qa_refs.json').read_text(encoding='utf-8'))
  5106. _hit = next((r for r in _qa.get('refs', []) if r.get('claim_id') == _cid), None)
  5107. _body = _jdump(dict(contract_sha256=_qa.get('contract_sha256'), claim=_hit, err=None if _hit else 'unknown claim_id'), ensure_ascii=False)
  5108. self._send(_body, 'application/json; charset=utf-8')
  5109. except FileNotFoundError as e:
  5110. self._send(_jdump(dict(err='facts derived 缺失: %s' % e), ensure_ascii=False), 'application/json; charset=utf-8', 503)
  5111. elif u.path == '/api/rpt_compose':
  5112. q = urllib.parse.parse_qs(u.query)
  5113. if q.get('probe', [''])[0]:
  5114. # 前端只探"编排接口是否存在" (v2 bindReport probe=1); 原先照跑 rpt_compose('', [], model) 走模型,
  5115. # 汇报定制页首屏空等 ~23 s (2026-09-06 实测) —— 探针只答存在性, 不算数.
  5116. self._send(_jdump(dict(ok=True, probe=True), ensure_ascii=False), 'application/json; charset=utf-8'); return
  5117. r = rpt_compose(q.get('need', [''])[0], [x for x in q.get('canvas', [''])[0].split(',') if x],
  5118. q.get('model', ['qwen3:8b'])[0])
  5119. self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8')
  5120. elif u.path == '/api/ask_status':
  5121. self._send(_jdump(ask_status(), ensure_ascii=False), 'application/json; charset=utf-8')
  5122. elif u.path == '/api/ask_models':
  5123. # 与云端问答微服务 (windscada_ask_serve.py) 同形: {models: {id: {...}}}。此前本地没有
  5124. # 这条路由, 经典页的模型下拉在本地永远是"无可用模型" —— v2 与经典页同源取清单。
  5125. self._send(_jdump(dict(models={k: {} for k in ASK_MODELS}), ensure_ascii=False),
  5126. 'application/json; charset=utf-8')
  5127. elif u.path == '/api/ontology':
  5128. self._send(_jdump(ontology_view(), ensure_ascii=False), 'application/json; charset=utf-8')
  5129. elif u.path == '/api/ont_list':
  5130. self._send(_jdump(dict(rows=ontology_list(q.get('type', ''), q.get('t', ''), q.get('limit', 60))),
  5131. ensure_ascii=False), 'application/json; charset=utf-8')
  5132. elif u.path == '/api/ont_obj':
  5133. self._send(_jdump(ontology_obj(q.get('id', '')), ensure_ascii=False), 'application/json; charset=utf-8')
  5134. elif u.path == '/api/channels':
  5135. self._send(_jdump(channel_list(), ensure_ascii=False), 'application/json; charset=utf-8')
  5136. elif u.path.startswith('/static/'):
  5137. f = ST / u.path[len('/static/'):]
  5138. if f.is_file():
  5139. self._send(f.read_bytes(), 'text/html; charset=utf-8')
  5140. else:
  5141. # 裸 404 会让总览 iframe 变成"一块空白"(用户 2026-09-17 报的正是这个现象) ⇒ 结构化缺件页
  5142. self._send(_gap_page(u.path, f, '总览(全场状态)'), 'text/html; charset=utf-8')
  5143. elif u.path.startswith('/turbines/'):
  5144. f = ST / u.path.lstrip('/')
  5145. if f.is_file():
  5146. raw = f.read_text(encoding='utf-8', errors='ignore')
  5147. light = '<style>:root[data-theme="light"]{--ground:#f3f6f9;--panel:#fff;--panel-2:#f8fbfc;--ink:#0c1622;--muted:#5a6b7d;--line:#d8e1e9;--line-2:#eaf0f5;--accent:#0d7183;--accent-soft:#e6f3f5;--accent-ink:#095764;--ok:#137a68;--warn:#8a5a12;--bad:#a32d38;--unknown:#4d5d6e;--info:#0d7183}</style><script>(function(){try{document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>'
  5148. raw = raw.replace('</head>', light + '</head>', 1) if '</head>' in raw else light + raw
  5149. self._send(raw)
  5150. else:
  5151. self._send(_gap_page(u.path, f, '机组详情页'), 'text/html; charset=utf-8')
  5152. elif u.path.startswith('/windcms/'):
  5153. # windcms 振动分析本体嵌入 (用户令: 原振动分析板块进入振动融合板块; 静态挂载非复制,
  5154. # 正本仍在 outputs/rudong/windcms 由振动线维护, 本路由只读)
  5155. base = (ST.parent / 'windcms').resolve()
  5156. f = (base / u.path[len('/windcms/'):]).resolve()
  5157. if base in f.parents or f == base:
  5158. if f.is_dir(): f = f / 'index.html'
  5159. if f.is_file():
  5160. ext = f.suffix.lower()
  5161. mime = {'.html': 'text/html; charset=utf-8', '.json': 'application/json; charset=utf-8',
  5162. '.png': 'image/png', '.svg': 'image/svg+xml', '.css': 'text/css',
  5163. '.js': 'application/javascript', '.md': 'text/plain; charset=utf-8'}.get(ext, 'application/octet-stream')
  5164. self._send(f.read_bytes(), mime)
  5165. else:
  5166. self._send('404', code=404)
  5167. else:
  5168. self._send('403', code=403)
  5169. elif u.path == '/api/reload':
  5170. # 产物重载 (2026-09-16 用户令: 重算产物即时可用于页面呈现)。
  5171. # 平时**不需要**调它 —— _load() 每次请求都会比产物指纹, 变了自动重载;
  5172. # 这个端点给"重算结束想立刻生效"的显式动作, 也便于运维界面/脚本确认当前指纹。
  5173. _old = reload_products(urllib.parse.parse_qs(u.query).get('why', ['manual'])[0])
  5174. self._send(_jdump(dict(ok=True, old_stamp=_old, stamp=products_stamp(force=True),
  5175. files=len(_product_files())), ensure_ascii=False),
  5176. 'application/json; charset=utf-8')
  5177. else:
  5178. self._send('404', code=404)
  5179. except ProductsMissing as e:
  5180. # 产物缺失不是"程序坏了": 回 200 + 结构化无数据, 前端按正常渲染显示原因 (2026-09-11 空状态检查)
  5181. self._send(_jdump(dict(kind='multiline', title='无产物', unit='', months=[], series=[],
  5182. err='no_products',
  5183. note=f'{e.what} 不存在 → {_P.rel(e.path)}; 先放原始件再跑 '
  5184. f'scripts/rebuild_from_raw.py (SCADA 侧加 --scada) 或 '
  5185. f'scripts/rebuild_all.py 生成产物。'
  5186. f'"包内没有生成端"的件按用户令 2026-09-17 **运行期不由交付包补齐**: '
  5187. f'要么等研发补生成端(逐族可逆性见 docs/系统设计说明.md §13.6), '
  5188. f'要么由人离线补救(products_restore_missing.py --stash <…>, 不在运行链里); '
  5189. f'2026-09-16 起清除产物不留备份, 故没有 --on 还原'), ensure_ascii=False),
  5190. 'application/json; charset=utf-8')
  5191. except Exception as e:
  5192. import traceback
  5193. self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500)
  5194. if __name__ == '__main__':
  5195. # 统一日志口径 (用户令 2): 之后每一行都带 时间戳/级别/组件(详情见 src/logfile.py)
  5196. import pathlib as _pl, sys as _sys0
  5197. _sys0.path.insert(0, str(_pl.Path(__file__).resolve().parents[1]))
  5198. from src import logfile as _lf; _lf.prefix_stdout('detail')
  5199. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  5200. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  5201. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  5202. import sys as _sys
  5203. for _s in (_sys.stdout, _sys.stderr):
  5204. try: _s.reconfigure(errors='replace')
  5205. except Exception: pass
  5206. import argparse
  5207. ap = argparse.ArgumentParser()
  5208. import os
  5209. ap.add_argument('--port', type=int, default=int(os.environ.get('PORT', 8033))) # PORT 环境变量优先, 供 preview 分配端口
  5210. ap.add_argument('--host', default='127.0.0.1', help='0.0.0.0 = 对局域网开放 (演示用, 结束请关)')
  5211. ap.add_argument('--farm', default=None, help='场名 (默认 rudong; 可用场见 configs/farms/)')
  5212. a = ap.parse_args()
  5213. print(f'windscada serve :{a.port}', flush=True)
  5214. # --host 0.0.0.0 供局域网演示 (2026-08-28 用户: 怎么发给别人)。
  5215. # 默认仍只绑本机 — 内部数据不该因为一次演示就对整个网段开放, 要开是显式动作。
  5216. if getattr(a, 'farm', None):
  5217. # ★CFG/ST 在模块加载时就绑定了 (第 81 行), 这里必须显式重绑而不是只切 current —
  5218. # 否则 --farm 只改了配置模块里的指针, 服务用的仍是启动时那个场 (静默不生效)。
  5219. # 用模块字典赋值而非 global: main 里更早处已引用过 CFG, global 声明会触发
  5220. # "used prior to global declaration"。改模块属性等效且不受声明位置约束。
  5221. from src.windscada import config as _cfg
  5222. import sys as _sysmod
  5223. _me = _sysmod.modules[__name__]
  5224. _cfg.set_current(a.farm)
  5225. _me.CFG = _cfg.farm()
  5226. _me.ST = pathlib.Path(_me.CFG['store'])
  5227. _CACHE.clear()
  5228. _STAMP['val'] = None # 换场后产物指纹必须重算, 否则新场的缓存判为"未变化"
  5229. print(f'场: {_me.CFG["name"]} ({a.farm}, {_me.CFG["n_turbines"]} 台, 仓 {_me.ST})')
  5230. else:
  5231. print(f'场: {CFG["name"]} (默认; 可用 {list(__import__("src.windscada.config", fromlist=["available"]).available())})')
  5232. _host = getattr(a, 'host', '127.0.0.1')
  5233. if _host != '127.0.0.1':
  5234. import socket as _sk
  5235. try:
  5236. _ip = _sk.gethostbyname(_sk.gethostname())
  5237. except Exception:
  5238. _ip = _host
  5239. print(f'⚠ 已对局域网开放: http://{_ip}:{a.port} (同网段任何人可访问, 演示结束请关闭)')
  5240. # ★按窗重算的**预热**(用户令 2026-09-21:判级/曲线随所选时间窗变化)。
  5241. # 判级矩阵 ≈25 s/窗、镜头 ≈10~45 s/窗 —— 不预热的话重启后第一个访客要等 pending 轮询。
  5242. # 这里在**后台守护线程**里顺序把预设窗算进进程缓存(页面照常可用,未就位的窗如实标 pending)。
  5243. def _warm():
  5244. if _os.environ.get('GUANLAN_WIN_WARM', '1') == '0':
  5245. print('[warm] 按窗预热被 GUANLAN_WIN_WARM=0 关掉(按需算,首次看窗会 pending 一轮)', flush=True)
  5246. return
  5247. import time as _t
  5248. n = 0
  5249. for w in list(WINDOWS):
  5250. try:
  5251. sysmx_of(w) # 未命中即起后台线程
  5252. if _win_wait('sysmx', w, timeout=180) is not None:
  5253. n += 1
  5254. _t.sleep(2) # 留口气: 小内存机器上连续重算更容易被系统盯上
  5255. except Exception as e:
  5256. print(f'[warm] 判级 {w} 失败: {type(e).__name__}: {e}'[:160], flush=True)
  5257. for w in ('2026年',):
  5258. try:
  5259. curves_of(w) # 未命中即起后台线程
  5260. _win_wait('curves', w, timeout=240)
  5261. except Exception as e:
  5262. print(f'[warm] 镜头 {w} 失败: {type(e).__name__}: {e}'[:160], flush=True)
  5263. print(f'[warm] 按窗缓存预热完成: 判级 {n}/{len(WINDOWS)} 窗(可用内存 {_mem_mb()} MB)', flush=True)
  5264. _thr.Thread(target=_warm, name='win-warm', daemon=True).start()
  5265. ThreadingHTTPServer((_host, a.port), H).serve_forever()