discriminators.py 391 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633
  1. # -*- coding: utf-8 -*-
  2. """discriminators.py — 稳定判别器纯函数库 (Batch2 部分增量, 2026-06-16).
  3. 抽自已跨多场实跑的 per-farm 脚本 (RV-2: 搬真逻辑不改判据), 给 5 个分析 skill 配真引擎。
  4. 本批只抽 **物理/统计扎实、跨场同机制、变体面已收敛** 的 4 个 (用户 + Gemini/DeepSeek 裁决"部分增量"):
  5. curtail_vs_fault_sync — 限电vs故障 同步性三证 源 scripts/jtxq_analysis.py:36-51 SOP §4.3 n=16
  6. mast_usability — 测风塔可用性 (切变/风向/月ratio) 源 scripts/gbs_mast.py SOP §4.5
  7. ntf_correction — 机舱风/自由来流 NTF + 空间代表性 源 scripts/jtxq_mast.py:99 SOP §4.5
  8. nbm_residual — 温度 NBM 残差 (pooled OLS + MAD-z + EWMA) 源 scripts/litloop_r4_temp_nbm.py SOP §4.6 n=3
  9. yaw_column_usability — 对风角列可用性 (per机任意零点 artifact 拦截) 源 cq 昌平坳 SOP §4.4① n=2 [2026-06-17 增]
  10. 变体面未收敛的 (per-OEM 状态/限功率码解码 90012/910021、glitchy 内置可用率、setpoint 节流/binding) 仍 per-farm, 不抽
  11. (2026-06-17 复核: builtin_avail_verify 变体面仍未收敛=不抽; curtailment_setpoint_split n=1[暂行]<§0.3 准入线=不抽)。
  12. 全为纯函数: 入 DataFrame/array + 列名, 出结构化 dict; 无文件 I/O / 无 print。
  13. """
  14. import numpy as np
  15. import pandas as pd
  16. from .analysis_kit import theil_sen # 原子层稳健斜率 (禁在此重造; 见 analysis_kit docstring)
  17. __all__ = ['leading_edge_shape_screen', 'curtail_vs_fault_sync', 'offline_vs_curtail_split', 'mast_usability', 'ntf_correction', 'nbm_residual', 'ewma_exceed',
  18. 'near_equal_columns', 'power_channel_scale_check', 'channel_swap_screen', 'yaw_column_usability', 'availability_calibers',
  19. 'fleet_sector_devz', 'matched_load_overheat', 'vibration_trend', 'order_track_1p', 'naive_fft_1p',
  20. 'blade_imbalance_1p', 'mcsa_feasibility_gate', 'mcsa_method_skeleton', 'oil_quality_gate',
  21. 'cooling_efficiency', 'pitch_stick', 'cp_operating_peak', 'reference_channel_liveness',
  22. 'idle_zero_check', 'node_minus_sink_dt', 'dende_spread', 'phase_imbalance',
  23. 'daily_thermal_table', 'thermal_chain_align', 'gearbox_bearing_multichannel',
  24. 'matched_load_sink_dt', 'lubrication_pump_duty_split', 'thermal_beta_family',
  25. 'heater_actuator_screen', 'response_ratio', 'spectral_line_gates',
  26. 'vib_scalar_typology', 'impact_ringing_shift', 'bearing_end_symmetry',
  27. 'shaft_harmonic_ratios', 'fleet_two_component_split',
  28. 'madz_null_rate', 'fleet_madz_base_rate']
  29. # ---------------------------------------------------------------- §4.7 气动 Cp (运行段取峰, 止低风 P/v³ 伪影)
  30. def cp_operating_peak(ws, p_kw, *, rho, area, ws_min=4.0, ws_max=20.0, bin_w=0.5, hi_thresh=0.515):
  31. """运行段功率系数峰 (SOP §4.7; 止低风 P/v³ 数值爆点伪影 = memory cp-lowwind-pv3-artifact)。
  32. Cp=P/(0.5·ρ·A·v³) 在 ws≲4 分母趋零 → 数值冲 0.7-0.8 假"超 Betz", 是纯数值伪影非真超物理。
  33. 本函数**只取运行段 ws≥ws_min** 逐 bin 中位 + clip 0<Cp<0.593, **禁 max-over-all-bins**(会抓低风爆点)。
  34. 偏高(>hi_thresh, 高于现代机典型~0.47)= 风速读低**嫌疑·候选**(主证须用相对出力, 非 Cp 绝对值)。物理。
  35. 入: ws/p_kw 同长 array; rho 现场空气密度 kg/m³ (RULE-1 修正, 禁标准密度裸算); area 扫风面积 m²。
  36. 出 dict: {cp_curve {ws_bin: cp}, cp_peak, peak_ws, n_lowwind_artifact (剔的 ws<ws_min 且>Betz 点数),
  37. verdict ∈ {Cp偏高·读低嫌疑, Cp正常, 数据不足}, note}。凉水泉 2109 实证: 原"超物理极限"=此伪影。
  38. """
  39. ws = np.asarray(ws, float)
  40. p = np.asarray(p_kw, float)
  41. m = np.isfinite(ws) & np.isfinite(p) & (ws > 0) & (p > 0)
  42. ws, p = ws[m], p[m]
  43. if len(ws) < 20:
  44. return {'cp_curve': {}, 'cp_peak': None, 'peak_ws': None,
  45. 'n_lowwind_artifact': 0, 'verdict': '数据不足', 'note': f'有效点 n={len(ws)}<20'}
  46. cp_all = p * 1000.0 / (0.5 * rho * area * ws ** 3)
  47. n_art = int(np.sum((ws < ws_min) & (cp_all > 0.593))) # 低风伪影点 (诊断用): ws<ws_min 且 cp>Betz
  48. op = (ws >= ws_min) & (ws < ws_max) & (cp_all > 0) & (cp_all < 0.593) # 运行段 + clip Betz
  49. if int(op.sum()) < 10:
  50. return {'cp_curve': {}, 'cp_peak': None, 'peak_ws': None,
  51. 'n_lowwind_artifact': n_art, 'verdict': '数据不足', 'note': '运行段有效 bin<10'}
  52. wb = ws[op] // bin_w * bin_w
  53. s = pd.Series(cp_all[op]).groupby(wb).median()
  54. cp_peak = float(s.max())
  55. peak_ws = float(s.idxmax())
  56. verdict = 'Cp偏高·读低嫌疑' if cp_peak > hi_thresh else 'Cp正常'
  57. return {'cp_curve': {float(k): float(v) for k, v in s.items()},
  58. 'cp_peak': cp_peak, 'peak_ws': peak_ws, 'n_lowwind_artifact': n_art, 'verdict': verdict,
  59. 'note': f'运行段 ws≥{ws_min} 峰 {cp_peak:.3f}@{peak_ws:.1f}m/s; '
  60. f'剔低风伪影点 {n_art} (ws<{ws_min} 且>Betz = P/v³ 爆点, 非真超物理)'}
  61. # ---------------------------------------------------------------- §4.3 限电同步性
  62. def _longest_zero_block_days(times, p_abs, p_zero_kw, max_gap_days=2.0):
  63. """最长**数据连续** |P|<p_zero_kw 段的 wall-clock 跨度(天). times/p_abs 已按时间排序的 numpy array。
  64. 起止在 z(零功率)边沿; **额外在相邻行间隔 > max_gap_days 处断段** —— 否则稀疏/缺测台(几行零功率横跨
  65. 数月、中间大段无数据)会把"缺数据"误报成"连续离线块"(独立Critic 2026-07-07 逮的 latent 稳健性洞:
  66. langmushan 真数据块 99.6% 行覆盖·最大内隙 1 天=真连续, 但他场未必)。返回 (best_days, start_ts, end_ts)。
  67. NaT 时戳先剔 (无法锚定时长; langmushan 每台 144 个 NaT 若不剔 → span=NaT 污染 max, 误把 A30 297天块判 nan)。
  68. """
  69. times = np.asarray(times)
  70. p_abs = np.asarray(p_abs, dtype=float)
  71. valid = ~pd.isna(times)
  72. if not valid.all():
  73. times, p_abs = times[valid], p_abs[valid]
  74. n = len(times)
  75. if n == 0:
  76. return 0.0, None, None
  77. z = (p_abs < p_zero_kw) # NaN 功率 → False (未知≠已知零, 不并入零块)
  78. if not z.any():
  79. return 0.0, None, None
  80. gap = np.diff(times) / np.timedelta64(1, 'D') # 相邻行间隔(天), 长 n-1
  81. gap_prev = np.concatenate(([np.inf], gap)) # 行 i 之前的间隔
  82. gap_next = np.concatenate((gap, [np.inf])) # 行 i 之后的间隔
  83. prev_z = np.concatenate(([False], z[:-1]))
  84. next_z = np.concatenate((z[1:], [False]))
  85. # 段起 = 零行 ∧ (前行非零 ∨ 前隔过大); 段止 = 零行 ∧ (后行非零 ∨ 后隔过大)
  86. starts = np.flatnonzero(z & (~prev_z | (gap_prev > max_gap_days)))
  87. ends = np.flatnonzero(z & (~next_z | (gap_next > max_gap_days)))
  88. spans = (times[ends] - times[starts]) / np.timedelta64(1, 'D')
  89. k = int(np.argmax(spans))
  90. return float(spans[k]), times[starts[k]], times[ends[k]]
  91. def offline_vs_curtail_split(df, *, time_col, turbine_col, p_col, gen_speed_col,
  92. curtail_col=None, ws_col=None, ws_min=4.0,
  93. p_zero_kw=20.0, gen_offline_rpm=50.0, gen_turn_rpm=500.0,
  94. offline_frac_thresh=0.5, zero_block_days_thresh=30.0, max_gap_days=2.0):
  95. """限电候选桶: 「机组离线/停机」vs「真节流限电」分辨 + 连续零块时长闸 (SOP §4.3; 解 §0.9 限电三义混淆)。
  96. 朗目山铁证 (2026-07-07 独立Critic clean-room 翻案): 把"限电候选桶"(有风却 P~0) 判电网弃风前必过两闸——
  97. ① 离线占比: 桶内 |generator_speed| < gen_offline_rpm (发电机静止) 的占比 = 机组彻底离线/停机。
  98. 发电机不转 → **物理上不可能是"节流限电"**(节流 = 发电机在转、功率被外部指令压低)。真限电签名 =
  99. gen 转(≥gen_turn_rpm)但 P~0。离线占比 > offline_frac_thresh → 判"离线/停机主导·非节流弃风", 须辨
  100. 停机型限电(同步性 §4.3) vs 故障 vs 投产前离线, 成因 INSUFFICIENT, **不出节流弃风¥**。
  101. ② 连续零块: per台最长连续 P~0 (|P|<p_zero_kw) 块 > zero_block_days_thresh 天 → 电网调度物理上造不出
  102. 连续数月的零 (调度限电是小时级偶发态) → **决定性**判"投产前离线/长停"(cause INSUFFICIENT), 与同步/散布无关。
  103. 朗目山实证: 桶内 84% gen=0 离线 (真限电仅 0.6%); A30 连续 297 天零块 (健康台 A01 仅 12 天); 18/33 台
  104. Sep-Oct 才首并 → 前稿"弃风26%/37.5GWh ROI"误判, 实为投产前离线 (数字全对、故事讲反)。物理: 发电机静止 ≠ 节流。
  105. ⚠ 边界: 离线占比闸(①)**不足以**独立否定停机型电网限电(同步短停也 gen=0) → ① 只降级+禁节流¥、不否定;
  106. 连续零块闸(②)才是"非电网"的决定性证据(数月连续零 sync 也不可能是调度)。n=1 [暂行] (langmushan; §0.9 铁律锚)。
  107. 桶定义: (|p_col| < p_zero_kw) [∧ ws_col ≥ ws_min 若给] [∧ curtail_col > 0.5 若给]。
  108. 入长表; p_col 有功(kW); gen_speed_col 发电机转速(rpm, 可负→取 abs); time_col 时戳; turbine_col 台号。
  109. 出 dict: {n_bucket, offline_frac, true_curtail_frac, ambiguous_frac, max_zero_block_days,
  110. worst_zero_block_turbine, n_turbines_long_offline, turbines_long_offline, verdict, cause, evidence};
  111. verdict ∈ {疑投产前离线/长停(非弃风), 离线/停机主导·非节流弃风, 真节流限电候选, 桶空/无候选}。
  112. """
  113. p_abs_all = pd.to_numeric(df[p_col], errors='coerce').abs()
  114. gen = pd.to_numeric(df[gen_speed_col], errors='coerce').abs()
  115. bucket = (p_abs_all < p_zero_kw)
  116. if ws_col and ws_col in df:
  117. bucket = bucket & (pd.to_numeric(df[ws_col], errors='coerce') >= ws_min)
  118. if curtail_col and curtail_col in df:
  119. bucket = bucket & (pd.to_numeric(df[curtail_col], errors='coerce') > 0.5)
  120. n_bucket = int(bucket.sum())
  121. # ② 最长连续零块 per 台 (wall-clock; 与桶无关, 覆盖全序列) — 决定性"非电网"证据
  122. per_turb_block = {}
  123. sub = df[[time_col, turbine_col, p_col]].copy()
  124. sub['_pabs'] = pd.to_numeric(sub[p_col], errors='coerce').abs()
  125. sub['_t'] = pd.to_datetime(sub[time_col], errors='coerce')
  126. for tid, g in sub.groupby(turbine_col, sort=False):
  127. g = g.sort_values('_t')
  128. best, _, _ = _longest_zero_block_days(g['_t'].to_numpy(), g['_pabs'].to_numpy(), p_zero_kw,
  129. max_gap_days=max_gap_days)
  130. per_turb_block[str(tid)] = round(best, 1)
  131. max_zero_block_days = max(per_turb_block.values()) if per_turb_block else 0.0
  132. worst_turb = max(per_turb_block, key=per_turb_block.get) if per_turb_block else None
  133. turbines_long_offline = sorted([t for t, dd in per_turb_block.items() if dd > zero_block_days_thresh])
  134. n_long = len(turbines_long_offline)
  135. if n_bucket == 0:
  136. # 桶空但仍可能有长零块(全零台 ws 缺失场景) → 决定性②仍先判
  137. if max_zero_block_days > zero_block_days_thresh:
  138. ev = (f'⚠ 限电候选桶空(无"有风却P~0"), 但 per台最长连续零块 {max_zero_block_days:.0f}天@{worst_turb} '
  139. f'(>{zero_block_days_thresh:.0f}天闸, {n_long}台超闸) = 电网调度造不出连续数月零 → 投产前离线/长停。')
  140. return {'n_bucket': 0, 'offline_frac': None, 'true_curtail_frac': None, 'ambiguous_frac': None,
  141. 'max_zero_block_days': round(max_zero_block_days, 1), 'worst_zero_block_turbine': worst_turb,
  142. 'n_turbines_long_offline': n_long, 'turbines_long_offline': turbines_long_offline,
  143. 'verdict': '疑投产前离线/长停(非弃风)', 'cause': 'INSUFFICIENT_投产vs停役vs弃风(须业主并网台账+停机工单)',
  144. 'evidence': ev}
  145. return {'n_bucket': 0, 'offline_frac': None, 'true_curtail_frac': None, 'ambiguous_frac': None,
  146. 'max_zero_block_days': round(max_zero_block_days, 1), 'worst_zero_block_turbine': worst_turb,
  147. 'n_turbines_long_offline': n_long, 'turbines_long_offline': turbines_long_offline,
  148. 'verdict': '桶空/无候选', 'cause': None, 'evidence': '限电候选桶(有风却P~0)为空且无长零块'}
  149. # ① 桶内离线占比 (gen 静止) vs 真节流签名 (gen 转但 P~0)
  150. gen_b = gen[bucket]
  151. valid = gen_b.notna()
  152. offline_frac = float((gen_b[valid] < gen_offline_rpm).mean()) if bool(valid.any()) else None
  153. true_curtail_frac = float((gen_b[valid] >= gen_turn_rpm).mean()) if bool(valid.any()) else None
  154. ambiguous_frac = (round(max(0.0, 1.0 - offline_frac - true_curtail_frac), 3)
  155. if (offline_frac is not None and true_curtail_frac is not None) else None)
  156. of_s = f'{offline_frac:.0%}' if offline_frac is not None else 'NA'
  157. tc_s = f'{true_curtail_frac:.1%}' if true_curtail_frac is not None else 'NA'
  158. result = {'n_bucket': n_bucket,
  159. 'offline_frac': round(offline_frac, 3) if offline_frac is not None else None,
  160. 'true_curtail_frac': round(true_curtail_frac, 3) if true_curtail_frac is not None else None,
  161. 'ambiguous_frac': ambiguous_frac, 'max_zero_block_days': round(max_zero_block_days, 1),
  162. 'worst_zero_block_turbine': worst_turb, 'n_turbines_long_offline': n_long,
  163. 'turbines_long_offline': turbines_long_offline}
  164. # 决定性②: 连续数月零块 = 非电网 (sync 也救不了) → 投产前离线/长停
  165. if max_zero_block_days > zero_block_days_thresh:
  166. result.update({
  167. 'verdict': '疑投产前离线/长停(非弃风)',
  168. 'cause': 'INSUFFICIENT_投产vs停役vs弃风(须业主并网台账+停机工单)',
  169. 'evidence': (f'⚠ 限电候选桶 n={n_bucket}: 离线占比 {of_s}(gen<{gen_offline_rpm:.0f}rpm 静止) vs 真节流'
  170. f'签名(gen≥{gen_turn_rpm:.0f}转但P~0) {tc_s}; **最长连续零块 {max_zero_block_days:.0f}天@'
  171. f'{worst_turb}** (>{zero_block_days_thresh:.0f}天闸; {n_long}台超闸={turbines_long_offline[:6]}'
  172. f'{"..." if n_long > 6 else ""}). 调度造不出连续数月零 → 投产前离线/长停, 成因INSUFFICIENT, '
  173. f'撤弃风¥ (须业主并网台账定投产vs停役vs弃风)。')})
  174. return result
  175. # ①: 离线占比高但无长块 → 停机/离线主导(非节流弃风); 须辨停机型限电(同步性)vs故障 → 禁节流¥, 不擅判投产
  176. if offline_frac is not None and offline_frac > offline_frac_thresh:
  177. result.update({
  178. 'verdict': '离线/停机主导·非节流弃风',
  179. 'cause': 'INSUFFICIENT_停机型限电vs故障停机vs离线(须同步性§4.3+业主台账)',
  180. 'evidence': (f'⚠ 限电候选桶 n={n_bucket}: 离线占比 {of_s} > 闸{offline_frac_thresh:.0%}(gen静止=彻底停机) '
  181. f'vs 真节流签名(gen转P~0) {tc_s}; 最长连续零块 {max_zero_block_days:.0f}天(≤闸, 非投产前离线)。'
  182. f'发电机静止≠节流 → 非节流弃风, 须辨停机型电网限电(同步性)vs故障停机, 禁用节流模型套弃风¥。')})
  183. return result
  184. # 真节流签名: gen 转但 P~0, 无长块, 离线占比低 → 可按节流限电候选走同步性三证
  185. result.update({
  186. 'verdict': '真节流限电候选', 'cause': None,
  187. 'evidence': (f'限电候选桶 n={n_bucket}: 离线占比 {of_s} ≤ 闸{offline_frac_thresh:.0%} 且真节流签名(gen转P~0) '
  188. f'{tc_s}; 最长连续零块 {max_zero_block_days:.0f}天 ≤ 闸{zero_block_days_thresh:.0f}天 → '
  189. f'非投产前离线, 可按节流限电候选走同步性三证 (curtail_vs_fault_sync)。')})
  190. return result
  191. def curtail_vs_fault_sync(df, *, time_col, turbine_col, curtail_col,
  192. freq_col=None, gen_col=None, month_col=None,
  193. sync_thresh=0.5, n_total=None, ws_col=None, p_col=None,
  194. gen_speed_col=None, offline_frac_thresh=0.5, zero_block_days_thresh=30.0,
  195. max_gap_days=2.0):
  196. """限电 vs 故障 同步性判别 (SOP §4.3 三证, 源 jtxq_analysis). 只覆盖**停机型**限电。
  197. 三证: ①同步台数比例(≥半数台同时处于 curtail 态的窗占比) ②电网频率偏移(curtail vs 发电)
  198. ③季节集中(月度 curtail 率峰/均)。 同步=电网限电 / 散布=per机故障。
  199. df: 长表, curtail_col 为 [0,1] 处于限电候选态的占比/标志 (>0.5 视为在限电态)。
  200. gen_speed_col/p_col (可选, 强烈建议): 给定则跑**离线vs节流 + 连续零块守卫** (offline_vs_curtail_split;
  201. 解 §0.9 限电三义混淆): generator 静止=离线/停机(物理上不可能是节流) vs gen转但P≈0=真节流;
  202. per台最长连续零块 > zero_block_days_thresh 天 = 调度造不出的连续数月零 → **决定性**判投产前离线/长停,
  203. 不出弃风; 离线占比高但块短 → 附 offline_caution (禁用节流模型套¥), 不擅改同步裁决。n=1 [暂行] 朗目山。
  204. ws_col/p_col (可选, 强烈建议): 给定则跑**功率压制守卫** —— 真停机型限电必在同风速段把功率压到
  205. 显著低于非限电态; 若限电标志在匹配风速 bin 不压功率 = setpoint 标签 artifact (n=2: 唐龙 CURT 阈过高
  206. + 阜山 pset∈{2000,2050,2080} 均满发) → INSUFFICIENT, 不报散布故障/电网限电。
  207. 返回 dict: {n_curtail_windows, sync_fraction, median_simul, n_total, verdict, freq_offset_hz,
  208. season_peak_ratio, corroboration_votes, evidence};
  209. verdict ∈ {电网同步限电, **电网限电(部分调度)**, per机故障(散布), 无限电候选,
  210. **INSUFFICIENT(疑部分调度,旁证不足)**, **INSUFFICIENT(限电vs故障不可分)**,
  211. 疑误派生(限电信号常年on), 限电信号存疑(未压功率)}。
  212. ★2026-07-26: verdict 从"纯 sync_frac>=0.5 单门"升级为**多证阶梯**。旧版盲区(可靠性卡④注入
  213. + 如东 P5 真场坐实): 电网只调度 <50% 机组的部分限电被误判 per机故障 -> 去追不存在的故障。
  214. 新增两档"部分调度"与"INSUFFICIENT", **允许说不知道而非强行二选一**。
  215. """
  216. piv = df.pivot_table(index=time_col, columns=turbine_col, values=curtail_col, aggfunc='mean')
  217. ntot = int(n_total or piv.shape[1])
  218. in_c = piv > 0.5
  219. n_simul = in_c.sum(axis=1)
  220. busy = n_simul[n_simul > 0]
  221. if len(busy) == 0:
  222. return {'n_curtail_windows': 0, 'sync_fraction': None, 'verdict': '无限电候选',
  223. 'n_total': ntot, 'evidence': '无窗处于 curtail 态'}
  224. sync_frac = float((busy >= ntot / 2.0).mean()) # ≥半数台同时
  225. median_simul = float(busy.median())
  226. freq_offset = None
  227. if freq_col and gen_col and freq_col in df and gen_col in df:
  228. fq_c = df.loc[df[curtail_col] > 0.5, freq_col].median()
  229. fq_g = df.loc[df[gen_col] > 0.5, freq_col].median()
  230. if pd.notna(fq_c) and pd.notna(fq_g):
  231. freq_offset = float(fq_g - fq_c)
  232. season_peak = None
  233. mcol = month_col
  234. if mcol is None and pd.api.types.is_datetime64_any_dtype(df[time_col]):
  235. mser = df[time_col].dt.month
  236. elif mcol and mcol in df:
  237. mser = df[mcol]
  238. else:
  239. mser = None
  240. if mser is not None:
  241. mrate = df.assign(_m=mser).groupby('_m')[curtail_col].mean()
  242. if len(mrate) and mrate.mean() > 0:
  243. season_peak = float(mrate.max() / mrate.mean()) # 峰/均, >2 = 季节集中
  244. # 常年-on 守卫 (n=唐龙一期 tl_clean CURT=2100>铭牌设定~1944 → 88%正常次额定运行被误标限电):
  245. # 真电网限电是偶发态; curtail 占 >50% 全时几乎必是误派生(派生阈值高于铭牌设定/把正常次额定当限电)
  246. # → 不判电网限电, 标疑误派生 (corr(P,ws) 高也是 tell, 但此处仅用占比, 简单鲁棒)。
  247. curtail_duty = float((pd.to_numeric(df[curtail_col], errors='coerce') > 0.5).mean())
  248. if curtail_duty > 0.5:
  249. ev = (f'⚠ 限电信号占 {curtail_duty:.0%} 全时 = 不可能是真电网限电(应偶发态); 疑 curtail 派生阈值高于'
  250. f'铭牌功率设定(把正常次额定运行误标限电); 同步窗占比 {sync_frac:.1%} 无意义。须核 curtail 派生口径。')
  251. return {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
  252. 'median_simul': median_simul, 'n_total': ntot, 'verdict': '疑误派生(限电信号常年on)',
  253. 'curtail_duty': round(curtail_duty, 3), 'freq_offset_hz': freq_offset,
  254. 'season_peak_ratio': season_peak, 'evidence': ev}
  255. # 离线 vs 节流 + 连续零块守卫 (n=1 langmushan; 解 §0.9 限电三义混淆):
  256. # 判"限电/弃风"前必分 generator 静止(离线/停机, 物理上不可能是节流) vs gen转但P~0(真节流)。
  257. # 决定性②(per台连续数月零块) → 覆盖同步/散布, 判投产前离线/长停; ①(离线占比高但短块) → 附 caution 禁节流¥, 不擅改同步裁决。
  258. _offline_caution = None
  259. if gen_speed_col and p_col and gen_speed_col in df and p_col in df:
  260. split = offline_vs_curtail_split(
  261. df, time_col=time_col, turbine_col=turbine_col, p_col=p_col, gen_speed_col=gen_speed_col,
  262. curtail_col=curtail_col, ws_col=ws_col, offline_frac_thresh=offline_frac_thresh,
  263. zero_block_days_thresh=zero_block_days_thresh, max_gap_days=max_gap_days)
  264. if split['verdict'] == '疑投产前离线/长停(非弃风)':
  265. return {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
  266. 'median_simul': median_simul, 'n_total': ntot, 'verdict': '疑投产前离线/长停(非弃风)',
  267. 'curtail_duty': round(curtail_duty, 3), 'offline_frac': split['offline_frac'],
  268. 'true_curtail_frac': split['true_curtail_frac'], 'max_zero_block_days': split['max_zero_block_days'],
  269. 'n_turbines_long_offline': split['n_turbines_long_offline'], 'cause': split['cause'],
  270. 'freq_offset_hz': freq_offset, 'season_peak_ratio': season_peak, 'evidence': split['evidence']}
  271. if split['verdict'] == '离线/停机主导·非节流弃风':
  272. _offline_caution = (f"⚠离线占比 {split['offline_frac']:.0%} 高(gen静止=停机非节流); 同步裁决维持但弃风损失"
  273. f"须先剥离离线台+业主台账, 禁用节流模型套¥ ({split['cause']})")
  274. # 功率压制守卫 (n=2: 唐龙 CURT 阈过高 + 阜山 pset 标签{2000/2050/2080}均满发):
  275. # 真停机型限电必在"同风速"下把功率压到显著低于非限电态; 若限电标志在匹配中风速段不压功率
  276. # = setpoint 标签 artifact (pset 多个正常值都满发), 非真限电 → INSUFFICIENT, 不报"散布故障/电网限电"。
  277. if ws_col and p_col and ws_col in df and p_col in df:
  278. # 只在**发电态**比 (停机台 P=0∧pset=0→curt=True 会污染 curt 中位, 假装"压功率"→守卫漏判)
  279. dch = df[df[gen_col] > 0.5] if (gen_col and gen_col in df) else df
  280. cur = pd.to_numeric(dch[curtail_col], errors='coerce') > 0.5
  281. ws = pd.to_numeric(dch[ws_col], errors='coerce')
  282. pw = pd.to_numeric(dch[p_col], errors='coerce')
  283. caps = []
  284. for lo in range(6, 12): # 中风段: 功率随风变, 真限电会显著压低
  285. m = (ws >= lo) & (ws < lo + 1)
  286. pc = pw[m & cur].median(); pf = pw[m & ~cur].median()
  287. if (m & cur).sum() >= 100 and (m & ~cur).sum() >= 100 and pd.notna(pc) and pd.notna(pf):
  288. caps.append(float(pf - pc)) # >0 = 限电态被压低
  289. if caps:
  290. cap_kw = float(np.median(caps))
  291. if cap_kw <= 40: # kW; 中风段真停机限电应压数百 kW
  292. ev = (f'⚠ 限电标志在匹配中风速段未压低功率(中位差 {cap_kw:+.0f}kW ≤ 阈40) = setpoint 标签 artifact '
  293. f'(非真停机限电; pset 多个正常值都满发); 同步窗占比 {sync_frac:.1%} 无意义。须核 curtail 派生口径或用真限电信号。')
  294. return {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
  295. 'median_simul': median_simul, 'n_total': ntot, 'verdict': '限电信号存疑(未压功率)',
  296. 'curtail_duty': round(curtail_duty, 3), 'power_cap_kw': round(cap_kw, 1),
  297. 'freq_offset_hz': freq_offset, 'season_peak_ratio': season_peak, 'evidence': ev}
  298. # ---- verdict: 多证阶梯 (2026-07-26 升级; 旧版是纯 sync_frac>=0.5 单门) ----
  299. # 旧版盲区 (可靠性卡④ 注入实测 + 如东 P5 真场坐实): 电网只调度 <50% 机组的**部分限电**
  300. # (现实极常见) 被误判 'per机故障(散布)' -> 去追不存在的故障 / 把限电欠发当机组缺陷。
  301. # 注入: grid_frac 0.2/0.3/0.4 -> 100% 误判; 如东真场 sync 25.3% 却确有台账证明的真限电。
  302. # 修法 = 多证融合 + **允许输出"证据不足"而非强行二选一**:
  303. # sync 成立 -> 直接判 (保原行为, 不翻 jtxq 59% 等已有锚点)
  304. # sync 不成立 -> 看 median_simul(绝对同时台数) / season_peak(季节集中) / freq_offset(过频) 投票
  305. # 阈值依据: 可靠性卡④ 注入表 —— 部分电网调度 median_simul 4~8 台, 真散布故障仅 ~2 台;
  306. # 3 台是**模糊带** (如东实测 3.0), 故 simul 单独不足以判, 须旁证。
  307. votes, vote_ev = 0, []
  308. if median_simul >= 4:
  309. votes += 1; vote_ev.append(f'同时台数 {median_simul:.0f}≥4(部分电网调度量级, 真散布故障≈2)')
  310. if season_peak is not None and season_peak > 3:
  311. votes += 2; vote_ev.append(f'季节峰/均 {season_peak:.1f}×>3(强集中)')
  312. elif season_peak is not None and season_peak > 2:
  313. votes += 1; vote_ev.append(f'季节峰/均 {season_peak:.1f}×>2')
  314. if freq_offset is not None and abs(freq_offset) >= 0.02:
  315. votes += 1; vote_ev.append(f'频率偏移 {freq_offset:+.3f}Hz(电网态)')
  316. if sync_frac >= sync_thresh:
  317. verdict = '电网同步限电'
  318. elif votes >= 2 and median_simul >= 3:
  319. verdict = '电网限电(部分调度)'
  320. elif votes >= 1 and median_simul >= 3:
  321. verdict = 'INSUFFICIENT(疑部分调度,旁证不足)'
  322. elif votes == 0 and median_simul <= 2:
  323. verdict = 'per机故障(散布)'
  324. else:
  325. verdict = 'INSUFFICIENT(限电vs故障不可分)'
  326. ev = (f'同步窗占比 {sync_frac:.1%} (中位同时 {median_simul:.0f}/{ntot}台); '
  327. + (f'频率偏移 {freq_offset:+.3f}Hz; ' if freq_offset is not None else '')
  328. + (f'季节峰/均 {season_peak:.1f}×; ' if season_peak is not None else '')
  329. + (f'旁证票 {votes} [{"; ".join(vote_ev)}]' if vote_ev else f'旁证票 {votes}')
  330. + (f' || {_offline_caution}' if _offline_caution else ''))
  331. out = {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
  332. 'median_simul': median_simul, 'n_total': ntot, 'verdict': verdict, 'curtail_duty': round(curtail_duty, 3),
  333. 'freq_offset_hz': freq_offset, 'season_peak_ratio': season_peak,
  334. 'corroboration_votes': votes, 'evidence': ev}
  335. if _offline_caution:
  336. out['offline_caution'] = _offline_caution
  337. return out
  338. # ---------------------------------------------------------------- §4.4 偏航列可用性
  339. def yaw_column_usability(df, *, turbine_col, yaw_err_col, spread_thresh=30.0,
  340. max_abs_thresh=45.0, min_per_turbine=200, min_within_iqr=1.0):
  341. """对风角/偏航误差列可用性 (SOP §4.4①, 源 cq 昌平坳, n=2). 防 风向−机舱位置 的 per机任意零点 artifact。
  342. 真 native 对风角: 各台控制到≈0, per台中位聚集近0 (标定 offset ±数°); 派生 wind_dir−nacelle 编码器
  343. 零点任意 → per机中位散布大 (昌平坳 66° 乱跳)。判: ①inter-turbine 中位极差 > spread_thresh = 任意
  344. 零点 artifact ②任一台 |中位| > max_abs_thresh = 可疑 → usable=False → 偏航判据走【INSUFFICIENT】。
  345. 不做角度 wrap (wrap 会掩盖 0-360 误用), 保守偏 INSUFFICIENT。
  346. 返回 {usable, reason, n_turbines, inter_turbine_spread, max_abs_median, per_turbine_median}。
  347. """
  348. d = df.dropna(subset=[turbine_col, yaw_err_col])
  349. vals = pd.to_numeric(d[yaw_err_col], errors='coerce')
  350. # 退化列守卫 (n=怀朔 内蒙): 真 vane 对风角是连续量, 几乎不会精确=0; 大比例精确0 = 缺测填0/sentinel,
  351. # per机中位被零膨胀拉成 0 → 误判"完美对风/定论". 此类列无真对风信号 → INSUFFICIENT 非"对中健康"。
  352. frac_zero = float((vals == 0).mean()) if vals.notna().any() else 1.0
  353. if frac_zero > 0.5:
  354. nt = int((vals.groupby(d[turbine_col]).count() >= min_per_turbine).sum())
  355. return {'usable': None,
  356. 'reason': f'退化列: {frac_zero:.0%} 值精确=0 (零膨胀/sentinel, 非真对风角); 需 native vane 列或现场独立量',
  357. 'n_turbines': nt, 'inter_turbine_spread': None, 'max_abs_median': None,
  358. 'per_turbine_median': {}, 'frac_zero': round(frac_zero, 3)}
  359. med = vals.groupby(d[turbine_col]).median()
  360. cnt = vals.groupby(d[turbine_col]).count()
  361. med = med[(cnt >= min_per_turbine) & med.notna()]
  362. if len(med) < 2:
  363. return {'usable': None, 'reason': f'有效台<2 (per台需≥{min_per_turbine}样本)',
  364. 'n_turbines': int(len(med)), 'inter_turbine_spread': None,
  365. 'max_abs_median': None, 'per_turbine_median': {}}
  366. # 台内离散度门 (2026-07-16 jtxq实证): 真 native vane 对风角台内 IQR~10°(连续量); 零填/塌缩列台内 IQR≈0.
  367. # frac_zero 门查 raw-row 精确0占比, spread 门查任意零点(大极差) —— 但"per台中位塌缩≈0 + 极小极差 + IQR≈0"
  368. # 的退化列(jtxq 10min 层缺对风角度→伪列: 28/30台中位≈0.00 spread0.28°)两门都溜过 → 被误判"完美对风定论".
  369. # 靠台内离散拦: per台 IQR 中位 < min_within_iqr = 无真对风散布信号 → INSUFFICIENT(非对中健康).
  370. grp = vals.groupby(d[turbine_col])
  371. iqr = (grp.quantile(0.75) - grp.quantile(0.25)).reindex(med.index)
  372. med_iqr = float(iqr.median())
  373. if med_iqr < min_within_iqr:
  374. return {'usable': None,
  375. 'reason': f'退化列(台内离散): per台IQR中位 {med_iqr:.2f}°<{min_within_iqr}° = 无真对风散布(零填/塌缩伪列, 常见=输入层缺对风角度), 非"完美对风"; 需 native vane 列或现场独立量',
  376. 'n_turbines': int(len(med)), 'inter_turbine_spread': None, 'max_abs_median': None,
  377. 'per_turbine_median': {}, 'median_within_iqr': round(med_iqr, 3)}
  378. spread = float(med.max() - med.min())
  379. max_abs = float(med.abs().max())
  380. arbitrary, extreme = spread > spread_thresh, max_abs > max_abs_thresh
  381. usable = not (arbitrary or extreme)
  382. reason = (f'per机中位极差 {spread:.1f}°>{spread_thresh:.0f}° = 任意零点 artifact (非真对风角)' if arbitrary
  383. else (f'某台中位 |{max_abs:.1f}°|>{max_abs_thresh:.0f}° 可疑' if extreme
  384. else f'per机中位聚集 (极差 {spread:.1f}°), 列可用'))
  385. return {'usable': usable, 'reason': reason, 'n_turbines': int(len(med)),
  386. 'inter_turbine_spread': round(spread, 2), 'max_abs_median': round(max_abs, 2),
  387. 'median_within_iqr': round(med_iqr, 2),
  388. 'per_turbine_median': {str(k): round(float(v), 2) for k, v in med.items()}}
  389. # ---------------------------------------------------------------- §4.5 测风塔可用性
  390. def _ang_diff(a, b):
  391. d = np.abs((a - b + 180.0) % 360.0 - 180.0)
  392. return d
  393. def mast_usability(mast_long, *, time_col, height_col, ws_col, wd_col=None,
  394. heights=None, dir_max_deg=30.0, ratio_band=0.15):
  395. """测风塔可用性预检 (SOP §4.5, 源 gbs_mast/jtxq_mast)。schemas mast_usability.json 键。
  396. ①切变物理性: 各层中位风速单调增高 ∧ α∈[0.05,0.45] (α=polyfit(ln h, ln v))。
  397. ②双高度风向一致: 顶/底层风向角差中位 < dir_max_deg。
  398. ③月 ratio 稳定: (顶/底层风速比) 月度 max-min < ratio_band。
  399. 任一硬 FAIL → verdict=FAIL (唐珍 80m<10m / 风向44.6° / 月ratio0.74 = 塔坏)。
  400. 返回 {shear_physical, shear_alpha, monotonic, direction_consistency, direction_diff_deg,
  401. monthly_ratio, monthly_ratio_spread, verdict, layer_medians}。
  402. """
  403. piv = mast_long.pivot_table(index=time_col, columns=height_col, values=ws_col, aggfunc='mean')
  404. hs = list(heights) if heights else sorted(h for h in piv.columns
  405. if isinstance(h, (int, float, np.integer, np.floating)))
  406. v = piv[hs].median().values.astype(float)
  407. mono = bool(np.all(np.diff(v) > 0))
  408. with np.errstate(all='ignore'):
  409. alpha = float(np.polyfit(np.log(np.asarray(hs, float)), np.log(v), 1)[0]) if (v > 0).all() else np.nan
  410. shear_ok = bool(mono and np.isfinite(alpha) and 0.05 < alpha < 0.45)
  411. dir_ok, dir_diff = None, None
  412. if wd_col:
  413. wpiv = mast_long.pivot_table(index=time_col, columns=height_col, values=wd_col, aggfunc='mean')
  414. if hs[0] in wpiv and hs[-1] in wpiv:
  415. dd = _ang_diff(wpiv[hs[-1]], wpiv[hs[0]]).median()
  416. if pd.notna(dd):
  417. dir_diff = float(dd)
  418. dir_ok = bool(dir_diff < dir_max_deg)
  419. ratio_ok, ratio_spread = None, None
  420. if pd.api.types.is_datetime64_any_dtype(piv.index):
  421. r = (piv[hs[-1]] / piv[hs[0]]).replace([np.inf, -np.inf], np.nan)
  422. mr = r.groupby(piv.index.month).median().dropna()
  423. if len(mr) >= 2:
  424. ratio_spread = float(mr.max() - mr.min())
  425. ratio_ok = bool(ratio_spread < ratio_band)
  426. verdict = 'PASS' if (shear_ok and dir_ok is not False and ratio_ok is not False) else 'FAIL'
  427. return {'shear_physical': shear_ok, 'shear_alpha': alpha, 'monotonic': mono,
  428. 'direction_consistency': dir_ok, 'direction_diff_deg': dir_diff,
  429. 'monthly_ratio': ratio_ok, 'monthly_ratio_spread': ratio_spread,
  430. 'verdict': verdict, 'layer_medians': {h: round(float(x), 2) for h, x in zip(hs, v)}}
  431. # ---------------------------------------------------------------- §4.5 NTF + 空间代表性
  432. def ntf_correction(nacelle_ws, freestream_ws, *, turbine=None, sector=None,
  433. reads_low_thresh=0.97, rep_band=(0.9, 1.1)):
  434. """机舱风/自由来流 NTF + 空间代表性 (SOP §4.5, 源 jtxq_mast)。
  435. NTF = median(机舱ws / 自由来流ws); <reads_low_thresh = 机舱读低 (解释绝对达成虚高)。
  436. 给 turbine → per机 NTF; 各机比 ∉ rep_band 的台占多 → 空间代表性差, 场级绝对达成只报区间。
  437. 给 sector → 分扇区 NTF, 极差 > 0.1 = 方向加权混合非机型 NTF (jtxq 0.794 戒)。
  438. 返回 {ntf_median, reads_low, n, per_turbine_range, frac_in_rep_band, per_sector_range,
  439. spatial_representative, caveat}。
  440. """
  441. nac = np.asarray(nacelle_ws, float)
  442. free = np.asarray(freestream_ws, float)
  443. m = np.isfinite(nac) & np.isfinite(free) & (free > 0)
  444. ratio = nac[m] / free[m]
  445. ntf = float(np.median(ratio)) if ratio.size else np.nan
  446. per_t_range = frac_in = None
  447. if turbine is not None:
  448. tb = pd.Series(np.asarray(turbine)[m], name='t')
  449. pt = pd.Series(ratio).groupby(tb.values).median()
  450. if len(pt):
  451. per_t_range = float(pt.max() - pt.min())
  452. frac_in = float(((pt >= rep_band[0]) & (pt <= rep_band[1])).mean())
  453. per_s_range = None
  454. if sector is not None:
  455. sb = pd.Series(np.asarray(sector)[m])
  456. ps = pd.Series(ratio).groupby(sb.values).median()
  457. if len(ps) >= 2:
  458. per_s_range = float(ps.max() - ps.min())
  459. # 空间代表性: 各机比多在 [0.9,1.1] 且扇区极差≤0.1
  460. rep = None
  461. if frac_in is not None:
  462. rep = bool(frac_in >= 0.8 and (per_s_range is None or per_s_range <= 0.1))
  463. return {'ntf_median': ntf, 'reads_low': bool(np.isfinite(ntf) and ntf < reads_low_thresh),
  464. 'n': int(ratio.size), 'per_turbine_range': per_t_range, 'frac_in_rep_band': frac_in,
  465. 'per_sector_range': per_s_range, 'spatial_representative': rep,
  466. 'caveat': '单 mast 空间代表性 caveat; 绝对达成率非空间代表则只报区间 (SOP §4.5)'}
  467. # ---------------------------------------------------------------- §4.5 机群相对功率一致性 (控扇区)
  468. def fleet_sector_devz(df, *, turbine_col, power_col, ws_ref_col, sector_col,
  469. bin_ms=0.5, min_cell=10, min_turb=8, resp_kw=(75.0, 1425.0), spread_floor=8.0):
  470. """机群相对功率一致性 — 控风向扇区 (SOP §4.5; 源 jtxq勘误/gbs/wjw 实证 n=3).
  471. 单 mast 方向代表性 artifact: 仅按 ws-bin 池化比机群中位 → 单 mast 在某扇区系统偏估来流, 该扇区全场假高/假亏;
  472. 当某台能量权重落该偏差扇区, 被误读成 per 机欠发 (jtxq DJ23 池化 devz-2.07 假【预警】, 控扇区后 -0.67 落回正常).
  473. 修: 在 (ws_ref bin × 风向扇区) 内比机群**中位数**, 方向偏差扇区内共模消掉. devz=(台中位−群中位)/(1.4826·MAD).
  474. ⚠ 仅消**方向**偏差; 远 mast 的**距离/选址梯度**(wjw 57D塔, sc_devz~资源proxy R²0.47)须额外资源proxy去混.
  475. ⚠ ws_ref 须独立量(测风塔自由来流), 非机舱风(self_ref); 密度归一后传入. 结论上限【预警】(机组级禁定论).
  476. ⚠⚠ sc_devz 灵敏度 (_jtxq/_wjw_resid_sensitivity 注入实证, 2026-06-18): median-over-cells 对**均匀**欠发盲 —
  477. 注入 12% 均匀亏损仅 devz≈-0.66 (低风场 per-cell 大 MAD 稀释: 多数低风 cell 的 d% 只几 kW < spread_floor), 检出地板 >12%/None。
  478. 能量加权% 敏感但健康本底散布 ±15-37% (单塔空间代表残差) 淹没之; 资源proxy去混残差 (王家湾 resid_z, R²高场) 仅收紧基线,
  479. 不放大均匀小亏信号 → 仅捕**粗大多扇区**欠发 (W006 级 ~30%, resid_z-3.4), 对 ≤~12% 均匀亏不恢复灵敏。
  480. → 本函数 = **粗大欠发检测器**, 非 subtle 检测器; "无 sc_devz 预警" = "无粗大偏离"(**大盲区**),
  481. 须**逐场注入校准检出地板** + magnitude(sc_dev_kw) + 跨场/历史 prior, **非 sc_devz 单阈当筛查**。
  482. 返回 {per_turbine: {tid: {sc_devz, sc_dev_kw, n_cells, n_sec, persist_below, persist_above}},
  483. n_qualified_cells, caveat}.
  484. """
  485. def _mad(s):
  486. s = np.asarray(s, float)
  487. return float(1.4826 * np.median(np.abs(s - np.median(s))))
  488. a = df[[ws_ref_col, sector_col, turbine_col, power_col]].dropna().copy()
  489. a.columns = ['ws', 'sec', 'tid', 'P']
  490. a['wb'] = np.round(a['ws'] / bin_ms) * bin_ms
  491. g = a.groupby(['wb', 'sec', 'tid'], as_index=False).agg(pm=('P', 'median'), nn=('P', 'size'))
  492. g = g[g['nn'] >= min_cell]
  493. b = g.groupby(['wb', 'sec']).agg(fleet=('pm', 'median'), nt=('pm', 'size'), sp=('pm', _mad)).reset_index()
  494. b = b[(b['nt'] >= min_turb) & (b['fleet'] >= resp_kw[0]) & (b['fleet'] <= resp_kw[1])]
  495. b['sp'] = b['sp'].clip(lower=spread_floor)
  496. g = g.merge(b[['wb', 'sec', 'fleet', 'sp']], on=['wb', 'sec'], how='inner')
  497. g['dev'] = g['pm'] - g['fleet']; g['devz'] = g['dev'] / g['sp']
  498. out = {}
  499. for tid, d in g.groupby('tid'):
  500. out[tid] = {'sc_devz': round(float(d['devz'].median()), 2),
  501. 'sc_dev_kw': round(float(np.average(d['dev'], weights=d['nn'])), 1),
  502. 'n_cells': int(len(d)), 'n_sec': int(d['sec'].nunique()),
  503. 'persist_below': round(float((d['devz'] < -1).mean()), 2),
  504. 'persist_above': round(float((d['devz'] > 1).mean()), 2)}
  505. # ⭐检测下限(解析, 源 gbs): 均匀欠发达 devz 门所需 % = k·(cell_MAD/cell_P 中位). 抽自各farm手搓→canonical自动带.
  506. cell_mad = float(b['sp'].median()) if len(b) else float('nan')
  507. cell_p = float(b['fleet'].median()) if len(b) else float('nan')
  508. warn_pct = round(2 * cell_mad / cell_p * 100, 1) if cell_p else None # devz=-2(预警)所需均匀欠发%
  509. ref_pct = round(cell_mad / cell_p * 100, 1) if cell_p else None # devz=-1(参考)
  510. return {'per_turbine': out, 'n_qualified_cells': int(len(b)),
  511. 'detection_floor_pct': {'warn_devz2': warn_pct, 'ref_devz1': ref_pct, 'cell_mad_kw': round(cell_mad, 1),
  512. 'note': '均匀欠发须≥此%才达对应 devz 门(=2×/1× cell_MAD/cell_P 中位); '
  513. '<此% 此法盲 → "无预警"=检测下限内无可检出 ≠ 确认健康(细缺陷走振动/温度轴)'},
  514. 'caveat': '控扇区消方向偏差; 距离梯度须额外资源proxy去混; ws_ref须独立量(非机舱self_ref); 上限【预警】; '
  515. '⚠sc_devz对均匀欠发盲=粗大检测器非subtle(见 detection_floor_pct, 通常数十%): 无预警=无粗大偏离(大盲区), 须 magnitude+prior'}
  516. # ---------------------------------------------------------------- §4.6 温度 NBM 残差
  517. def ewma_exceed(resid, lam=0.2):
  518. """残差 EWMA 超 3σ_ewma 时间占比 (持续性, 源 litloop_r4)。"""
  519. r = pd.Series(resid).dropna()
  520. if len(r) < 200:
  521. return np.nan
  522. sig = r.std()
  523. e = r.ewm(alpha=lam).mean()
  524. sig_e = sig * np.sqrt(lam / (2 - lam))
  525. return float((np.abs(e) > 3 * sig_e).mean())
  526. # ---------------------------------------------------------------- §1.1③/§4.6c 参考通道 per-台存活守卫
  527. def reference_channel_liveness(s, *, min_nunique=10, max_stuck_frac=0.5, min_std=1e-3,
  528. valid_range=None, min_n=500, round_decimals=1):
  529. """参考/被排名通道 **per-台** 存活校验 (SOP §1.1③ / §4.6c 列 liveness 先验的机器实现)。
  530. 用途: dT/matched-load/NBM 类判据拿某通道 (舱外温度/环温/塔底 做温差或回归基线; 或被排名的部件温本身)
  531. 前, 必先确认该通道在**这台**机上活着。死/冻结的参考会把"参考坏"伪装成"部件热" —— 06淄川
  532. F0810001022 戒: 舱外温度 2024.08 起冻在 -0.1°C (整窗 97.8% 行恒值 + 偶发 ±300°C 野值), 致
  533. dT=变流器冷却水−舱外 假登顶 rank1/24, 而原始变流器冷却水实为 fleet 中位 (41°C, rank12/24, 跨载荷平),
  534. 被误报"变流器冷却水异常 onset 2024.09" —— 见 memory sensor_reference_liveness_trap。
  535. ⚠ 强于 §4.6c 旧"铁律①"(nuniq>1 ∧ 有方差): 半程冻结+野值的传感器在**整窗**上 nuniq=297、std=6.1
  536. (皆非零) → 旧规则放行; **stuck_frac (单一众数值占比) 才是决定性判据** (本例 0.978 vs 健康全场 ≤0.017)。
  537. nunique/std 仅作 corroborate (野值会把二者顶高 → 单看必漏)。
  538. 判据 (任一触发 = 非存活 dead):
  539. ① stuck_frac > max_stuck_frac —— 卡在单一众数值 (冻结/坐死; **主判, 抗野值**)
  540. ② nunique < min_nunique —— 近常数列 (corroborate)
  541. ③ std < min_std —— 无方差死列 (corroborate)
  542. valid_range 给定时先把物理域外读数 (野值/哨兵) 置 NaN 再统计 (野值不算"活", 但也不进众数分母)。
  543. 入: s (单台某通道 Series); 出 dict:
  544. {live(True/False/None), reason, stuck_frac, stuck_value, nunique, std, n, n_valid}。
  545. live=None = 有效样本不足 (n_valid<min_n) → 调用方按 INSUFFICIENT 处置 (既不当存活也不当死)。
  546. """
  547. v = pd.to_numeric(s, errors='coerce')
  548. if valid_range is not None:
  549. lo, hi = valid_range
  550. v = v.where((v >= lo) & (v <= hi))
  551. v = v.dropna()
  552. n_valid = int(v.size)
  553. base = {'stuck_frac': None, 'stuck_value': None, 'nunique': None, 'std': None,
  554. 'n': int(getattr(s, 'size', len(s))), 'n_valid': n_valid}
  555. if n_valid < min_n:
  556. return {'live': None, 'reason': f'有效样本不足(n_valid={n_valid}<{min_n})', **base}
  557. r = v.round(round_decimals)
  558. vc = r.value_counts()
  559. stuck_frac = float(vc.iloc[0] / n_valid)
  560. stuck_value = float(vc.index[0])
  561. nunique = int(r.nunique())
  562. std = float(v.std())
  563. fails = []
  564. if stuck_frac > max_stuck_frac:
  565. fails.append(f'卡值{stuck_value:g}占比{stuck_frac:.1%}>{max_stuck_frac:.0%}(冻结)')
  566. if nunique < min_nunique:
  567. fails.append(f'nunique={nunique}<{min_nunique}(近常数)')
  568. if std < min_std:
  569. fails.append(f'std={std:.3g}<{min_std:g}(无方差)')
  570. live = not fails
  571. return {'live': live, 'reason': ('alive' if live else '; '.join(fails)),
  572. 'stuck_frac': round(stuck_frac, 3), 'stuck_value': round(stuck_value, 3),
  573. 'nunique': nunique, 'std': round(std, 4), 'n': base['n'], 'n_valid': n_valid}
  574. def _theil_sen(y):
  575. """Theil-Sen 斜率 (中位 pairwise, 抗季节抖/单点尖峰; x=等距月序)。"""
  576. n = len(y)
  577. sl = [(y[j] - y[i]) / (j - i) for i in range(n) for j in range(i + 1, n)]
  578. return float(np.median(sl)) if sl else 0.0
  579. def residual_trend(resid, time, *, min_month_n=500, min_months=5, recent_months=6,
  580. slope_thresh=1.5, level_thresh=5.0):
  581. """per-台残差时间轨迹 → 发展性检测 (SOP §4.6; 补"年/窗聚合 z 埋没发展性故障", 太阳山 W091 教训).
  582. 年 resid_mean→z 把"前静默+后爆发"平均掉 → 发展性故障年 z 低被埋 (W091 年 z=2.34 最弱却逼近报警)。
  583. ★关键: 斜率用**近窗(末 recent_months 月)**非全序列 —— 全序列 Theil-Sen 仍被前平坦期稀释(W091 全序列
  584. 1.14°/月 <阈, 近6月 ~+4.5 才逮), 是年 z 稀释病搬到月分辨率的翻版。
  585. 月分箱(剔 n<min_month_n 薄月防端点 artifact, W091 末月 n=112 不算数); developing = 近窗斜率≥slope_thresh
  586. ∧ 末月残差≥level_thresh ∧ 近半均>早半(排恒偏/季节回落)。与端点-n 陷阱互补相反(一防薄端点当趋势/一防厚聚合埋趋势)。
  587. resid/time: 该台逐行残差 + 时间。返回 {slope_recent, slope_full, last_resid, n_months, developing, monthly}。
  588. """
  589. s = pd.DataFrame({'r': pd.to_numeric(resid, errors='coerce'),
  590. 't': pd.to_datetime(time, errors='coerce')}).dropna()
  591. if s.empty:
  592. return {'slope_recent': None, 'slope_full': None, 'last_resid': None,
  593. 'n_months': 0, 'developing': False, 'monthly': []}
  594. s['ym'] = s['t'].dt.to_period('M')
  595. g = s.groupby('ym')['r'].agg(med='median', n='size').reset_index()
  596. g = g[g['n'] >= min_month_n].sort_values('ym') # 剔薄月(端点防护)
  597. monthly = [(str(r.ym), round(float(r.med), 2), int(r.n)) for r in g.itertuples(index=False)]
  598. if len(g) < min_months:
  599. return {'slope_recent': None, 'slope_full': None,
  600. 'last_resid': (monthly[-1][1] if monthly else None),
  601. 'n_months': len(g), 'developing': False, 'monthly': monthly}
  602. y = g['med'].to_numpy(float)
  603. recent = y[-recent_months:]
  604. slope_full = _theil_sen(y)
  605. slope_recent = _theil_sen(recent) if len(y) >= 3 else slope_full # ★近窗斜率
  606. last_resid = float(y[-1])
  607. half = len(y) // 2
  608. recent_gt_early = float(np.mean(y[half:])) > float(np.mean(y[:half])) + level_thresh / 2
  609. # ★末月须近近窗峰值: 真持续发展=峰在末端(W091 末月=峰); 中段尖峰后回落=峰在中段(平阴A11 末月0.59×峰)剔除
  610. recent_max = float(np.max(recent))
  611. near_peak = last_resid >= 0.8 * recent_max if recent_max > 0 else True
  612. developing = bool(slope_recent >= slope_thresh and last_resid >= level_thresh
  613. and recent_gt_early and near_peak)
  614. return {'slope_recent': round(slope_recent, 2), 'slope_full': round(slope_full, 2),
  615. 'last_resid': round(last_resid, 2), 'recent_max': round(recent_max, 2), 'n_months': len(g),
  616. 'developing': developing, 'monthly': monthly}
  617. def nbm_residual(df, *, comp_col, p_col, rotor_col=None, turbine_col, time_col,
  618. amb_col=None, gen_mask=None, z_thresh=2.0, min_per_turbine=1000,
  619. amb_fallback_cols=None, amb_valid_range=(-40.0, 55.0),
  620. amb_neighbor_median=False, amb_neighbor_dev_max=2.0,
  621. trend_gate=True, trend_slope_thresh=1.5, trend_level_thresh=5.0,
  622. cold_soak_ref_col=None, cold_soak_rotor_col=None,
  623. common_fit_window=None, common_h0_window=None, common_test_start=None,
  624. common_freq='D'):
  625. """温度 NBM 残差第二道 (SOP §4.6, 源 litloop_r4_temp_nbm)。
  626. 全场 pooled OLS `comp ~ 1 + P [+ rotor] + Tmed [+ amb]` (Tmed=同时刻全场该部件中位温, 吸收季节/环温共模),
  627. per 台残差均值 → fleet MAD-z; z≥z_thresh 升候选 (绝对温度需另判, 防 C 系基线低污染)。
  628. rotor_col=None: 无转速场 (如 max/min/avg 结构) 用 P+Tmed (P 已主导载荷, 略弱但可用)。
  629. gen_mask: 可选布尔 Series (发电态过滤, 如 f_gen>0.8)。
  630. ⚠ 参考通道存活守卫 (§1.1③/§4.6c, 2026-06-28 加, 06淄川 F0810001022 戒): amb_col 做回归特征前
  631. **逐台**经 reference_channel_liveness 验活。某台 amb 冻结/坐死 (如舱外温度卡 -0.1°C) → 先试
  632. amb_fallback_cols (舱内/塔底环境温度) 顶替; 无活备选则该台**整体剔出拟合** (冻结参考不可建模 + 污染
  633. pooled β) 并入 ref_dead = INSUFFICIENT (而非凭坏参考出假"偏热"候选)。amb_valid_range 先剔物理域外野值。
  634. amb_neighbor_median (§4.6d, 2026-07-14 海装/神木双实证 + 注入验证): True → 环温回归量弃各台自身读数,
  635. 统一用**同时刻健康邻机 amb 中位** (健康池 = liveness live ∧ |台amb−场中位|中位 ≤ amb_neighbor_dev_max)。
  636. 动机: 各台自身 amb 受安装位/机舱排热污染不一 (海装 19/32 台 |偏|>2℃, 明阳 8/33) → 逐台 amb 引入台间
  637. 伪差 (amb 虚高压残差把真热台藏住, 海装 A02; amb 全死台被 INSUFFICIENT 而实为最热, 海装 A19)。统一场参考
  638. 后台间残差严格可比, amb 死台也可建模 (不再剔出)。健康池 <3 台 → 响亮退回逐台模式 (note 警告)。
  639. 局限① (2026-07-14 双模型审 Gemini): 无法区分设备故障 vs 持续局地微气象 — 升定论必现场量。
  640. 局限② (>半数共模盲区, DP test 逮): 池准入以场中位为锚 → **>50% 台同向偏置时中位随污染走**,
  641. 偏置台 dev≈0 反入池、干净台反被剔 (与 fleet 相对法同款天花板, 不另设检测) — 环温这种场级气象量
  642. 的>半数同向硬件偏置在物理上罕见 (安装位差异是台间随机非共模), 但知边界。
  643. ★★ **批次盲 (2026-09-07 实验 K 接线)**: 本判据的 z 是 **fleet 相对**量 —— per 台残差均值减 fleet 中位后
  644. 取 MAD-z。全场同步平移时 z **逐值不变** (如东 38 台×4 通道实测 max|Δz|=0.00e+00) ⇒ **批次退化必然全绿**。
  645. 故返回值**恒含 `batch_blind`**: `flagged==[]` 只能读作"无台显著偏离机群", **不可读作"机群健康"**。
  646. 补救 = 共模图: 传 common_fit_window/common_h0_window/common_test_start 三窗 → 对 `_Tmed`(全场中位部件温)
  647. 扣除 P/环温后的序列跑时序 SPRT (共用 `_grand_chart`, 与 `fleet_two_component_split` 同一判据), 结果入
  648. `common`, 并把 batch_blind.common_chart_ran 置 True。**不传三窗 = 共模轴未测**, 此时 batch_blind 会明说。
  649. ⚠ common_h0_window **应与测试窗同历月** (季节错配会把共模图点亮); 粒度默认日级 (小时粒度 φ≈0.85 不合格)。
  650. ⚠ 共模图**不能单独定因** (批次退化 vs 场级参考量变化不可分) ⇒ 单靠它最高 准定论·预警。
  651. 返回 dict: {per_turbine: [{tid, resid_mean, resid_std, z, ewma_exc, n} ...排序], flagged: [tid...],
  652. batch_blind: {...}, common: {...}|None,
  653. n_total, ref_dead:[{tid,reason}...], ref_fallback:[{tid,col}...],
  654. amb_mode: 'neighbor'|'per_turbine'|None, amb_neighbor_excluded:[{tid,dev|reason}...]}。
  655. """
  656. # ★批次盲声明恒建 (含早返回路径): 调用方不必判断 key 是否存在
  657. batch_blind = {
  658. 'axis': 'deviation (fleet-relative)',
  659. 'blind_to': '全场同步平移 (批次退化 / 场级参考量变化)',
  660. 'why': 'z = per台残差均值 − fleet中位 后取 MAD-z; 全体同移则 z 逐值不变 (实测 max|Δz|=0.00e+00)',
  661. 'not_a_threshold_issue': True,
  662. 'common_chart_ran': False,
  663. 'read_as': '⚠ flagged 为空 ≠ 机群健康, 只等于"无台显著偏离机群"; 共模轴**未测**',
  664. }
  665. d = df if gen_mask is None else df[gen_mask]
  666. rotor = [rotor_col] if rotor_col else []
  667. # Tmed = 同时刻全场该部件中位温
  668. d = d.dropna(subset=[comp_col, p_col, turbine_col, time_col] + rotor).copy()
  669. # §1.1③/§4.6c 参考通道 per-台存活守卫: amb 冻结会把"参考坏"伪装成"部件热" (F0810001022 戒)
  670. ref_dead, ref_fallback = {}, {}
  671. amb_feat = None
  672. amb_mode = None
  673. neighbor_excluded = []
  674. if amb_col and amb_col in d.columns:
  675. amb_feat = '_amb_eff'
  676. d[amb_feat] = pd.to_numeric(d[amb_col], errors='coerce')
  677. d.loc[~d[amb_feat].between(*amb_valid_range), amb_feat] = np.nan # 物理域外野值剔
  678. if amb_neighbor_median:
  679. # §4.6d 邻机参考: 健康池 = liveness ∧ 偏差谱 |dev|≤dev_max
  680. fmed = d.groupby(time_col)[amb_feat].median()
  681. dev = (d[amb_feat] - d[time_col].map(fmed))
  682. per_dev = dev.groupby(d[turbine_col]).median()
  683. pool = []
  684. for tid, g in d.groupby(turbine_col):
  685. lv = reference_channel_liveness(g[amb_col], valid_range=amb_valid_range, min_n=min_per_turbine)
  686. dv = per_dev.get(tid)
  687. if lv['live'] is not True:
  688. neighbor_excluded.append({'tid': str(tid), 'reason': lv['reason']})
  689. elif dv is None or pd.isna(dv) or abs(dv) > amb_neighbor_dev_max:
  690. neighbor_excluded.append({'tid': str(tid), 'dev': (None if dv is None or pd.isna(dv) else round(float(dv), 2))})
  691. else:
  692. pool.append(tid)
  693. if len(pool) >= 3:
  694. ref = d[d[turbine_col].isin(pool)].groupby(time_col)[amb_feat].median()
  695. d[amb_feat] = d[time_col].map(ref) # 所有台统一场参考 (amb 死台也可建模)
  696. amb_mode = 'neighbor'
  697. else: # 守护失败必响亮: 池太小退回逐台, 不静默
  698. neighbor_excluded.append({'tid': '_POOL_TOO_SMALL_', 'reason': f'健康amb池仅{len(pool)}台(<3), 退回逐台模式'})
  699. amb_neighbor_median = False
  700. if not amb_neighbor_median:
  701. amb_mode = 'per_turbine'
  702. fbs = [c for c in (amb_fallback_cols or []) if c in d.columns]
  703. for tid, g in d.groupby(turbine_col):
  704. lv = reference_channel_liveness(g[amb_col], valid_range=amb_valid_range, min_n=min_per_turbine)
  705. if lv['live'] is True:
  706. continue
  707. used = next((fb for fb in fbs
  708. if reference_channel_liveness(g[fb], valid_range=amb_valid_range,
  709. min_n=min_per_turbine)['live'] is True), None)
  710. if used:
  711. d.loc[g.index, amb_feat] = pd.to_numeric(d.loc[g.index, used], errors='coerce')
  712. ref_fallback[str(tid)] = used
  713. else:
  714. ref_dead[str(tid)] = lv['reason']
  715. if ref_dead: # 死参考台整体剔出拟合 → 入 ref_dead (INSUFFICIENT), 不出假候选
  716. d = d[~d[turbine_col].astype(str).isin(ref_dead)]
  717. rd = [{'tid': t, 'reason': r} for t, r in ref_dead.items()]
  718. rf = [{'tid': t, 'col': c} for t, c in ref_fallback.items()]
  719. med = d.groupby(time_col)[comp_col].median().rename('_Tmed')
  720. d = d.join(med, on=time_col)
  721. feats = [p_col] + rotor + ['_Tmed'] + ([amb_feat] if amb_feat else [])
  722. d = d.dropna(subset=feats)
  723. if len(d) < 5000:
  724. return {'per_turbine': [], 'flagged': [], 'n_total': 0, 'note': '样本不足(<5000)',
  725. 'batch_blind': batch_blind, 'common': None, 'ref_dead': rd, 'ref_fallback': rf, 'amb_mode': amb_mode, 'amb_neighbor_excluded': neighbor_excluded}
  726. Xa = np.column_stack([np.ones(len(d))] + [d[f].values.astype(float) for f in feats])
  727. beta, *_ = np.linalg.lstsq(Xa, d[comp_col].values.astype(float), rcond=None)
  728. d['_resid'] = d[comp_col].values.astype(float) - Xa @ beta
  729. agg = d.groupby(turbine_col).agg(resid_mean=('_resid', 'mean'), resid_std=('_resid', 'std'),
  730. n=('_resid', 'size')).reset_index().rename(columns={turbine_col: 'tid'})
  731. agg = agg[agg.n >= min_per_turbine]
  732. if agg.empty:
  733. return {'per_turbine': [], 'flagged': [], 'n_total': 0, 'note': 'per台样本不足',
  734. 'batch_blind': batch_blind, 'common': None, 'ref_dead': rd, 'ref_fallback': rf, 'amb_mode': amb_mode, 'amb_neighbor_excluded': neighbor_excluded}
  735. mad = (agg.resid_mean - agg.resid_mean.median()).abs().median() * 1.4826
  736. agg['z'] = (agg.resid_mean - agg.resid_mean.median()) / max(mad, 1e-6)
  737. ewma_map = {str(t): ewma_exceed(g.sort_values(time_col)['_resid'])
  738. for t, g in d.groupby(turbine_col) if len(g) >= min_per_turbine}
  739. agg['ewma_exc'] = agg['tid'].astype(str).map(ewma_map)
  740. # 时间趋势门 (SOP §4.6, W091 教训): 逐台残差轨迹 → developing (补年 z 埋没发展性故障)
  741. trend_map = ({str(t): residual_trend(g['_resid'], g[time_col],
  742. slope_thresh=trend_slope_thresh, level_thresh=trend_level_thresh)
  743. for t, g in d.groupby(turbine_col) if len(g) >= min_per_turbine}
  744. if trend_gate else {})
  745. agg = agg.sort_values('z', ascending=False)
  746. rows = [{'tid': str(r.tid), 'resid_mean': round(float(r.resid_mean), 2),
  747. 'resid_std': round(float(r.resid_std), 2), 'z': round(float(r.z), 2),
  748. 'ewma_exc': (None if pd.isna(r.ewma_exc) else round(float(r.ewma_exc), 3)),
  749. 'n': int(r.n), 'trend': trend_map.get(str(r.tid))} for r in agg.itertuples(index=False)]
  750. flagged = [r['tid'] for r in rows if r['z'] >= z_thresh]
  751. # developing = 时间门命中但年 z 未必过 (被年聚合埋的发展性台, 单列出防漏)
  752. developing = [r['tid'] for r in rows if (r.get('trend') or {}).get('developing')]
  753. # ★闭环: developing 命中自动跑冷浸审 (平陆20#教训 —— developing残差 传感器漂移与真故障同形)
  754. # 冷浸 rotor 独立于回归 rotor_col (回归可不含转速, 但冷浸必须 rotor 检停机)
  755. csr = cold_soak_rotor_col or rotor_col
  756. developing_adjudicated = {}
  757. if developing and cold_soak_ref_col and csr and cold_soak_ref_col in df.columns and csr in df.columns:
  758. cs = cold_soak_offset(df, comp_col=comp_col, ref_col=cold_soak_ref_col, rotor_col=csr,
  759. turbine_col=turbine_col, time_col=time_col, p_col=p_col)
  760. cs_map = {r['tid']: r for r in cs.get('per_turbine', [])}
  761. cs_flag = set(cs.get('flagged', []))
  762. cs_avail = bool(cs.get('per_turbine'))
  763. for tid in developing:
  764. det = cs_map.get(tid)
  765. if not cs_avail or det is None:
  766. verdict = 'INSUFFICIENT' # 无长停机/该台无冷浸样本 → 判不了
  767. elif tid in cs_flag:
  768. verdict = 'sensor_suspect' # 静止仍偏(尤其晚>早) → 传感器漂移嫌疑, 非真发热
  769. else:
  770. verdict = 'real_candidate' # 冷浸态不偏 → developing 更可能真部件
  771. developing_adjudicated[tid] = {'cold_soak_verdict': verdict,
  772. 'cold_recent': (det or {}).get('cold_recent'),
  773. 'dev_offset': (det or {}).get('dev_offset')}
  774. for r in rows: # 回填进 per_turbine.trend
  775. if r['tid'] == tid and isinstance(r.get('trend'), dict):
  776. r['trend']['cold_soak'] = developing_adjudicated[tid]
  777. elif developing:
  778. for tid in developing:
  779. developing_adjudicated[tid] = {'cold_soak_verdict': 'not_checked',
  780. 'note': '缺 cold_soak_ref_col/rotor_col → developing 未过冷浸审, 须人工跑'}
  781. # ---- 共模图接线 (实验 K): 判据正本 = _grand_chart, 此处只备料不另写判据 ----
  782. common, cnote = None, []
  783. if common_fit_window and common_h0_window and common_test_start:
  784. fl = d.groupby(time_col).agg(_tm=('_Tmed', 'first'), _p=(p_col, 'median'))
  785. if amb_feat:
  786. fl['_a'] = d.groupby(time_col)[amb_feat].median()
  787. fl.index = pd.to_datetime(fl.index)
  788. fl = fl.resample(common_freq).median().dropna()
  789. flo, fhi = pd.Timestamp(common_fit_window[0]), pd.Timestamp(common_fit_window[1])
  790. f = fl[(fl.index >= flo) & (fl.index < fhi)]
  791. if len(f) < 60:
  792. common = {'decision': 'insufficient', 'verdict': 'INSUFFICIENT', 'candidate': False,
  793. 'reason': f'共模拟合窗样本 {len(f)}<60'}
  794. else:
  795. cols = ['_p'] + (['_a'] if '_a' in fl.columns else [])
  796. X = np.c_[np.ones(len(f)), f[cols].values]
  797. beta, *_ = np.linalg.lstsq(X, f['_tm'].values, rcond=None)
  798. Xa = np.c_[np.ones(len(fl)), fl[cols].values]
  799. gser = pd.Series(fl['_tm'].values - Xa @ beta, index=fl.index)
  800. common, _ = _grand_chart(gser, common_h0_window, common_test_start, note=cnote)
  801. batch_blind['common_chart_ran'] = True
  802. batch_blind['read_as'] = (
  803. f'共模轴已测: verdict={common.get("verdict")}. '
  804. + {'batch_shift_candidate': '★全场同步漂移候选 — deviation 轴看不见它, 勿因 flagged 为空判健康',
  805. 'no_change': 'flagged 为空 + 共模无变 ⇒ 可读作"该窗未见机群级异常"(仍非绝对健康: 共模图不能与场级参考量变化区分)',
  806. 'INSUFFICIENT': '⚠ 共模轴阴性**不可信** (样本不足或 φ→1 致阶跃被白化压没), 不等于"没有批次漂"'}
  807. .get(common.get('verdict'), ''))
  808. if cnote:
  809. batch_blind['common_note'] = cnote
  810. return {'per_turbine': rows, 'flagged': flagged, 'developing': developing,
  811. 'developing_adjudicated': developing_adjudicated, 'n_total': len(rows),
  812. 'batch_blind': batch_blind, 'common': common,
  813. 'ref_dead': rd, 'ref_fallback': rf, 'amb_mode': amb_mode, 'amb_neighbor_excluded': neighbor_excluded}
  814. # ---------------------------------------------------------------- §1.2 CT-2 跨列全等/别名
  815. def near_equal_columns(df, cols=None, rel_tol=0.01, min_frac=0.99, min_overlap=500,
  816. corr_thresh=0.9999, slope_tol=0.02, intercept_tol=0.5):
  817. """CT-2 跨列全等/线性别名检测 (SOP §1.2, 双模型评审 2026-06-16). **WARN 级, 非 hard-fail**.
  818. 防别名/重名列被当两路独立证据(假冗余"多源印证", 如金华山 t_mainbrg≡t_genbrg 99.99%)。
  819. 两路检测(双模型评审): ① strict 近等 frac(|a-b|/avg<rel_tol)≥min_frac
  820. ② linear 别名 corr≈1 ∧ OLS slope≈1 ∧ intercept≈0 (抓同传感器不同标定 b=a+5/b=1.01a, 纯近等判据漏的)。
  821. 跳过真死列(std≈0)与时间列; 命名提示独立物理点(DE/NDE/_1/_2/L/R/相)→标低置信"需现场确认"。
  822. 别名也可能是硬件冗余设计 → 输出候选供人工结合点位表确认, 不作报告作废。
  823. 返回 [{col_a, col_b, kind, equal_frac, corr, note}] (按 equal_frac 降序)。
  824. """
  825. import re
  826. num = df.select_dtypes(include='number')
  827. cand = [c for c in (cols or list(num.columns)) if c in num.columns]
  828. time_re = re.compile(r'(^t\d*$|time|timestamp|^ts$|日期|时间)', re.I)
  829. indep_re = re.compile(r'(_de\b|_nde\b|_[12]\b|_[lr]\b|相[uvw]|[uvw]相|north|south|前|后|左|右)', re.I)
  830. live = [c for c in cand if not time_re.search(str(c))
  831. and int(num[c].notna().sum()) > min_overlap and float(num[c].std(skipna=True) or 0) > 1e-9]
  832. out = []
  833. for i in range(len(live)):
  834. ai = num[live[i]]
  835. for j in range(i + 1, len(live)):
  836. ca, cb = live[i], live[j]
  837. m = ai.notna() & num[cb].notna()
  838. if int(m.sum()) < min_overlap:
  839. continue
  840. a = ai[m].to_numpy(float)
  841. b = num[cb][m].to_numpy(float)
  842. denom = (np.abs(a) + np.abs(b)) / 2.0 + 1e-9
  843. eq_frac = float(np.mean(np.abs(a - b) / denom < rel_tol))
  844. sa, sb = a.std(), b.std()
  845. corr = float(np.corrcoef(a, b)[0, 1]) if sa > 0 and sb > 0 else 0.0
  846. kind = None
  847. if eq_frac >= min_frac:
  848. kind = 'near_equal'
  849. elif corr > corr_thresh:
  850. slope, intercept = np.polyfit(a, b, 1)
  851. if abs(slope - 1) < slope_tol and abs(intercept) < intercept_tol * (sa + 1e-9):
  852. kind = 'linear_alias'
  853. if kind:
  854. indep = bool(indep_re.search(str(ca)) or indep_re.search(str(cb)))
  855. out.append({'col_a': ca, 'col_b': cb, 'kind': kind,
  856. 'equal_frac': round(eq_frac, 4), 'corr': round(corr, 5),
  857. 'note': ('命名提示独立物理点→现场确认(可能真相近非别名)' if indep
  858. else '疑似同源复制→勿当两路独立证据')})
  859. return sorted(out, key=lambda r: -r['equal_frac'])
  860. # ---------------------------------------------------------------- §1.2 CT-3 功率通道相对缩放 mis-scale 守卫
  861. def power_channel_scale_check(df, *, grid_col, gen_col, turbine_col=None,
  862. gen_floor_kw=100.0, misscale_gate=0.5, normal_band=(0.85, 1.15),
  863. const_iqr_gate=0.10, min_pts=200):
  864. """功率通道相对缩放 mis-scale 守卫 (SOP §1.2 CT-3, **WARN 级 DQ, 非 hard-fail**; [暂行] n=1 海装 09肥城, 2026-06-28).
  865. 业主端 CT 变比/单位/导出缩放配置错 → 某功率列被整体压成真值的 1/k (海装 09肥城 A11/A12: k≈5.16,
  866. 电网有功 max 424kW vs 发电机有功 2150kW)。这种**纯比例**错读不越物理上限、不触 range/IQR/stuck 清洗门,
  867. 却污染一切功率分档分析 —— 实证 A12 转速比假 WATCH = 用被压电网功率分档把高功率点挤进低档错配速度区
  868. (scripts/sop_adversarial/feicheng_a12_rotratio_cleanpower_rerun.py: gen/rot 偏 +48%→+0.02% 翻案)。
  869. 本守卫: 发电区 (gen_col>gen_floor_kw, 用**参照功率**门避免被压列自污) 比 median(grid/gen);
  870. 偏离 1.0 即标 mis-scale, 给 implied_scale=1/ratio 与 ratio 的 IQR/median (恒定性: ≤const_iqr_gate
  871. = 纯常数缩放, 支持常数矫正; 否则非常数 → 须查别因不可裸 ×k)。
  872. turbine_col 给定 → per 台逐查 (机群里只**部分**台 mis-scale 是常态: 海装 33 台仅 2 台)。
  873. ⚠ 命中处置 = 该台该列**禁用于功率分档**, 改用干净的同义功率列 (发电机有功/有功功率); 既有干净原生列时
  874. 优先换列 (a) 而非反推 CT 变比 ×k (b)。grid_col≡gen_col(传同一列)→ratio≈1 不误报。
  875. ⚠ [暂行] n=1: 仅海装 1 场实证, < §0.3 跨场准入线, 暂不进 §4 canon 判据面; 但机制 (CT变比/单位/导出缩放)
  876. farm-agnostic, 跨场复现到 n≥2 再升 canon。
  877. 入: df (单台或多台长表); grid_col 待查功率列; gen_col 参照功率列 (发电机有功, 须干净); turbine_col 可选。
  878. 出 dict: {per_turbine:{tid:{ratio_median, implied_scale, ratio_iqr_over_med, constant_scale,
  879. grid_max, gen_max, n_gen, verdict}}, misscaled:[tid...], n_checked, n_misscaled, verdict, note};
  880. verdict ∈ {mis-scale, 正常, 可疑(部分偏), 样本不足}。
  881. """
  882. if grid_col not in df.columns or gen_col not in df.columns:
  883. return {'per_turbine': {}, 'misscaled': [], 'n_checked': 0, 'n_misscaled': 0,
  884. 'verdict': '样本不足', 'note': f'缺列 grid={grid_col in df.columns} gen={gen_col in df.columns}'}
  885. def _one(sub):
  886. grid = pd.to_numeric(sub[grid_col], errors='coerce')
  887. gen = pd.to_numeric(sub[gen_col], errors='coerce')
  888. m = grid.notna() & gen.notna() & (gen > gen_floor_kw) # 发电区门用参照功率 (被压列不自污)
  889. n_gen = int(m.sum())
  890. rec = {'ratio_median': None, 'implied_scale': None, 'ratio_iqr_over_med': None,
  891. 'constant_scale': None, 'grid_max': (float(grid.max()) if grid.notna().any() else None),
  892. 'gen_max': (float(gen.max()) if gen.notna().any() else None), 'n_gen': n_gen,
  893. 'verdict': '样本不足'}
  894. if n_gen < min_pts:
  895. return rec
  896. r = (grid[m] / gen[m]).replace([np.inf, -np.inf], np.nan).dropna()
  897. if len(r) < min_pts:
  898. return rec
  899. med = float(r.median())
  900. iqr = float(r.quantile(0.75) - r.quantile(0.25))
  901. rec['ratio_median'] = round(med, 4)
  902. rec['ratio_iqr_over_med'] = round(iqr / med, 4) if med else None
  903. rec['implied_scale'] = round(1.0 / med, 4) if med else None
  904. rec['constant_scale'] = bool(rec['ratio_iqr_over_med'] is not None
  905. and rec['ratio_iqr_over_med'] <= const_iqr_gate)
  906. if med < misscale_gate:
  907. rec['verdict'] = 'mis-scale'
  908. elif normal_band[0] <= med <= normal_band[1]:
  909. rec['verdict'] = '正常'
  910. else:
  911. rec['verdict'] = '可疑(部分偏)'
  912. return rec
  913. per = {}
  914. if turbine_col and turbine_col in df.columns:
  915. for tid, sub in df.groupby(turbine_col):
  916. per[str(tid)] = _one(sub)
  917. else:
  918. per['_all'] = _one(df)
  919. misscaled = sorted(t for t, r in per.items() if r['verdict'] == 'mis-scale')
  920. suspect = [t for t, r in per.items() if r['verdict'] == '可疑(部分偏)']
  921. checked = [t for t, r in per.items() if r['verdict'] != '样本不足']
  922. if misscaled:
  923. verdict = 'mis-scale'
  924. elif suspect:
  925. verdict = '可疑(部分偏)'
  926. elif checked:
  927. verdict = '正常'
  928. else:
  929. verdict = '样本不足'
  930. scales = [per[t]['implied_scale'] for t in misscaled if per[t]['implied_scale']]
  931. note = (f"{len(misscaled)}/{len(checked)} 台 {grid_col} 被压 (median(grid/gen)<{misscale_gate}); "
  932. f"implied k≈{np.median(scales):.2f}; 处置=换用干净功率列做功率分档" if misscaled
  933. else f"{len(checked)} 台 {grid_col} 相对 {gen_col} 缩放正常 (~1.0)")
  934. return {'per_turbine': per, 'misscaled': misscaled, 'n_checked': len(checked),
  935. 'n_misscaled': len(misscaled), 'verdict': verdict, 'note': note}
  936. # ---------------------------------------------------------------- §1.2 CT-4 孪生温度通道互换筛
  937. def channel_swap_screen(df, pairs, *, turbine_col, gen_mask=None, min_per_turbine=1000,
  938. swap_win_ratio=0.5, anti_min=2.0, swap_abs_max=3.0):
  939. """孪生温度通道互换(接线/映射 swap)筛 (SOP §1.2 CT-4, **WARN 级 DQ, 非 hard-fail**;
  940. [暂行] n=2 场 2026-07-06: mawang 450102036 (DE/NDE, 原 ⭐确证经此翻案为镜像) +
  941. wangjiawan W006/026/024; hefeng #18/#19 = 共热负样本, 未误报).
  942. 成对温度通道(发电机 DE/NDE 轴承、_a/_b 等)被现场接线或点位映射**互换** → 某台一端逆机群
  943. 基线偏热、另一端对称偏冷(反对称)。误当"局部单端真发热/强特异"会派现场核**健康**部件
  944. (mawang 036 原裁决即此坑)。**判据 = swap-match**: 交换该台两通道后是否落回机群(swap 残差 ≪ no-swap 残差)。
  945. ⚠ **基线方向从机群实测, 禁假设哪端更热** (wangjiawan DE>NDE / mawang·hefeng NDE>DE, 相反!)。
  946. ⚠ **共热(两端同向偏)= 真部件/子系统热, 非 swap** (hefeng 三相+双轴承齐热=真); 单通道无配对=免疫。
  947. ⚠ **仅出候选供点位表确认, 不作废/不改数据** (WARN; 单手段不终结推翻确证级裁决 — 见 memory
  948. scada-residual-claim-discipline)。基线无方向(|fleet_d|<σ, 如对称三相)→ 跳过(swap 不可辨)。
  949. pairs: [(col_a, col_b), ...] 已知物理配对通道 (调用方给, 不猜)。
  950. gen_mask: 可选发电态布尔 Series (温度须发电态比)。
  951. swap_win_ratio: swap残差 < swap_win_ratio × no_swap残差 才算"交换更优"。
  952. anti_min: 该台 (a−b) 距机群中位 ≥ anti_min 稳健σ 且**反号** 才算真反对称(非噪声)。
  953. swap_abs_max: swap残差绝对上限(°C), 落机群噪声内才算 clean swap。
  954. 返回 [{turbine, pair, d, fleet_d, anti_sigma, swap_resid, no_swap_resid, ratio, verdict}]
  955. (仅 swap 嫌疑入候选, 按 |anti_sigma| 降序)。
  956. """
  957. d = df if gen_mask is None else df[gen_mask]
  958. out = []
  959. for ca, cb in pairs:
  960. if ca not in d.columns or cb not in d.columns:
  961. continue
  962. sub = d.dropna(subset=[ca, cb, turbine_col])
  963. g = sub.groupby(turbine_col)
  964. n = g.size()
  965. keep = n[n >= min_per_turbine].index
  966. if len(keep) < 5: # 机群太小无法立基线
  967. continue
  968. a = g[ca].median().loc[keep] # per 台 中位
  969. b = g[cb].median().loc[keep]
  970. diff = a - b
  971. m_d = float(diff.median()) # 机群基线 (稳健, 中位抗少数 swap)
  972. s_d = max(float((diff - m_d).abs().median()) * 1.4826, 1e-6)
  973. if abs(m_d) < s_d: # 基线无方向 (对称对, swap 不可辨) → 跳过
  974. continue
  975. fleet_a = float(a.median()); fleet_b = float(b.median())
  976. for t in keep:
  977. dt = float(diff[t])
  978. if np.sign(dt) == np.sign(m_d) or abs(dt - m_d) < anti_min * s_d:
  979. continue # 非反对称 (含共热/正常) → 不入候选
  980. no_swap = max(abs(float(a[t]) - fleet_a), abs(float(b[t]) - fleet_b))
  981. swap = max(abs(float(b[t]) - fleet_a), abs(float(a[t]) - fleet_b))
  982. if swap < swap_win_ratio * no_swap and swap <= swap_abs_max:
  983. out.append({'turbine': str(t), 'pair': f'{ca}|{cb}',
  984. 'd': round(dt, 2), 'fleet_d': round(m_d, 2),
  985. 'anti_sigma': round((dt - m_d) / s_d, 2),
  986. 'swap_resid': round(float(swap), 2),
  987. 'no_swap_resid': round(float(no_swap), 2),
  988. 'ratio': round(float(swap / max(no_swap, 1e-6)), 3),
  989. 'verdict': 'swap嫌疑(待点位表确认)'})
  990. return sorted(out, key=lambda r: -abs(r['anti_sigma']))
  991. # ---------------------------------------------------------------- §4.1 模块1.1 可用率多口径
  992. def availability_calibers(df, *, ws_col, p_col, rated, cutin=None, cutout=25.0,
  993. gen_frac=0.02, pba_min_op=500):
  994. """可用率多口径 A_WIL + A_PBA (SOP §4.1 模块1.1; 源 litloop_r1_availability, RV-2 搬真逻辑不改判据).
  995. A_WIL (DNV wind-in-limits) = 发电时间 / 风在[cutin,cutout]内时间 — 损失因子用此 (非全时段 A_op)。
  996. A_PBA proxy (IEC 61400-26) = E_actual / E_potential, 潜在=该台自身运行态分bin中位曲线
  997. (停机段机舱风转移函数不同 → caveat, 标参考)。
  998. A_op (运行口径) 因状态源 per 场变体 (官方码/状态派生/OEM内置) **不在此抽**, 留 per-farm 计算后传入比对
  999. (§0.3 变体面未收敛)。零发电×有风红旗 同理不在此抽: **两函数的实现均已在本模块落地** —— builtin_avail_verify 见 §4.3 内置可利用率列(先验真), zero_gen_redflag 见同批(n≥2 提级); 本处仅说明"不入 A_op 抽取面、留 per-farm 传入比对"。
  1000. cutin=None 时经验派生 (最低风速bin中 ≥50% 样本发电的bin下缘, 缺则 3.0); gen_frac: 发电态门=P>gen_frac·rated。
  1001. 返回 {cutin, a_wil, n_wil, a_pba}; a_* 为 NaN 表样本不足。
  1002. """
  1003. d = df.dropna(subset=[ws_col, p_col])
  1004. gen_thr = gen_frac * rated
  1005. if cutin is None: # 经验切入风速 (同源 derive_cutin)
  1006. lab = pd.cut(d[ws_col], np.arange(0, 10.25, 0.25))
  1007. frac = d.groupby(lab, observed=True)[p_col].apply(lambda s: (s > gen_thr).mean())
  1008. ok = frac[frac >= 0.5]
  1009. cutin = float(ok.index[0].left) if len(ok) else 3.0
  1010. inlim = d[(d[ws_col] >= cutin) & (d[ws_col] <= cutout)] # A_WIL (同源 wil_availability)
  1011. if len(inlim) == 0:
  1012. a_wil, n_wil = float('nan'), 0
  1013. else:
  1014. a_wil, n_wil = float((inlim[p_col] > gen_thr).mean()), int(len(inlim))
  1015. op = d[d[p_col] > gen_thr] # A_PBA proxy (同源 pba_proxy)
  1016. if len(op) < pba_min_op:
  1017. a_pba = float('nan')
  1018. else:
  1019. bins_p = np.arange(0, 26.5, 0.5)
  1020. dd = d.assign(_bin=pd.cut(d[ws_col], bins_p))
  1021. curve = op.assign(_bin=pd.cut(op[ws_col], bins_p)).groupby('_bin', observed=True)[p_col].median()
  1022. pot = dd['_bin'].map(curve).astype(float).where(dd[ws_col] >= cutin, 0.0) # 切入下潜在=0
  1023. act = dd[p_col].clip(lower=0)
  1024. pot = np.maximum(pot, act) # 潜在≥实发 (防曲线低估>100%)
  1025. a_pba = float(act.sum() / pot.sum()) if pot.sum() > 0 else float('nan')
  1026. return {'cutin': round(float(cutin), 2), 'a_wil': a_wil, 'n_wil': n_wil, 'a_pba': a_pba}
  1027. # ================================================================ ④ 部件健康 (3.1-3.6, loop训练硬化 2026-06-20)
  1028. # 本批 7 函数抽自跨场实跑 bespoke (RV-2 搬真逻辑不改判据); 数据受限模块 (3.3 MCSA/3.4 油液) = 诚实数据门, 禁假"健康"。
  1029. # 合成已知真值见 scripts/test_discriminators.py。
  1030. # ---------------------------------------------------------------- §4.6 模块3.1 matched-load 双判 (NBM 盲区兜底)
  1031. def matched_load_overheat(df, *, comp_cols, p_col, turbine_col, time_col, rated,
  1032. gen_mask=None, p_lo_frac=0.30, p_hi_frac=0.95, bin_kw=100.0,
  1033. min_cell=30, min_bins=3, dt_z_thresh=3.0, dt_abs_floor=2.0,
  1034. hot_dev_c=1.5, cluster_day_frac=0.5, cluster_hot_z=2.5,
  1035. min_cluster_days=30, comp_valid_range=(-40.0, 200.0)):
  1036. """温度 matched-load 双判 — NBM 盲区兜底 (SOP §4.6/模块3.1, 源 lsq_temp_matchedload, 凉水泉 n=1暂行)。
  1037. ⭐ nbm_residual 是机群**极值**检测器: 中度(+4-6°C)/多部件/簇状升温被宽 fleet 残差散布稀释成
  1038. MAD-z<2 → 漏检 (凉水泉 2208F 戒: 齿油/主轴/高速轴各 +4°C, 单部件 NBM z<2, 险被'更先进判别器'erase)。
  1039. 本函数 = 其【兜底】(非替代): 不靠残差散布, 直接看同载荷温差 + 簇状, 逮 NBM 极值盲区。
  1040. 双路 (任一升=候选, 与 nbm_residual 互补不重复):
  1041. ① ΔT-z 路径 (逮单部件强升温, 2205F/2109F 型): 同功率 bin (p_lo_frac~p_hi_frac×rated, bin_kw 分箱)
  1042. 内 per台部件温中位 − fleet 同 bin 中位 = dev; 跨多 bin 取中位 ΔT (同功率比, 排载荷混杂);
  1043. fleet MAD-z≥dt_z_thresh ∧ |ΔT|≥dt_abs_floor。 (z≥3 故意严于 nbm z≥2: 本=corroborating 第二判)
  1044. ② 簇状路径 (逮中度多部件, 2208F 型): per台 per日 部件日中位偏热>hot_dev_c 的部件数;
  1045. (≥3部件齐热 ∧ 占比≥cluster_day_frac) ∨ (≥2部件 ∧ 其一 ΔT-z≥cluster_hot_z ∧ 占比≥cluster_day_frac+0.1)。
  1046. 阈值 RULE-3 依据: hot_dev_c=1.5°C=日中位噪声带上沿; dt_z_thresh=3 (±3σ-class, 严于 NBM);
  1047. dt_abs_floor=2°C 防小散布场 z 虚高; 实测 fleet ΔT MAD 1.8-3.3°C (n=凉水泉)。
  1048. comp_cols: 部件温列名 list (齿油/主轴承/高速轴/发电机轴承等); rated: 铭牌功率 kW;
  1049. gen_mask: 可选发电态布尔 Series。⚠ ΔT=相对 fleet (非 OEM 绝对阈), 上限【预警】候选。
  1050. ⚠ 被排名通道存活守卫 (§4.6c, 2026-06-28 加): 每个部件温列**逐台**经 reference_channel_liveness 验活,
  1051. 冻结/坐死的列 (comp_valid_range 先剔野值) 把该台从 ΔT 排名与簇状计数中剔出 (入 dead_channel),
  1052. 防"传感器坏"伪装"部件热" (同 nbm_residual 的 amb 守卫, 06淄川 F0810001022 戒)。
  1053. 返回 dict: {per_turbine: {tid: {components:{col:{dT,z}}, n_hot_components, cluster_day_frac_ge2/ge3,
  1054. max_dt_c, max_dt_z, flag, reason}}, flagged:[tid...按 max_dt_z 降序], n_components,
  1055. n_power_bins_used, dead_channel:[{comp,tids}...], caveat}。
  1056. """
  1057. d = df if gen_mask is None else df[gen_mask]
  1058. comp_cols = [c for c in comp_cols if c in d.columns]
  1059. if not comp_cols or p_col not in d.columns or turbine_col not in d.columns:
  1060. return {'per_turbine': {}, 'flagged': [], 'n_components': 0, 'note': '无有效部件温列/功率列'}
  1061. p_all = pd.to_numeric(d[p_col], errors='coerce')
  1062. band = d[(p_all >= p_lo_frac * rated) & (p_all <= p_hi_frac * rated)].copy()
  1063. if band.empty:
  1064. return {'per_turbine': {}, 'flagged': [], 'n_components': 0, 'note': '匹配功率带内无样本'}
  1065. band['_pb'] = np.round(pd.to_numeric(band[p_col], errors='coerce') / bin_kw) * bin_kw
  1066. # 路径①: 同功率 bin ΔT (fleet 同 bin 中位为基线, 跨 bin 取中位 → 排载荷混杂) + per部件 MAD-z
  1067. comp_dt, comp_z, dead_channel = {}, {}, {}
  1068. for c in comp_cols:
  1069. sub = band.dropna(subset=[c, '_pb', turbine_col])
  1070. if sub.empty:
  1071. continue
  1072. # §4.6c per-台被排名通道存活守卫: 冻结的部件温会把"传感器坏"伪装成 ΔT 离群 → 剔出排名+簇状
  1073. dead = {str(t) for t, gc in sub.groupby(turbine_col)
  1074. if reference_channel_liveness(gc[c], valid_range=comp_valid_range, min_n=200)['live'] is False}
  1075. if dead:
  1076. dead_channel[c] = sorted(dead)
  1077. sub = sub[~sub[turbine_col].astype(str).isin(dead)]
  1078. if sub.empty:
  1079. continue
  1080. g = sub.groupby(['_pb', turbine_col])[c].agg(med='median', nn='size').reset_index()
  1081. g = g[g['nn'] >= min_cell]
  1082. if g.empty:
  1083. continue
  1084. fl = g.groupby('_pb')['med'].median().rename('fleet')
  1085. g = g.merge(fl, on='_pb')
  1086. g['dev'] = g['med'] - g['fleet']
  1087. nb = g.groupby(turbine_col)['_pb'].nunique()
  1088. dt = g.groupby(turbine_col)['dev'].median()
  1089. dt = dt[nb >= min_bins] # 须跨≥min_bins 个功率 bin (单 bin 不算)
  1090. if dt.empty:
  1091. continue
  1092. comp_dt[c] = dt
  1093. m = dt.median()
  1094. mad = (dt - m).abs().median() * 1.4826
  1095. comp_z[c] = (dt - m) / max(mad, 1e-6)
  1096. # 路径②: 多部件同热天占比 (簇状; per时刻 fleet 中位为基线 → per日中位偏热部件数)
  1097. cl_frac3, cl_frac2 = {}, {}
  1098. if len(comp_cols) >= 2:
  1099. gg = d.copy()
  1100. dev_cols = []
  1101. for c in comp_cols:
  1102. fl = gg.groupby(time_col)[c].transform('median')
  1103. gg['_dv_' + c] = pd.to_numeric(gg[c], errors='coerce') - fl
  1104. dev_cols.append('_dv_' + c)
  1105. gg['_day'] = pd.to_datetime(gg[time_col]).dt.date
  1106. for tid, s in gg.groupby(turbine_col):
  1107. # 死通道 (路径①守卫所逮) 不进簇状计数: 高位冻结的部件温会假装"天天偏热"
  1108. cols_ok = [dc for dc in dev_cols if str(tid) not in dead_channel.get(dc[4:], ())]
  1109. if not cols_ok:
  1110. continue
  1111. s = s.dropna(subset=cols_ok)
  1112. if s.empty:
  1113. continue
  1114. byday = s.groupby('_day')[cols_ok].median()
  1115. if len(byday) < min_cluster_days: # 天数不足 → 不出簇状判 (防短窗假占比)
  1116. continue
  1117. n_hot = (byday > hot_dev_c).sum(axis=1)
  1118. cl_frac3[str(tid)] = float((n_hot >= 3).mean())
  1119. cl_frac2[str(tid)] = float((n_hot >= 2).mean())
  1120. all_t = set()
  1121. for dt in comp_dt.values():
  1122. all_t |= {str(x) for x in dt.index}
  1123. all_t |= set(cl_frac2)
  1124. out, flagged = {}, []
  1125. for tid in sorted(all_t):
  1126. comps, max_z, max_dt, hot_zs = {}, -np.inf, -np.inf, []
  1127. for c in comp_cols:
  1128. if c in comp_dt and tid in comp_dt[c].index:
  1129. v = float(comp_dt[c].loc[tid]); z = float(comp_z[c].loc[tid])
  1130. comps[c] = {'dT': round(v, 1), 'z': round(z, 2)}
  1131. max_z = max(max_z, z); max_dt = max(max_dt, v)
  1132. if v > hot_dev_c:
  1133. hot_zs.append(z)
  1134. n_hot = len(hot_zs)
  1135. f3, f2 = cl_frac3.get(tid), cl_frac2.get(tid)
  1136. dt_flag = (max_z >= dt_z_thresh) and (max_dt >= dt_abs_floor)
  1137. cluster_flag = ((n_hot >= 3 and f3 is not None and f3 >= cluster_day_frac) or
  1138. (n_hot >= 2 and max(hot_zs or [-9]) >= cluster_hot_z
  1139. and f2 is not None and f2 >= cluster_day_frac + 0.1))
  1140. is_flag = bool(dt_flag or cluster_flag)
  1141. rs = []
  1142. if dt_flag:
  1143. rs.append(f'单部件ΔT-z≥{dt_z_thresh:g}(maxΔT{max_dt:+.1f}°C,z{max_z:+.1f})')
  1144. if cluster_flag:
  1145. rs.append(f'簇状{n_hot}部件同热{(f3 if n_hot>=3 else f2):.0%}天(每部件>{hot_dev_c:g}°C)')
  1146. out[tid] = {'components': comps, 'n_hot_components': n_hot,
  1147. 'cluster_day_frac_ge2': (None if f2 is None else round(f2, 2)),
  1148. 'cluster_day_frac_ge3': (None if f3 is None else round(f3, 2)),
  1149. 'max_dt_c': (None if max_dt == -np.inf else round(max_dt, 1)),
  1150. 'max_dt_z': (None if max_z == -np.inf else round(max_z, 2)),
  1151. 'flag': is_flag, 'reason': ('; '.join(rs) if rs else '无升温签名')}
  1152. if is_flag:
  1153. flagged.append(tid)
  1154. flagged.sort(key=lambda t: -(out[t]['max_dt_z'] or -9))
  1155. dch = [{'comp': c, 'tids': ts} for c, ts in dead_channel.items()]
  1156. return {'per_turbine': out, 'flagged': flagged, 'n_components': len(comp_dt),
  1157. 'n_power_bins_used': int(band['_pb'].nunique()), 'dead_channel': dch,
  1158. 'caveat': 'matched-load=NBM极值检测器盲区兜底(逮中度+4-6°C/多部件/簇状, 凉水泉2208F戒); '
  1159. 'ΔT=相对fleet同功率bin(非OEM绝对阈), 上限【预警】候选; 绝对失控须OEM定值核; '
  1160. '⚠盲点(Critic): 某台功率带与全场完全不重叠(孤立高/低bin)→ΔT路径fleet基线=自身→致盲(false-neg), '
  1161. '须 fleet 功率带重叠才有效(同风况机组通常重叠); '
  1162. '§4.6c 守卫: 冻结/坐死的部件温列已逐台剔出排名+簇状(见 dead_channel)'}
  1163. # ---------------------------------------------------------------- §4.6 R5 模块3.2 振动趋势 (level 粗筛)
  1164. def _theil_sen_slope(x, y):
  1165. """Theil-Sen 月度斜率 (中位 pairwise slope, 抗离群月; 源 R5 趋势, 比 OLS 稳)。"""
  1166. x = np.asarray(x, float); y = np.asarray(y, float)
  1167. n = len(x)
  1168. if n < 3:
  1169. return np.nan
  1170. sl = [(y[j] - y[i]) / (x[j] - x[i]) for i in range(n) for j in range(i + 1, n) if x[j] != x[i]]
  1171. return float(np.median(sl)) if sl else np.nan
  1172. def vibration_trend(df, *, vib_cols, turbine_col, time_col,
  1173. z_thresh=2.0, min_per_turbine=1000, min_months=4,
  1174. min_nunique=30, slope_frac_thresh=0.03, persist_frac=0.6,
  1175. mad_floor_frac=0.02):
  1176. """振动 level 趋势第二道 — 分测点 fleet null MAD-z + 月度爬升斜率 + 持续性 (SOP §4.6 R5, 模块3.2).
  1177. 源 scripts/litloop_r5_vib_survey.py (RV-2 搬真逻辑不改判据)。**10min RMS/std level = 粗筛, verdict 上限【预警】**。
  1178. 物理铁律 (必 honor):
  1179. ① **禁跨测点混池**: 主轴承/齿轮箱/发电机/塔筒 物理量级差数倍, 混池→fleet 中位落某簇 + MAD 撑大/缩小
  1180. → 整通道系统偏移成假 z (实证: 3 低幅通道 +1 高幅健康塔通道混池 → 塔台假 z≈15; per 通道则 |z|≈2 中性)。
  1181. ② **无频带/测点标定元数据 → 绝对 ISO 10816/20816 区界 INSUFFICIENT** (10min 奈奎斯特仅 0.03-0.5Hz, 做不了频谱)
  1182. → 只出相对 level-trend, 不套区界表; 部件级 (BPFO/BPFI/啮合/1P) 须 CMS 高频频谱 (留下一轮)。
  1183. ③ **退化/近死振动列守卫** (R5 tl3/hl near-dead): nunique<min_nunique 或 p95<量化步长 = 近死列 → INSUFFICIENT。
  1184. ④ **趋势爬升须持续性门** (单事件≠趋势): 月度中位 Theil-Sen 斜率正 ∧ 后半段多数月>基线+半幅 才算"在涨"。
  1185. 阈值 RULE-3: fleet null MAD-z>z_thresh(=2, 非 magic RMS≥6 —— R5 明记 RMS≥6 是未标定绝对值无意义);
  1186. slope_frac=每月涨>fleet中位幅5% 才显著; persist_frac=0.6。flagged = z>阈 ∨ (显著正斜率 ∧ 持续)。
  1187. vib_cols: 单列名 str 或 测点列名 list (每列=一测点通道, 各自成池)。
  1188. 返回 dict: {per_channel:{测点:{guard, n_turbines, per_turbine:[{tid,level_median,z,slope_per_month,
  1189. slope_frac,persist,n_months,n,climbing}], flagged}}, flagged:[(channel,tid)], verdict, ceiling, caveat}。
  1190. """
  1191. cols = [vib_cols] if isinstance(vib_cols, str) else list(vib_cols)
  1192. per_channel = {}
  1193. flagged_all = []
  1194. any_usable = False
  1195. for col in cols:
  1196. if col not in df.columns:
  1197. per_channel[col] = {'guard': 'missing', 'n_turbines': 0, 'per_turbine': [], 'flagged': [],
  1198. 'reason': f'列 {col} 不存在'}
  1199. continue
  1200. d = df.dropna(subset=[col, turbine_col, time_col]).copy()
  1201. s = pd.to_numeric(d[col], errors='coerce')
  1202. d = d.assign(_v=s).dropna(subset=['_v'])
  1203. # ③ 退化/近死列守卫 (nunique 粗 + p95<量化步长). 量化步长 = 最小正相邻差 (排重后)。
  1204. nunq = int(d['_v'].nunique())
  1205. uvals = np.unique(d['_v'].to_numpy())
  1206. qstep = float(np.min(np.diff(uvals))) if uvals.size >= 2 else np.inf
  1207. p95 = float(d['_v'].quantile(0.95))
  1208. if nunq < min_nunique or (np.isfinite(qstep) and p95 < qstep):
  1209. per_channel[col] = {
  1210. 'guard': 'near_dead', 'n_turbines': 0, 'per_turbine': [], 'flagged': [],
  1211. 'reason': (f'退化/近死列: nunique={nunq}(<{min_nunique}) 或 p95={p95:.4g}<量化步长{qstep:.4g} '
  1212. f'(缺测填常数/sentinel/量化太粗, 无真振动信号) → 不出假 z, 标 INSUFFICIENT'),
  1213. 'nunique': nunq, 'p95': round(p95, 4)}
  1214. continue
  1215. # ① per 测点通道各自 fleet 中位 + MAD-z (绝不跨通道混池)
  1216. lev = d.groupby(turbine_col)['_v'].median()
  1217. cnt = d.groupby(turbine_col)['_v'].count()
  1218. lev = lev[(cnt >= min_per_turbine) & lev.notna()]
  1219. if len(lev) < 2:
  1220. per_channel[col] = {'guard': 'few_turbines', 'n_turbines': int(len(lev)),
  1221. 'per_turbine': [], 'flagged': [],
  1222. 'reason': f'有效台<2 (per台需≥{min_per_turbine}样本) → 无 fleet null, INSUFFICIENT'}
  1223. continue
  1224. any_usable = True
  1225. fleet_med = float(lev.median())
  1226. mad_raw = float(1.4826 * (lev - lev.median()).abs().median())
  1227. # MAD 地板 (Critic 逮: 原 min_dev_frac 硬截断会 mask 真中度escalation; 改floor MAD 分母, 同 fleet_sector_devz
  1228. # spread_floor 思想): tight-fleet MAD→tiny 时 floor 到 mad_floor_frac·fleet中位 → z 不爆炸但不硬截真信号。
  1229. mad = max(mad_raw, mad_floor_frac * abs(fleet_med), 1e-9)
  1230. # ④ 月度爬升斜率 + 持续性 (per 台自比, 免标定; 单事件≠趋势)
  1231. rows = []
  1232. for tid, g in d.groupby(turbine_col):
  1233. if len(g) < min_per_turbine:
  1234. continue
  1235. gm = (g.set_index(pd.to_datetime(g[time_col]))['_v']
  1236. .resample('MS').median().dropna())
  1237. n_months = int(len(gm))
  1238. slope = slope_frac = np.nan
  1239. persist = climbing = False
  1240. if n_months >= min_months:
  1241. xm = np.arange(n_months)
  1242. slope = _theil_sen_slope(xm, gm.values) # level / month
  1243. base = float(gm.iloc[:max(1, n_months // 3)].median()) # 前 1/3 月基线
  1244. amp = float(gm.max() - gm.min())
  1245. slope_frac = (slope / fleet_med) if fleet_med > 0 else np.nan
  1246. if amp > 0:
  1247. thr = base + 0.5 * amp
  1248. half = gm.iloc[n_months // 2:]
  1249. persist = bool((half > thr).mean() >= persist_frac)
  1250. climbing = bool(np.isfinite(slope_frac) and slope_frac >= slope_frac_thresh and persist)
  1251. z = float((lev[tid] - fleet_med) / mad) if tid in lev.index else np.nan
  1252. rows.append({'tid': str(tid), 'level_median': round(float(lev.get(tid, np.nan)), 4),
  1253. 'z': (None if not np.isfinite(z) else round(z, 2)),
  1254. 'slope_per_month': (None if not np.isfinite(slope) else round(slope, 5)),
  1255. 'slope_frac': (None if not np.isfinite(slope_frac) else round(slope_frac, 4)),
  1256. 'persist': persist, 'n_months': n_months,
  1257. 'n': int(len(g)), 'climbing': climbing})
  1258. rows.sort(key=lambda r: (-(r['z'] if r['z'] is not None else -9)))
  1259. # flag = z≥阈 (z 已用 floored MAD, tight-fleet 不爆炸) ∨ 趋势爬升(slope+持续门). 不再硬截 level 偏差 (会mask真中度).
  1260. flg = [r['tid'] for r in rows
  1261. if (r['z'] is not None and r['z'] >= z_thresh) or r['climbing']]
  1262. # 检测下限透明 (同 fleet_sector_devz detection_floor): 达 z 阈所需 level 偏 fleet 中位 % (mad被floor时=阈×floor)
  1263. det_floor_pct = round(z_thresh * mad / abs(fleet_med) * 100, 1) if fleet_med else None
  1264. per_channel[col] = {'guard': 'ok', 'n_turbines': int(len(lev)), 'fleet_median': round(fleet_med, 4),
  1265. 'mad': round(mad, 4), 'mad_floored': bool(mad > mad_raw + 1e-12),
  1266. 'detection_floor_pct': det_floor_pct, 'per_turbine': rows, 'flagged': flg}
  1267. flagged_all += [(col, t) for t in flg]
  1268. if not any_usable:
  1269. verdict = 'INSUFFICIENT(退化列/样本/<2台)'
  1270. elif flagged_all:
  1271. verdict = '振动 level 离群/爬升候选(预警)'
  1272. else:
  1273. verdict = '各测点 level 平稳'
  1274. return {'per_channel': per_channel, 'flagged': flagged_all, 'verdict': verdict, 'ceiling': '预警',
  1275. 'caveat': ('分测点各自 fleet null(禁混池); 10min level=粗筛 verdict上限【预警】; '
  1276. '无频带/测点标定→绝对 ISO 区界 INSUFFICIENT(奈奎斯特0.03-0.5Hz, 不套区界表); '
  1277. '部件级(BPFO/BPFI/啮合/1P)须 CMS 高频频谱')}
  1278. # ---------------------------------------------------------------- §4.1 模块3.2b 叶片不平衡 1P 阶次跟踪
  1279. def order_track_1p(accel, rpm, fs, *, order=1.0, detrend=True, min_cycles=4.0, samples_per_rev=64):
  1280. """振动加速度按瞬时转速**阶次跟踪**提 1P (=order) 幅值 (源 blade_imb_chainB_ordertrack.order_amp,
  1281. RV-2 搬真逻辑; 升级: rpm 随时间变 → 角域重采样, 锁 1P 为常数阶次, 规避固定频率 FFT 低频泄漏 artifact)。
  1282. 为什么必阶次跟踪 (铁律②): 转速变 → 1P 时域频率漂移; 固定频率 FFT 在一个 bin 收不全漂移的能量 → 1P 被低估/糊。
  1283. 角域重采样 (按累积转角等角度抽样) 把"每转一周期"固定成常数阶次, 不平衡 1P 落单一阶次 bin, 与转速变化无关。
  1284. 算法: ① 重力/DC 自标定 (deg2 detrend 扣重力 ~9.8m/s² + 慢漂); ② 瞬时相位 φ(t)=2π·order·∫(rpm/60)dt,
  1285. 按 φ 均匀重采样 (每转 samples_per_rev 点); ③ 角域 1P = 每转整 1 周期 → 谐波回归 (2×2 正规方程直解)。
  1286. 转速近恒定 (cv<2%) 退化单频时域回归 (与 bespoke order_amp 逐位等价)。
  1287. 入: accel (1d array), rpm (标量或同长 array, r/min), fs (Hz)。
  1288. 出: dict {amp_1p, n_rev, mean_rpm, rpm_cv, method, note}; amp_1p=NaN 表样本/转速不足。
  1289. """
  1290. a = np.asarray(accel, float)
  1291. a = a[np.isfinite(a)]
  1292. n = a.size
  1293. if n < 64 or fs <= 0:
  1294. return {'amp_1p': np.nan, 'n_rev': 0.0, 'mean_rpm': np.nan, 'rpm_cv': np.nan,
  1295. 'method': None, 'note': '样本不足(<64)或 fs 非法'}
  1296. t = np.arange(n) / float(fs)
  1297. dur = t[-1] if n > 1 else 0.0
  1298. rpm_arr = np.asarray(rpm, float)
  1299. if rpm_arr.ndim == 0:
  1300. rpm_t = np.full(n, float(rpm_arr))
  1301. else:
  1302. rpm_arr = rpm_arr[np.isfinite(rpm_arr)]
  1303. if rpm_arr.size == 0:
  1304. return {'amp_1p': np.nan, 'n_rev': 0.0, 'mean_rpm': np.nan, 'rpm_cv': np.nan,
  1305. 'method': None, 'note': 'rpm 全 NaN'}
  1306. rt = np.linspace(0.0, dur, rpm_arr.size)
  1307. rpm_t = np.interp(t, rt, rpm_arr)
  1308. mean_rpm = float(np.mean(rpm_t))
  1309. if mean_rpm <= 0:
  1310. return {'amp_1p': np.nan, 'n_rev': 0.0, 'mean_rpm': mean_rpm, 'rpm_cv': np.nan,
  1311. 'method': None, 'note': '转速≤0 (停机; CMS 0RPM 假标签须先 join 真转速)'}
  1312. rpm_cv = float(np.std(rpm_t) / mean_rpm)
  1313. rev = np.concatenate([[0.0], np.cumsum((rpm_t[:-1] + rpm_t[1:]) / 2.0 / 60.0) / fs])
  1314. n_rev = float(rev[-1])
  1315. if n_rev < min_cycles: # 1P 周期数不足 → marginal, 拒 (zyx 短窗戒)
  1316. return {'amp_1p': np.nan, 'n_rev': round(n_rev, 2), 'mean_rpm': round(mean_rpm, 2),
  1317. 'rpm_cv': round(rpm_cv, 4), 'method': None,
  1318. 'note': f'仅 {n_rev:.1f} 转 < min_cycles={min_cycles} (1P 估计 marginal)'}
  1319. x = a - np.polyval(np.polyfit(t, a, 2), t) if detrend else a - a.mean()
  1320. def _harm_amp(sig, phase): # 谐波回归 2×2 正规方程直解 (源 order_amp)
  1321. c, s = np.cos(phase), np.sin(phase)
  1322. a11, a12, a22 = float(c @ c), float(c @ s), float(s @ s)
  1323. b1, b2 = float(c @ sig), float(s @ sig)
  1324. det = a11 * a22 - a12 * a12
  1325. if det <= 1e-12 or not np.isfinite(det):
  1326. return 0.0
  1327. A = (a22 * b1 - a12 * b2) / det
  1328. B = (a11 * b2 - a12 * b1) / det
  1329. return float(np.hypot(A, B))
  1330. if rpm_cv < 0.02: # 转速近恒定 → 单频时域回归 (= bespoke order_amp)
  1331. f1 = order * mean_rpm / 60.0
  1332. amp = _harm_amp(x, 2 * np.pi * f1 * t)
  1333. method = 'single_freq_regression'
  1334. else: # 转速变 → 角域重采样锁阶次 (规避漂移泄漏)
  1335. n_grid = max(int(n_rev * samples_per_rev), 16)
  1336. rev_u = np.linspace(0.0, n_rev, n_grid)
  1337. x_ang = np.interp(rev_u, rev, x)
  1338. amp = _harm_amp(x_ang, 2 * np.pi * order * rev_u)
  1339. method = 'angular_resample'
  1340. return {'amp_1p': float(amp), 'n_rev': round(n_rev, 2), 'mean_rpm': round(mean_rpm, 2),
  1341. 'rpm_cv': round(rpm_cv, 4), 'method': method,
  1342. 'note': f'阶次跟踪 1P 幅值 (重力 detrend={detrend}, {method})'}
  1343. def naive_fft_1p(accel, rpm, fs, *, order=1.0, tol=0.15):
  1344. """对照: 固定频率 FFT 在 mean_rpm 处取 1P (=铁律②的 artifact 路径; 仅供测试/教学对比, 非判据)。
  1345. 用 mean(rpm) 算单一 f1, 在 ±tol 带内取 FFT 峰。转速变时 1P 频率漂移 → 能量糊到带外/邻 bin,
  1346. 此值系统偏低 → 演示"为什么不能用固定频率 FFT"。源 blade_imb_cms_1p_verify.harmonics。
  1347. """
  1348. a = np.asarray(accel, float); a = a[np.isfinite(a)]
  1349. rpm_arr = np.asarray(rpm, float)
  1350. mean_rpm = float(np.nanmean(rpm_arr)) if rpm_arr.size else np.nan
  1351. if a.size < 64 or not np.isfinite(mean_rpm) or mean_rpm <= 0:
  1352. return np.nan
  1353. x = a - a.mean()
  1354. win = np.hanning(x.size)
  1355. A = 2 * np.abs(np.fft.rfft(x * win)) / win.sum()
  1356. fr = np.fft.rfftfreq(x.size, 1 / float(fs))
  1357. f1 = order * mean_rpm / 60.0
  1358. m = (fr > f1 * (1 - tol)) & (fr < f1 * (1 + tol))
  1359. return float(A[m].max()) if m.any() else np.nan
  1360. def blade_imbalance_1p(captures, *, fs, order=1.0, z_thresh=2.0, min_captures=3,
  1361. pitch_p95_by_blade=None, collective_tol=0.5,
  1362. gravity_compensated=False, gravity_amp=9.81, gravity_tol_frac=0.30):
  1363. """叶片不平衡 1P 谐波判别 — **重力感知门 + fleet null z** (SOP §4.1 模块3.2b).
  1364. ⚠⚠ 重力 1P 铁律 (2026-06-20 独立 Critic 逮, 改写自首版): 旋转坐标系下叶根/挥舞/摆振向加速度的**重力
  1365. 分量本身就是 1P** (g·sin(转角), 每转一周期)。order_track_1p 的 deg2 detrend 只去 DC/慢漂, **去不掉
  1366. 1P 振荡** → 原始挥舞/摆振向通道的 1P 幅值 ≈ 1g(~9.8), 是**重力非不平衡**。真不平衡 1P 与重力 1P 同频
  1367. 矢量相加 → 不知重力相位无法分离 (反相时真故障被抵消成低 1P, 假装健康)。源 bespoke
  1368. blade_imb_chainB_ordertrack 已明示"1P死"(AC耦合/高通传感器 → 真不平衡 1P 在通带下, 只剩重力), 转用
  1369. modal-z。故本函数 **默认 raw 通道 → 重力主导 → INSUFFICIENT** (诚实, 非假不平衡)。
  1370. 判据:
  1371. • gravity_compensated=False (默认, raw 挥舞/摆振向): 若 fleet 中位 1P 幅值落在 gravity_amp±gravity_tol_frac
  1372. 内 → verdict='INSUFFICIENT(重力主导1P)', **不出不平衡 flag**; 路由 = modal-z(源 chainB) 或 切向/已补偿通道。
  1373. 即便不在 1g 附近, raw 通道 1P 仍含未知重力相位 → 仍 INSUFFICIENT(不可信), 标 caveat。
  1374. • gravity_compensated=True (调用方断言: 切向/扭矩向通道, 或已矢量扣除平衡参考窗重力相量): 才跑 fleet null
  1375. MAD-z, **双侧**(高 z=不平衡候选; 极低 z/幅值近0=疑死/低增益通道, 非健康), 上限【预警】, 定论须现场叶检。
  1376. ① 集距死前置: pitch_p95_by_blade 三叶 p95 极差<collective_tol=集距 → pitch_dead_indicator=True (桨距判不出, 走振动)。
  1377. ② order_track_1p 阶次跟踪 (转速变→角域重采样, 非固定 FFT)。 ③ CMS '0RPM' 假标签: rpm≤0/不足 → 弃 capture。
  1378. 入: captures — list of {'turbine', 'accel', 'rpm', 'fs'?}; gravity_compensated 见上。
  1379. 出: dict {verdict, per_turbine:[{tid,amp_1p_median,z,side,n_cap,mean_rpm}], flagged, fleet_median, fleet_mad,
  1380. n_turbines, gravity_dominated, gravity_compensated, pitch_dead_indicator, caveat}。
  1381. """
  1382. pitch_dead = None
  1383. if pitch_p95_by_blade:
  1384. vals = [float(v) for v in pitch_p95_by_blade.values() if v is not None and np.isfinite(v)]
  1385. if len(vals) >= 2:
  1386. pitch_dead = bool((max(vals) - min(vals)) < collective_tol)
  1387. by_turb = {}
  1388. for cap in captures:
  1389. f = float(cap.get('fs', fs))
  1390. r = order_track_1p(cap.get('accel'), cap.get('rpm'), f, order=order)
  1391. if r['method'] is None or not np.isfinite(r['amp_1p']): # ③ 0RPM/marginal capture 弃
  1392. continue
  1393. by_turb.setdefault(cap.get('turbine'), []).append((r['amp_1p'], r['mean_rpm']))
  1394. rows = []
  1395. for tid, lst in by_turb.items():
  1396. amps = np.array([a for a, _ in lst], float)
  1397. if len(amps) < min_captures: # per台捕获不足 → 不入 fleet (诚实)
  1398. continue
  1399. rows.append({'tid': str(tid), 'amp_1p_median': float(np.median(amps)),
  1400. 'n_cap': int(len(amps)), 'mean_rpm': round(float(np.mean([m for _, m in lst])), 1)})
  1401. base = {'per_turbine': rows, 'flagged': [], 'fleet_median': None, 'fleet_mad': None,
  1402. 'n_turbines': len(rows), 'gravity_compensated': gravity_compensated,
  1403. 'pitch_dead_indicator': pitch_dead}
  1404. if len(rows) < 2:
  1405. return {**base, 'verdict': 'INSUFFICIENT(有效台<2)', 'gravity_dominated': None,
  1406. 'caveat': f'有效台<2 (per台需≥{min_captures}捕获); 叶片级 1P 须 CMS 高频振动 + 真转速 join'}
  1407. amp_all = np.array([r['amp_1p_median'] for r in rows], float)
  1408. fleet_med = float(np.median(amp_all))
  1409. grav_dom = bool(abs(fleet_med - gravity_amp) <= gravity_tol_frac * gravity_amp)
  1410. for r in rows:
  1411. r['amp_1p_median'] = round(r['amp_1p_median'], 4)
  1412. # ⚠ 默认 raw 通道: 1P 不可信 → INSUFFICIENT (诚实)。两条 raw-通道致盲机制 (zyx 实证, memory item4):
  1413. # ① 摆振向(edgewise): 重力 1P 主导 (g·sinθ≈1g, deg2 去不掉) → fleet 1P≈1g (grav_dom)。
  1414. # ② 挥舞向(flapwise): 重力投影弱(1P 小), 但传感器标定不一/bimodal → 跨台绝对 1P 极差巨大(uncal), 不可比。
  1415. # 两者真不平衡都与重力同频矢量相加(反相被抵消假健康) → raw 一律拒, 路由 modal-z / 切向补偿通道。
  1416. if not gravity_compensated:
  1417. amps_pos = [r['amp_1p_median'] for r in rows if r['amp_1p_median'] and r['amp_1p_median'] > 0]
  1418. xt_ratio = (max(amps_pos) / min(amps_pos)) if len(amps_pos) >= 2 and min(amps_pos) > 0 else None
  1419. uncal = bool(xt_ratio is not None and xt_ratio > 5) # 跨台 1P >5× = 传感器标定不一/bimodal
  1420. gcav = ('⚠ raw 通道 1P 不可作叶片不平衡: '
  1421. + (f'摆振向重力主导(fleet 1P={fleet_med:.2f}≈1g, 重力=1P deg2去不掉); ' if grav_dom else '')
  1422. + (f'挥舞向跨台 1P 极差 {xt_ratio:.0f}×=传感器标定不一/bimodal, 绝对1P跨台不可比; ' if uncal else f'fleet 中位 1P={fleet_med:.3f}; ')
  1423. + '真不平衡与重力同频矢量相加(反相被抵消假健康)。路由 modal-z(源 chainB "1P死") 或 切向/补偿通道(gravity_compensated=True)。')
  1424. if pitch_dead:
  1425. gcav = '⚠ 集距口径(桨距判不出叶片不平衡); ' + gcav
  1426. return {**base, 'verdict': 'INSUFFICIENT(raw通道1P不可信:重力/未标定)', 'gravity_dominated': grav_dom,
  1427. 'cross_turbine_1p_ratio': (round(xt_ratio, 1) if xt_ratio else None), 'uncalibrated': uncal,
  1428. 'fleet_median': round(fleet_med, 4), 'caveat': gcav}
  1429. # gravity_compensated=True: 才跑 fleet null MAD-z (双侧)
  1430. mad = float(1.4826 * np.median(np.abs(amp_all - fleet_med)))
  1431. for r in rows:
  1432. z = float((r['amp_1p_median'] - fleet_med) / max(mad, 1e-9))
  1433. r['z'] = round(z, 2)
  1434. r['side'] = ('high_imbalance' if z >= z_thresh else ('low_suspect_dead' if z <= -z_thresh else 'normal'))
  1435. rows.sort(key=lambda x: x['z'], reverse=True)
  1436. flagged = [r['tid'] for r in rows if r['side'] == 'high_imbalance']
  1437. low_suspect = [r['tid'] for r in rows if r['side'] == 'low_suspect_dead']
  1438. cav = ('已补偿/切向通道 fleet null MAD-z 双侧; 高 z=不平衡候选(上限【预警】, 定论须现场叶检质量配平/气动外形); '
  1439. '极低 z/幅值近0=疑死/低增益通道(非健康, 须核传感器); 阶次跟踪扣转速; CMS 0RPM 须先 join 真转速')
  1440. if low_suspect:
  1441. cav = f'⚠ 低 1P 疑死/低增益通道台 {low_suspect} (非健康, 须核传感器); ' + cav
  1442. if pitch_dead:
  1443. cav = '⚠ 集距口径(桨距判不出, 已转振动 1P); ' + cav
  1444. return {**base, 'verdict': ('叶片1P不平衡候选(预警)' if flagged else '已补偿通道 1P fleet 中位(无离群)'),
  1445. 'flagged': flagged, 'fleet_median': round(fleet_med, 4), 'fleet_mad': round(mad, 4),
  1446. 'gravity_dominated': grav_dom, 'caveat': cav}
  1447. # ---------------------------------------------------------------- §4.1 模块3.3 MCSA 数据维度门
  1448. # MCSA 须 kHz 级原始电流波形捕特征边带的物理依据 (RULE-3, 奈奎斯特): 电流频谱需分辨电频基波 f1(~50Hz) 叠
  1449. # 机械故障调制边带 (转子断条 2sf 贴基波 / 气隙偏心偏离几十~上百Hz / 轴承特征调制到 f1±数百Hz / PWM 2~4kHz)。
  1450. # 取保守上界 f_max≈650Hz → 奈奎斯特 fs≥1300Hz; 含谐波分辨+抗混叠裕度(~5×) fs≥~3kHz。门阈默认 2000Hz 偏保守;
  1451. # 10min(1/600Hz)/秒级(1Hz) 聚合层远不足 → 不可做 MCSA(数据维度不够), 显式 ≠ "电气健康"(SOP §0.2)。
  1452. _MCSA_KHZ_FLOOR_HZ = 2000.0
  1453. def mcsa_feasibility_gate(*, sampling_hz=None, columns=None, column_sampling_hz=None,
  1454. electrical_params=None, khz_floor_hz=_MCSA_KHZ_FLOOR_HZ,
  1455. waveform_col_hint=('current_wave', 'i_wave', 'stator_current_raw',
  1456. 'iu_raw', 'iv_raw', 'iw_raw', '电流波形')):
  1457. """MCSA 可行性门 — 诚实数据维度门 (SOP §4.1 模块3.3; RULE-3 奈奎斯特, n=物理). 无 IO。
  1458. 判**能不能做 MCSA**, 不做 MCSA 本身。MCSA 须 kHz 级原始定子电流波形 (转子断条 2sf/气隙偏心/轴承特征
  1459. 边带靠电流频谱高频特征定位, 奈奎斯特要求 fs 覆盖最高特征边带)。SCADA 聚合层只有功率/电流**均值**
  1460. (10min=1/600Hz, 秒级=1Hz) → 无 kHz 波形 → MCSA 不可做, **显式 ≠ "电气健康"** (数据没覆盖故障频带)。
  1461. 入: sampling_hz (标量, 最快通道采样率) 或 columns+column_sampling_hz (取电流类列最高采样率比阈);
  1462. electrical_params (须 极对数 ∧ (槽数|滑差), 缺则即便有 kHz 波形也定位不了特征频率)。
  1463. 返回 dict (保守偏 INSUFFICIENT): {feasible, verdict, reason, required_data, method_when_feasible,
  1464. max_sampling_hz, khz_floor_hz, has_electrical_params}。
  1465. """
  1466. required = ['kHz 级原始定子电流波形 (三相, fs≥~数 kHz; 非 10min/秒级均值)',
  1467. '电气设计参数: 极对数(pole_pairs) + 槽数(slots) 或 滑差(slip) (特征频率定位须)',
  1468. '转速/滑差通道 (2sf 边带与轴承阶次定位须)']
  1469. method_when_feasible = ('mcsa_method_skeleton(): 定子电流 FFT/包络谱 → 转子断条 2sf 边带 / '
  1470. '气隙偏心边带 / 轴承特征频率; 滑差+极对数定位特征频率。**待 kHz 数据, 未首跑**。')
  1471. cand_hz = []
  1472. if sampling_hz is not None and np.isfinite(sampling_hz):
  1473. cand_hz.append(float(sampling_hz))
  1474. if columns and column_sampling_hz:
  1475. cur_hz = [float(column_sampling_hz[c]) for c in columns
  1476. if c in column_sampling_hz and np.isfinite(column_sampling_hz.get(c, np.nan))
  1477. and any(h in str(c).lower() for h in waveform_col_hint)]
  1478. if cur_hz:
  1479. cand_hz.append(max(cur_hz))
  1480. else:
  1481. all_hz = [float(v) for v in column_sampling_hz.values() if np.isfinite(v)]
  1482. if all_hz:
  1483. cand_hz.append(max(all_hz))
  1484. max_hz = max(cand_hz) if cand_hz else None
  1485. ep = electrical_params or {}
  1486. has_pp = any(k in ep and ep[k] is not None for k in ('pole_pairs', 'p', '极对数'))
  1487. has_loc = any(k in ep and ep[k] is not None for k in ('slots', 'slip', 's', '槽数', '滑差'))
  1488. has_ep = bool(has_pp and has_loc)
  1489. base = {'max_sampling_hz': max_hz, 'khz_floor_hz': float(khz_floor_hz),
  1490. 'has_electrical_params': has_ep, 'required_data': required,
  1491. 'method_when_feasible': method_when_feasible}
  1492. if max_hz is None:
  1493. return {**base, 'feasible': False, 'verdict': 'MCSA 可行性不可判(未提供采样率/列)',
  1494. 'reason': '未提供 sampling_hz 或 (columns+column_sampling_hz); 无法判数据是否含 kHz 波形。'}
  1495. if max_hz < khz_floor_hz:
  1496. return {**base, 'feasible': False, 'verdict': 'MCSA 不可做(数据维度不够)',
  1497. 'reason': (f'最快采样率 {max_hz:g}Hz < kHz 门阈 {khz_floor_hz:g}Hz(奈奎斯特覆不住 2sf/偏心/'
  1498. f'轴承特征边带, RULE-3): SCADA 聚合层只有功率/电流均值, 无 kHz 原始波形 → '
  1499. f'MCSA 不可做(数据维度不够), **明确 ≠ "电气健康"**(数据没覆盖故障频带, 非"看过没隐患")。')}
  1500. if not has_ep:
  1501. return {**base, 'feasible': False, 'verdict': 'MCSA 不可做(无电气设计参数)',
  1502. 'reason': (f'采样率 {max_hz:g}Hz≥{khz_floor_hz:g}Hz 满足 kHz 维度, 但缺电气设计参数'
  1503. f'(须极对数∧(槽数|滑差)定位 2sf/偏心/轴承特征频率) → 暂不可做; 补参数即可行。')}
  1504. return {**base, 'feasible': True, 'verdict': 'MCSA feasible (待首跑)',
  1505. 'reason': (f'采样率 {max_hz:g}Hz≥{khz_floor_hz:g}Hz 满足 kHz 维度 ∧ 电气设计参数齐 → '
  1506. f'可做 MCSA; 走 method skeleton。**campaign 实际无此数据, 未首跑**。')}
  1507. def mcsa_method_skeleton(*, stator_current, fs, pole_pairs, line_freq=50.0,
  1508. slip=None, rotor_rpm=None, bearing_geom=None):
  1509. """MCSA 方法骨架 (定子电流频谱诊断) — **待 kHz 高频数据, 未首跑; 占位骨架, 勿当已验证**。
  1510. 数据维度门 (mcsa_feasibility_gate) 通过后才走此。诊断三族: ①转子断条 2sf 边带 f=f1·(1±2k·s)
  1511. ②气隙偏心边带 f=f1·[1±k·(1−s)/p] ③轴承特征频率调制 f1±m·f_b。定位须滑差 s + 极对数 p (轴承须几何)。
  1512. **campaign 无 kHz 电流波形, 从未首跑 → 禁直接调用产出结论 (未验证 claim)**。
  1513. """
  1514. raise NotImplementedError(
  1515. 'mcsa_method_skeleton 待 kHz 级原始电流波形数据首跑 + 校准, 当前 campaign 无此数据 → '
  1516. '禁产出结论 (未验证)。先过 mcsa_feasibility_gate, 有 kHz 波形再实现频谱/边带提取。')
  1517. # ---------------------------------------------------------------- §3.4 模块3.4 齿轮箱油液 (条件门)
  1518. def oil_quality_gate(df=None, *, columns=None, oil_sensor_cols=None, oil_temp_cols=None,
  1519. time_col=None, turbine_col=None, slope_window=30, min_points=300,
  1520. iso4406_warn_jump=2, water_ppm_spec=None):
  1521. """齿轮箱油液 条件门 (SOP 模块 3.4, 条件性). 诚实路由, **禁假"油液正常"**.
  1522. campaign 多数场条件受限 (无在线油液传感/无化验报告, knowledge_base 只有油液文献 PDF=文献非实测)。
  1523. SCADA 给不出油质定论 — 本函数只做诚实条件门 + 路由, 三档:
  1524. (a) 有颗粒计数(ISO 4406)/水分 ppm 在线传感列 → 趋势 + 突变检测, verdict 至【准定论·预警】。
  1525. (b) 只有油温/油压 (无油质传感) → 冷却/堵塞 proxy 非油质 → 路由 3.5 冷却, **禁判油质** (郭家店 a8 形态)。
  1526. (c) 都无 → 【INSUFFICIENT】N/A (不强建=反膨胀), **禁**"油液正常/无隐患"。
  1527. 定论须 offline 化验 (颗粒/水分/粘度/元素谱定位磨损部件) — SCADA 永远给不出。
  1528. RULE-3: 颗粒 ISO 4406:1999 等级跳≥2 档 (每档~×2 浓度); 水分 ppm 规格上限 (DL/T 1096 类); 油温只 proxy。
  1529. 返回 dict: {channel_class: 'oil_sensor'|'temp_proxy_only'|'none', verdict, reason, routed_to, action,
  1530. (oil_sensor 分支另带) trends}。verdict ∈ SOP §0.2 六枚举。
  1531. """
  1532. sensor = [c for c in (oil_sensor_cols or []) if df is None or c in df.columns]
  1533. temp = [c for c in (oil_temp_cols or []) if df is None or c in df.columns]
  1534. if not sensor and not temp: # (c) 都无 → INSUFFICIENT N/A
  1535. return {'channel_class': 'none', 'verdict': '【INSUFFICIENT】',
  1536. 'reason': ('无油液在线传感(颗粒计数/水分)且无油温/油压通道, 无取样化验报告 → 油质状态不可知。'
  1537. '**非"油液正常/无隐患"** (反膨胀: 无传感无化验不强建本模块)。'),
  1538. 'routed_to': None,
  1539. 'action': 'N/A — 不强建。如需油质结论须加装在线油液传感(颗粒/水分) 或 取样送 offline 化验。'}
  1540. if temp and not sensor: # (b) 只油温/油压 → proxy, 路由 3.5
  1541. return {'channel_class': 'temp_proxy_only',
  1542. 'verdict': '【INSUFFICIENT】(油质); 油温=proxy非油质, 路由 3.5 冷却',
  1543. 'reason': (f'仅有油温/油压通道 ({", ".join(map(str, temp))}), 无颗粒计数/水分传感。'
  1544. '油温/油压是**冷却/堵塞 proxy 非油质** (温升多为负荷驱动 artifact, 须负荷归一防因果反置)。'
  1545. '**禁**据此判油质劣化或"油液正常"。'),
  1546. 'routed_to': '3.5 冷却系统 (负荷归一温升 + ΔT/效能)',
  1547. 'action': '油温/油压趋势交 3.5 冷却分析 (防把负荷驱动温升误当油液问题)。油质定论仍须 offline 化验。'}
  1548. trends = {} # (a) 有油质传感 → 趋势 + 突变
  1549. if df is not None and time_col and time_col in df.columns:
  1550. def _trend(s):
  1551. s = pd.to_numeric(s, errors='coerce').dropna()
  1552. if len(s) < min_points:
  1553. return {'slope_per_window': None, 'last_vs_baseline': None,
  1554. 'jump_flag': None, 'n': int(len(s)), 'note': f'样本<{min_points}, 趋势不可靠'}
  1555. base = float(s.iloc[:max(slope_window, len(s) // 4)].median())
  1556. last = float(s.iloc[-slope_window:].median()) if len(s) >= slope_window else float(s.median())
  1557. x = np.arange(len(s), dtype=float)
  1558. slope = float(np.polyfit(x, s.values.astype(float), 1)[0]) * slope_window
  1559. return {'slope_per_window': round(slope, 4), 'baseline': round(base, 3),
  1560. 'last_window': round(last, 3), 'last_vs_baseline': round(last - base, 3), 'n': int(len(s))}
  1561. grp = df.sort_values(time_col)
  1562. iters = ([(None, grp)] if not (turbine_col and turbine_col in df.columns)
  1563. else list(grp.groupby(turbine_col)))
  1564. for col in sensor:
  1565. per = {}
  1566. for tid, g in iters:
  1567. tr = _trend(g[col])
  1568. jf = None
  1569. lv = tr.get('last_vs_baseline')
  1570. if lv is not None:
  1571. if 'iso' in str(col).lower() or '4406' in str(col) or '颗粒' in str(col):
  1572. jf = bool(lv >= iso4406_warn_jump)
  1573. elif water_ppm_spec is not None and tr.get('last_window') is not None:
  1574. jf = bool(tr['last_window'] > water_ppm_spec)
  1575. tr['jump_flag'] = jf
  1576. per[str(tid) if tid is not None else '_all'] = tr
  1577. trends[col] = per
  1578. any_jump = any(t.get('jump_flag') for col in trends.values() for t in col.values())
  1579. verdict = '【准定论·预警】' if any_jump else '【候选】'
  1580. reason = ('在线油液传感 (颗粒计数 ISO 4406 / 水分 ppm) 趋势'
  1581. + ('检出突变/超规格 → 油质劣化候选 (升【预警】); ' if any_jump else '未见突变 → 单方法单窗信号; ')
  1582. + '部件级/磨损定位**仍须 offline 化验** (元素谱定位磨损部件), SCADA 在线趋势上限【预警】不下根因。')
  1583. return {'channel_class': 'oil_sensor', 'verdict': verdict, 'reason': reason, 'routed_to': None,
  1584. 'action': '颗粒/水分预警台 → 取样送 offline 化验 (颗粒/水分/粘度/元素谱) 确诊 + 定位磨损部件 (齿/轴承)。',
  1585. 'trends': trends}
  1586. # ---------------------------------------------------------------- §4.1 模块3.5 冷却系统效能
  1587. def cooling_efficiency(df, *, temp_col, p_col, turbine_col, time_col,
  1588. ws_col=None, amb_col=None, rated=None,
  1589. p_bin_kw=100.0, min_cell=200, min_per_turbine=1000,
  1590. z_thresh=2.0, abs_temp_floor=2.0, causal_ws_lo=8.0, causal_ws_hi=12.0,
  1591. causal_margin_kw=50.0, causal_margin_frac=0.03, near_rated_frac=0.90,
  1592. release_bins=8, mono_min_frac=0.75, comp_valid_range=(-40.0, 200.0)):
  1593. """冷却系统效能判别 (SOP §4.1 模块3.5, 源 gjd a8_cooling 探索, n=1[条件性], RV-2 搬真逻辑不改判据).
  1594. 回答冷却衰减否, 但**必先排因果反置**: 功率↑→发热→冷却水温↑ = 负荷驱动温升, NOT 高温→降容。三层
  1595. (郭家店 1338MWh→真197MWh / 21%高估 教训 + memory temp_derate_overtemp_verification):
  1596. A. 因果方向守卫 (ws_col 给定时): 同风速 bin 高冷却温组 vs 低温组功率中位。真降容=高温伴低功率;
  1597. 负荷驱动 artifact=高温组功率**反而更高** → causal='load_driven'。极端高温@高风仍近额定 = 坐实非降容。
  1598. **禁在 load_driven 下报"冷却衰减致降容"**。
  1599. B. 释放曲线分型: gen 态按部件温分位 bin, per-bin 功率上界 p95 随温**单调上升** = 冷油保护低温冷启
  1600. (冬季集中, 真降容但正常保护) 非过热; 高温侧无功率被压 = 非高温降容。
  1601. C. 真冷却衰减候选 (防漏检, 卡明写): 同功率 bin (扣负荷消因果) 各台冷却温偏移 → fleet MAD-z; z≥阈
  1602. 且系统偏热 = 冷却候选 (散热器/水泵/风扇)。与 A/B 正交: A 防误当衰减, C 防漏真衰减。
  1603. 无流量/进出水温列 → 不算 ΔT, 只比同负荷温水平。真过热确诊须 30 秒实温 vs 告警阈 (F级155°C, 留下一轮)。
  1604. 阈值 RULE-3: 同功率 bin MAD-z≥2 (RULE-4); 因果方向=功率↑→温升(物理); 释放曲线单调; 禁 magic number。
  1605. ⚠ 被排名通道存活守卫 (§4.6c, 2026-06-28 加): temp_col **逐台**经 reference_channel_liveness 验活,
  1606. 冻结/坐死的台 (comp_valid_range 先剔野值) 从同功率偏移排名中剔出 (入 dead_channel), 防"传感器坏"
  1607. 伪装"冷却偏热" (06淄川 F0810001022 戒: 舱外温度冻结同因致 dT 类判据假阳, 此处护冷却水温自身)。
  1608. 返回 dict: {causal, causal_detail, hi_minus_lo_kw, near_rated_at_high_temp, cooling_type,
  1609. release_monotonic_frac, release_curve, per_turbine, flagged, n_total, verdict, evidence, dead_channel}。
  1610. """
  1611. d = df.dropna(subset=[temp_col, p_col, turbine_col, time_col]).copy()
  1612. T = pd.to_numeric(d[temp_col], errors='coerce')
  1613. P = pd.to_numeric(d[p_col], errors='coerce')
  1614. d = d.assign(_T=T, _P=P).dropna(subset=['_T', '_P'])
  1615. if len(d) < 2000:
  1616. return {'causal': None, 'cooling_type': None, 'per_turbine': [], 'flagged': [],
  1617. 'n_total': 0, 'verdict': 'INSUFFICIENT', 'evidence': '样本不足(<2000)', 'dead_channel': []}
  1618. gen = d[d['_P'] > 0]
  1619. if len(gen) < 1000:
  1620. gen = d
  1621. causal, causal_detail, hi_lo, near_rated = None, None, None, None
  1622. if ws_col and ws_col in d:
  1623. ws = pd.to_numeric(gen[ws_col], errors='coerce')
  1624. g = gen.assign(_ws=ws).dropna(subset=['_ws'])
  1625. band = g[(g['_ws'] >= causal_ws_lo) & (g['_ws'] < causal_ws_hi)]
  1626. if len(band) >= 2 * min_cell:
  1627. tmed = band['_T'].median()
  1628. hi = band[band['_T'] > tmed]['_P']; lo = band[band['_T'] <= tmed]['_P']
  1629. if len(hi) >= min_cell and len(lo) >= min_cell:
  1630. hi_lo = float(hi.median() - lo.median())
  1631. # 因果阈按 rated 归一 (RULE-3, Critic 逮: 50kW 绝对阈跨机型不可比 — 6MW 的 0.83% vs 1.5MW 的 3.3%)
  1632. margin = causal_margin_frac * rated if rated else causal_margin_kw
  1633. causal = 'load_driven' if hi_lo > margin else 'temp_limits_power'
  1634. causal_detail = (f'同 ws[{causal_ws_lo:.0f},{causal_ws_hi:.0f}) 高冷却温组 P_med−低温组 P_med '
  1635. f'= {hi_lo:+.0f}kW (>{margin:.0f}={causal_margin_frac:.0%}rated=高温伴高功率→负荷驱动温升, 非降容)')
  1636. if rated:
  1637. hot = g[(g['_T'] >= g['_T'].quantile(0.95)) & (g['_ws'] >= causal_ws_hi)]
  1638. if len(hot) >= min_cell:
  1639. near_rated = bool(hot['_P'].median() >= near_rated_frac * rated)
  1640. cooling_type, mono_frac, rel_curve = None, None, None
  1641. try:
  1642. qb = pd.qcut(gen['_T'], q=release_bins, duplicates='drop')
  1643. rc = gen.groupby(qb, observed=True).agg(p_ceil=('_P', lambda s: float(s.quantile(0.95))),
  1644. t_mid=('_T', 'median'), n=('_P', 'size'))
  1645. rc = rc[rc['n'] >= min_cell]
  1646. if len(rc) >= 4:
  1647. ceils = rc['p_ceil'].values
  1648. ups = np.sum(np.diff(ceils) > 0)
  1649. mono_frac = float(ups / max(len(ceils) - 1, 1))
  1650. rel_curve = {round(float(t), 1): round(float(p), 0)
  1651. for t, p in zip(rc['t_mid'].values, ceils)}
  1652. cooling_type = 'cold_start_release' if mono_frac >= mono_min_frac else 'no_temp_release'
  1653. except (ValueError, IndexError):
  1654. pass
  1655. gen = gen.assign(_pb=(np.round(gen['_P'] / p_bin_kw) * p_bin_kw))
  1656. cell_med = gen.groupby('_pb')['_T'].transform('median')
  1657. gen = gen.assign(_dT=gen['_T'] - cell_med)
  1658. agg = (gen.groupby(turbine_col).agg(temp_offset=('_dT', 'mean'), n=('_dT', 'size'))
  1659. .reset_index().rename(columns={turbine_col: 'tid'}))
  1660. agg = agg[agg['n'] >= min_per_turbine]
  1661. # §4.6c 被排名通道存活守卫: 冻结的冷却水温会把"传感器坏"伪装成同功率偏热 → 剔出排名 (F0810001022 戒)
  1662. dead_channel = sorted({str(t) for t, gt in gen.groupby(turbine_col)
  1663. if reference_channel_liveness(gt['_T'], valid_range=comp_valid_range,
  1664. min_n=min_per_turbine)['live'] is False})
  1665. if dead_channel:
  1666. agg = agg[~agg['tid'].astype(str).isin(dead_channel)]
  1667. rows, flagged = [], []
  1668. if not agg.empty:
  1669. mad = float((agg['temp_offset'] - agg['temp_offset'].median()).abs().median() * 1.4826)
  1670. agg['z'] = (agg['temp_offset'] - agg['temp_offset'].median()) / max(mad, 1e-6)
  1671. agg = agg.sort_values('z', ascending=False)
  1672. rows = [{'tid': str(r.tid), 'temp_offset': round(float(r.temp_offset), 2),
  1673. 'z': round(float(r.z), 2), 'n': int(r.n)} for r in agg.itertuples(index=False)]
  1674. # 绝对温度地板 (同 matched_load dt_abs_floor / vibration mad_floor): C层 z 分母无 floor → tight-fleet
  1675. # 亚度温差被放大成 z≥2 假阳 → 候选还须 temp_offset≥abs_temp_floor(=2°C, <2°C 冷却衰减无运维意义)。
  1676. flagged = [r['tid'] for r in rows if r['z'] >= z_thresh and r['temp_offset'] >= abs_temp_floor]
  1677. # ⚠ flagged (C层真衰减候选) 始终先 surface (Critic 2026-06-20 逮: 原 load_driven 分支在检查 flagged 前 return
  1678. # → 顶层 verdict 掩盖真衰减台, 违反卡"勿因防高估而漏检"). 整体因果/冷启作为限定语并报, 不吞掉个体候选。
  1679. if flagged:
  1680. top = rows[0]
  1681. ctx = ('负荷驱动温升(整体)' if causal == 'load_driven'
  1682. else ('低温冷启保护(整体)' if cooling_type == 'cold_start_release' else None))
  1683. verdict = (f'{ctx} + {len(flagged)}台同功率偏热候选' if ctx else '冷却衰减候选')
  1684. ev = (f'C层: 同功率bin(扣负荷消因果)内 {len(flagged)}台冷却温系统性偏高 top {top["tid"]} '
  1685. f'{top["temp_offset"]:+.1f}°C(z={top["z"]}); 散热器/水泵/风扇检修候选(真过热须30秒实温 vs F级155°C). '
  1686. + (causal_detail + '. ' if causal_detail else ''))
  1687. elif causal == 'load_driven':
  1688. verdict = '负荷驱动温升(非冷却衰减)'
  1689. ev = (causal_detail or '') + (f'; 极端高温@高风 P_med 仍近额定({near_rated_frac:.0%}rated)' if near_rated else '') \
  1690. + ' → 因果反置守卫: 不报"冷却衰减致降容"(郭家店 1338MWh→真197MWh 教训)'
  1691. elif cooling_type == 'cold_start_release':
  1692. verdict = '低温冷启保护(正常降容)'
  1693. ev = (f'释放曲线随温单调升 (升幅占比 {mono_frac:.0%}≥{mono_min_frac:.0%}): {rel_curve} '
  1694. f'= 冷油保护低温冷启 (暖机后释放, 冬季集中), 真降容但正常保护非过热')
  1695. elif rows:
  1696. verdict = '同功率温度fleet中位(正常)'
  1697. ev = f'同功率 bin 内各台温偏移皆 |z|<{z_thresh} (max |z|={max(abs(r["z"]) for r in rows):.1f}); 无系统性偏热'
  1698. else:
  1699. verdict = 'INSUFFICIENT'
  1700. ev = f'per 台样本不足(需≥{min_per_turbine}) 或无有效功率 bin'
  1701. return {'causal': causal, 'causal_detail': causal_detail, 'hi_minus_lo_kw': hi_lo,
  1702. 'near_rated_at_high_temp': near_rated, 'cooling_type': cooling_type,
  1703. 'release_monotonic_frac': mono_frac, 'release_curve': rel_curve,
  1704. 'per_turbine': rows, 'flagged': flagged, 'n_total': len(rows),
  1705. 'verdict': verdict, 'evidence': ev, 'dead_channel': dead_channel}
  1706. # ---------------------------------------------------------------- §4.1 3.6 + §4.6 卡桨筛查
  1707. def pitch_stick(df, *, turbine_col, time_col, blade_cols=None,
  1708. pitch_col=None, blade_id_col=None, moving_col=None,
  1709. rate_thresh=0.5, collective_spread_deg=0.5, z_thresh=2.0,
  1710. abs_floor_deg=1.0, abs_floor_mad_k=3.0, lag_consist_min=0.6,
  1711. min_move_samples=200, min_move_range_deg=3.0, min_turbines=4):
  1712. """变桨卡桨(stick)筛查 — 运动段三叶同步性 + 速率均衡 MAD-z (SOP §4.1 3.6 + §4.6 卡滞决策树).
  1713. 源 (RV-2 搬真逻辑不改判据): scripts/zyx_pitch_stick_sec.py + zyx_pitch_stick.py. n=1 [zyx F29/F35].
  1714. 仅做**秒级**卡桨筛查 (10min 层平均掉运动细节)。
  1715. ⭐⭐ 能力边界 (诚实): SCADA 只筛**卡死(stick)**, 筛不出**渐进磨损(wear)** —— wear≠stick。zyx OEM 确诊
  1716. F29/F35 轴承 Fe 磨损但 SCADA 三叶同步/速率/(电机电流死列) 全正常 = double-null。渐进磨损确诊**必油脂化验**。
  1717. 陷阱: ② 变桨电机电流=死列, 桨距给定镜像实际 → 直接判据废 → 用运动段三叶同步性(非静态/非给定)。
  1718. ③ 集距死(三桨同值, per-row 极差≈0) → 不可分 → INSUFFICIENT。 ④ 必在运动段比同步性。
  1719. 算法 per 台: 运动段 = moving_col 或 |Δβ|/Δt>rate_thresh 派生; ① 三叶同步性 = 运动段 per-row 极差 p95
  1720. (用 p95 非中位: 真卡滞常间歇, 中位被健康段冲淡); ② 速率均衡 = per 叶速率中位 → lag_blade 离群叶;
  1721. fleet MAD-z (RULE-4): sync_spread 跨台 MAD-z = 主轴; 卡滞候选 = sync_z≥z_thresh ∧ (sync_spread−
  1722. fleet中位)≥abs_floor_deg (绝对地板拦健康场 MAD→0 微 z 假阳)。<min_turbines 台跳 fleet z。
  1723. 入: 宽表 blade_cols=[叶1,叶2,叶3] 或 长表 pitch_col+blade_id_col; moving_col 可选。
  1724. 返回 dict: {per_turbine:[{tid,sync_spread_deg,rate_imbalance,lag_blade,rate_by_blade,n_move,
  1725. sync_z,imb_z,verdict,note}], flagged, collective_pitch, n_turbines, wear_blind_caveat, caveat}。
  1726. """
  1727. WEAR_CAVEAT = ('⚠ SCADA 只筛卡死(stick), 对渐进磨损(wear)盲: zyx F29/F35 OEM确诊轴承Fe磨损但'
  1728. '三叶同步/速率/(电机电流死列)全正常=double-null。渐进磨损确诊须油脂取样化验'
  1729. '(offline lab, Fe/Cu/水分/基础油), 非可选。')
  1730. if blade_cols:
  1731. d = df.dropna(subset=[turbine_col, time_col]).copy()
  1732. bcols = list(blade_cols)
  1733. elif pitch_col and blade_id_col:
  1734. d = df.dropna(subset=[turbine_col, time_col, blade_id_col, pitch_col]).copy()
  1735. piv = d.pivot_table(index=[turbine_col, time_col], columns=blade_id_col,
  1736. values=pitch_col, aggfunc='mean')
  1737. bcols = [f'_b{c}' for c in piv.columns]
  1738. piv.columns = bcols
  1739. d = piv.reset_index()
  1740. else:
  1741. raise ValueError('须给 blade_cols=[叶1,叶2,叶3] 或 (pitch_col + blade_id_col)')
  1742. if len(bcols) < 3:
  1743. return {'per_turbine': [], 'flagged': [], 'collective_pitch': False, 'n_turbines': 0,
  1744. 'wear_blind_caveat': WEAR_CAVEAT, 'caveat': f'三叶不全 (仅 {len(bcols)} 叶) → INSUFFICIENT'}
  1745. for c in bcols:
  1746. d[c] = pd.to_numeric(d[c], errors='coerce')
  1747. d = d.dropna(subset=bcols)
  1748. row_spread_all = d[bcols].max(axis=1) - d[bcols].min(axis=1) # ③ 集距死守卫
  1749. if len(row_spread_all) and float((row_spread_all <= collective_spread_deg).mean()) > 0.98:
  1750. return {'per_turbine': [], 'flagged': [], 'collective_pitch': True,
  1751. 'n_turbines': int(d[turbine_col].nunique()), 'wear_blind_caveat': WEAR_CAVEAT,
  1752. 'caveat': (f'集距死: {float((row_spread_all <= collective_spread_deg).mean()):.0%} 样本三叶'
  1753. f'极差 ≤{collective_spread_deg}° (三桨同值/集距控制) → per叶不可分 → 卡桨筛查 '
  1754. f'INSUFFICIENT。须秒级独立桨距通道 (集距场转 1P 谐波/振动, 见 blade_imbalance)。')}
  1755. recs = []
  1756. for tid, g in d.groupby(turbine_col):
  1757. g = g.sort_values(time_col)
  1758. B = g[bcols].to_numpy(float)
  1759. if moving_col and moving_col in g:
  1760. mv = g[moving_col].to_numpy(bool)
  1761. rate = np.abs(np.diff(B, axis=0))
  1762. mv_rate = mv[1:]
  1763. else:
  1764. rate = np.abs(np.diff(B, axis=0))
  1765. mv_rate = rate.max(axis=1) > rate_thresh
  1766. if mv_rate.sum() < min_move_samples:
  1767. recs.append({'tid': str(tid), 'sync_spread_deg': None, 'rate_imbalance': None,
  1768. 'lag_blade': None, 'rate_by_blade': None, 'n_move': int(mv_rate.sum()),
  1769. 'sync_z': None, 'imb_z': None, 'verdict': 'INSUFFICIENT(运动段不足)',
  1770. 'note': f'运动段样本 {int(mv_rate.sum())} < {min_move_samples}'})
  1771. continue
  1772. mv_rows = np.r_[False, mv_rate] | np.r_[mv_rate, False]
  1773. mv_rows = mv_rows[:len(B)]
  1774. Bm = B[mv_rows]
  1775. # 运动段净幅守卫 (Critic 逮: rate_thresh 被 AR(1) 噪声尖峰击穿→静止噪声台被当"运动"假阳).
  1776. # 真变桨在运动段会真实扫过角度范围; 纯噪声只在小带抖 → 桨距幅<阈 = 疑噪声非真运动 → INSUFFICIENT。
  1777. move_range = float(Bm.max() - Bm.min()) if Bm.size else 0.0
  1778. if move_range < min_move_range_deg:
  1779. recs.append({'tid': str(tid), 'sync_spread_deg': None, 'rate_imbalance': None,
  1780. 'lag_blade': None, 'rate_by_blade': None, 'n_move': int(mv_rate.sum()),
  1781. 'sync_z': None, 'imb_z': None, 'verdict': 'INSUFFICIENT(疑噪声非真运动)',
  1782. 'note': f'运动段桨距幅 {move_range:.1f}°<{min_move_range_deg}° (疑噪声尖峰被当运动段, 非真变桨)'})
  1783. continue
  1784. spread = (Bm.max(axis=1) - Bm.min(axis=1))
  1785. sync_spread = float(np.percentile(spread, 95)) if spread.size else float('nan')
  1786. rate_mv = rate[mv_rate]
  1787. rate_by_blade = np.median(rate_mv, axis=0)
  1788. rmax = float(rate_by_blade.max()); rmin = float(rate_by_blade.min())
  1789. imb = float((rmax - rmin) / rmax) if rmax > 1e-9 else 0.0
  1790. # lag 一致性 (替 median-rate 确认, Critic 逮后改): 高 spread 行里**同一叶**持续当离群的占比.
  1791. # 真卡死(即便间歇): 冻结叶在冻结窗里恒为离群 → 一致性高(~1.0); 三叶同噪健康台: 离群叶逐行随机 → ~0.33.
  1792. # (median 每叶速率washes out 间歇冻结的 75% 正常运动, 故不用 rate_imbalance 当闸, 只留作 info。)
  1793. hi = spread >= np.percentile(spread, 95) if spread.size else np.zeros(len(Bm), bool)
  1794. lag_consistency = 0.0
  1795. if hi.any():
  1796. dev = np.abs(Bm[hi] - np.median(Bm[hi], axis=1, keepdims=True)) # (H,3)
  1797. counts = np.bincount(np.argmax(dev, axis=1), minlength=3) # 每高spread行最离群叶 → 计数
  1798. lag_blade = int(np.argmax(counts) + 1)
  1799. lag_consistency = float(counts.max() / counts.sum())
  1800. else:
  1801. lag_blade = int(np.argmin(rate_by_blade) + 1)
  1802. recs.append({'tid': str(tid), 'sync_spread_deg': round(sync_spread, 3),
  1803. 'rate_imbalance': round(imb, 3), 'lag_blade': lag_blade,
  1804. 'lag_consistency': round(lag_consistency, 3),
  1805. 'rate_by_blade': [round(float(x), 3) for x in rate_by_blade],
  1806. 'n_move': int(mv_rate.sum()), 'sync_z': None, 'imb_z': None,
  1807. 'verdict': None, 'note': ''})
  1808. valid = [r for r in recs if r['sync_spread_deg'] is not None]
  1809. if len(valid) >= min_turbines:
  1810. def _madz(vals):
  1811. v = np.asarray(vals, float)
  1812. med = np.median(v)
  1813. mad = np.median(np.abs(v - med)) * 1.4826
  1814. return (v - med) / mad if mad > 1e-9 else np.zeros_like(v)
  1815. sync_vals = [r['sync_spread_deg'] for r in valid]
  1816. sync_med = float(np.median(sync_vals))
  1817. sync_mad = float(np.median(np.abs(np.asarray(sync_vals) - sync_med)) * 1.4826)
  1818. # 绝对地板按真实噪声本底自适应 (Critic 逮: abs_floor=1° < 实测健康噪声 p95~1.4°, 是按确定性合成定的偏低).
  1819. # 取 max(abs_floor_deg, k·fleet_MAD) → 健康噪声大时地板随之抬高, 不被单台噪声台击穿。
  1820. eff_floor = max(abs_floor_deg, abs_floor_mad_k * sync_mad)
  1821. sz = _madz(sync_vals)
  1822. iz = _madz([r['rate_imbalance'] for r in valid])
  1823. for r, a, b in zip(valid, sz, iz):
  1824. r['sync_z'] = round(float(a), 2)
  1825. r['imb_z'] = round(float(b), 2)
  1826. # 卡滞 = 同步极差离群(主轴) ∧ 绝对超本底 ∧ **lag 一致性确认**(Critic 逮 名实不符: 原只 sync 单轴,
  1827. # 分不出"一叶冻结"vs"三叶同噪同步"). lag_consistency = 高spread行里同一叶持续离群占比: 真卡死~1.0,
  1828. # 同噪健康台~0.33 → ≥lag_consist_min 确认是单叶滞后非随机噪声 (对间歇卡死稳, median-rate 会washes out)。
  1829. stick = ((a >= z_thresh) and ((r['sync_spread_deg'] - sync_med) >= eff_floor)
  1830. and r['lag_consistency'] >= lag_consist_min)
  1831. r['verdict'] = '卡滞候选' if stick else '同步健康'
  1832. if stick:
  1833. r['note'] = (f'运动段三叶同步极差离群 (sync_z={r["sync_z"]}, 超中位 '
  1834. f'{r["sync_spread_deg"] - sync_med:+.2f}°≥地板{eff_floor:.2f}°) ∧ lag一致性确认 '
  1835. f'({r["lag_consistency"]:.0%}同叶离群≥{lag_consist_min:.0%}); '
  1836. f'冻结/滞后叶={r["lag_blade"]}; 须变桨系统检查+取脂送检')
  1837. flagged = [r['tid'] for r in valid if r['verdict'] == '卡滞候选']
  1838. zcaveat = f'地板={eff_floor:.2f}°(max(1°,3·MAD)); 卡滞须 sync离群∧绝对超本底∧lag一致性 三确认(防同噪健康台假阳)。'
  1839. else:
  1840. for r in valid:
  1841. r['verdict'] = '单台(无fleet基线)'
  1842. r['note'] = f'有效台<{min_turbines}, 无 fleet MAD-z 基线 → 只给原始指标, 不出卡滞定论'
  1843. flagged = []
  1844. zcaveat = f'有效台 {len(valid)}<{min_turbines}: 无 fleet z 基线, 不出 flagged (须跨台 null 分布, RULE-4)。'
  1845. recs.sort(key=lambda r: (r['sync_z'] is None, -(r['sync_z'] or -99)))
  1846. return {'per_turbine': recs, 'flagged': flagged, 'collective_pitch': False,
  1847. 'n_turbines': len(valid), 'wear_blind_caveat': WEAR_CAVEAT,
  1848. 'caveat': ('运动段三叶同步性(极差p95)+速率均衡 fleet MAD-z; 仅筛卡死(stick), 渐进磨损(wear)盲须化验; '
  1849. '卡死签名须卡死叶冻结窗叠被指令运动窗(否则盲); 集距死场不可分→INSUFFICIENT; '
  1850. '须秒级独立桨距三叶通道。' + zcaveat)}
  1851. # ================================================================ 长清 loop 提级 (2026-06-28; [暂行]→firm 落地)
  1852. # 三函数抽自长清专项复证 (静偏 §4.4⑥⑦ n=6 / 分机型 §4.5+§4.6 n=2 / 时长口径 §5 D4 长清). RV-2 搬真逻辑.
  1853. # ---------------------------------------------------------------- §4.4 ⑥⑦ 静偏: offset vs scatter + 自指守卫
  1854. def static_yaw_assessment(df, *, turbine_col, yaw_err_col, power_col=None, ws_col=None,
  1855. rated=None, gen_frac=0.05, min_per_turbine=200,
  1856. offset_warn=3.0, robust_z=3.0, ci_z=1.96,
  1857. cos2_ws_lo=6.0, cos2_ws_hi=9.0):
  1858. """静态偏航评估 (SOP §4.4 ⑥⑦; 源 datang-crosscheck 五场 + 长清复证, n=6).
  1859. 分两件事别混: **offset (per台中位 = vane 系统偏置)** vs **scatter (per台 std = 跟踪质量/湍流)**。
  1860. - vane 中位≈0 = 自指, 只证控制器跟随 vane, **禁下"对风良好"[定论]** → self_ref_insufficient=True (须 dome-fit/cos² 复核)。
  1861. - 真静偏候选 = |offset| 跨台 robust-z≥robust_z ∧ |offset|>offset_warn ∧ offset 的 SE-CI 排除 0 (⑦ 峰CI门 SE 近似)。
  1862. - scatter 离群 (std robust-z 高) = 湍流/松控 (watch), **非机械偏航病**, 不与 offset 候选混。
  1863. - 有 power+ws 时给 cos² 损失旁证: 候选台中风段功率随 |yaw_err| 是否按 cos² 掉; 无掉 = vane 标定偏非真气流失配。
  1864. 实证: 长清 25 台静偏全亚度 (中位 0.17°/MAD 0.18°, 最大 A12 0.54° z2.1 / A20 0.53° z2.0, 无 z≥3),
  1865. A12/A20 排名靠前是 scatter(std 11.5°/11.3° z>3) 非 offset, 无 cos² 损失 → 全场无真静偏, A12/A20 仅 watch。
  1866. 返回 {per_turbine, self_ref_insufficient, fleet_offset_median, fleet_offset_mad,
  1867. static_bias_candidates, scatter_candidates, cos2_loss_pct, verdict}。
  1868. """
  1869. d = df.dropna(subset=[turbine_col, yaw_err_col]).copy()
  1870. d['_y'] = pd.to_numeric(d[yaw_err_col], errors='coerce')
  1871. if power_col is not None and rated is not None:
  1872. d = d[pd.to_numeric(d[power_col], errors='coerce') > gen_frac * rated] # 发电态 (排 idle/偏航激活)
  1873. g = d.dropna(subset=['_y']).groupby(turbine_col)['_y']
  1874. stat = g.agg(['median', 'std', 'count'])
  1875. stat = stat[stat['count'] >= min_per_turbine]
  1876. if len(stat) < 2:
  1877. return {'per_turbine': [], 'self_ref_insufficient': None, 'fleet_offset_median': None,
  1878. 'fleet_offset_mad': None, 'static_bias_candidates': [], 'scatter_candidates': [],
  1879. 'cos2_loss_pct': {}, 'verdict': f'有效台<2 (per台需≥{min_per_turbine})'}
  1880. fleet_med = float(stat['median'].median())
  1881. mad = float((stat['median'] - fleet_med).abs().median()) or 1e-9
  1882. scat_med = float(stat['std'].median())
  1883. scat_mad = float((stat['std'] - scat_med).abs().median()) or 1e-9
  1884. recs = []
  1885. for tid, row in stat.iterrows():
  1886. off, sd, n = float(row['median']), float(row['std']), int(row['count'])
  1887. off_z = (off - fleet_med) / (1.4826 * mad)
  1888. scat_z = (sd - scat_med) / (1.4826 * scat_mad)
  1889. se_med = 1.2533 * sd / np.sqrt(n) # 中位标准误 ≈ 1.2533·σ/√n
  1890. ci_lo, ci_hi = off - ci_z * se_med, off + ci_z * se_med
  1891. ci_excl0 = not (ci_lo <= 0 <= ci_hi)
  1892. is_bias = (abs(off_z) >= robust_z) and (abs(off) > offset_warn) and ci_excl0
  1893. is_scatter = scat_z >= robust_z
  1894. kind = '静偏候选' if is_bias else ('散布离群(湍流/松控·watch)' if is_scatter else '正常')
  1895. recs.append({'tid': str(tid), 'offset_deg': round(off, 3), 'scatter_std_deg': round(sd, 2), 'n': n,
  1896. 'offset_robust_z': round(float(off_z), 2), 'scatter_robust_z': round(float(scat_z), 2),
  1897. 'offset_ci': [round(float(ci_lo), 3), round(float(ci_hi), 3)], 'kind': kind})
  1898. recs.sort(key=lambda r: -abs(r['offset_robust_z']))
  1899. bias = [r['tid'] for r in recs if r['kind'] == '静偏候选']
  1900. scatter = [r['tid'] for r in recs if r['kind'].startswith('散布')]
  1901. max_abs_off = max(abs(r['offset_deg']) for r in recs)
  1902. self_ref = (len(bias) == 0) and (max_abs_off <= offset_warn)
  1903. cos2 = {}
  1904. if power_col is not None and ws_col is not None and bias:
  1905. ww = pd.to_numeric(d[ws_col], errors='coerce')
  1906. band = (ww >= cos2_ws_lo) & (ww <= cos2_ws_hi)
  1907. for tid in bias:
  1908. sub = d[(d[turbine_col].astype(str) == tid) & band]
  1909. if len(sub) < 50:
  1910. cos2[tid] = None; continue
  1911. ye = sub['_y'].abs(); pw = pd.to_numeric(sub[power_col], errors='coerce')
  1912. base, hi = pw[ye < 1.0].median(), pw[(ye >= 2.0) & (ye < 6.0)].median()
  1913. cos2[tid] = (None if (pd.isna(base) or pd.isna(hi) or base <= 0)
  1914. else round(float(hi / base - 1.0) * 100, 1)) # <0=有损失(真失配); ≈0=vane偏
  1915. if self_ref:
  1916. verdict = (f'全场 |offset|≤{offset_warn:.0f}° (最大 {max_abs_off:.2f}°), 无 vane 系统偏置; '
  1917. f'但 vane 中位≈0 自指 → 禁"对风良好"[定论], 须 dome-fit/cos² 复核')
  1918. elif bias:
  1919. verdict = f'静偏候选 {bias} (offset 离群+CI排0); cos²损失%={cos2 or "未算"}'
  1920. else:
  1921. verdict = f'无静偏候选; 散布离群 {scatter} = 湍流/松控 watch (非机械偏航病)'
  1922. return {'per_turbine': recs, 'self_ref_insufficient': self_ref,
  1923. 'fleet_offset_median': round(fleet_med, 3), 'fleet_offset_mad': round(mad, 3),
  1924. 'static_bias_candidates': bias, 'scatter_candidates': scatter,
  1925. 'cos2_loss_pct': cos2, 'verdict': verdict}
  1926. # ---------------------------------------------------------------- §4.5/§4.6/§1.2 齿比聚类 → 机型变体 (查收资表先于"编码器artifact")
  1927. def gear_ratio_model_clusters(df, *, turbine_col, gen_rpm_col, rotor_rpm_col, power_col=None,
  1928. rated=None, gen_frac=0.30, rotor_min=3.0, rotor_max=25.0,
  1929. min_per_turbine=200, outlier_pct=2.0, rotor_dia_ref=None):
  1930. """齿比(gen/rotor)聚类识别同场混入的不同机型/叶轮变体 (SOP §4.5 分机型 + §4.6 转速比型内对照 + §1.2; 长清A17, n=2).
  1931. **顺序硬门 (纠错教训)**: 单台齿比/转速离群, 判"编码器 PPR config artifact / rescale" 前 **必先核官方收资表机型** ——
  1932. 同 OEM 同容量同场可能混不同转子直径变体。**机型差异铁证 = 转子直径比 ≈ 齿比比 ∧ 同设计叶尖速** (大转子配大齿比落同 gen rpm)。
  1933. 实证: 长清 A17 齿比 124.69 vs 余 24 台 117.84 (官方 W2000-105=117.91); 收资表 A17=W2000-111(111m) 余 W2000-105(105m);
  1934. 111/105=1.057 ≈ 124.69/117.91=1.058; A17 14.46rpm×111 ≈ fleet 15.33×105 ≈ 84m/s 同叶尖速 → 机型差异非编码器偏 →
  1935. **不可 rescale 归一** (×1.058 会推到 89m/s 超设计 6%, 把正确读数搞坏)。gen rpm fleet-normal 而 rotor 系统低 = 大转子真低转速。
  1936. rotor_dia_ref={anchor:直径} 给则反推离群台疑似直径 = base × 齿比比。
  1937. 返回 {per_turbine, fleet_ratio_median, n_turbines, outliers, verdict}。
  1938. """
  1939. d = df.dropna(subset=[turbine_col, gen_rpm_col, rotor_rpm_col]).copy()
  1940. gen = pd.to_numeric(d[gen_rpm_col], errors='coerce')
  1941. rot = pd.to_numeric(d[rotor_rpm_col], errors='coerce')
  1942. mask = rot.between(rotor_min, rotor_max) & (gen > 0)
  1943. if power_col is not None and rated is not None:
  1944. mask &= pd.to_numeric(d[power_col], errors='coerce') > gen_frac * rated
  1945. d = d[mask].assign(_r=(gen / rot)[mask])
  1946. g = d.dropna(subset=['_r']).groupby(turbine_col)['_r']
  1947. med, cnt = g.median(), g.count()
  1948. med = med[cnt >= min_per_turbine]
  1949. if len(med) < 2:
  1950. return {'per_turbine': {}, 'fleet_ratio_median': None, 'n_turbines': int(len(med)),
  1951. 'outliers': [], 'verdict': f'有效台<2 (per台需≥{min_per_turbine})'}
  1952. fleet_med = float(med.median())
  1953. rel = (med - fleet_med) / fleet_med * 100.0
  1954. outliers = []
  1955. for tid, r in med.items():
  1956. relpct = float((r - fleet_med) / fleet_med * 100.0)
  1957. if abs(relpct) > outlier_pct:
  1958. rr = float(r / fleet_med)
  1959. e = {'tid': str(tid), 'ratio': round(float(r), 2), 'rel_pct': round(relpct, 2),
  1960. 'ratio_ratio_vs_fleet': round(rr, 4),
  1961. 'suspect': '不同机型/叶轮变体 (先核收资表机型, 非编码器artifact; 转子直径比≈齿比比+同叶尖速=铁证; 禁rescale归一)'}
  1962. if rotor_dia_ref:
  1963. e['inferred_rotor_dia_m'] = round(float(next(iter(rotor_dia_ref.values()))) * rr, 1)
  1964. outliers.append(e)
  1965. if not outliers:
  1966. verdict = f'齿比紧带 (中位 {fleet_med:.2f}, 极差 {float(rel.max() - rel.min()):.1f}%), 全场同机型, 无变体离群'
  1967. else:
  1968. verdict = (f'{len(outliers)} 台齿比离群(>{outlier_pct:.0f}%): {[o["tid"] for o in outliers]} → '
  1969. f'**先核收资表机型** (疑不同转子直径变体, 非编码器artifact, 禁rescale归一)')
  1970. return {'per_turbine': {str(k): round(float(v), 2) for k, v in med.items()},
  1971. 'fleet_ratio_median': round(fleet_med, 2), 'n_turbines': int(len(med)),
  1972. 'outliers': outliers, 'verdict': verdict}
  1973. # ---------------------------------------------------------------- §5 D4/§4.6 故障 计数 vs 真时长口径 (穿越码膨胀)
  1974. def _parse_dt_ms(s):
  1975. """解析 'YYYY-MM-DD HH:MM:SS:fff' (末位:为毫秒) 等时戳; 退回标准 to_datetime。"""
  1976. s2 = s.astype(str).str.strip().str.replace(
  1977. r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):(\d+)$', r'\1.\2', regex=True)
  1978. return pd.to_datetime(s2, errors='coerce')
  1979. def fault_duration_caliber(df, *, code_col, activate_col, reset_col, desc_col=None,
  1980. short_event_s=120.0, valid_max_h=24.0, top_gap=2.0, top_n=5):
  1981. """故障台账 计数 vs 真时长 口径 (SOP §5 D4 + §4.6; 源 长清 90027, n=1).
  1982. 计数 Top ≠ 真问题: 单次极短(<short_event_s)的高频穿越/瞬时码 (如高偏航误差穿越), 每次独立计数 → 计数膨胀;
  1983. 真口径 = Σ(reset−activate) 时长。is_count_artifact = 单次中位<short_event_s ∧ 计数排名 top_n 内 ∧ 时长排名远差(≥计数排名×top_gap)。
  1984. 实证: 长清 90027 高偏航误差穿越 计数 126552(48.4% #1) → 真时长 2102h(2.8%, 计数#1→描述级时长#3), 每次中位 54.5s,
  1985. 偏航族(含软启动器等)膨胀 5.77x; 时长真 #1#2 = SCADA 手停(4889h)/湍流保护(4300h) (非偏航)。
  1986. 返回 {per_code(按时长降序), count_top, duration_top, count_artifacts, n_events}。
  1987. """
  1988. d = df.copy()
  1989. dur_s = (_parse_dt_ms(d[reset_col]) - _parse_dt_ms(d[activate_col])).dt.total_seconds()
  1990. ok = dur_s.between(0, valid_max_h * 3600)
  1991. d = d.assign(_dur=dur_s)[ok]
  1992. if len(d) == 0:
  1993. return {'per_code': [], 'count_top': [], 'duration_top': [], 'count_artifacts': [],
  1994. 'n_events': 0, 'reason': '无可解析时长事件 (查时戳格式/双时戳列)'}
  1995. desc_map = {}
  1996. if desc_col is not None:
  1997. desc_map = d.groupby(code_col)[desc_col].agg(
  1998. lambda s: s.astype(str).mode().iloc[0] if len(s.dropna()) else '').to_dict()
  1999. g = d.groupby(code_col)['_dur']
  2000. agg = pd.DataFrame({'count': g.size(), 'dur_h': g.sum() / 3600.0,
  2001. 'median_s': g.median(), 'p95_s': g.quantile(0.95)})
  2002. tot_c, tot_h = agg['count'].sum(), agg['dur_h'].sum()
  2003. agg['count_rank'] = agg['count'].rank(ascending=False, method='min').astype(int)
  2004. agg['dur_rank'] = agg['dur_h'].rank(ascending=False, method='min').astype(int)
  2005. recs = []
  2006. for code, row in agg.iterrows():
  2007. c_share = row['count'] / tot_c * 100 if tot_c else 0.0
  2008. h_share = row['dur_h'] / tot_h * 100 if tot_h else 0.0
  2009. artifact = (row['median_s'] < short_event_s and row['count_rank'] <= top_n
  2010. and row['dur_rank'] >= row['count_rank'] * top_gap)
  2011. recs.append({'code': str(code), 'desc': str(desc_map.get(code, '')),
  2012. 'count': int(row['count']), 'dur_h': round(float(row['dur_h']), 1),
  2013. 'median_event_s': round(float(row['median_s']), 1),
  2014. 'count_share_pct': round(float(c_share), 1), 'dur_share_pct': round(float(h_share), 1),
  2015. 'count_rank': int(row['count_rank']), 'dur_rank': int(row['dur_rank']),
  2016. 'is_count_artifact': bool(artifact),
  2017. 'inflation_x': round(float(c_share / h_share), 2) if h_share > 0 else None})
  2018. by_c = sorted(recs, key=lambda r: -r['count'])
  2019. by_h = sorted(recs, key=lambda r: -r['dur_h'])
  2020. return {'per_code': by_h, 'count_top': [r['code'] for r in by_c[:top_n]],
  2021. 'duration_top': [r['code'] for r in by_h[:top_n]],
  2022. 'count_artifacts': [r['code'] for r in recs if r['is_count_artifact']],
  2023. 'n_events': int(len(d))}
  2024. # ================================================================ n≥2 待实现批 提级 (2026-06-28 续; 昨晚训练)
  2025. # ---------------------------------------------------------------- §4.3 内置可利用率列 优先但先验真 (glitchy报0却满发; n=3)
  2026. def builtin_avail_verify(df, *, avail_col, p_col, turbine_col, rated, gen_frac=0.02,
  2027. unavail_frac=0.5, glitch_tol=0.02, min_per_turbine=200):
  2028. """内置可利用率列 优先但先验真 (SOP §4.3; 源 #10/#16, n=3).
  2029. 内置 avail 列是 OEM 自算 ground truth (优于粗状态派生: 后者把降额/待机变体误当停机, 0.919 vs 真 100),
  2030. 但 glitchy 列会报"不可用"却满发 (t30/47 avail≈0 而 P~2100kW) → 必交叉发电态验真:
  2031. 发电态(P>gen_frac·rated)行里被内置列标"不可用"(avail≤unavail_frac·标度)的占比 > glitch_tol → glitchy, use_builtin=False 退状态派生。
  2032. avail 自动判 0-1 / 0-100 标度。返回 {per_turbine, glitchy_turbines, fleet_use_builtin}。
  2033. """
  2034. d = df.dropna(subset=[turbine_col, avail_col, p_col]).copy()
  2035. a = pd.to_numeric(d[avail_col], errors='coerce'); p = pd.to_numeric(d[p_col], errors='coerce')
  2036. scale = 100.0 if a.dropna().quantile(0.95) > 1.5 else 1.0
  2037. unavail_lvl = unavail_frac * scale
  2038. d = d.assign(_a=a, _gen=(p > gen_frac * rated), _unav=(a <= unavail_lvl))
  2039. recs = []
  2040. for tid, g in d.groupby(turbine_col):
  2041. if len(g) < min_per_turbine:
  2042. continue
  2043. n_gen = int(g['_gen'].sum())
  2044. frac = float((g['_gen'] & g['_unav']).sum() / n_gen) if n_gen else 0.0
  2045. glitchy = frac > glitch_tol
  2046. recs.append({'tid': str(tid), 'avail_builtin_mean': round(float(g['_a'].mean() / scale), 4),
  2047. 'gen_while_unavail_frac': round(frac, 4), 'n_gen': n_gen,
  2048. 'glitchy': glitchy, 'use_builtin': not glitchy})
  2049. recs.sort(key=lambda r: -r['gen_while_unavail_frac'])
  2050. glitchy = [r['tid'] for r in recs if r['glitchy']]
  2051. return {'per_turbine': recs, 'glitchy_turbines': glitchy, 'fleet_use_builtin': len(glitchy) == 0,
  2052. 'caveat': f'glitchy(发电态被内置列标不可用>{glitch_tol:.0%})→退状态派生; 全clean→内置列优先(避粗状态把降额/待机误停机)'}
  2053. # ---------------------------------------------------------------- §4.3 零发电×风在限内 交叉红旗 (状态口径失真; n=2)
  2054. def zero_gen_redflag(df, *, ws_col, p_col, turbine_col, rated, cutin=3.0, cutout=25.0,
  2055. gen_frac=0.02, time_col=None, a_op=None, gap_pp=0.10,
  2056. zero_run_days=14, min_per_turbine=200):
  2057. """零发电×风在限内 交叉红旗 (SOP §4.3; 源 唐珍B8/jtxq DJ24, n=2).
  2058. per台 A_op(状态派生可用率, 外传) − A_WIL(风在限内发电率) > gap_pp(10pp) 或 连续≥zero_run_days 天 P≈0∧ws≥cutin
  2059. → 状态派生口径失真 (状态列谎报/功率通道死/长期人工停机) → 改报 WIL 口径并标 [需O&M核], 禁进 fleet 中位头条。
  2060. (唐珍 B8: A_op0.79/A_WIL0.29 四月零发电有风, 状态报 75-96% 可用)。a_op={tid:值} 可选; time_col 给则算最长连续零发有风天。
  2061. 返回 {per_turbine, redflag_turbines}。
  2062. """
  2063. d = df.dropna(subset=[turbine_col, ws_col, p_col]).copy()
  2064. ws = pd.to_numeric(d[ws_col], errors='coerce'); p = pd.to_numeric(d[p_col], errors='coerce')
  2065. d = d.assign(_inlim=ws.between(cutin, cutout), _gen=(p > gen_frac * rated))
  2066. recs = []
  2067. for tid, g in d.groupby(turbine_col):
  2068. if len(g) < min_per_turbine:
  2069. continue
  2070. inlim = g[g['_inlim']]
  2071. a_wil = float(inlim['_gen'].mean()) if len(inlim) else float('nan')
  2072. aop = (a_op or {}).get(str(tid), (a_op or {}).get(tid))
  2073. gap = (float(aop) - a_wil) if (aop is not None and pd.notna(a_wil)) else None
  2074. max_run = None
  2075. if time_col is not None and time_col in g.columns:
  2076. gg = g.assign(_t=pd.to_datetime(g[time_col], errors='coerce')).dropna(subset=['_t'])
  2077. day = gg.assign(_d=gg['_t'].dt.date).groupby('_d').agg(
  2078. zero=('_gen', lambda s: (~s).mean()), wind=('_inlim', 'mean'))
  2079. bad = ((day['zero'] > 0.8) & (day['wind'] > 0.3)).astype(int)
  2080. run = mx = 0
  2081. for b in bad:
  2082. run = run + 1 if b else 0
  2083. mx = max(mx, run)
  2084. max_run = int(mx)
  2085. redflag = bool((gap is not None and gap > gap_pp) or (max_run is not None and max_run >= zero_run_days))
  2086. recs.append({'tid': str(tid), 'a_wil': round(a_wil, 4) if pd.notna(a_wil) else None,
  2087. 'a_op': round(float(aop), 4) if aop is not None else None,
  2088. 'gap_pp': round(gap, 4) if gap is not None else None,
  2089. 'max_zero_gen_days': max_run, 'redflag': redflag})
  2090. recs.sort(key=lambda r: -((r['gap_pp'] or 0)))
  2091. return {'per_turbine': recs, 'redflag_turbines': [r['tid'] for r in recs if r['redflag']],
  2092. 'caveat': f'红旗台改报 A_WIL 口径 + 标[需O&M核], 禁进 fleet 中位; gap>{gap_pp:.0%} 或 连续≥{zero_run_days}天零发有风'}
  2093. # ---------------------------------------------------------------- §4.8 变桨电机超温·活动依赖守卫 (真卡涩 vs 加性零偏; n=9)
  2094. def pitch_motor_temp_activity_guard(df, *, motor_temp_col, pitch_col, turbine_col, time_col,
  2095. p_col, gen_rpm_col=None, amb_col=None, fleet_temp_col=None,
  2096. pitch_rate_active=0.3, parked_p=5.0, parked_rpm=10.0,
  2097. dt_floor=2.0, min_per_turbine=300):
  2098. """变桨电机超温 活动依赖守卫 (SOP §4.8; 源 和风法跨9场, n=9: 7 REFUTE + 威海真 + 阜山锁底).
  2099. 真机构卡涩/润滑/轴承劣化产热只在变桨电机做功(变桨)时出现 → idle dT≈0 → active dT 骤升 → 对桨距速度 dose-response 单调升。
  2100. 反之 ①全活动档恒定温偏 ②停机(gen_rpm<parked_rpm ∧ P<parked_p)≈运行同温 ③冷季放大(corr(dT,amb)<0) 任一 = standing/加性零偏
  2101. = 传感(PT100)/接线/接触电阻/待机加热电气故障 非机构。dT = motor_temp − (fleet_temp_col 或 同时刻全场中位)。
  2102. 返回 per台 {dt_active, dt_parked, dose_response, cold_corr, kind}; kind∈{真卡涩候选, 加性零偏, 加热器锁底/standing, 待查, 正常}。
  2103. """
  2104. d = df.dropna(subset=[turbine_col, motor_temp_col, pitch_col, p_col]).copy()
  2105. d['_T'] = pd.to_numeric(d[motor_temp_col], errors='coerce')
  2106. d['_pitch'] = pd.to_numeric(d[pitch_col], errors='coerce')
  2107. d['_p'] = pd.to_numeric(d[p_col], errors='coerce')
  2108. d['_amb'] = pd.to_numeric(d[amb_col], errors='coerce') if amb_col and amb_col in d.columns else np.nan
  2109. if fleet_temp_col is not None:
  2110. d['_dT'] = d['_T'] - pd.to_numeric(d[fleet_temp_col], errors='coerce')
  2111. else:
  2112. d['_dT'] = d['_T'] - d.groupby(time_col)['_T'].transform('median') # 同时刻全场中位基线
  2113. recs = []
  2114. for tid, g in d.groupby(turbine_col):
  2115. g = g.sort_values(time_col)
  2116. if len(g) < min_per_turbine:
  2117. continue
  2118. rate = g['_pitch'].diff().abs()
  2119. active = rate > pitch_rate_active
  2120. if gen_rpm_col and gen_rpm_col in g.columns:
  2121. parked = (pd.to_numeric(g[gen_rpm_col], errors='coerce') < parked_rpm) & (g['_p'] < parked_p)
  2122. else:
  2123. parked = g['_p'] < parked_p
  2124. dt_active = float(g['_dT'][active].median()) if active.sum() > 20 else np.nan
  2125. dt_parked = float(g['_dT'][parked].median()) if parked.sum() > 20 else np.nan
  2126. dose = np.nan
  2127. m = active & g['_dT'].notna() & rate.notna()
  2128. if m.sum() > 50 and rate[m].std() > 0:
  2129. dose = float(np.corrcoef(rate[m], g['_dT'][m])[0, 1])
  2130. cold_corr = np.nan
  2131. ma = g['_dT'].notna() & g['_amb'].notna()
  2132. if ma.sum() > 50 and g['_amb'][ma].std() > 0:
  2133. cold_corr = float(np.corrcoef(g['_amb'][ma], g['_dT'][ma])[0, 1])
  2134. kind = '正常'
  2135. if pd.notna(dt_active) and dt_active > dt_floor:
  2136. if pd.notna(dt_parked) and dt_parked > dt_active - 1.0:
  2137. kind = '加性零偏(传感/接线/待机加热)'
  2138. elif pd.notna(cold_corr) and cold_corr < -0.2:
  2139. kind = '加热器锁底/standing'
  2140. elif pd.notna(dose) and dose > 0.2 and (pd.isna(dt_parked) or dt_parked < dt_floor):
  2141. kind = '真卡涩候选'
  2142. else:
  2143. kind = '待查(活动偏热但dose/parked不全)'
  2144. recs.append({'tid': str(tid), 'dt_active': round(dt_active, 2) if pd.notna(dt_active) else None,
  2145. 'dt_parked': round(dt_parked, 2) if pd.notna(dt_parked) else None,
  2146. 'dose_response': round(dose, 2) if pd.notna(dose) else None,
  2147. 'cold_corr': round(cold_corr, 2) if pd.notna(cold_corr) else None, 'kind': kind})
  2148. recs.sort(key=lambda r: -((r['dt_active'] if r['dt_active'] is not None else -99)))
  2149. return {'per_turbine': recs,
  2150. 'stick_candidates': [r['tid'] for r in recs if r['kind'] == '真卡涩候选'],
  2151. 'sensor_refuted': [r['tid'] for r in recs if '零偏' in r['kind'] or 'standing' in r['kind']],
  2152. 'caveat': '10min电流多场恒0→守卫只温度单腿, 真信号止于【预警】(须秒级电流波形+绝缘); 加热器锁底须pitch-WORK耦合+位置跟随滞后+floor检测补门'}
  2153. # ---------------------------------------------------------------- §4.6 温度伪报四类证伪分类 (默认REFUTED, 决定闸=载荷依赖; n=6)
  2154. def temp_sensor_falsify(df, *, comp_col, ref_col, p_col, turbine_col, rated,
  2155. ceiling=155.0, gen_frac=0.10, parked_p=5.0, idle_dt_tol=1.5,
  2156. slope_mult=1.05, min_per_turbine=300):
  2157. """温度伪报四类证伪分类法 (SOP §4.6; 默认 REFUTED, 决定闸=载荷依赖检验双向都验; 源跨6场, n=6).
  2158. NBM 裸 z 最大者多为传感器伪影。dt = comp − ref (ref=配对通道/同时刻全场中位/环温)。四类:
  2159. ①**加性零偏**: 温-温 slope≈1 ∧ 怠速/停机 dt 不归零 (真摩擦/铜损热应趋零) ∧ dt 不随载升 (海勒斯 a≈+13℃满发z塌+0.9)。
  2160. ②**乘性增益**: 温-温回归 slope>slope_mult (福山×1.10/义渡口A19×1.15) ∧ 怠速 dt 仍偏正。
  2161. ③**钉天花板/railed**: 绝缘上限(ceiling)钉死 ∧ 停机态占比高(无电流不可能 I²R) (肥城A2 8月railed 50%停机态)。
  2162. 决定闸: **加性偏随载升=真过热(I²R, 夏邱A39 +1.9→+7.2℃) / 全段恒定加性偏=零偏 REFUTE**。
  2163. 返回 per台 {tt_slope, idle_dt, load_rise, railed_frac, kind}; kind∈{加性零偏, 乘性增益, 钉天花板railed, 真过热候选, 待查}。
  2164. """
  2165. d = df.dropna(subset=[turbine_col, comp_col, ref_col, p_col]).copy()
  2166. d['_c'] = pd.to_numeric(d[comp_col], errors='coerce')
  2167. d['_r'] = pd.to_numeric(d[ref_col], errors='coerce')
  2168. d['_p'] = pd.to_numeric(d[p_col], errors='coerce')
  2169. d['_dt'] = d['_c'] - d['_r']
  2170. recs = []
  2171. for tid, g in d.groupby(turbine_col):
  2172. if len(g) < min_per_turbine:
  2173. continue
  2174. gen = g['_p'] > gen_frac * rated
  2175. parked = g['_p'] < parked_p
  2176. tt_slope = np.nan
  2177. sub = g[gen & g['_c'].notna() & g['_r'].notna()]
  2178. if len(sub) > 50 and sub['_r'].std() > 0:
  2179. tt_slope = float(np.polyfit(sub['_r'], sub['_c'], 1)[0])
  2180. idle_dt = float(g['_dt'][parked].median()) if parked.sum() > 20 else np.nan
  2181. load_rise = np.nan
  2182. if gen.sum() > 50 and g['_p'][gen].std() > 0:
  2183. load_rise = float(np.polyfit(g['_p'][gen], g['_dt'][gen], 1)[0] * rated) # 全载 dt 变化估计
  2184. railed = g['_c'] >= (ceiling - 1.0)
  2185. railed_frac = float(railed.mean())
  2186. railed_parked = float((railed & parked).sum() / max(int(railed.sum()), 1))
  2187. kind = '待查'
  2188. if railed_frac > 0.05 and railed_parked > 0.3:
  2189. kind = '钉天花板railed(共享采集/停机态钉死)'
  2190. elif pd.notna(tt_slope) and tt_slope > slope_mult and pd.notna(idle_dt) and idle_dt > idle_dt_tol:
  2191. kind = '乘性增益(传感器)'
  2192. elif pd.notna(idle_dt) and idle_dt > idle_dt_tol and (pd.isna(load_rise) or abs(load_rise) < 1.0):
  2193. kind = '加性零偏(传感/接线)'
  2194. elif pd.notna(idle_dt) and idle_dt <= idle_dt_tol and pd.notna(load_rise) and load_rise > 2.0:
  2195. kind = '真过热候选(加性偏随载升I²R)'
  2196. recs.append({'tid': str(tid), 'tt_slope': round(tt_slope, 3) if pd.notna(tt_slope) else None,
  2197. 'idle_dt': round(idle_dt, 2) if pd.notna(idle_dt) else None,
  2198. 'load_rise': round(load_rise, 2) if pd.notna(load_rise) else None,
  2199. 'railed_frac': round(railed_frac, 3), 'kind': kind})
  2200. recs.sort(key=lambda r: -((r['idle_dt'] if r['idle_dt'] is not None else -99)))
  2201. return {'per_turbine': recs,
  2202. 'real_overheat_candidates': [r['tid'] for r in recs if r['kind'].startswith('真过热')],
  2203. 'sensor_refuted': [r['tid'] for r in recs if any(k in r['kind'] for k in ('零偏', '乘性', 'railed'))],
  2204. 'caveat': '决定闸=载荷依赖(加性偏随载升=真I²R过热 / 全段恒定=零偏REFUTE); ref须先过liveness; 真过热候选上限【预警】须OEM绝对阈交叉'}
  2205. # ---------------------------------------------------------------- §4.6 部件温度判据家族 (热源模型 T=T_sink+Q·R, Batch3, 2026-07-02)
  2206. # 源: 郭家店发电机(2026-06-29~30)+齿轮箱(2026-07-01~02)双部件深挖 per-farm 脚本 (gjd_gen/gearbox_full_diagnose,
  2207. # gjd_thermal_daily/chains); 判据细则 docs/部件温度诊断判据SOP.md; DP 锚 B11/A6/A18/A19/A13/A23/A8。
  2208. def _load_band(df, p_col, rated, lo_frac, hi_frac):
  2209. p = pd.to_numeric(df[p_col], errors='coerce')
  2210. return df[(p >= lo_frac * rated) & (p <= hi_frac * rated)]
  2211. def idle_zero_check(df, *, comp_col, p_col, turbine_col, rpm_col=None, amb_col=None,
  2212. thermal_mass='small', p_max=5.0, rpm_max=1.0, bias_c=3.0,
  2213. amb_bin_c=5.0, min_idle_n=50, valid_range=(-40.0, 200.0)):
  2214. """idle 停机归零判据 — 加性探头偏检测, **必过热质量适用门** (SOP §4.6 idle 门; 部件温度SOP §2/§3)。
  2215. 原理: 停机态 Q≈0 → T≈T_热汇; 某台停机态仍比同状态机群偏 |dev|≥bias_c = 加性探头偏 (④传感器)。
  2216. ★热质量门 (郭家店 A19 戒): 齿轮箱热质量大, 停机后油/轴承滞热, "停机偏高"=热滞留非探头偏 —
  2217. A19 曾被 idle 误判"高速/低速加性", 改轴承-油ΔT 才归位整箱热。thermal_mass='large' → **拒跑**,
  2218. 返回 applicable=False + alternative 指向 node_minus_sink_dt + 现场RTD (教训焊进函数签名)。
  2219. ★ambient 分箱 (辉腾梁 T31 戒): idle 时机群环温散度大 (冷机位 −20~−25℃) 淹没零偏 → raw idle 假阴;
  2220. amb_col 给定时按环温带 (amb_bin_c) 分箱后组内比 (T31 raw z−0.25 → 分箱后 −6.33 显形)。
  2221. DP 锚: A23 冷却水 idle+7.4℃ (加性, 先校再评散热器) / A19 拒跑 / T31 分箱显形。
  2222. 出: {applicable, per_turbine:{tid:{idle_dev,n_idle}}, flagged(按|dev|降序), caveat}。
  2223. """
  2224. if thermal_mass == 'large':
  2225. return {'applicable': False,
  2226. 'reason': '大热质量部件(齿轮箱类) idle 失灵: 停机滞热=热滞留非探头偏 (郭家店 A19 戒)',
  2227. 'alternative': 'node_minus_sink_dt (轴承-油ΔT 一致性) + 现场 RTD 交叉'}
  2228. if thermal_mass != 'small':
  2229. return {'applicable': False, 'reason': "thermal_mass 须 'small'|'large'"}
  2230. p = pd.to_numeric(df[p_col], errors='coerce')
  2231. m = p < p_max
  2232. if rpm_col is not None and rpm_col in df.columns:
  2233. m &= pd.to_numeric(df[rpm_col], errors='coerce') < rpm_max
  2234. d = df[m].copy()
  2235. v = pd.to_numeric(d.get(comp_col), errors='coerce')
  2236. lo, hi = valid_range
  2237. d = d[(v >= lo) & (v <= hi)]
  2238. if d.empty:
  2239. return {'applicable': True, 'per_turbine': {}, 'flagged': [], 'note': '无停机态有效样本'}
  2240. cv = pd.to_numeric(d[comp_col], errors='coerce')
  2241. if amb_col is not None and amb_col in d.columns:
  2242. ab = pd.to_numeric(d[amb_col], errors='coerce')
  2243. d = d[ab.notna()].copy()
  2244. cv = pd.to_numeric(d[comp_col], errors='coerce')
  2245. d['_ab'] = (pd.to_numeric(d[amb_col], errors='coerce') / amb_bin_c).round() * amb_bin_c
  2246. d['_dev'] = cv - d.groupby('_ab')[comp_col].transform('median')
  2247. else:
  2248. d['_dev'] = cv - float(cv.median())
  2249. per = {}
  2250. for tid, g in d.groupby(turbine_col):
  2251. if len(g) < min_idle_n:
  2252. continue
  2253. per[str(tid)] = {'idle_dev': round(float(g['_dev'].median()), 2), 'n_idle': int(len(g))}
  2254. flagged = sorted((t for t, r in per.items() if abs(r['idle_dev']) >= bias_c),
  2255. key=lambda t: -abs(per[t]['idle_dev']))
  2256. return {'applicable': True, 'per_turbine': per, 'flagged': flagged,
  2257. 'caveat': 'idle 只挡加性; 乘性 idle≈0 挡不住须现场RTD; amb 分箱剥停机态环温散度 (辉腾梁T31戒)'}
  2258. def node_minus_sink_dt(df, *, node_col, sink_col, p_col, turbine_col, rated,
  2259. second_ref_col=None, dt_gate=4.0, sink_bias_c=5.0,
  2260. p_lo_frac=0.50, p_hi_frac=0.98, min_n=200, valid_range=(-40.0, 200.0)):
  2261. """局部摩擦判据 = 节点−即时热汇 ΔT (SOP §4.6 ΔT 家族; 部件温度SOP §0/§3)。
  2262. 原理: 即时热汇消共模, ΔT=该节点自身产热。实例: 齿箱 轴承−油 ≥dt_gate(4℃)=该轴承真摩擦
  2263. (高速/低速分); 发电机 绕组−冷却水 分 Q/R。主轴承脂润滑无即时热汇通道 → 勿用本函数, 走双分箱
  2264. (matched_load_overheat)。
  2265. ★参考先"活"再"准" (A13 戒): sink 逐台过 reference_channel_liveness (挡死/冻结); **liveness
  2266. 挡不住读偏** — A13 油温读低 −10℃ 致轴承-油ΔT 虚高 +20.4 假登顶。second_ref_col (冷却水/环温)
  2267. 给定时核 per台 (sink−second_ref) 相对机群偏离, dev≤−sink_bias_c (sink 读低) 或 ≥+sink_bias_c →
  2268. sink_suspect, 该台 verdict=INSUFFICIENT (先校 sink 探头; 行动紧迫度≠证据强度)。
  2269. DP 锚: A19 双轴承-油ΔT +11/+17=整箱 / A10 +4.2 / A13 油温读低→INSUFFICIENT。
  2270. 出: {per_turbine:{tid:{dt, sink_live, sink_dev, verdict}}, flagged, insufficient, fleet_dt_median}。
  2271. """
  2272. band = _load_band(df, p_col, rated, p_lo_frac, p_hi_frac).dropna(subset=[node_col, sink_col])
  2273. if band.empty:
  2274. return {'per_turbine': {}, 'flagged': [], 'insufficient': [], 'note': '匹配功率带内无样本'}
  2275. per, dts = {}, {}
  2276. for tid, g in band.groupby(turbine_col):
  2277. if len(g) < min_n:
  2278. continue
  2279. tid = str(tid)
  2280. live = reference_channel_liveness(g[sink_col], valid_range=valid_range, min_n=min(min_n, 200))
  2281. nd = pd.to_numeric(g[node_col], errors='coerce')
  2282. sk = pd.to_numeric(g[sink_col], errors='coerce')
  2283. dt = float((nd - sk).median())
  2284. rec = {'dt': round(dt, 2), 'sink_live': live['live'], 'sink_dev': None, 'verdict': '正常'}
  2285. if live['live'] is False:
  2286. rec['verdict'] = f'INSUFFICIENT(即时热汇通道死: {live["reason"]})'
  2287. else:
  2288. if second_ref_col is not None and second_ref_col in g.columns:
  2289. rf = pd.to_numeric(g[second_ref_col], errors='coerce')
  2290. rec['sink_dev'] = float((sk - rf).median())
  2291. per[tid] = rec
  2292. dts[tid] = dt
  2293. continue
  2294. per[tid] = rec
  2295. if dts and second_ref_col is not None:
  2296. sd = pd.Series({t: per[t]['sink_dev'] for t in dts if per[t]['sink_dev'] is not None})
  2297. if not sd.empty:
  2298. fleet_sd = float(sd.median())
  2299. for t in sd.index:
  2300. dev = sd[t] - fleet_sd
  2301. per[t]['sink_dev'] = round(dev, 2)
  2302. if abs(dev) >= sink_bias_c:
  2303. per[t]['verdict'] = (f'INSUFFICIENT(sink读偏嫌疑 {dev:+.1f}℃, 先校 {sink_col} 探头再评 '
  2304. f'{node_col}; A13戒)')
  2305. fleet_dt = float(pd.Series(dts).median()) if dts else None
  2306. for t, dt in dts.items():
  2307. over = dt - fleet_dt if fleet_dt is not None else np.nan
  2308. per[t]['dt_over_fleet'] = round(over, 2) if pd.notna(over) else None
  2309. # 候选门 = 超 fleet 中位 ≥ dt_gate (非裸绝对阈: 设计签名机型固定阈会假阳, EN156 NDE>DE 戒)
  2310. if per[t]['verdict'] == '正常' and pd.notna(over) and over >= dt_gate:
  2311. per[t]['verdict'] = f'候选(节点−热汇ΔT 超fleet中位 {over:+.1f}℃≥{dt_gate}=真摩擦嫌疑→CMS)'
  2312. flagged = sorted((t for t in per if per[t]['verdict'].startswith('候选')),
  2313. key=lambda t: -(per[t].get('dt_over_fleet') or 0.0))
  2314. insufficient = [t for t in per if per[t]['verdict'].startswith('INSUFFICIENT')]
  2315. return {'per_turbine': per, 'flagged': flagged, 'insufficient': insufficient,
  2316. 'fleet_dt_median': round(fleet_dt, 2) if fleet_dt is not None else None,
  2317. 'caveat': ('ΔT=相对判据上限【预警】(超peer口径, 防设计签名固定阈假阳); 绝对 dt 同报供 OEM 阈交叉; '
  2318. '齿箱高速/低速分列各跑; 主轴承(无即时热汇)勿用本函数走双分箱')}
  2319. def dende_spread(df, *, de_col, nde_col, p_col, turbine_col, rated,
  2320. spread_gate=5.0, idle_p_max=5.0, idle_bias_c=3.0,
  2321. hi_frac=(0.60, 0.98), lo_frac=(0.10, 0.35), min_n=200):
  2322. """配对轴承 DE−NDE 张开符号判据 + 单端探头污染守卫 (SOP §4.6; 部件温度SOP §3)。
  2323. 张开消共模 (同台同环境, Simpson 免疫): 高载张开 >>+spread_gate = DE 局部病 / <<−spread_gate =
  2324. NDE 局部病 / ≈0 = 非局部 (整体偏暖或传感器)。rise = 高载张开 − 低载张开 (真摩擦随载升)。
  2325. ★污染守卫 (A18 戒): 单端探头偏会撑高张开 — **idle 张开 |≥idle_bias_c| = 单端探头嫌疑**, verdict
  2326. 加"两端探头都验"(A18 = DE 真热 idle+1.3 + NDE 探头读低 idle−4.1 撑高张开 → 确诊DE+坏NDE探头)。
  2327. DP 锚: B11 +7.7(DE) / A6 −12.8(NDE) / A18 探头污染。
  2328. 出: {per_turbine:{tid:{spread_hi, spread_lo, rise, spread_idle, cls}}, de_flag, nde_flag, probe_suspect}。
  2329. """
  2330. p = pd.to_numeric(df[p_col], errors='coerce')
  2331. hi = df[(p >= hi_frac[0] * rated) & (p <= hi_frac[1] * rated)]
  2332. lo = df[(p >= lo_frac[0] * rated) & (p <= lo_frac[1] * rated)]
  2333. idle = df[p < idle_p_max]
  2334. per = {}
  2335. for tid, g in hi.groupby(turbine_col):
  2336. if len(g) < min_n:
  2337. continue
  2338. tid = str(tid)
  2339. sp = pd.to_numeric(g[de_col], errors='coerce') - pd.to_numeric(g[nde_col], errors='coerce')
  2340. s_hi = float(sp.median())
  2341. gl = lo[lo[turbine_col].astype(str) == tid]
  2342. s_lo = float((pd.to_numeric(gl[de_col], errors='coerce')
  2343. - pd.to_numeric(gl[nde_col], errors='coerce')).median()) if len(gl) >= 30 else np.nan
  2344. gi = idle[idle[turbine_col].astype(str) == tid]
  2345. s_id = float((pd.to_numeric(gi[de_col], errors='coerce')
  2346. - pd.to_numeric(gi[nde_col], errors='coerce')).median()) if len(gi) >= 30 else np.nan
  2347. rise = s_hi - s_lo if pd.notna(s_lo) else np.nan
  2348. if s_hi >= spread_gate:
  2349. cls = 'DE局部'
  2350. elif s_hi <= -spread_gate:
  2351. cls = 'NDE局部'
  2352. else:
  2353. cls = '非局部(整体或传感器)'
  2354. if pd.notna(s_id) and abs(s_id) >= idle_bias_c:
  2355. cls += f'+单端探头嫌疑(idle张开{s_id:+.1f}, 两端探头都验; A18戒)'
  2356. per[tid] = {'spread_hi': round(s_hi, 2), 'spread_lo': round(s_lo, 2) if pd.notna(s_lo) else None,
  2357. 'rise': round(rise, 2) if pd.notna(rise) else None,
  2358. 'spread_idle': round(s_id, 2) if pd.notna(s_id) else None, 'cls': cls}
  2359. return {'per_turbine': per,
  2360. 'de_flag': sorted((t for t, r in per.items() if r['cls'].startswith('DE局部')),
  2361. key=lambda t: -per[t]['spread_hi']),
  2362. 'nde_flag': sorted((t for t, r in per.items() if r['cls'].startswith('NDE局部')),
  2363. key=lambda t: per[t]['spread_hi']),
  2364. 'probe_suspect': [t for t, r in per.items() if '探头嫌疑' in r['cls']],
  2365. 'caveat': '张开定局部+端别; 真摩擦应 rise>0; ≈0双高须回 matched_load/双分箱判整体或传感器'}
  2366. def phase_imbalance(df, *, phase_cols, p_col, turbine_col, rated, imb_gate=6.0,
  2367. idle_p_max=5.0, idle_tol=3.0, p_lo_frac=0.50, p_hi_frac=0.98, min_n=200):
  2368. """绕组三相平衡判据 + idle 分流 (探头 vs 真 I²R) (SOP §4.6; 部件温度SOP §3)。
  2369. 匹配载荷相间极差 healthy≈0 (郭家店全场中位 2.2℃); imb_load > imb_gate → idle 分流:
  2370. **idle 仍偏 (≥idle_tol)** = 该相 RTD 探头偏 (A3 W相 / A17 V相);
  2371. **idle 平 载带偏** = 真 I²R 不平衡 → 查相电流/功率因数 (A23 U相 峰120℃ / B3 U相)。
  2372. 出: {per_turbine:{tid:{imb_load, imb_idle, worst_phase, cls}}, electrical, probe}。
  2373. """
  2374. band = _load_band(df, p_col, rated, p_lo_frac, p_hi_frac)
  2375. p = pd.to_numeric(df[p_col], errors='coerce')
  2376. idle = df[p < idle_p_max]
  2377. per = {}
  2378. for tid, g in band.groupby(turbine_col):
  2379. if len(g) < min_n:
  2380. continue
  2381. tid = str(tid)
  2382. med = {c: float(pd.to_numeric(g[c], errors='coerce').median()) for c in phase_cols}
  2383. imb = max(med.values()) - min(med.values())
  2384. worst = max(med, key=lambda c: abs(med[c] - float(np.median(list(med.values())))))
  2385. gi = idle[idle[turbine_col].astype(str) == tid]
  2386. if len(gi) >= 30:
  2387. med_i = {c: float(pd.to_numeric(gi[c], errors='coerce').median()) for c in phase_cols}
  2388. imb_i = max(med_i.values()) - min(med_i.values())
  2389. else:
  2390. imb_i = np.nan
  2391. if imb <= imb_gate:
  2392. cls = '均衡'
  2393. elif pd.notna(imb_i) and imb_i >= idle_tol:
  2394. cls = f'{worst}相RTD探头嫌疑(idle仍偏{imb_i:.1f}℃; A3 W相型)'
  2395. elif pd.notna(imb_i):
  2396. cls = f'{worst}相真I²R不平衡嫌疑(idle平带载偏→查相电流/功率因数; A23 U相型)'
  2397. else:
  2398. cls = f'{worst}相偏(无idle样本, 分流不了→现场)'
  2399. per[tid] = {'imb_load': round(imb, 2), 'imb_idle': round(imb_i, 2) if pd.notna(imb_i) else None,
  2400. 'worst_phase': worst, 'cls': cls}
  2401. return {'per_turbine': per,
  2402. 'electrical': [t for t, r in per.items() if 'I²R' in r['cls']],
  2403. 'probe': [t for t, r in per.items() if '探头' in r['cls']],
  2404. 'caveat': '峰温另对绝对阈(F级155/报警130); 真I²R须相电流/功率因数电气确认'}
  2405. def daily_thermal_table(df, *, comp_cols, p_col, turbine_col, time_col, gen_p_min=10.0,
  2406. valid_range=(-40.0, 200.0)):
  2407. """逐日热底表 — 热链纪律的数据底座 (SOP §4.6 同系统条; 郭家店 daily_thermal.parquet 模式)。
  2408. 日偏离 = 台**发电态**日均温 − 同日机群日中位 (per 通道)。温度专项场把本表列入 §0.8
  2409. required_outputs; 任一天可追溯"偏离是否先于跳闸、维修后是否回落"。
  2410. 出: DataFrame [date, tid, channel, dev, t_mean, n] (长表; 调用方自行落 parquet)。
  2411. """
  2412. p = pd.to_numeric(df[p_col], errors='coerce')
  2413. d = df[p >= gen_p_min].copy()
  2414. d['_day'] = pd.to_datetime(d[time_col]).dt.date
  2415. lo, hi = valid_range
  2416. out = []
  2417. for c in comp_cols:
  2418. if c not in d.columns:
  2419. continue
  2420. v = pd.to_numeric(d[c], errors='coerce')
  2421. sub = d[(v >= lo) & (v <= hi)][[turbine_col, '_day', c]].dropna()
  2422. g = sub.groupby([turbine_col, '_day'])[c].agg(t_mean='mean', n='size').reset_index()
  2423. fl = g.groupby('_day')['t_mean'].median().rename('fleet')
  2424. g = g.merge(fl, on='_day')
  2425. g['dev'] = g['t_mean'] - g['fleet']
  2426. for r in g.itertuples(index=False):
  2427. out.append((getattr(r, '_day', r[1]), str(r[0]), c, round(float(r.dev), 2),
  2428. round(float(r.t_mean), 2), int(r.n)))
  2429. return pd.DataFrame(out, columns=['date', 'tid', 'channel', 'dev', 't_mean', 'n'])
  2430. def thermal_chain_align(daily_dev, faults, *, system_map, fault_time_col, fault_tid_col,
  2431. fault_code_col, dev_gate=6.0, trips_gate=10, silent_trips_max=2):
  2432. """温度健康链 同系统对齐 + 真联动判据 (SOP §4.6 热链纪律; 用户 2026-06-30 纠错机器化)。
  2433. 四维链 (偏离→故障→维修→损失) 只可**同系统**连: system_map = {通道名(或其子串): [同系统故障码前缀]}
  2434. (发电机轴承↔'70021'/'70022' · 发电机冷却↔'70036'/'70065' · 齿箱↔'80')。跨系统按台聚合=伪联动
  2435. (A3 发电机DE偏离+15℃ 配 齿箱跳闸126次, 峰谷不同步=独立链) — 本函数**按构造禁止**: 每通道只统计
  2436. 映射到的同系统码, 不映射的码不进该通道的账。
  2437. 真联动 = 同系统 ∧ 同月 ∧ 月均偏离≥dev_gate ∧ 月跳闸≥trips_gate (A8 冷却水+16.7℃↔同月19跳)。
  2438. **有码不跳 = 静默前兆是正常链态** (发电机轴承 70021×3/70022×1, 越90/95℃阈才跳) → silent 列表
  2439. (max月均偏离≥dev_gate ∧ 窗内同系统跳闸≤silent_trips_max), 非缺数据。
  2440. 入: daily_dev = daily_thermal_table 输出 (date/tid/channel/dev); faults = 事件表 (time/tid/code)。
  2441. 出: {monthly:[{tid,channel,month,dev_mean,trips}], linkages, silent, caveat}。
  2442. """
  2443. dd = daily_dev.copy()
  2444. dd['month'] = pd.to_datetime(dd['date']).dt.to_period('M').astype(str)
  2445. ft = faults.copy()
  2446. ft['month'] = pd.to_datetime(ft[fault_time_col]).dt.to_period('M').astype(str)
  2447. ft['code_s'] = ft[fault_code_col].astype(str)
  2448. ft['tid_s'] = ft[fault_tid_col].astype(str)
  2449. monthly, linkages, silent = [], [], []
  2450. gm = dd.groupby(['tid', 'channel', 'month'])['dev'].mean().reset_index()
  2451. for (tid, ch), g in gm.groupby(['tid', 'channel']):
  2452. prefixes = None
  2453. for key, pfx in system_map.items():
  2454. if key in ch:
  2455. prefixes = [str(x) for x in pfx]
  2456. break
  2457. sub_f = ft[ft['tid_s'] == str(tid)]
  2458. if prefixes is not None:
  2459. sub_f = sub_f[sub_f['code_s'].str.startswith(tuple(prefixes))]
  2460. else:
  2461. sub_f = sub_f.iloc[0:0] # 通道无映射码 → 零账, 不借别系统的码
  2462. trips_by_m = sub_f.groupby('month').size()
  2463. total_trips = int(trips_by_m.sum())
  2464. max_dev = float(g['dev'].max())
  2465. for r in g.itertuples(index=False):
  2466. tr = int(trips_by_m.get(r.month, 0))
  2467. monthly.append({'tid': str(tid), 'channel': ch, 'month': r.month,
  2468. 'dev_mean': round(float(r.dev), 2), 'trips': tr})
  2469. if float(r.dev) >= dev_gate and tr >= trips_gate:
  2470. linkages.append({'tid': str(tid), 'channel': ch, 'month': r.month,
  2471. 'dev_mean': round(float(r.dev), 2), 'trips': tr, 'kind': '真联动(同系统同月)'})
  2472. if max_dev >= dev_gate and total_trips <= silent_trips_max and prefixes is not None:
  2473. silent.append({'tid': str(tid), 'channel': ch, 'max_month_dev': round(max_dev, 2),
  2474. 'total_trips': total_trips, 'kind': '静默前兆(有码不跳=正常链态, 非缺数据)'})
  2475. return {'monthly': monthly, 'linkages': linkages, 'silent': silent,
  2476. 'caveat': '跨系统伪联动按构造禁止(每通道只记映射码); 维修/损失维接同表时同样只许同系统'}
  2477. # ---------------------------------------------------------------- §4.6 齿轮箱轴承退化 多通道印证决策树 (跨4场沉淀 2026-07-06 /loop)
  2478. def gearbox_bearing_multichannel(*, vib=None, temp=None, log=None):
  2479. """齿轮箱轴承退化 多通道印证决策树 (SOP §4.6 同名条; 诺木洪4#/元宝山18#/淄川#008/威海苘山08#).
  2480. 把振动(独立CMS/第三方) + 温度(SCADA) + 日志 的证据按决策树聚合 → verdict + data_tier + cross_check。
  2481. 纯逻辑聚合器(不读df; 各通道证据先由 nbm_residual/vibration_trend/OCR 等产出后传入)。
  2482. vib : {'source': 'independent_cms'|'third_party'|'none', 'confirmed': bool, 'part': str, 'step_aligned': bool}
  2483. source=诊断上限(独立CMS真波形 > 第三方PDF > 无); confirmed=振动是否确诊该部件; step_aligned=是否有事件对齐step。
  2484. temp : {'channel_available': bool, 'abs_z': float, 'power_matched': bool, 'delta_rising': bool, 'oil_temp_low': bool, 'sync_rising': bool}
  2485. channel_available=SCADA有无对应部件温度通道; abs_z=绝对温度fleet-z; power_matched=是否功率bin匹配后仍成立; oil_temp_low=油温是否偏低(A13陷阱)。
  2486. log : {'event': str|None, 'time_aligned': bool} 齿箱/润滑故障事件 + 是否与振动/温度时间对齐。
  2487. """
  2488. vib = vib or {}; temp = temp or {}; log = log or {}
  2489. src = vib.get('source', 'none')
  2490. tier = {'independent_cms': 'A_独立CMS真波形', 'third_party': 'C_第三方PDF', 'none': 'B_仅SCADA温度'}.get(src, 'B_仅SCADA温度')
  2491. R = [] # reasoning
  2492. # ④ 油温读低陷阱前置提示: ΔT大 + 油温低 → 必用绝对温度+功率匹配复核 (A13/18#/08#)
  2493. oil_trap = bool(temp.get('oil_temp_low')) and not temp.get('power_matched', False)
  2494. if temp.get('oil_temp_low'):
  2495. R.append('油温偏低(A13陷阱): 轴承-油ΔT大先查油温读低, 用轴承绝对温度+功率匹配复核')
  2496. # ③ 印证强度定 verdict
  2497. # 三源一致(独立CMS振动确诊+step对齐 + 温度同步升 + 日志时间对齐) = 预警最强
  2498. if src == 'independent_cms' and vib.get('confirmed') and vib.get('step_aligned') \
  2499. and temp.get('sync_rising') and log.get('time_aligned'):
  2500. verdict, cross = '预警', '三源一致(振动step+温度同步+日志对齐)'
  2501. R.append('独立CMS振动确诊+事件step对齐, 温度同步升, 日志时间对齐 → 三源印证最强')
  2502. # 振动确诊 + 无对应温度通道 → 以振动为准 (淄川#008 中速轴无中速温度通道)
  2503. elif vib.get('confirmed') and not temp.get('channel_available', True):
  2504. verdict, cross = '候选·预警', '以振动为准(SCADA无对应温度通道)'
  2505. R.append('振动确诊该部件但SCADA无对应温度通道 → 以振动为准, 勿因温度不印证否定(早期损伤振动先于温度)')
  2506. # 振动确诊 + 有温度通道但绝对温度系统性最低(温度反证) → 查通道(疑传感器偏低不可用)
  2507. elif vib.get('confirmed') and temp.get('channel_available', True) and temp.get('abs_z', 0.0) <= -2.0:
  2508. verdict, cross = '削弱·查通道', '温度反证→查通道(绝对温度系统性最低=疑传感器偏低不可用, 既不印证也不否定)'
  2509. R.append('振动报该部件但SCADA温度全场最低(z≤-2): 若功率匹配后仍系统性最低=传感器偏低、通道不可用')
  2510. # 温度单通道强(无振动源, SCADA轴承绝对温度真高+功率匹配+ΔT升) = 候选·预警 (元宝山18#)
  2511. elif src == 'none' and temp.get('abs_z', 0.0) >= 2.0 and temp.get('power_matched') and temp.get('delta_rising'):
  2512. verdict, cross = '候选·预警', '温度单通道强(轴承绝对温度真高+功率匹配+ΔT升)'
  2513. R.append('无振动源, SCADA轴承绝对温度fleet-z≥2且功率匹配确认(排低负荷/油温伪影)+ΔT月度升 → 温度单通道候选')
  2514. # 温度候选未坐实(abs_z≥2但未功率匹配 or 油温陷阱未复核)
  2515. elif temp.get('abs_z', 0.0) >= 2.0 and oil_trap:
  2516. verdict, cross = '候选·待坐实', '温度候选但油温陷阱未复核(须绝对温度+功率匹配)'
  2517. R.append('轴承-油ΔT/残差高但油温偏低且未功率匹配复核 → 待坐实(防A13油温读低伪影)')
  2518. else:
  2519. verdict, cross = '参考', '无足够多通道证据(单弱信号或已证伪)'
  2520. return {
  2521. 'verdict': verdict,
  2522. 'data_tier': tier,
  2523. 'cross_check': cross,
  2524. 'oil_temp_trap': oil_trap,
  2525. 'reasoning': R,
  2526. 'caveat': '第三方CMS非独立(RV-2)必标注+SCADA交叉; 同台持续稳, 跨月绝对评级抖动(厂商口径)忽略. 部件级定论须现场油样/内窥.',
  2527. }
  2528. def cold_soak_offset(df, *, comp_col, ref_col, rotor_col, turbine_col, time_col,
  2529. p_col=None, rs_stop=0.5, p_stop=20.0, settle_rows=36,
  2530. min_n=100, min_split=30, offset_thresh=5.0, recent_frac=0.5,
  2531. near_frac=0.8, min_month_rows=30, min_month_turbines=10):
  2532. """冷浸物理测(等平衡+时间split): 转子停 comp−ref 偏移 → 传感器偏置 vs 真发热杀手判据 (平陆20# 教训, SOP §4.6).
  2533. 物理第一原则: 转子停(rs<rs_stop [∧ |P|<p_stop]) = 该部件**零摩擦生热** → comp(轴承)绝不可能比 ref(油浴/配对)热;
  2534. **静止态仍异常偏 = 加性传感器偏置(非真热)**, 分"真发热 vs 传感器"最硬物理判据, 优先级高于绝对/ΔT/载荷。
  2535. ★两个必须(平陆20#实证, 缺一给假象):
  2536. ①**等平衡**: 只取连续停≥settle_rows(默认36=6h)后 —— brief停残余热污染(20#未平衡冷浸-油+11°/平衡后仅+2.6°假象);
  2537. ②**时间split**(近期vs早期): 发展性传感器漂移 pooled 会被早期正常稀释(20# pooled平衡+2.6° 掩盖 晚期+14.2°/早期+1.5°)。
  2538. 用途: developing/偏热候选升"真故障"前**必过此测** —— 静止(尤其晚期)仍偏 = 传感器嫌疑, 换目标修传感器非抢部件。
  2539. 返回 {per_turbine:[{tid, cold_offset, cold_recent, cold_early, dev_offset, n}...], fleet_median_recent,
  2540. flagged:[|晚期偏fleet|>阈 ∨ dev_offset(|晚|>|早|=发展性漂移, **双向**)], note}。
  2541. direction: 'high'(读高型=报警阈被蒙蔽) / 'low'(读低型=真超温时保护延迟) — 两向都入修单, 后果不同。
  2542. split_quality:{n_months, **n_months_usable**(该月有≥min_month_turbines 台各≥min_month_rows 样本),
  2543. max_month_share, early/recent_calendar_months, month_overlap, verdict, **tripped**(全部命中档), **near**(逼近档)}
  2544. — verdict != 'ok' ⇒ **early/recent 及其差值只可作筛, 不得作趋势判据**。
  2545. per_turbine.month_shape_ratio: 月间sd/月内sd — **<1 ⇒ 该台月序列的形状(趋势/阶跃/双态)不可读**,
  2546. None=可用月<4。可读性前置, 非异常判据, 全场高命中属预期(健康台本无月尺度结构)。
  2547. near_miss:[未过阈但达 near_frac(默认0.8)x阈 的台] — **交付名单必须与 flagged 一起给**, 否则「差一点」的台整个消失。
  2548. ⚠**early/recent 只能读「有没有变」, 不能读「怎么变」, 且读之前必先扣 fleet 逐月共模**
  2549. (2026-09-03 神木远景 66 台 365 天实证): ①**fleet 自己有季节性** —— 冷态 Δ 的 fleet 月中位在
  2550. [+0.92, +2.91] 摆动, 幅度 1.99℃, 与个体信号同量级 ⇒ 不扣共模会把季节读成个体漂移
  2551. (实测一台 raw 晚−早 +0.64 看似上升, 扣 fleet 同月中位后 13 个月全平 = 纯季节, 趋势轴假阳性)。
  2552. ②**逐月样本数极不均** —— 实测四台的月样本数极差 23x~276x(某台 7 个月中一个月占 552 样本、
  2553. 其余 2~116) ⇒ early/recent 的 pooled 值由"哪几个月碰巧有多少冷浸样本"支配, 该台的
  2554. 晚−早 大部分是**权重伪影**而非信号。③**两个点区分不了 ramp / 阶跃 / 双态** —— 三者都能给出同一个
  2555. 晚−早。⇒ 判"发展性漂移"必须**看逐月形状**(并行线实证: Theil-Sen 报 4 台漂移, 查形状后 3 台改判为
  2556. 阶跃或双态)。本函数的 early/recent 是**筛子不是判据**。
  2557. ⚠**cold_offset(pooled) 禁与别法的 pooled 直接比**: 被测量随时间变时, 两种采样的 pooled 之差
  2558. 第一解释是**采样时间分布之差**, 不是物理也不是污染 (实测: 冷浸 pooled −30.1 vs 最冷箱 −16.4 差 13.7,
  2559. 逐月对拍却一致到 ≤1.2 —— 全部来自月份权重)。**跨法对拍必先配时间(逐月/逐段)**; 本函数的 early/recent
  2560. 就是为此而设, 比 pooled 优先用。反之「两法一致」只证被测量稳, 不证方法对。
  2561. """
  2562. d = df.dropna(subset=[comp_col, ref_col, rotor_col, turbine_col, time_col]).copy()
  2563. d = d.sort_values([turbine_col, time_col])
  2564. rs = pd.to_numeric(d[rotor_col], errors='coerce')
  2565. d['_stop'] = rs.abs() < rs_stop
  2566. if p_col and p_col in d.columns:
  2567. d['_stop'] = d['_stop'] & (pd.to_numeric(d[p_col], errors='coerce').abs() < p_stop)
  2568. # per-台 run-length: 连续停机段内第几行 (block=累计非停行数, 停行同block; cumcount=段内位置)
  2569. d['_blk'] = d.groupby(turbine_col)['_stop'].transform(lambda s: (~s).cumsum())
  2570. d['_runlen'] = d.groupby([turbine_col, '_blk']).cumcount()
  2571. eq = d[d['_stop'] & (d['_runlen'] >= settle_rows)].copy() # 等平衡态(≥settle_rows连续停)
  2572. if len(eq) < min_n:
  2573. h = settle_rows * 10 / 60
  2574. return {'per_turbine': [], 'fleet_median_recent': None, 'flagged': [], 'near_miss': [],
  2575. 'split_quality': {'verdict': 'no_samples'},
  2576. 'note': f'等平衡冷浸样本不足(<{min_n}); 无≥{h:.0f}h连续停机 → 冷浸测不可用(不代表无偏置)'}
  2577. eq['_off'] = pd.to_numeric(eq[comp_col], errors='coerce') - pd.to_numeric(eq[ref_col], errors='coerce')
  2578. eq = eq.dropna(subset=['_off'])
  2579. eq['_t'] = pd.to_datetime(eq[time_col], errors='coerce')
  2580. eq['_m'] = eq['_t'].dt.to_period('M')
  2581. _fleet_m = eq.groupby('_m')['_off'].median() # fleet 逐月共模
  2582. def _mshape(sub, _min_rows=30, _min_months=4):
  2583. """月间sd/月内sd, **在扣 fleet 逐月共模后的序列上算**: <1 ⇒ 该台月序列的形状不可读
  2584. (月内噪声盖过月间信号); None=可用月<4。
  2585. ★必须扣共模: fleet 季节共模会抬高月间sd, 让形状显得比实际可读 —— 实测某台
  2586. raw 1.05(过) vs 扣共模 0.67(不过), 而其"上升"经独立复核确为纯季节。
  2587. 全场分布也随之改变: raw 中位 1.10 / 38% <1; 扣共模 中位 0.78 / 71% <1。"""
  2588. try:
  2589. s = sub.assign(_d=sub['_off'] - sub['_m'].map(_fleet_m))
  2590. gm = s.groupby('_m')['_d']
  2591. keep = gm.size() >= _min_rows
  2592. med, sd = gm.median()[keep], gm.std()[keep]
  2593. if len(med) < _min_months:
  2594. return None
  2595. ws = float(sd.median())
  2596. if not ws or ws <= 0:
  2597. return None
  2598. return round(float(med.std()) / ws, 2)
  2599. except Exception:
  2600. return None
  2601. tmid = eq['_t'].quantile(recent_frac) # 时间分位分早/晚
  2602. rows = []
  2603. for tid, g in eq.groupby(turbine_col):
  2604. if len(g) < min_n:
  2605. continue
  2606. rec = g[g['_t'] >= tmid]['_off']
  2607. ear = g[g['_t'] < tmid]['_off']
  2608. co = float(g['_off'].median())
  2609. cr = float(rec.median()) if len(rec) >= min_split else None
  2610. ce = float(ear.median()) if len(ear) >= min_split else None
  2611. # 双向 (2026-09-03): 发展性 = **幅度**增长, 读低型同样致盲 (SOP §4.6 停机冷浸判别三盲区 ②:
  2612. # 读高型=报警阈被蒙蔽·假报警风险; 读低型=真超温时保护延迟 — 两向都该入测点修单)。
  2613. # 原实现只判正向, 漏读低型 (邻线实测: 某台 早期 -30.70 → 近期 -10.80 全场最大变化, 零标记)。
  2614. dev = bool(cr is not None and ce is not None
  2615. and abs(cr) - abs(ce) > offset_thresh and abs(cr) > offset_thresh)
  2616. rows.append({'tid': str(tid), 'cold_offset': round(co, 2),
  2617. 'cold_recent': (None if cr is None else round(cr, 2)),
  2618. 'cold_early': (None if ce is None else round(ce, 2)),
  2619. # month_shape_ratio (2026-09-03 并行线提, 双线实证): 月中位本身也是一个概括量,
  2620. # 读它的形状(趋势/阶跃/双态)之前必须先证它是**稳的概括**。
  2621. # 月间sd/月内sd < 1 ⇒ 月序列主要反映月内抽样, 其形状不可读(分箱伪影)。
  2622. # ⚠ 这是**可读性前置**不是异常判据 —— 健康台本就没有月尺度结构, 故全场高命中
  2623. # 属预期(实测神木 55 台可算, 中位 0.78, 71% <1), **不适用 RULE-11 的命中率闸**。
  2624. 'month_shape_ratio': _mshape(g),
  2625. 'dev_offset': dev,
  2626. # direction 见下方 fleet 参照段统一赋值 (读高/读低是相对 fleet 而非相对 0)
  2627. 'direction': None,
  2628. 'n': int(len(g))})
  2629. if not rows:
  2630. return {'per_turbine': [], 'fleet_median_recent': None, 'flagged': [], 'near_miss': [], 'note': 'per台等平衡样本不足'}
  2631. recents = [r['cold_recent'] for r in rows if r['cold_recent'] is not None]
  2632. fleet_rec = float(np.median(recents)) if recents else None
  2633. rows.sort(key=lambda r: -(r['cold_recent'] if r['cold_recent'] is not None else r['cold_offset']))
  2634. def _dev_fleet(r):
  2635. if fleet_rec is None or r['cold_recent'] is None:
  2636. return None
  2637. return abs(r['cold_recent'] - fleet_rec)
  2638. for r in rows:
  2639. _d = _dev_fleet(r)
  2640. r['dev_fleet'] = (None if _d is None else round(_d, 2))
  2641. # 读高/读低必须相对 fleet 中位, 不能相对 0 —— fleet 冷态 Δ 常非零 (实测神木中间轴 DE−NDE
  2642. # fleet 中位 +2.14℃ = 该配对本身不对称), 相对 0 定向会给出与判定矛盾的标签:
  2643. # 某台 cr=-2.36 相对 0 只有 2.36 (不定向) 但相对 fleet 偏 4.50 (已进 near_miss) 且实为读低。
  2644. r['direction'] = (None if _d is None or _d < near_frac * offset_thresh
  2645. else ('high' if r['cold_recent'] > fleet_rec else 'low'))
  2646. flagged = [r['tid'] for r in rows if r['dev_offset']
  2647. or (_dev_fleet(r) is not None and _dev_fleet(r) > offset_thresh)]
  2648. # near_miss (2026-09-03): 刀刃判据必带近失名单 —— 单一阈的必然性质是「差一点」的台整个消失,
  2649. # 而它们可能有独立证据 (实测: 某台 |dev_fleet|=4.70 vs 阈 5.0 未标记, 但其最冷箱 -4.1 是独立读低证据)。
  2650. # 与「报命中率必同时报距阈余量」同源, 只是余量在下方。交付名单必须 flagged + near_miss 一起给。
  2651. near_miss = [{'tid': r['tid'], 'dev_fleet': r['dev_fleet'], 'direction': r['direction'],
  2652. 'frac_of_thresh': round(_dev_fleet(r) / offset_thresh, 2)}
  2653. for r in rows if r['tid'] not in flagged and _dev_fleet(r) is not None
  2654. and _dev_fleet(r) >= near_frac * offset_thresh]
  2655. # split_quality (2026-09-03): 冷浸取样在时间上天然有偏 —— 长停多在冬季/检修季 ⇒
  2656. # early/recent 的对比很可能是**季节对比**而非时间漂移对比。双线实证: 一侧近期段 93.4% 落在冬季月、
  2657. # 早期段基本是夏秋; 另一侧过质量门的月份只剩 4 个(早/近各 2 月)。⇒ 机器报, 不靠记得查。
  2658. # ★必须用 eq(等平衡冷浸样本)与实际分界 tmid, 不能用全体行与时间中点 ——
  2659. # 否则这个"诊断量"算的是另一个总体, 与它要诊断的对象不是同一批数据。
  2660. _mm = eq['_t']
  2661. _cal = _mm.dt.month
  2662. _early_m = set(_cal[_mm < tmid].dropna().astype(int).unique().tolist())
  2663. _recent_m = set(_cal[_mm >= tmid].dropna().astype(int).unique().tolist())
  2664. _per = eq.assign(_m=_mm.dt.to_period('M'))
  2665. _n_months = int(_per['_m'].nunique()) if len(_per) else 0
  2666. # ★可用月 != 有样本的月: 一个月要能支撑对比, 需该月有足够多的台各自有足够多样本。
  2667. # (2026-09-03 订正: 原 n_months 数的是"有≥1样本的月", 同一份数据库数 12 而对方按
  2668. # 台级质量门数 4 —— 我据那个 4 标了 <6 的阈, 而它出自另一套过滤器 ⇒ 该阈未被任何场验证。
  2669. # 修法不是改阈, 是让计数去数"可用月", 使阈与它声称衡量的东西一致。)
  2670. _cnt = _per.groupby(['_m', turbine_col]).size()
  2671. _usable = _cnt[_cnt >= min_month_rows].groupby(level=0).size()
  2672. _n_usable = int((_usable >= min_month_turbines).sum())
  2673. _share = _per['_m'].value_counts(normalize=True)
  2674. _max_share = float(_share.iloc[0]) if len(_share) else 1.0
  2675. _ov = (len(_early_m & _recent_m) / len(_early_m | _recent_m)) if (_early_m | _recent_m) else 0.0
  2676. _trip = []
  2677. if _n_usable < 6:
  2678. _trip.append('too_few_months')
  2679. if _ov < 0.34:
  2680. _trip.append('season_confounded')
  2681. if _max_share > 0.5:
  2682. _trip.append('single_month_dominated')
  2683. _sv = _trip[0] if _trip else 'ok'
  2684. # near 带: 与 near_miss 同一纪律 —— 只报最先命中的那一档, 会看不见它同时逼近别档
  2685. _near = []
  2686. if 'too_few_months' not in _trip and _n_usable < 6 / 0.8:
  2687. _near.append(f'too_few_months(可用月 {_n_usable})')
  2688. if 'season_confounded' not in _trip and _ov < 0.34 / 0.8:
  2689. _near.append(f'season_confounded(重叠 {_ov:.2f})')
  2690. if 'single_month_dominated' not in _trip and _max_share > 0.5 * 0.8:
  2691. _near.append(f'single_month_dominated(单月 {_max_share:.3f})')
  2692. split_quality = {'n_months': _n_months, 'n_months_usable': _n_usable,
  2693. 'max_month_share': round(_max_share, 3),
  2694. 'early_calendar_months': sorted(_early_m), 'recent_calendar_months': sorted(_recent_m),
  2695. 'month_overlap': round(_ov, 2), 'verdict': _sv, 'tripped': _trip, 'near': _near,
  2696. # 每档的实测触发状态随输出一起走 —— 否则"这套判据是实证的"会把零触发的档一起盖进去。
  2697. # (写进文档的会被跳过, 写进返回值的不会。2026-09-03 双线定)
  2698. 'verdict_provenance': {
  2699. 'season_confounded': '实测触发 n=2 场 (远景神木 重叠0.17 / 明阳昌平坳 重叠0.08)',
  2700. 'too_few_months': '实测触发 n=1 场 (明阳昌平坳 可用月 4; 远景神木 9 未触发)',
  2701. 'single_month_dominated': '**零实测触发**; 最接近 明阳昌平坳 单月 0.401 (逼近未命中) '
  2702. '⇒ 该档阈 0.5 属推定, 未被任何场验证',
  2703. },
  2704. 'note': '非 ok 时 cold_early/cold_recent 与其差值只可作筛, 不得作趋势判据; '
  2705. 'tripped 列全部命中档, near 列逼近但未命中档(0.8x)'}
  2706. h = settle_rows * 10 / 60
  2707. return {'per_turbine': rows, 'fleet_median_recent': (None if fleet_rec is None else round(fleet_rec, 2)),
  2708. 'flagged': flagged, 'near_miss': near_miss, 'split_quality': split_quality,
  2709. 'note': f'等平衡(连续停≥{h:.0f}h)静止态 {comp_col}−{ref_col}; 时间split早/晚; '
  2710. f'flagged = |晚期偏fleet|>{offset_thresh}° ∨ dev_offset(|晚|>|早|=发展性传感器漂移); **双向**(读低型=保护延迟, SOP §4.6 三盲区②); 静止无摩擦热轴承不可能比油热'}
  2711. def leading_edge_shape_screen(df, *, turbine_col, time_col, power_col, ref_ws_col,
  2712. ref_dir_col=None, coords=None, rotor_d=None,
  2713. setpoint_col=None, setpoint_min=None,
  2714. p_gen_kw=20.0, n_fleet_min=12,
  2715. bins=None, low_bins=(4.0, 5.0), mid_bins=(6.0, 7.0, 8.0),
  2716. high_bins=(10.0, 11.0, 12.0),
  2717. wake_max_d=8.0, hw_near=30.0, hw_far=20.0,
  2718. n_mid_min=600, blade_age_years=None):
  2719. """前缘腐蚀(LE erosion) 性能签名筛查 (SOP §4.7 气动轴; 源 fushan decay_m3_leading_edge v2-geomwake, n=1[暂行])
  2720. ★**签名**: 相对亏空「**中风(Region2)最深 + 近额定收敛**」= LE。
  2721. ≠ 低风最深(尾流/卡滞) · ≠ 高风更深(降容) · ≠ 全段均匀(uniform_low)。
  2722. 物理: Region2 受 Cp 限, 气动亏空按比例现; 近/超额定后各台**都能到额定**(只是所需风速略高),
  2723. 故亏空**自动收敛** -> 形状是**凹坑**不是单调斜线。
  2724. ⚠**禁用线性斜率拟合该形状** —— 对凹坑恰好得 ≈0 (2026-07-26 实证: 我用斜率法在如东得"0台",
  2725. 纯属统计量测不出真签名, 结论已撤回)。
  2726. ★★**三道机器强制门** (本函数存在的主要理由 —— 原方法只写在场脚本注释里, 结果被重造成更差的版本):
  2727. ① **ref_ws_col 必须是独立风(测风塔)**: 分 bin 与归一**禁用机舱风**(self_ref 会把仪表漂移
  2728. 算成气动亏空)。本函数**机器核**: 同一 time 下 ref_ws_col 若**逐台不同** -> 判为非独立风,
  2729. verdict 直接锁 INSUFFICIENT 并在 gate_failures 报出 (不靠调用方自觉)。
  2730. ② **几何尾流扇区剔除**: 需 coords + ref_dir_col + rotor_d。dist<wake_max_d·D 的邻机,
  2731. 半宽 hw_near(<5D)/hw_far(5~8D)。尾流亏空**也随风速变化**, 不剔会伪造/掩盖签名
  2732. (fushan 实测部分台剔掉 61%)。缺 coords 或 ref_dir_col -> 只能出全样本版, verdict 降级。
  2733. ③ ★**全向性判别 (判 LE 的必要条件)**: 「中风最深+高风收敛」是**任何**有效风速折损机制
  2734. (尾流/湍流/地形绕流)**共有**的形状, **非前缘专属**。前缘是**叶片属性 -> 必须全向一致**。
  2735. 故形状门过后**必须做扇区分解**: 若亏空集中在少数扇区 -> **改判来流/位置效应, 不是 LE**。
  2736. 实证 (fushan A2): 中风亏空 −3.802pp 形状门刚过, 扇区分解 **~90% 来自北扇区**,
  2737. 剔该扇区残余 −0.408pp≈0 -> 改判「北向来流劣化候选」。**缺 ref_dir_col 则 LE 一律 INSUFFICIENT**。
  2738. ★**共性盲区 (必随结论输出, 不可省)**: fleet-relative 轴对**全场均匀**腐蚀**天然盲**
  2739. (共模被 fleet 中位吸收)。同龄同环境同机型的场若**普遍**轻中度 LE, 本轴**既不能确认也不能排除**
  2740. = INSUFFICIENT; 破盲区须**无人机前缘影像抽检**或**绝对基准**(投运初期功率曲线/保证曲线)。
  2741. 量级参考: Sandia 重度 LE 的 AEP 损失可达 5%; IEA Wind Task 46(2025) 0.5~5%;
  2742. fushan 台间检测下限 2σ≈3pp -> **轻度(<3pp)落噪声内**。
  2743. ★叶龄先验: LE 高发段 ≈ 7 年往后 (blade_age_years 给出则入 note; 太新的场做了也是空)。
  2744. 入: df 长表 (多台); ref_ws_col=测风塔风速(**每时刻全场同值**); ref_dir_col=测风塔风向(绝对, 对北);
  2745. coords={台号: (lon, lat)}; rotor_d=转子直径 m; setpoint_col/setpoint_min 可选剥限电/降档。
  2746. 出 dict: {per_turbine{台:{depth_low_pp, depth_mid_pp, conv_high_pp, le_score_pp, shape_class,
  2747. strength, z_mid_fleet, n_mid, sector_dev_pp, worst_sector, omnidirectional}},
  2748. curves, le_shape_gate_passed, le_candidates_final, wake_excluded_frac,
  2749. gate_failures, near_miss, expected_fp, blindspot, verdict}
  2750. verdict ≤ **候选** (前缘确诊须无人机/叶片巡检影像 + 粗糙度记录)。
  2751. """
  2752. import numpy as _np
  2753. import pandas as _pd
  2754. BINS = _np.arange(4, 14, 1.0) if bins is None else _np.asarray(bins, dtype=float)
  2755. d = df.copy()
  2756. for c in (power_col, ref_ws_col):
  2757. d[c] = _pd.to_numeric(d[c], errors='coerce')
  2758. gate_failures = []
  2759. # ---- 门① ref_ws 必须独立 (机器核, 不靠调用方自觉) ----
  2760. per_t_spread = d.groupby(time_col)[ref_ws_col].nunique(dropna=True)
  2761. frac_multi = float((per_t_spread > 1).mean()) if len(per_t_spread) else 1.0
  2762. ws_independent = frac_multi < 0.01
  2763. if not ws_independent:
  2764. gate_failures.append(
  2765. 'ref_ws_col 非独立风: %.1f%% 的时刻该列逐台不同 -> 疑为机舱风(self_ref)。'
  2766. '分bin/归一必须用测风塔独立风, 否则仪表漂移会被算成气动亏空。LE verdict 锁 INSUFFICIENT。'
  2767. % (100 * frac_multi))
  2768. # ---- 门② 几何尾流扇区 ----
  2769. wake = {}
  2770. can_wake = (coords is not None) and (ref_dir_col is not None) and (rotor_d is not None)
  2771. if can_wake:
  2772. # 坐标含非有限值的台: 其尾流对无法计算, 该台**及其邻机**都会少剔扇区 -> 静默降低
  2773. # 剔除力度而不报警。响亮化 (原为静默): 缺失台点名进 gate_failures, 消费方可据以决定
  2774. # 是否补坐标再跑。(rudong tid34 lon 缺失, 修 isfinite 后由"造假扇区"变"少扇区", 仍须知情)
  2775. _bad_coord = sorted(str(k) for k, v in coords.items()
  2776. if not (_np.isfinite(v[0]) and _np.isfinite(v[1])))
  2777. if _bad_coord:
  2778. gate_failures.append(
  2779. '坐标非有限值的台 %s (共 %d/%d) -> 其尾流扇区无法构造, 该台及其邻机剔除力度偏低; '
  2780. '补全坐标后重跑, 或对这些台的 LE 结论降级' % (_bad_coord[:10], len(_bad_coord), len(coords)))
  2781. for i, (lo_i, la_i) in coords.items():
  2782. secs = []
  2783. for j, (lo_j, la_j) in coords.items():
  2784. if i == j:
  2785. continue
  2786. dE = (lo_j - lo_i) * _np.cos(_np.deg2rad(la_i)) * 111320.0
  2787. dN = (la_j - la_i) * 110540.0
  2788. dist = float(_np.hypot(dE, dN)); dd = dist / float(rotor_d)
  2789. # 坐标缺失 -> dd=NaN, 而 NaN>x 与 NaN<=0 均为 False 会漏过下面的排除判据,
  2790. # 把该邻机当"在闸内"并按 hw_far 造出一个假扇区 (rudong tid34 lon 缺失实测:
  2791. # 有效对数 66 虚胀成 136)。isfinite 必须先判。
  2792. if not _np.isfinite(dd) or dd > wake_max_d or dd <= 0:
  2793. continue
  2794. brg = float(_np.rad2deg(_np.arctan2(dE, dN)) % 360.0)
  2795. secs.append((brg, hw_near if dd < 5.0 else hw_far, str(j), round(dd, 2)))
  2796. wake[str(i)] = secs
  2797. else:
  2798. gate_failures.append('缺 coords/ref_dir_col/rotor_d -> **无法剔尾流扇区**, 只能出全样本版; '
  2799. '尾流亏空亦随风速变化, 会伪造/掩盖 LE 签名 -> verdict 降级')
  2800. # ---- 发电/非限电闸 + fleet 同时刻配对 ----
  2801. m = d[power_col] > p_gen_kw
  2802. if setpoint_col is not None and setpoint_min is not None:
  2803. d[setpoint_col] = _pd.to_numeric(d[setpoint_col], errors='coerce')
  2804. m = m & (d[setpoint_col] >= setpoint_min)
  2805. base = d[m].copy()
  2806. med = base.groupby(time_col)[power_col].agg(['median', 'size'])
  2807. med.columns = ['P_med', 'n_gen']
  2808. base = base.merge(med, on=time_col)
  2809. base = base[(base.n_gen >= n_fleet_min) & (base.P_med > 0)]
  2810. if not len(base):
  2811. return {'verdict': 'INSUFFICIENT', 'gate_failures': gate_failures + ['配对后样本为 0'],
  2812. 'per_turbine': {}, 'le_candidates_final': []}
  2813. base['ratio'] = base[power_col] / base.P_med
  2814. base['bin'] = _pd.cut(base[ref_ws_col], BINS, right=False, labels=BINS[:-1]).astype(float)
  2815. base = base.dropna(subset=['bin'])
  2816. # free-flow 主工作集
  2817. if can_wake:
  2818. inw = _np.zeros(len(base), bool)
  2819. dirs = _pd.to_numeric(base[ref_dir_col], errors='coerce').values
  2820. tvals = base[turbine_col].astype(str).values
  2821. for k, secs in wake.items():
  2822. ix = (tvals == k)
  2823. if not ix.any() or not secs:
  2824. continue
  2825. sub = _np.zeros(int(ix.sum()), bool)
  2826. dsub = dirs[ix]
  2827. for c, hw, _n, _dd in secs:
  2828. sub |= (_np.abs((dsub - c + 180) % 360 - 180) <= hw)
  2829. inw[ix] = sub
  2830. base['in_wake'] = inw
  2831. work = base[~base.in_wake].copy()
  2832. wake_frac = base.groupby(base[turbine_col].astype(str)).in_wake.mean().round(4).to_dict()
  2833. else:
  2834. base['in_wake'] = False
  2835. work = base
  2836. wake_frac = {}
  2837. LOW, MID, HIGH = list(low_bins), list(mid_bins), list(high_bins)
  2838. def _scores(sub):
  2839. g = sub.groupby('bin').ratio.agg(['median', 'std', 'size']).reindex(BINS[:-1])
  2840. overall = float(sub.ratio.median())
  2841. raw = 100 * (g['median'] - 1)
  2842. shape = 100 * (g['median'] / overall - 1) if overall else raw * _np.nan
  2843. dep = lambda bb: float(_np.nanmean([shape.get(b, _np.nan) for b in bb]))
  2844. return g, raw, shape, dep
  2845. tids = sorted({str(x) for x in work[turbine_col].dropna().unique()}) # dropna 先于 str: Arrow str backend 部分空 id 会让 sorted 混型崩 (memory arrow-str-backend-astype-nan-footgun; 锁 tests/sop/test_tids_nan_safe_dp.py)
  2846. curves, sc = {}, {}
  2847. for tb in tids:
  2848. sub = work[work[turbine_col].astype(str) == tb]
  2849. g, raw, shape, dep = _scores(sub)
  2850. hi = sub[sub.bin.isin(HIGH)]
  2851. achv_hi = float(100 * (hi.ratio.median() - 1)) if len(hi) >= 300 else _np.nan
  2852. curves[tb] = {'bins': [float(b) for b in BINS[:-1]],
  2853. 'n': [int(x) if x == x else 0 for x in g['size']],
  2854. 'deficit_raw_pp': [None if x != x else round(float(x), 3) for x in raw],
  2855. 'shape_dev_pp': [None if x != x else round(float(x), 3) for x in shape]}
  2856. sc[tb] = {'depth_low_pp': round(dep(LOW), 3), 'depth_mid_pp': round(dep(MID), 3),
  2857. 'achv_high_raw': None if achv_hi != achv_hi else round(achv_hi, 3),
  2858. 'n_mid': int(_np.nansum([g['size'].get(b, 0) for b in MID]))}
  2859. ah = _pd.Series({t: sc[t]['achv_high_raw'] for t in tids}, dtype=float)
  2860. ah_med = float(ah.median()) if ah.notna().any() else 0.0
  2861. dm = _pd.Series({t: sc[t]['depth_mid_pp'] for t in tids}, dtype=float)
  2862. mad = float((dm - dm.median()).abs().median()) * 1.4826 # MADK
  2863. for tb in tids:
  2864. s = sc[tb]
  2865. v = s['achv_high_raw']
  2866. s['conv_high_pp'] = None if v is None else round(v - ah_med, 3)
  2867. s['z_mid_fleet'] = round(float((s['depth_mid_pp'] - float(dm.median())) / mad), 4) if mad > 0 else None
  2868. dl, dmid = s['depth_low_pp'], s['depth_mid_pp']
  2869. ch = s['conv_high_pp'] or 0.0
  2870. le = ch - dmid
  2871. s['le_score_pp'] = round(le, 3)
  2872. if s['n_mid'] < n_mid_min:
  2873. cls = 'INSUFFICIENT_n'
  2874. elif (dl - dmid <= -2.0) and dl <= -3.0:
  2875. cls = 'wake_or_lowws_like'
  2876. elif ch <= -3.0 and (dmid - ch >= -1.5):
  2877. cls = 'derate_like'
  2878. elif dmid <= -3.0 and le >= 2.5:
  2879. cls = 'LE_like'
  2880. elif dmid <= -3.0 and abs(dmid - ch) < 2.5 and abs(dl - dmid) < 2.0:
  2881. cls = 'uniform_low'
  2882. else:
  2883. cls = 'normal'
  2884. s['shape_class'] = cls
  2885. s['strength'] = (('strong' if dmid <= -6.0 and abs(s['z_mid_fleet'] or 0) >= 2.0
  2886. else 'moderate' if dmid <= -4.0 else 'weak')
  2887. if cls == 'LE_like' else 'none')
  2888. shape_passed = [t for t in tids if sc[t]['shape_class'] == 'LE_like']
  2889. # ---- 门③ 全向性判别 (判 LE 的必要条件) ----
  2890. le_final, sector_note = [], None
  2891. if ref_dir_col is None:
  2892. sector_note = ('缺 ref_dir_col -> **无法做扇区分解**; 而「中风最深+高风收敛」是任何有效风速折损'
  2893. '机制共有形状, 前缘必须全向一致 -> **LE 一律 INSUFFICIENT**, 形状门过者只作待查')
  2894. gate_failures.append(sector_note)
  2895. else:
  2896. SEC = _np.arange(0, 360, 45.0)
  2897. for tb in shape_passed:
  2898. sub = work[work[turbine_col].astype(str) == tb].copy()
  2899. sub['sec'] = (_pd.to_numeric(sub[ref_dir_col], errors='coerce') // 45).astype('float')
  2900. mid = sub[sub.bin.isin(MID)]
  2901. if not len(mid):
  2902. continue
  2903. dev = {}
  2904. omed = float(mid.ratio.median())
  2905. for s_, gg in mid.groupby('sec'):
  2906. if len(gg) >= 50 and omed:
  2907. dev[int(s_)] = round(float(100 * (gg.ratio.median() / omed - 1)), 3)
  2908. sc[tb]['sector_dev_pp'] = dev
  2909. if dev:
  2910. worst = min(dev, key=dev.get)
  2911. sc[tb]['worst_sector'] = '%d-%d°' % (worst * 45, worst * 45 + 45)
  2912. sc[tb]['worst_sector_share_of_mid'] = round(float((mid.sec == worst).mean()), 4)
  2913. # ★剔最差扇区后**重算同一个 depth_mid 统计量** (不是拿 ratio 中位比自身总体 ——
  2914. # 那样得到的是别的量; 2026-07-26 移植回归逮到此错: 残余 +5.183 vs 正解 −0.408)
  2915. rest_all = sub[sub.sec != worst]
  2916. if len(rest_all) >= n_mid_min:
  2917. _g2, _raw2, _sh2, dep2 = _scores(rest_all)
  2918. res_dep = dep2(MID)
  2919. else:
  2920. res_dep = _np.nan
  2921. sc[tb]['depth_mid_excl_worst_sector_pp'] = None if res_dep != res_dep else round(res_dep, 3)
  2922. # 全向 = 剔掉最差扇区后**亏空仍在**(保留至少一半深度); 否则=单扇区来流/位置效应
  2923. omni = bool(abs(sc[tb]['depth_mid_pp']) > 0 and res_dep == res_dep
  2924. and res_dep <= 0.5 * sc[tb]['depth_mid_pp'])
  2925. sc[tb]['omnidirectional'] = omni
  2926. if omni:
  2927. le_final.append(tb)
  2928. else:
  2929. sc[tb]['reclassified'] = ('非全向 -> 改判**来流/位置效应候选**(尾流残余/地形绕流/'
  2930. '背风湍流), 非前缘: 剔最差扇区后中风残余由 %.3f -> %.3f pp'
  2931. % (sc[tb]['depth_mid_pp'], res_dep))
  2932. nm = [('%s: depth_mid %.3f pp / le_score %.3f / class=%s — 门缘披露'
  2933. % (t, sc[t]['depth_mid_pp'], sc[t]['le_score_pp'], sc[t]['shape_class']))
  2934. for t in tids if -3.5 <= sc[t]['depth_mid_pp'] <= -2.0]
  2935. verdict = ('INSUFFICIENT (门未过, 见 gate_failures)' if gate_failures and not ws_independent
  2936. else ('候选: LE_like 且全向 = %s' % le_final) if le_final
  2937. else ('形状门过 %s 但扇区分解后无全向存活 -> 前缘证据=无' % shape_passed) if shape_passed
  2938. else '本场无前缘签名候选 (形状门零过)')
  2939. return {'per_turbine': sc, 'curves': curves,
  2940. 'le_shape_gate_passed': shape_passed, 'le_candidates_final': le_final,
  2941. 'wake_excluded_frac': wake_frac, 'wake_geometry': wake,
  2942. 'ws_independent': ws_independent, 'gate_failures': gate_failures,
  2943. 'near_miss': nm,
  2944. 'expected_fp': {'n_compare': len(tids), 'alpha': 0.046,
  2945. 'expected_fp': round(len(tids) * 0.046, 2)},
  2946. 'blade_age_years': blade_age_years,
  2947. 'blindspot': ('★fleet-relative 对**全场均匀**腐蚀天然盲(共模被 fleet 中位吸收): 同龄同环境'
  2948. '同机型的场若普遍轻中度 LE, 本轴**既不能确认也不能排除**=INSUFFICIENT; '
  2949. '破盲须无人机前缘影像抽检或绝对基准(投运初期/保证曲线)。'
  2950. '量级: Sandia 重度 LE AEP 损失可达 5%; IEA Wind Task 46(2025) 0.5~5%; '
  2951. '台间检测下限 2σ≈3pp -> 轻度(<3pp)落噪声内'),
  2952. 'verdict': verdict,
  2953. 'note': ('签名=中风最深+近额定收敛(凹坑, **禁线性斜率拟合**); '
  2954. '三道机器强制门=①独立风分bin ②几何尾流剔除 ③**全向性判别(判LE必要条件)**; '
  2955. 'verdict ≤ 候选, 确诊须无人机/叶片巡检影像+粗糙度记录')}
  2956. def matched_load_sink_dt(df, *, node_cols, sink_col, p_col, turbine_col, rated,
  2957. time_col=None, gen_mask=None, p_lo_frac=0.30, p_hi_frac=0.95,
  2958. bin_kw=100.0, min_cell=30, min_bins=3, dt_z_thresh=2.5,
  2959. dt_abs_floor=2.0, small_mad_c=0.5, monthly=False,
  2960. valid_range=(-40.0, 200.0),
  2961. ld_lo_frac=0.50, ld_hi_frac=0.75, ld_min_seg_bins=3,
  2962. ld_hi_abs=None, ld_min_gap=1.0,
  2963. windows=(3, 6, 9, 12, 18)):
  2964. """同载荷 × 消热汇 ΔT 判据 — matched_load_overheat 与 node_minus_sink_dt 的分层融合
  2965. (SOP §4.6; 如东 U3v2 2026-08-09 实战反哺)。
  2966. ⭐ 为什么要融合 (两法各自的盲区, 如东 n=1 实证):
  2967. · matched_load_overheat 控住了载荷分布 (100kW 分箱同载荷比), 但**比的是部件绝对温**,
  2968. 不消热汇 → **冷却器故障会伪装成"多部件齐热"**。如东 20# 冷却器旁通失效致油温本身
  2969. +2.5℃(z 6.78), 该法读出 HSRot +4.7 / IMSRot +3.0 / 油温 +2.5 三处齐热, 形似三轴承同时坏;
  2970. 减热汇后这两个轴承通道分别降到 **+2.0℃ 与 +0.3℃**(齐热消散), 而高速轴发电机侧为 **−4.6℃**
  2971. (明显偏冷) —— 真正热的是油本身, 不是轴承。
  2972. · node_minus_sink_dt 消了整机共模 (节点−即时热汇), 但**不控载荷分布** → 常年负荷偏高的台
  2973. ΔT 系统性偏高 (摩擦生热随载荷升)。
  2974. ⇒ 本函数 = 分箱控载荷 ∧ 减热汇消共模 ∧ 跨箱取中位, 两层混杂一次消掉。
  2975. ★小分母闸 (small_mad_c, 本次实战新增): 台间 MAD < small_mad_c 的通道**禁用 z**, 只用绝对值门。
  2976. 如东 IMSRot−油温 全场中位 −0.21℃、台间 MAD 仅 0.45℃ → 2.7℃ 的绝对差就给出 z>6,
  2977. 与"%/月 小分母虚大"是同一现象; 该通道下 z 排名不可作判据。
  2978. ★★dt_z_thresh 的实际把关力必须**仿真**, 不可查正态表 (2026-08-09 如东 n=38 实测):
  2979. MAD 在小样本下系统性偏低 → z 被整体放大; 且 38 个 z 共用同一分子分母来源、彼此不独立
  2980. ⇒ 解析式 "P(|z|>2.5)×n" 会**低估** 1.8 倍。蒙特卡洛 20 万次实测 (n=38, |z|≥2.5):
  2981. 正态零分布 单通道期望误报 0.844 台 (解析天真值 0.472); 4 通道合计 3.37 台; 某通道≥2 个的概率 61%
  2982. t5 重尾 单通道 1.899 台; 4 通道 7.59 台; ≥2 的概率 95%
  2983. 族错误率 5% 的经验阈 = **z 3.79 (正态) / 6.52 (t5)** —— 远高于默认 2.5。
  2984. ⇒ 纪律: (a) 默认 2.5 只是**筛查门**, 单独命中 = 候选, 升级必须靠跨源多链汇聚, 不可称"统计显著";
  2985. (b) 报候选时须同时给"该通道 z 闸与绝对门谁在真正约束" —— 若 dt_abs_floor < dt_z_thresh×mad,
  2986. 则约束在 z 那一半, 即落在上述误报率里; (c) **不要据此把阈值一味调高** —— 发电机线把 R² 门设过严
  2987. 造成整族假阴性 (634→67 月, 真异常台从第 2 名压到第 29 名) 是同一枚硬币的反面。
  2988. ⚠ 旁证 (反直觉但重要): small_mad_c 触发后**禁用 z 只走绝对门**的通道, 抗运气能力反而更好 ——
  2989. 如东 IMSRot 台间 MAD 0.45℃, 2.0℃ 绝对门 = 4.44 个 MAD 单位 > 3.79 经验阈; 而 z 闸通道 (IMSGen,
  2990. MAD 1.22℃) 的 z 2.5 ≈ 3.05℃ 反而更松。**物理门不随样本量漂移, 是抗多重比较的主依靠。**
  2991. ★★"多链汇聚才升级"这条防线, 其前提 (各链独立) 必须**实测**, 不能假设 (2026-08-09 如东 38 台实测):
  2992. 发电机线实证反例: DE|now 与 配对差|now 的 r=0.803 → "多轴汇聚"名存实亡 (同一份证据数两遍)。
  2993. 如东齿轮箱线实测结果 (可作本判据的适用性佐证):
  2994. · 四个节点 ΔT 彼此 |r| ≤ 0.265, 与振动/油液 |r| ≤ 0.311 → **共用同一热汇并未把它们绑在一起**,
  2995. 每个节点的 ΔT 仍带独立信息 ⇒ 本判据用于多节点并列筛查是成立的。
  2996. · 但**同节点的 ΔT 水平 与 该节点 β 模型的 α (截距) r 高达 0.78~0.93** (数学同源) ⇒
  2997. "ΔT 高 ∧ α 破门" **不是两条独立证据**, 叙述与计票都只能算一票。
  2998. α 的正当用法是与 β 作**背离比较** (α 高而 β 低 ⇒ 更像测温链偏置) —— 那是分辨用途、非印证用途。
  2999. ★通道存活守卫 (§4.6c, 承 matched_load_overheat/node_minus_sink_dt): 热汇通道逐台验活, 死则整台剔出
  3000. (ΔT 失参照会造伪离群); 被排名部件通道亦逐台验活, 冻结/坐死列剔出该台该通道 (防"传感器坏"伪装
  3001. "部件热")。出 dead_sink_tids / dead_node_channels 供交付声明。
  3002. ★同季差之后必看 fleet 同季分布 (2026-08-09 与发电机分析线交叉核对确立): 个体的年际同季差看着大
  3003. (如 +6℃), 若 fleet 同季中位本就 +1~2℃ 且十余台更高, 那是**年际共模非个体异常** — 如东 24# 发电机
  3004. 驱动端"同季 +6℃ 持续恶化"经此检验撤回 (该台排 11~12/38, 非离群)。判个体异常须给出该台在 fleet
  3005. 同季差分布中的排名, 不可只报自身同季差。
  3006. ★"改造/更换前后"比较必须控载荷 (承 §4.11 EL-4): 换件机组换后的平均负荷可能系统性变化 (如东三台
  3007. 换发电机轴承台换后高载窗均负荷降 99~155kW), 不控载荷会高估/低估效果 — 如东三台换高速轴的温差
  3008. 回落幅度按本函数分箱口径重算后由"3~7℃"修正为"2.4~5.6℃"。
  3009. ★★"更换前后"还必须配**未更换对照组**, 只有净效应才算成效 (2026-08-09 与发电机分析线交叉确立):
  3010. 控住载荷之后仍不够 —— 同期整个机群可能都在变好 (季节/油品批次/负荷结构)。
  3011. 净效应 = Δ_换件台 − median(Δ_对照组), 对照组用**完全相同的前后月份**。
  3012. · 发电机线实证 (反例): 2# "换轴承后温升回落 = 更换有效", 加对照组后 2# 前后变化 0.00K、
  3013. 27 台对照同期中位**也是 0.00K** → 净效应零, 结论已撤回。
  3014. · 齿轮箱线实证 (正例): 20#/22#/28# 换高速轴, 净效应 −5.62/−5.59/−2.32K,
  3015. 三台均居对照分布**第 0 百分位**(35~36 台对照无一降幅相当), 为对照 MAD 的 4~10 倍 → 成效坐实。
  3016. ⚠ 对照组的排除项只应含**处置本身**。例行换油/滤芯这类全场背景作业不可用来筛对照
  3017. (换件台自己也经历), 否则把共模从对照里一并剔掉 → 高估净效应。
  3018. 如东首版把 7 类干预全排, 后窗拉到 14 月时对照组塌到 2 台 —— **对照组塌缩 = 判据设错的信号**,
  3019. 不是"本场没有可用对照"。
  3020. ⚠ 前后窗长度须与头条 claim 同口径: 22# 用"后 3 月"得 3.96K、用"换后全窗"得 5.58K,
  3021. 因该台换后**逐月继续走低到第 6 个月才稳**, 3 月窗切在爬坡段上。两口径并列输出防漂移。
  3022. ★★热汇"改善"同样会在**未更换通道**上造伪信号 (2026-08-09 如东实逮, 与 20# 冷却器那个反向案例互补):
  3023. 本判据减的是即时热汇, 所以热汇往任一方向移动都会污染 ΔT ——
  3024. 不只"热汇故障变热"(20# 冷却器旁通失效致油温高 → 多个轴承伪装成齐热),
  3025. **"热汇因检修而变凉"同样致命**: 如东三台换高速轴后, 高速轴**转子侧**(未更换)ΔT 反升 0.45~1.87K,
  3026. 拆开绝对温才知道 20# 是油温降 4.12K 而转子侧轴承**自己也降了 2.57K**、只是没油降得多;
  3027. 28# 同理 (油 −1.08K / 轴承 −0.46K)。**三台无一真的变热**, 若只看 ΔT 会开出一张不该开的工单。
  3028. ⇒ 纪律: **判读换件/改造前后, 必须同时给出节点绝对温与热汇绝对温的各自变化**,
  3029. ΔT 的变化要能被 Δ(节点) − Δ(热汇) 分解复现, 否则不得作部件结论。
  3030. ★时序 (monthly=True): 全窗聚合看不见起点。如东 20# 曾被读作"换高速轴当月起冷却塌陷",
  3031. 逐月复核才发现该台**换轴前即为全场最低**、可归因阶跃仅约 2℃ (其余为季节共模) —— 涉及
  3032. 检修归责的结论必须带时序, 不可只给全窗一个数。
  3033. ★★载荷依赖型判据 (2026-08-09 补; 原"已知盲区"的解, 见文末残留部分):
  3034. 跨箱取中位对**只在高载才发作**的故障是稀释器 —— 一台低载正常、高载才热的机组,
  3035. 27 个箱取中位会把它冲平 (若只有末 6 个箱抬 +4℃、其余 21 箱为 0, 中位仍 ≈0, 主判据必漏)。
  3036. ⇒ 本函数现输出 per_tid.dev_by_bin (逐箱剖面) + **两个并列的载荷依赖性指标**:
  3037. · dev_slope_c_per_1000kw = Theil-Sen(dev ~ 功率箱) × 1000 —— **形态描述量, 不作闸**。
  3038. ★为什么不作闸 (与主判据同源的缺陷): Theil-Sen 取全体 pairwise 斜率的中位, 拐点型
  3039. (低载平 + 高载抬) 的正斜率对只占少数 —— 27 箱中末 6 箱抬升时正号对仅 126/351 = 36%,
  3040. 中位落在 0 附近甚至可被噪声翻号。**它和跨箱中位犯同一个错**(把局部发作摊薄),
  3041. 所以它只能描述"是渐进爬升还是拐点", 不能当检出闸。这也是 analysis_kit.theil_sen
  3042. docstring "对阶跃会摊成假渐进"那条铁律在本判据面上的同一现象。
  3043. · dev_hi_minus_lo = 高载段 dev 中位 − 低载段 dev 中位 —— **这个才是闸**。
  3044. 分段用**功率的绝对阈**(ld_hi_frac/ld_lo_frac × rated) 而非各台自身分位数:
  3045. 否则一台只有低载箱的机组, 它的"高载段"其实是低载段, 跨台不可比。
  3046. 判据 (load_dependent_flagged, 与 flagged **并列不混**):
  3047. 跨箱中位未破主门 ∧ |dev_hi_seg| ≥ ld_hi_abs (None→继承 dt_abs_floor)
  3048. ∧ |dev_hi_minus_lo| ≥ ld_min_gap ∧ 二者同号 ∧ (高载段台间 MAD 够大时) |z_hi| ≥ dt_z_thresh。
  3049. 两段各须 ≥ ld_min_seg_bins 个箱, 否则该台不参与 (不出数 ≠ 判正常)。
  3050. ⚠ 阈值的**可证伪判据** (不许当拍脑袋的数用):
  3051. · ld_min_gap=1.0℃: 若本场健康台 (主判据未命中且无独立佐证) 的 |dev_hi_minus_lo| 分布
  3052. 中位已 ≥ 0.5℃ 或 p90 ≥ 1.0℃, 则 1.0℃ 落在噪声里 → 该阈**在本场失效**, 须改用
  3053. 该分布的 p95 或提高 min_cell。判法: 直接看本函数输出的 dev_hi_minus_lo 全场分布。
  3054. · ld_hi_frac=0.75 / ld_lo_frac=0.50: 前提是**两段都有足够箱且 fleet 在两段都重叠**。
  3055. 若某场功率带窄 (如限电场常年压在 0.5×rated 以下) 致高载段 < ld_min_seg_bins 箱,
  3056. 本判据整体不出数 —— 这时 load_dependent_flagged 为空**只代表没测**, 不代表没有;
  3057. 输出的 n_bins_hi_seg / n_bins_lo_seg 就是这个失效的机器可读信号。
  3058. · |z_hi| ≥ dt_z_thresh 继承主判据的 2.5, 故**同样只是筛查门**: 上面 ★★dt_z_thresh 那段
  3059. 的蒙特卡洛结论 (n=38 族错误率 5% 的经验阈实为 3.79) 对本闸一字不改地适用,
  3060. 且高载段箱数只有全窗的 1/3 → dev_hi 更噪, 单独命中的证据力**弱于**主判据命中。
  3061. ⚠ 已知残留 (未解): (a) 本判据只分"高/低"两段, **只在中载发作**的故障 (两端正常) 仍检不出 ——
  3062. 须直接读 dev_by_bin; (b) 分箱是功率, 不是转速/扭矩, 变速段内同功率可对应不同扭矩,
  3063. 对扭矩依赖型劣化只是代理; (c) 高载段样本天然少于中载段, 检出力随之低。
  3064. ⚠ sink_diagnosis 的**定义域边界** (不是缺陷, 别当洞补; 2026-08-09 与发电机线交叉核画准):
  3065. 它答的是"这台的热汇**水平**是不是整体偏了", 不答"这台的热汇在**两个窗之间**是不是漂了"
  3066. —— 后者是**变化量**问题, 不在它的定义域内。**保持全窗口径是对的: 判别器答一个问题就好**,
  3067. 时间维交给下面那条纪律, 比给判别器加时间维便宜得多、也更不容易忘。
  3068. ★风险面**只有一个**: **同一台的跨时间窗比较** (换件前后/检修前后/季节前后)。
  3069. **跨机同窗比较不受影响** —— 热汇漂移是共模, fleet 中位跟着一起移 → 不产生假离群。
  3070. (这与"热汇被动会让所有温升整体平移、比个别台跳变更难察觉"是同一件事的两面:
  3071. 难察觉, 但也正因为是共模, 它不制造跨机假阳性。)
  3072. ⇒ 纪律: 凡做"同一台跨时间窗"的比较, **必须同时报节点绝对温与热汇绝对温的各自变化**
  3073. (三行表: 节点绝对温 / 热汇绝对温 / 两者之差), **不论 ΔT 是变了还是没变**。
  3074. **"没有变化"和"变化了"一样需要归因** —— ΔT 不动可能是真没变, 也可能是节点与热汇同向同幅
  3075. 相互抵消。正向查(ΔT 升了→是不是热汇造的)容易想到; 反向查(ΔT 没动→是不是热汇掩盖的)
  3076. 容易漏, 因为"没变化"看起来不需要解释。
  3077. 实例 (发电机线 26#, 三个限定须连带引用): 某台清废脂前后, 热汇(机舱温)涨幅比 fleet 中位多 2.88K,
  3078. 节点同幅跟涨、ΔT 恰好抵成 0.00K。① 该台**全窗**机舱温水平在机群里并不突出, 偏的是**变化量**;
  3079. ② 当时**未造成错误结论**(该台主判据是绝对温升排名 z=+4.21 全场第一 + 振动 + 润滑 + 台账,
  3080. 不依赖前后比较), 危险性是**潜在的** —— 若当时只有前后对比这一条证据就会被误导;
  3081. ③ 是**反向撞上**的(本为验证"ΔT 没降"不是假象, 顺手才看到热汇涨幅异常)。
  3082. ⇒ 该案例是"**为什么必须做分解**"的例子, 不是"判别式失效"的例子。
  3083. ★实测 (不是推理; 复跑 outputs/rudong/units/scripts/u3v2_load_dependent_known_answer.py):
  3084. · 摊薄倍数实证 (DP 合成台, 27 箱中末 9 箱抬 +4.5℃): 主判据跨箱中位读出 **dev=0.02℃**
  3085. (完全漏), 高低段差读出 4.49℃ (逮到); Theil-Sen 斜率 0.72 vs 段差隐含 2.49 ℃/1000kW
  3086. = **被摊薄 3.5 倍** —— "斜率不作闸"这条设计理由由此坐实, 非断言。
  3087. · 阈值可证伪判据的实测值 (如东 38 台, 2025-01~2026-07, 全场 |dev_hi_minus_lo| 中位/p90):
  3088. HSGen 0.61/1.26 · HSRot 0.24/0.53 · IMSGen 0.19/0.51 · IMSRot 0.20/0.38 ℃
  3089. ⇒ **HSGen 通道 p90 已 ≥1.0 ⇒ ld_min_gap=1.0 落在该通道噪声内, 本场该通道判为失效**
  3090. (要用须提到该通道 p95≈1.4 以上, 或加大 min_cell 压噪); 其余三通道 1.0℃ ≈ 2~5×p90, 有效。
  3091. 这就是"阈值带可证伪判据"的具体形态: 同一个 1.0℃, 在同一场的不同通道上一个失效三个有效。
  3092. · known-answer (如东): 四通道 flagged 与改动前落盘产物**逐字一致**, 载荷依赖名单**零命中**,
  3093. 最近 near-miss 余量 −0.49 (非刀刃); 该场已知的 26#/29#/10#/7# 均为全段偏热型, 不该被本判据
  3094. 改判, 实测未改判。零命中的正确读法见 load_dependent_note。
  3095. node_cols: 部件温列名 list; sink_col: 即时热汇列 (齿箱=齿轮油温, 发电机=冷却水/环温)。
  3096. monthly=True 时须给 time_col, 额外返回 per_month 的逐台 dev (同法逐月计算)。
  3097. 返回 {per_node: {node: {fleet_dt_median, mad, z_usable, gate, flagged,
  3098. per_tid: {tid: {dt, dev, z, n_bins, n_samples, # ★n_bins 必须与 dev 同时呈现
  3099. dev_by_bin: {功率箱kW: dev}, # ★逐箱剖面 (跨箱中位的原料)
  3100. dev_slope_c_per_1000kw, # 形态描述量, 非闸 (拐点型会被摊薄)
  3101. dev_lo_seg, dev_hi_seg, dev_hi_minus_lo, z_hi_seg, # ★载荷依赖闸用这三个
  3102. n_bins_lo_seg, n_bins_hi_seg}}, # 段箱数不足 = 没测, 非判正常
  3103. n_bins_min_max, thin_evidence_tids, # 箱数 < 2×min_bins 的台 = 薄证据
  3104. flagged, # 主判据 (跨箱中位)
  3105. load_dependent_flagged, load_dependent_gate, # ★与 flagged 并列不混
  3106. hi_seg_mad, hi_seg_z_usable}},
  3107. n_power_bins, dead_sink_tids, dead_node_channels,
  3108. sink_diagnosis: {sink_col, fleet_sink_median, mad, z_usable, gate, note,
  3109. per_tid: {tid: {sink_dev, sink_z, n_nodes, n_nodes_opposite_sign, suspect}},
  3110. suspect_tids}, # ★命中=异常在热汇侧不在部件侧
  3111. caliber}。
  3112. """
  3113. d = df if gen_mask is None else df[gen_mask]
  3114. band = _load_band(d, p_col, rated, p_lo_frac, p_hi_frac).copy()
  3115. if band.empty:
  3116. return {'per_node': {}, 'n_power_bins': 0, 'note': '匹配功率带内无样本'}
  3117. band['_pb'] = np.round(pd.to_numeric(band[p_col], errors='coerce') / bin_kw) * bin_kw
  3118. sink = pd.to_numeric(band[sink_col], errors='coerce')
  3119. # §4.6c 通道存活守卫 (承 matched_load_overheat / node_minus_sink_dt 同款):
  3120. # ① 热汇通道死 → 该台整体剔出 (ΔT 失去参照, 留下会造伪离群)
  3121. # ② 被排名的部件通道逐台验活 → 冻结/坐死列剔出该台该通道 ("传感器坏"伪装"部件热")
  3122. dead_sink = {str(t) for t, gc in band.groupby(turbine_col)
  3123. if reference_channel_liveness(gc[sink_col], valid_range=valid_range,
  3124. min_n=200)['live'] is False}
  3125. if dead_sink:
  3126. band = band[~band[turbine_col].astype(str).isin(dead_sink)]
  3127. sink = sink.loc[band.index]
  3128. dead_node = {}
  3129. def _one(sub, pb_col):
  3130. """箱内 per台 ΔT 中位 → 减箱内全场中位 → 跨箱取中位。
  3131. 返回 (dev, absdt, nbins, nsamp, gbin)。★nbins/nsamp 必须外传 (2026-08-09 补):
  3132. min_bins 只做过滤, 但"靠 3 个箱判出来的 dev"与"靠 27 个箱判出来的"置信度天差地别,
  3133. 不出数就没人分得清 —— 交付时 dev 必须与其箱数同时呈现。
  3134. ★gbin (逐 (箱,台) 明细, 含 _dev) 亦外传 (2026-08-09 补): 跨箱中位是它的**摘要**,
  3135. 摘要会稀释局部发作 —— 不把原料交出来, 载荷依赖型故障就永远只能靠人重算一遍。
  3136. """
  3137. g = sub.groupby([pb_col, turbine_col])['_dt'].agg(med='median', nn='size').reset_index()
  3138. g = g[g['nn'] >= min_cell]
  3139. if g.empty:
  3140. return None, None, None, None, None
  3141. fl = g.groupby(pb_col)['med'].median().rename('_fleet')
  3142. g = g.merge(fl, on=pb_col)
  3143. g['_dev'] = g['med'] - g['_fleet']
  3144. nb = g.groupby(turbine_col)[pb_col].nunique()
  3145. ns = g.groupby(turbine_col)['nn'].sum()
  3146. dev = g.groupby(turbine_col)['_dev'].median()
  3147. dev = dev[nb >= min_bins]
  3148. absdt = g.groupby(turbine_col)['med'].median()
  3149. if dev.empty:
  3150. return None, None, None, None, None
  3151. return dev, absdt, nb, ns, g
  3152. # ── 载荷依赖型剖面 (解"跨箱中位稀释高载才发作"的盲区) ──────────────────────────
  3153. # 分段边界用**功率绝对值**(× rated), 不用各台自身分位数: 后者会把"只有低载箱的台"
  3154. # 的低载段当成高载段, 跨台不可比。
  3155. lo_edge = float(rated) * float(ld_lo_frac)
  3156. hi_edge = float(rated) * float(ld_hi_frac)
  3157. def _load_profile(gbin, keep):
  3158. """逐箱 dev 剖面 + 两个并列的载荷依赖性指标 (斜率=形态描述, 高低段差=闸)。"""
  3159. prof = {}
  3160. for t, gt in gbin.groupby(turbine_col):
  3161. if t not in keep:
  3162. continue
  3163. gt = gt.sort_values('_pb')
  3164. pbs = gt['_pb'].to_numpy(dtype='float64')
  3165. dvs = gt['_dev'].to_numpy(dtype='float64')
  3166. lo_m, hi_m = pbs <= lo_edge, pbs >= hi_edge
  3167. n_lo, n_hi = int(lo_m.sum()), int(hi_m.sum())
  3168. d_lo = float(np.median(dvs[lo_m])) if n_lo >= ld_min_seg_bins else None
  3169. d_hi = float(np.median(dvs[hi_m])) if n_hi >= ld_min_seg_bins else None
  3170. prof[t] = {
  3171. 'dev_by_bin': {int(round(b)): round(float(v), 2) for b, v in zip(pbs, dvs)},
  3172. # ★斜率只作形态描述 (渐进 vs 拐点), **不入闸** —— 见 docstring: Theil-Sen 对
  3173. # "低载平 + 高载抬"的拐点型同样会摊薄, 与跨箱中位犯同一个错。
  3174. 'dev_slope_c_per_1000kw': round(float(theil_sen(dvs, x=pbs)) * 1000.0, 3),
  3175. 'dev_lo_seg': None if d_lo is None else round(d_lo, 2),
  3176. 'dev_hi_seg': None if d_hi is None else round(d_hi, 2),
  3177. 'dev_hi_minus_lo': None if (d_lo is None or d_hi is None) else round(d_hi - d_lo, 2),
  3178. 'n_bins_lo_seg': n_lo, 'n_bins_hi_seg': n_hi, 'z_hi_seg': None}
  3179. return prof
  3180. out, per_month = {}, {}
  3181. for nc in [c for c in node_cols if c in band.columns]:
  3182. band['_dt'] = pd.to_numeric(band[nc], errors='coerce') - sink
  3183. _span = valid_range[1] - valid_range[0]
  3184. band.loc[(band['_dt'] < -_span) | (band['_dt'] > _span), '_dt'] = np.nan
  3185. sub = band.dropna(subset=['_dt', '_pb', turbine_col])
  3186. _dn = {str(t) for t, gc in sub.groupby(turbine_col)
  3187. if reference_channel_liveness(gc[nc], valid_range=valid_range, min_n=200)['live'] is False}
  3188. if _dn:
  3189. dead_node[nc] = sorted(_dn)
  3190. sub = sub[~sub[turbine_col].astype(str).isin(_dn)]
  3191. if sub.empty:
  3192. continue
  3193. dev, absdt, nbins, nsamp, gbin = _one(sub, '_pb')
  3194. if dev is None:
  3195. continue
  3196. med = float(dev.median())
  3197. mad = float((dev - med).abs().median() * 1.4826)
  3198. z_usable = mad >= small_mad_c
  3199. z = (dev - med) / max(mad, 1e-6)
  3200. gate = (f'|z|≥{dt_z_thresh} ∧ |dev|≥{dt_abs_floor}℃' if z_usable
  3201. else f'z 禁用(台间 MAD {mad:.2f}℃ < {small_mad_c}℃ 小分母) → 只用 |dev|≥{dt_abs_floor}℃')
  3202. flagged = sorted([str(t) for t in dev.index
  3203. if abs(dev[t]) >= dt_abs_floor and (not z_usable or abs(z[t]) >= dt_z_thresh)],
  3204. key=lambda x: -abs(float(dev[x])) if x in dev.index else 0)
  3205. # ── 载荷依赖型判据 (与 flagged 并列, **不混**) ─────────────────────────────
  3206. prof = _load_profile(gbin, set(dev.index))
  3207. hi_ser = pd.Series({t: p['dev_hi_seg'] for t, p in prof.items() if p['dev_hi_seg'] is not None},
  3208. dtype='float64')
  3209. ld_abs = dt_abs_floor if ld_hi_abs is None else float(ld_hi_abs)
  3210. hi_mad, hi_z_usable, ld_hits = None, False, []
  3211. if len(hi_ser) >= 3:
  3212. hi_med = float(hi_ser.median())
  3213. hi_mad = float((hi_ser - hi_med).abs().median() * 1.4826)
  3214. hi_z_usable = hi_mad >= small_mad_c
  3215. hi_z = (hi_ser - hi_med) / max(hi_mad, 1e-6)
  3216. for t in hi_ser.index:
  3217. prof[t]['z_hi_seg'] = round(float(hi_z[t]), 2) if hi_z_usable else None
  3218. for t in dev.index:
  3219. if str(t) in flagged or t not in hi_ser.index: # 主判据已逮到的不重复计
  3220. continue
  3221. p = prof[t]
  3222. d_hi, gapv = p['dev_hi_seg'], p['dev_hi_minus_lo']
  3223. if gapv is None or abs(d_hi) < ld_abs or abs(gapv) < ld_min_gap:
  3224. continue
  3225. if d_hi * gapv <= 0: # 高载段偏离方向须与"随载荷加剧"方向一致 (排非单调噪声)
  3226. continue
  3227. if hi_z_usable and abs(float(hi_z[t])) < dt_z_thresh:
  3228. continue
  3229. ld_hits.append((str(t), abs(float(d_hi))))
  3230. ld_hits = [s for s, _v in sorted(ld_hits, key=lambda kv: -kv[1])]
  3231. ld_gate = ((f'跨箱中位未破主门 ∧ 高载段(≥{hi_edge:.0f}kW) |dev|≥{ld_abs}℃'
  3232. + (f' ∧ |z_hi|≥{dt_z_thresh}' if hi_z_usable
  3233. else f' (z_hi 禁用: 高载段台间 MAD {hi_mad:.2f}℃ < {small_mad_c}℃ 小分母)')
  3234. + f' ∧ |高载段−低载段(≤{lo_edge:.0f}kW)|≥{ld_min_gap}℃ 且与高载段同号'
  3235. + f'; 两段各须≥{ld_min_seg_bins}箱')
  3236. if len(hi_ser) >= 3 else
  3237. f'未判(高载段(≥{hi_edge:.0f}kW)达 {ld_min_seg_bins} 箱的台仅 {len(hi_ser)} 台<3, 无 fleet 参照)')
  3238. out[nc] = {
  3239. 'fleet_dt_median': round(float(absdt.median()), 2),
  3240. 'mad': round(mad, 2), 'z_usable': bool(z_usable), 'gate': gate,
  3241. 'per_tid': {str(t): {'dt': round(float(absdt[t]), 2), 'dev': round(float(dev[t]), 2),
  3242. 'z': (round(float(z[t]), 2) if z_usable else None),
  3243. 'n_bins': int(nbins[t]), 'n_samples': int(nsamp[t]),
  3244. **prof.get(t, {})} for t in dev.index},
  3245. 'n_bins_min_max': [int(nbins[dev.index].min()), int(nbins[dev.index].max())],
  3246. 'thin_evidence_tids': sorted(str(t) for t in dev.index if int(nbins[t]) < min_bins * 2),
  3247. 'flagged': flagged,
  3248. 'load_dependent_flagged': ld_hits, 'load_dependent_gate': ld_gate,
  3249. 'hi_seg_mad': (None if hi_mad is None else round(hi_mad, 2)),
  3250. 'hi_seg_z_usable': bool(hi_z_usable),
  3251. 'load_dependent_note': ('本名单 = 跨箱中位漏掉、只在高载发作的候选; 与 flagged **并列不混**, '
  3252. '证据力**弱于** flagged (高载段箱数仅全窗约 1/3, dev 更噪, 且 z 门 2.5 '
  3253. '按本函数 n=38 蒙特卡洛实为筛查门非显著门)。空名单 ≠ 无此型故障: '
  3254. '须先看 n_bins_hi_seg —— 段箱数不足的台根本没被测。')}
  3255. if monthly and time_col is not None:
  3256. sub2 = sub.copy()
  3257. sub2['_ym'] = pd.to_datetime(sub2[time_col]).dt.strftime('%Y-%m')
  3258. mm = {}
  3259. for ym, gsub in sub2.groupby('_ym'):
  3260. dv, _a, _nb, _ns, _g = _one(gsub, '_pb')
  3261. if dv is not None:
  3262. mm[ym] = {str(t): round(float(dv[t]), 2) for t in dv.index}
  3263. per_month[nc] = mm
  3264. # ── 热汇自诊 (2026-08-09 补; 原为纯人工纪律, 现由函数主动报) ──────────────────────
  3265. # 本判据减的是即时热汇, 故**热汇本身异常时, 该台所有节点 ΔT 一起被推向反方向**:
  3266. # 热汇偏热 → 各节点看着"偏冷"(如东 20# 冷却器旁通失效: 油温高, 三个轴承 ΔT 齐负)
  3267. # 热汇偏冷 → 各节点看着"偏热"(换件后油温下降, 未换通道被动显示"变差")
  3268. # 这两种都不是部件问题, 但只看 ΔT 一律看不出来 —— 20# 当初是靠人眼发现的, 不可依赖。
  3269. # ★判别式 (第二版; 第一版"多数节点 dev 与热汇反号"**没通过 known-answer 检验**已废):
  3270. # 20# 实况是 油温 dev +2.49℃, 而四节点 ΔT dev = -4.57/+1.98/+0.70/+0.30 —— **只 1 个反号**。
  3271. # 真签名不是"节点反号", 而是 **节点绝对温偏高、减掉热汇后就不高了** (热汇解释了大部分抬升):
  3272. # abs_dev(节点绝对温 vs 全场) ≈ ΔT_dev + sink_dev。20# 的 HSRot/IMSGen/IMSRot 绝对 +4.5/+3.2/+2.8,
  3273. # 减热汇后只剩 +2.0/+0.7/+0.3 → 热汇解释了 56%/78%/89%。
  3274. # ⇒ 判别 = 热汇自身 dev 破门 ∧ 该台"绝对温已破门的节点"中≥半数被热汇解释掉一半以上。
  3275. sink_diag = {}
  3276. band['_dt'] = sink
  3277. _ssub = band.dropna(subset=['_dt', '_pb', turbine_col])
  3278. sdev, sabs, snb, _sns, _sg = _one(_ssub, '_pb') if not _ssub.empty else (None,) * 5
  3279. # 各节点的**绝对温** dev (与 ΔT dev 并置才能做上面的分解)
  3280. abs_dev = {}
  3281. for nc in [c for c in node_cols if c in band.columns]:
  3282. band['_dt'] = pd.to_numeric(band[nc], errors='coerce')
  3283. _asub = band.dropna(subset=['_dt', '_pb', turbine_col])
  3284. if _asub.empty:
  3285. continue
  3286. adv, _aa, _ab, _as, _ag = _one(_asub, '_pb')
  3287. if adv is not None:
  3288. abs_dev[nc] = adv
  3289. if sdev is not None:
  3290. smed = float(sdev.median())
  3291. smad = float((sdev - smed).abs().median() * 1.4826)
  3292. s_z_usable = smad >= small_mad_c
  3293. sz = (sdev - smed) / max(smad, 1e-6)
  3294. per_tid_s, suspects = {}, []
  3295. for t in sdev.index:
  3296. sd = float(sdev[t])
  3297. broke = abs(sd) >= dt_abs_floor and (not s_z_usable or abs(float(sz[t])) >= dt_z_thresh)
  3298. # 只考察"绝对温已经破门"的节点 —— 没抬升的节点谈不上"被热汇解释"
  3299. elevated, explained, detail = 0, 0, {}
  3300. for nc, adv in abs_dev.items():
  3301. if t not in adv.index:
  3302. continue
  3303. a = float(adv[t])
  3304. if abs(a) < dt_abs_floor:
  3305. continue
  3306. elevated += 1
  3307. ok = (a * sd > 0) and (abs(sd) >= 0.5 * abs(a)) # 同向 ∧ 热汇占抬升的一半以上
  3308. explained += int(ok)
  3309. detail[nc] = {'abs_dev': round(a, 2), 'sink_share': round(abs(sd) / abs(a), 2),
  3310. 'sink_explained': bool(ok)}
  3311. suspect = bool(broke and elevated and explained >= max(1, (elevated + 1) // 2))
  3312. per_tid_s[str(t)] = {'sink_dev': round(sd, 2),
  3313. 'sink_z': (round(float(sz[t]), 2) if s_z_usable else None),
  3314. 'n_nodes_abs_elevated': elevated, 'n_nodes_sink_explained': explained,
  3315. 'per_node': detail, 'suspect': suspect}
  3316. if suspect:
  3317. suspects.append(str(t))
  3318. sink_diag = {
  3319. 'sink_col': sink_col,
  3320. 'fleet_sink_median': round(float(sabs.median()), 2), 'mad': round(smad, 2),
  3321. 'z_usable': bool(s_z_usable),
  3322. 'gate': (f'热汇自身 |dev|≥{dt_abs_floor}℃' + (f' ∧ |z|≥{dt_z_thresh}' if s_z_usable else ' (z 禁用: 小分母)')
  3323. + f' ∧ 该台绝对温破门(|abs_dev|≥{dt_abs_floor}℃)的节点中≥半数满足'
  3324. ' [与热汇同向 ∧ |sink_dev|≥0.5|abs_dev|]'),
  3325. 'per_tid': per_tid_s,
  3326. 'suspect_tids': sorted(suspects, key=lambda x: -abs(per_tid_s[x]['sink_dev'])),
  3327. 'known_answer': ('如东 20# (已确诊冷却器旁通失效) 必须命中 —— 换判据后须复跑此检验; '
  3328. '第一版判别式 (多数节点反号) 在该台只得 1/4 反号, 检不出, 已废。'),
  3329. 'note': ('命中台 = **异常在热汇侧, 不在部件侧**; 其各节点 ΔT 已被热汇位移污染, '
  3330. '不得据以判部件健康, 须回到节点**绝对温**并与热汇绝对温分解对拍 '
  3331. '(ΔT 变化应能被 Δ节点−Δ热汇 复现)。零命中 ≠ 热汇必然干净: '
  3332. '若热汇与节点同向同幅漂移 (整机测温链偏置), 本判别看不出来。')}
  3333. # ── 窗长敏感性 (2026-08-09 补; 用户裁定"按 B 落地") ────────────────────────────
  3334. # ★为什么必须出: 窗长是**未预注册的自由参数**, 却能实质改变结论 ——
  3335. # 算法验证线实证: 某台发电机温度 5 月窗排 1/38(z=3.27), 12 月窗跌到 13/38(z=1.27)。
  3336. # 只报一个窗 = 事实上在"挑窗"; 把窗长写死进预注册又不可行(急性故障与慢性劣化时间尺度本就不同)。
  3337. # ⇒ 取中间路线: **不挑窗, 把窗的影响摊开** —— 每台报"在几个窗里过门", 让证据强度自己显形。
  3338. # 这与"同一结论翻案>2次即停手判 INSUFFICIENT"是同一逻辑: 不在多个可能里挑一个报。
  3339. # 判读: 全窗过门 = 稳健候选; 只在部分窗过门 = **窗长敏感, 证据强度低一档**, 须在交付中标明。
  3340. win_sens = {}
  3341. if windows and time_col is not None and time_col in band.columns:
  3342. _ym = pd.to_datetime(band[time_col], errors='coerce').dt.strftime('%Y-%m')
  3343. months = sorted(m for m in _ym.dropna().unique())
  3344. for nc in [c for c in node_cols if c in out]:
  3345. per_w, hit_cnt = {}, {}
  3346. for w in windows:
  3347. if w > len(months):
  3348. continue
  3349. keep = set(months[-int(w):])
  3350. sub = band[_ym.isin(keep)].copy()
  3351. sub['_dt'] = pd.to_numeric(sub[nc], errors='coerce') - pd.to_numeric(sub[sink_col], errors='coerce')
  3352. sub = sub.dropna(subset=['_dt', '_pb', turbine_col])
  3353. if sub.empty:
  3354. continue
  3355. dv, _ab, _nb, _ns, _g = _one(sub, '_pb')
  3356. if dv is None:
  3357. continue
  3358. m2 = float(dv.median())
  3359. d2 = float((dv - m2).abs().median() * 1.4826)
  3360. zu = d2 >= small_mad_c
  3361. z2 = (dv - m2) / max(d2, 1e-6)
  3362. hits = sorted(str(t) for t in dv.index
  3363. if abs(dv[t]) >= dt_abs_floor and (not zu or abs(z2[t]) >= dt_z_thresh))
  3364. per_w[int(w)] = {'months': [months[-int(w)], months[-1]], 'z_usable': bool(zu),
  3365. 'mad': round(d2, 2), 'flagged': hits}
  3366. for t in hits:
  3367. hit_cnt[t] = hit_cnt.get(t, 0) + 1
  3368. n_w = len(per_w)
  3369. win_sens[nc] = {
  3370. 'windows_tested': sorted(per_w),
  3371. 'per_window': per_w,
  3372. 'per_tid_hit_count': dict(sorted(hit_cnt.items(), key=lambda kv: -kv[1])),
  3373. 'stable': sorted(t for t, c in hit_cnt.items() if c == n_w),
  3374. 'window_sensitive': sorted(t for t, c in hit_cnt.items() if 0 < c < n_w),
  3375. 'note': (f'在全部 {n_w} 个窗长下都过门 = 稳健; 只在部分窗过门 = **窗长敏感, 证据强度低一档**, '
  3376. f'交付时须标明其过门窗与不过门窗。'),
  3377. }
  3378. res = {'per_node': out, 'n_power_bins': int(band['_pb'].nunique()),
  3379. 'dead_sink_tids': sorted(dead_sink), 'dead_node_channels': dead_node,
  3380. 'sink_diagnosis': sink_diag, 'window_sensitivity': win_sens,
  3381. 'caliber': (f'功率带 {p_lo_frac:.0%}~{p_hi_frac:.0%}×{rated}kW, {bin_kw:.0f}kW 分箱(箱内≥{min_cell} 样本, '
  3382. f'跨箱≥{min_bins} 箱); 箱内 per台 ΔT=(节点−{sink_col}) 中位 − 箱内全场中位; 跨箱取中位; '
  3383. f'小分母闸 MAD<{small_mad_c}℃ 禁用 z; 附热汇自诊(热汇自身破门∧多数节点反号); '
  3384. f'★另出逐箱 dev 剖面 + 载荷依赖型并列名单(高载段≥{hi_edge:.0f}kW vs 低载段≤{lo_edge:.0f}kW, '
  3385. f'段差≥{ld_min_gap}℃; 斜率仅作形态描述不入闸)')}
  3386. if monthly:
  3387. res['per_month'] = per_month
  3388. return res
  3389. def lubrication_pump_duty_split(df, *, timeon_col, counts_col, turbine_col, time_col,
  3390. window_s=600.0, min_counts_month=20, full_run_frac=1.0,
  3391. fleet_ref='median'):
  3392. """集中润滑泵异常的**两型分离** — 拆开 "秒/次" 这个混合指标
  3393. (SOP §4.6; 如东 U4 发电机 2026-08-09 实战反哺, known-answer 验证)。
  3394. ⭐ 为什么必须拆 (踩坑实证):
  3395. 月度 `sum(timeon)/sum(counts)` 是最自然的写法, 但它把**两种不同的物理现象混在一起** ——
  3396. 当泵**持续运转跨越多个记录窗**时, 该窗 timeon 计入分子而**不产生新的 counts 入分母**,
  3397. 比值被抬高。如东 26# 2025-08 该口径 = 853 秒, 曾被读作 "每次启动运转 853 秒";
  3398. 实测该月**启动 62 次(正常频次)、单次运转中位仅 70 秒**, 真相是该月含 **10.5 小时泵连续满转**。
  3399. 19# 更极端: 口径值 4086 秒 = **61.3 小时/月连续满转**。
  3400. ⭐ 拆分后的两个量 (物理含义互斥, 处置方向不同):
  3401. · **A 单次启动运转时长** = 仅 counts>0 的窗取 timeon/counts 的中位
  3402. → **输送阻力** (供油管路/分配块/排油背压). 换泵未必有效, 先查排油与管路。
  3403. · **B 泵连续满转时长** = timeon >= window_s*full_run_frac 的窗数 × window_s
  3404. → **泵本体 / 系统压力建立失败** (泵启动后停不下来).
  3405. ⭐ known-answer 验证 (这条是本判据可信的根据, 非推理):
  3406. 如东 38 台中 B 型累计最高的 19#(91.7h) 与 14#(43.7h) **正是换泵后立即痊愈的两台**;
  3407. 而 A 型高值台 (7# 190s / 25# 205s, 基准 55s) 泵满转仅 0.3h —— 用混合口径会把两者
  3408. 混为一谈并派错工单 (给 A 型台开换泵单)。
  3409. ⚠ 配套纪律:
  3410. · counts/timeon 是**事件计数类通道**: liveness 必须用 kind='event'
  3411. (低 nuniq + 大量零是正常形态, 用连续量口径会误杀)。
  3412. · **季节共模**: 润滑脂粘度随温度变化, A 指标有强季节成分
  3413. (如东 7# 两次峰值均在夏季、冬季回落至基线) → 趋势判读必须同月对同月。
  3414. · 若该机型有柱塞/脉冲反馈通道 (出脂量直接计量), 三角交叉更强:
  3415. B 高而柱塞脉冲不增 = 泵空转/压力建不起来; 柱塞正常而 A 高 = 管路阻力。
  3416. 参数
  3417. ----
  3418. window_s : 记录窗长 (秒), 10min 面 = 600
  3419. full_run_frac : 判 "整窗满运转" 的占比阈 (1.0 = timeon 达满窗)
  3420. min_counts_month : 月启动次数门 (低于此月不出数, 防稀疏月比值病态)
  3421. 返回
  3422. ----
  3423. {'fleet': {'A_median', 'A_mad'}, 'per_tid': {tid: {'A_now','A_peak','A_peak_ym',
  3424. 'B_total_h','B_peak_h','B_peak_ym','type'}}, 'monthly': [...], '_caliber': str}
  3425. type ∈ {'A(输送阻力)','B(泵打不停)','A+B(混合)','正常'}
  3426. """
  3427. import numpy as _np
  3428. import pandas as _pd
  3429. d = df[[turbine_col, time_col, timeon_col, counts_col]].copy()
  3430. d['_ym'] = _pd.to_datetime(d[time_col]).dt.strftime('%Y-%m')
  3431. full_thresh = window_s * float(full_run_frac)
  3432. rows = []
  3433. for (tid, ym), g in d.groupby([turbine_col, '_ym']):
  3434. c_sum = float(_pd.to_numeric(g[counts_col], errors='coerce').fillna(0).sum())
  3435. if c_sum < min_counts_month:
  3436. continue
  3437. t_on = _pd.to_numeric(g[timeon_col], errors='coerce').fillna(0)
  3438. c_on = _pd.to_numeric(g[counts_col], errors='coerce').fillna(0)
  3439. started = c_on > 0
  3440. a_med = float((t_on[started] / c_on[started]).median()) if started.any() else _np.nan
  3441. b_h = float((t_on >= full_thresh).sum() * window_s / 3600.0)
  3442. rows.append({'tid': tid, 'ym': ym, 'A_s': a_med, 'B_h': b_h,
  3443. 'mixed_ratio_s': float(t_on.sum() / c_sum), 'n_start': int(c_sum)})
  3444. if not rows:
  3445. return {'fleet': {}, 'per_tid': {}, 'monthly': [], '_caliber': 'no data'}
  3446. m = _pd.DataFrame(rows)
  3447. a_all = m.A_s.dropna()
  3448. a_fleet = float(a_all.median() if fleet_ref == 'median' else a_all.mean())
  3449. a_mad = float(_np.median(_np.abs(a_all - a_fleet))) if len(a_all) else _np.nan
  3450. yms = sorted(m.ym.unique())
  3451. now_yms = set(yms[-3:])
  3452. n_tid = int(m.tid.nunique())
  3453. # ★fleet 基线抗污染: 台数过少时中位会被异常台自身拉高 (DP 用例逮出) →
  3454. # n<5 不做相对判型 (只报绝对值); n>=5 用下半部 (p25) 作正常水位参照。
  3455. a_base = float(a_all.quantile(0.25)) if len(a_all) else _np.nan
  3456. per = {}
  3457. for tid, g in m.groupby('tid'):
  3458. g_now = g[g.ym.isin(now_yms)]
  3459. a_now = float(g_now.A_s.median()) if len(g_now) and g_now.A_s.notna().any() else _np.nan
  3460. ia = g.A_s.idxmax() if g.A_s.notna().any() else None
  3461. ib = g.B_h.idxmax() if len(g) else None
  3462. b_tot = float(g.B_h.sum())
  3463. a_hi = (not _np.isnan(a_now)) and (not _np.isnan(a_base)) and a_now > max(a_base * 1.5, a_base + 2 * (a_mad or 0))
  3464. b_hi = b_tot >= 1.0
  3465. if n_tid < 5:
  3466. typ = 'INSUFFICIENT(机群样本不足, 不做相对判型)'
  3467. else:
  3468. typ = ('A+B(混合)' if a_hi and b_hi else
  3469. 'A(输送阻力)' if a_hi else 'B(泵打不停)' if b_hi else '正常')
  3470. per[tid] = {'A_now': None if _np.isnan(a_now) else round(a_now, 1),
  3471. 'A_peak': None if ia is None else round(float(g.loc[ia, 'A_s']), 1),
  3472. 'A_peak_ym': None if ia is None else str(g.loc[ia, 'ym']),
  3473. 'B_total_h': round(b_tot, 1),
  3474. 'B_peak_h': None if ib is None else round(float(g.loc[ib, 'B_h']), 1),
  3475. 'B_peak_ym': None if ib is None else str(g.loc[ib, 'ym']),
  3476. 'type': typ}
  3477. return {'fleet': {'A_median': round(a_fleet, 1), 'A_base_p25': None if _np.isnan(a_base) else round(a_base, 1),
  3478. 'A_mad': None if _np.isnan(a_mad) else round(a_mad, 2),
  3479. 'n_tid': n_tid, 'n_month_cells': int(len(m))},
  3480. 'per_tid': per, 'monthly': m.to_dict('records'),
  3481. '_caliber': (f'A=counts>0窗的 timeon/counts 中位(输送阻力); B=timeon>={full_thresh:.0f}s 窗数×{window_s:.0f}s '
  3482. f'(泵连续满转); 月门 counts>={min_counts_month}; 现窗=末3个合格月; '
  3483. 'A_hi = A_now > max(1.5×基线, 基线+2MAD), 基线=A的p25(抗异常台自污染, 小样本中位会被拉高—DP用例逮出); 机群<5台不做相对判型; B_hi = B累计>=1h; '
  3484. '★禁用 sum(timeon)/sum(counts) 单一口径下结论(混合两型, 如东 26# 853s 实为 B 型)')}
  3485. # ---------------------------------------------------------------- §4.6 热阻β回归家族 (如东 U4 回灌 2026-08-08)
  3486. def thermal_beta_family(df, *, node_col, sink_col, p_col, turbine_col, time_col,
  3487. rated_kw, family_type='generic', probe_suspects=None,
  3488. min_row_month=30, min_n_month=500, min_p_iqr_mw=0.8,
  3489. min_months=5, recent_months=3, gate=2.5, nm_band=(2.0, 2.5),
  3490. min_fleet=8, gain_top=6, min_gain_n=5000):
  3491. """热阻 β 回归家族引擎: ΔT ~ α + β·P(MW) 逐台逐月 OLS → β/α×(现窗/趋势/同季差) 六轴 fleet-z (源 如东 U4 beta_thermal_regression, n=1 场×12族).
  3492. 模型: ΔT(node−sink) = α + β·P。β=载荷相关热阻代理(K/MW), α=固定损耗×热阻(K)。
  3493. ★铁律一 (RD-076 戒): **禁 ΔT/P 直除** — Q=a+b·P 下低功率 a/P 项发散, 月均 R=ΔT/P 的趋势符号可翻
  3494. (如东实证 r +0.32→−0.737)。旧口径只作锚复现对照, 禁作判据。
  3495. ★铁律二 (月质量门): 逐月 OLS 必过 n≥min_n_month ∧ P_IQR≥min_p_iqr_mw(MW) — 低风月 P 无跨度时
  3496. β 病态 (设计矩阵近奇异), 不剔则趋势轴被垃圾月污染。
  3497. ★铁律三 (族类型定主锚, axis_note 同): cabinet 族 ΔT 天然非负荷耦合 (Q≈const) ⇒ 主锚=α 轴
  3498. (R 恶化抬 α 不动 β), β 显著反而=负荷耦合发热异常; gearbox/trafo β/α 双轴并读;
  3499. paired 族(同部件双测点差)=α≈β≈0 预期, 破门=不对称信号; generator 空空冷却 sink 用独立环温。
  3500. ★铁律四 (%/月 小分母虚大): trend_pct = 斜率/|fleet β 中位|, |fleet β|<0.5 的族百分比虚大,
  3501. 判据以 z 与 K/MW/月 绝对斜率为准, %/月 仅供方向感。
  3502. ★铁律四b (环温混杂 → β 不可作热阻物理量解读; 如东 U4 β-v2 证伪, 2026-08-08 回灌):
  3503. **未把环温入回归时, β 会被环温季节性劫持** —— 如东实测 corr(β, 月环温)=0.939, 发电机前轴承族
  3504. β 冬 −0.99 → 夏 +3.61 K/MW **跨零变号**; 负 β = "功率越高相对环温越冷", 物理上无法作热阻解。
  3505. ⇒ ①**减去环温 sink 不等于控制了环温** (热阻与冷却效率本身随环温变, 残余依赖仍在), sink=环温 的
  3506. 族尤其要防; ②要作物理量解读须把环温入回归 `ΔT ~ α + β·P + γ·T_amb`; ③本函数当前实现**未含 γ 项**,
  3507. 故其 β **只可用于同月同口径的跨机相对比较**, **跨月/趋势类 β 结论一律降级** (ss_* 同季差轴正是
  3508. 为此设计, 趋势轴 trend_* 受此限最重)。n=1 场 [暂行], 换场须自行复核符号是否同样变号。
  3509. ★铁律四c (R² 类质量门必按族相对设, 禁跨族绝对门; 同源证伪): 各族 fleet R² 中位差异可达 8 倍
  3510. (如东 绕组 0.83 / DE 0.73 / 配对 0.62 / **前轴承族仅 0.104**) —— 绝对门 (如 R²≥0.30) 会对低信噪族
  3511. **毁灭性**: 如东实测砍掉前轴承族 89% 月份 (634→67), 把公认最异常台压到完全检不出 = **假阴性**。
  3512. 须用 `系数 × 该族 fleet R² 中位` 形式的相对门 (系数本身无独立依据, 属经验值须标注)。
  3513. 本函数当前用 月质量门(n + P_IQR) 而非 R² 门 — 若调用方自行加 R² 门, 必按本铁律设。n=1 [暂行]。
  3514. ★铁律五 (β 简并拆分, 结论级必写): β/α 异常 = 散热变差(R↑) / 产热增加(Q↑) / 传感 gain 漂移 三者同形,
  3515. 本引擎不可分辨。拆分靠外部交叉: 振动异常→产热(摩擦)路径; 振动净+润滑泵行为异常→润滑/排油路径;
  3516. 两者均净→散热回路候选 (如东 6# 例); gain_annex 旗→"不排增益传感"(旗≠传感定论, 真退化台本就 gain>1)。
  3517. ★铁律六 (多轴汇聚): 单轴破门不升候选 — 六轴×N台 多重比较自带, 调用方配 analysis_kit.expected_fp;
  3518. near_miss 超集披露 (精确集断言戒)。
  3519. 同季差轴 ss_*: 对齐 month-of-year 的相邻年差中位 (抗季节混杂, 解如东 RD-082/084 口径分歧)。
  3520. 返回 {caliber, family_type, fleet_beta_median_K_MW, fleet_alpha_median_K, per_turbine:{tid:{...六轴+z}},
  3521. gate_*/nearmiss_* ×6, gain_annex, axis_note, note}。样本/台数不足如实报 INSUFFICIENT。
  3522. """
  3523. from .analysis_kit import gen_mask, mad_z, near_miss, theil_sen, caliber_stamp
  3524. d = df.dropna(subset=[node_col, sink_col, p_col, turbine_col, time_col]).copy()
  3525. mask, cal = gen_mask(d, power_col=p_col, rated_kw=rated_kw)
  3526. g = d[mask].copy()
  3527. if len(g) < 2000:
  3528. return {'verdict': 'INSUFFICIENT', 'evidence': '发电态样本不足(<2000)', 'per_turbine': {}}
  3529. g['_mw'] = pd.to_numeric(g[p_col], errors='coerce') / 1000.0
  3530. g['_dt'] = pd.to_numeric(g[node_col], errors='coerce') - pd.to_numeric(g[sink_col], errors='coerce')
  3531. g['_ym'] = pd.to_datetime(g[time_col]).dt.to_period('M').astype(str)
  3532. g = g.dropna(subset=['_mw', '_dt'])
  3533. rows = []
  3534. for (tid, ym), x in g.groupby([turbine_col, '_ym']):
  3535. n = len(x)
  3536. if n < min_row_month:
  3537. continue
  3538. p_iqr = float(x['_mw'].quantile(.75) - x['_mw'].quantile(.25))
  3539. sol, *_ = np.linalg.lstsq(np.c_[x['_mw'].values, np.ones(n)], x['_dt'].values, rcond=None)
  3540. if bool(n >= min_n_month and p_iqr >= min_p_iqr_mw): # 月质量门 (铁律二)
  3541. rows.append((tid, ym, float(sol[0]), float(sol[1])))
  3542. if not rows:
  3543. return {'verdict': 'INSUFFICIENT', 'evidence': '无合格月 (月质量门全剔)', 'per_turbine': {}}
  3544. dfm = pd.DataFrame(rows, columns=['tid', 'ym', 'beta', 'alpha'])
  3545. fm = dfm.groupby('ym')[['beta', 'alpha']].median().rename(columns=lambda c: 'fleet_' + c)
  3546. dfm = dfm.join(fm, on='ym')
  3547. dfm['beta_dev'] = dfm.beta - dfm.fleet_beta
  3548. dfm['alpha_dev'] = dfm.alpha - dfm.fleet_alpha
  3549. fleet_b, fleet_a = float(dfm.fleet_beta.median()), float(dfm.fleet_alpha.median())
  3550. res = {}
  3551. for tid, x in dfm.groupby('tid'):
  3552. x = x.sort_values('ym')
  3553. if len(x) < min_months:
  3554. continue
  3555. ts = theil_sen(x.beta_dev.values)
  3556. tsa = theil_sen(x.alpha_dev.values)
  3557. per = pd.PeriodIndex(x.ym.values, freq='M')
  3558. ss_a, ss_b = [], []
  3559. for m in range(1, 13): # 同季差: month-of-year 对齐相邻年
  3560. ys = sorted(per[per.month == m].year)
  3561. for y in ys:
  3562. if y + 1 in ys:
  3563. k0, k1 = f'{y}-{m:02d}', f'{y + 1}-{m:02d}'
  3564. xi = x.set_index('ym')
  3565. ss_b.append(float(xi.loc[k1, 'beta_dev'] - xi.loc[k0, 'beta_dev']))
  3566. ss_a.append(float(xi.loc[k1, 'alpha_dev'] - xi.loc[k0, 'alpha_dev']))
  3567. res[tid] = {'n_months': int(len(x)),
  3568. 'beta_now': round(float(x.beta_dev.iloc[-recent_months:].median()), 3),
  3569. 'beta_level': round(float(x.beta.iloc[-recent_months:].median()), 3),
  3570. 'trend_K_MW_mo': round(float(ts), 4),
  3571. 'trend_pct_mo': round(float(ts) / abs(fleet_b) * 100, 2) if fleet_b else None,
  3572. 'alpha_now': round(float(x.alpha_dev.iloc[-recent_months:].median()), 3),
  3573. 'alpha_level': round(float(x.alpha.iloc[-recent_months:].median()), 3),
  3574. 'alpha_trend_K_mo': round(float(tsa), 4),
  3575. 'alpha_trend_pct_mo': round(float(tsa) / abs(fleet_a) * 100, 2) if fleet_a else None,
  3576. 'ss_beta': round(float(np.median(ss_b)), 3) if ss_b else None,
  3577. 'ss_alpha': round(float(np.median(ss_a)), 3) if ss_a else None,
  3578. 'probe_suspect': (probe_suspects or {}).get(tid)}
  3579. if not res:
  3580. return {'verdict': 'INSUFFICIENT', 'evidence': f'合格台不足 (per台合格月<{min_months})', 'per_turbine': {}}
  3581. axes = {'z_now': 'beta_now', 'z_trend': 'trend_pct_mo', 'z_alpha_now': 'alpha_now',
  3582. 'z_alpha_trend': 'alpha_trend_pct_mo', 'z_ss_alpha': 'ss_alpha', 'z_ss_beta': 'ss_beta'}
  3583. out = {'caliber': caliber_stamp(gen_mask=cal,
  3584. model='ΔT ~ α + β·P(MW), 逐台逐月 OLS (np.linalg.lstsq)',
  3585. month_gate={'min_n': min_n_month, 'min_p_iqr_mw': min_p_iqr_mw},
  3586. trend='theil_sen(β_dev 月序), β_dev = β_tid − fleet 月中位 (消共模)',
  3587. old_caliber='R=ΔT/P 直除已禁 (RD-076: 低功率发散, 趋势符号可翻)'),
  3588. 'family_type': family_type,
  3589. 'fleet_beta_median_K_MW': round(fleet_b, 3), 'fleet_alpha_median_K': round(fleet_a, 3),
  3590. 'axis_note': ('cabinet 族主锚=α轴(Q≈const); gearbox/trafo β/α 双轴并读; '
  3591. 'paired 族=同部件双测点差(α≈β≈0 预期, 破门=不对称信号); '
  3592. 'ss_*=同季差轴(相邻年 month-of-year 对齐, 抗季节)'),
  3593. 'per_turbine': res}
  3594. tids = sorted(res)
  3595. for zk, vk in axes.items():
  3596. vals = pd.Series({t: res[t][vk] for t in tids}, dtype='float64').dropna()
  3597. if len(vals) < min_fleet:
  3598. out['gate_' + zk], out['nearmiss_' + zk] = [], []
  3599. for t in tids:
  3600. res[t][zk] = None
  3601. continue
  3602. z = mad_z(vals)
  3603. nmr = near_miss(z.abs(), gate=gate, band=nm_band)
  3604. out['gate_' + zk] = sorted(nmr['gate'])
  3605. out['nearmiss_' + zk] = sorted(nmr['near_miss'])
  3606. for t in tids:
  3607. res[t][zk] = round(float(z[t]), 2) if t in z.index else None
  3608. gain = {'method': '双变量 OLS 台温~fleet同通道中位(剔本台), 发电态; gain∉[0.8,1.2] 旗 — '
  3609. '旗≠传感定论(真载荷退化台本就 gain>1), 但候选结论级须写"不排增益传感"'}
  3610. if gain_top:
  3611. zk6 = list(axes)
  3612. ranked = sorted(res, key=lambda t: -max(abs(res[t][k] or 0) for k in zk6))[:gain_top]
  3613. w = g.pivot_table(index=time_col, columns=turbine_col, values=node_col)
  3614. ch = {}
  3615. for t in ranked:
  3616. if t not in w.columns:
  3617. continue
  3618. others = w.drop(columns=[t]).median(axis=1)
  3619. xy = pd.concat([others, w[t]], axis=1, keys=['x', 'y']).dropna()
  3620. if len(xy) < min_gain_n:
  3621. continue
  3622. sol, *_ = np.linalg.lstsq(np.c_[xy.x.values, np.ones(len(xy))], xy.y.values, rcond=None)
  3623. ch[t] = {'gain': round(float(sol[0]), 3), 'offset': round(float(sol[1]), 2),
  3624. 'n': int(len(xy)), 'flag': bool(sol[0] > 1.2 or sol[0] < 0.8)}
  3625. gain['targets'] = list(ranked)
  3626. gain['result'] = ch
  3627. out['gain_annex'] = gain
  3628. out['note'] = ('β/α 异常简并于 散热变差(R↑)/产热增加(Q↑)/传感gain漂移, 本引擎不可分辨; '
  3629. '拆分靠交叉: 振动异常→产热; 振动净+润滑泵异常→润滑/排油; 均净→散热回路候选; '
  3630. '单轴破门不升候选 (六轴×N台多重比较, 配 expected_fp), near_miss 超集披露; '
  3631. '★本引擎未含 γ·T_amb 项 → β 受环温季节性劫持 (如东实测 corr(β,环温)=0.939, 可跨零变号), '
  3632. '禁作热阻物理量解读; 仅可同月同口径跨机相对比较, 跨月/趋势类 β 结论须降级')
  3633. return out
  3634. # ---------------------------------------------------------------- §4.6 温控执行器 (防凝露加热器等, 如东 U4 回灌 2026-08-08)
  3635. def heater_actuator_screen(df, *, heat_timeon_col, p_col, turbine_col, time_col,
  3636. rated_kw, amb_col=None, comp_cols=None, humid_col=None,
  3637. period_s=600.0, summer=(6, 7, 8), winter=(12, 1, 2),
  3638. z_gate=2.5, winter_fail_pct=1.0, low_humid=50.0,
  3639. strict_p_frac=0.125, on_thresh_s=300.0,
  3640. amb_band=(5.0, 15.0), risk_tids=None, humid_bins=(40, 50, 60, 70, 80, 90)):
  3641. """温控执行器(防凝露加热器)三判据 screen: 卡开候选 / 失效候选 / 湿度驱动验证 (源 如东 U4 ax8+heater_humidity, n=1 场×38台).
  3642. ★铁律一 (错判 38 台戒, 2026-08-08): **恒温控制系统的 ON/OFF 窗温差对比 ≈0 是正常表现** —
  3643. 控制目标即恒温 (控制成功 ⇒ 部件温度不随加热态摆动), **禁把"加热 ON 时不升温"当失效判据**
  3644. (preflight v1 曾据此错判全场 38 台"加热无效")。idle_on_response 返回仅供参考, 不入 verdict。
  3645. 执行器**功能判据 = 控制目标达成率** (如凝露风险窗内湿度/温度是否保持在目标带), 非温差响应。
  3646. ★铁律二 (卡开判据双证): 卡开候选 = 夏季 duty 高 z (温控正常台夏季基本不加热)
  3647. × 低湿窗 duty (湿度驱动控制下低湿仍高 duty=不响应输入) × 稳定运行整窗加热
  3648. (P>rated×strict_p_frac ∧ timeon≥period_s 整窗, **排启停边界窗** — loose 口径全场无一台为 0,
  3649. 系统性混入边界窗, 不可作单台证据; 如东 strict fleet 中位 0.18% vs loose 6.35%)。
  3650. ★铁律三 (湿度驱动验证): 现场控制律可偏离 OEM 温度限值 (集成层自设, OEM"自担责"条款) —
  3651. humid_col 给定时验 ON 率随湿度 bin 单调升 (如东 10.5%→99.5%) = 湿度驱动坐实;
  3652. 判"低湿仍开"前先核湿度探头 (探头读低会造"低湿假象", 如东 20# 读低 20pp 分类失真)。
  3653. ★铁律四 (运行中加热=违规轴): OEM 硬警告"仅在电机关闭时运行防凝露加热器" (运行中加热致内部温度↑
  3654. 加速绝缘热老化, 且混入热阻 β 回归 α 项)。strict 口径高台单列, 非失效但须现场核温控接线。
  3655. risk_tids: 外部冷凝风险台集 (如绝缘 D 轴), 冬季低 duty × risk 交叉才升失效候选 (单腿不升)。
  3656. 返回 {caliber, fleet_summer_duty_pct, fleet_winter_duty_pct, per_turbine, stuckon_cand,
  3657. winter_low, fail_cand, runstate_strict_pct, runstate_loose_pct, humid_on_curve,
  3658. low_humid_duty, idle_on_response_K_ref(参考, 禁作判据), duty_traj_flagged, note}。
  3659. """
  3660. from .analysis_kit import mad_z, caliber_stamp
  3661. d = df.dropna(subset=[heat_timeon_col, p_col, turbine_col, time_col]).copy()
  3662. if len(d) < 2000:
  3663. return {'verdict': 'INSUFFICIENT', 'evidence': '样本不足(<2000)', 'per_turbine': {}}
  3664. d['_ht'] = pd.to_numeric(d[heat_timeon_col], errors='coerce').fillna(0.0)
  3665. d['_p'] = pd.to_numeric(d[p_col], errors='coerce')
  3666. d['_t'] = pd.to_datetime(d[time_col])
  3667. d['_ym'] = d['_t'].dt.to_period('M').astype(str)
  3668. d['_mo'] = d['_t'].dt.month
  3669. hm = d.groupby([turbine_col, '_ym']).agg(hT=('_ht', 'sum'), n=('_t', 'size'), mo=('_mo', 'first')).reset_index()
  3670. hm['duty'] = hm.hT / (hm.n * period_s)
  3671. smr = hm[hm.mo.isin(summer)].groupby(turbine_col).duty.mean() * 100
  3672. wtr = hm[hm.mo.isin(winter)].groupby(turbine_col).duty.mean() * 100
  3673. if len(smr) < 5:
  3674. return {'verdict': 'INSUFFICIENT', 'evidence': '夏季月覆盖台数不足(<5)', 'per_turbine': {}}
  3675. rz = mad_z(smr)
  3676. stuckon = sorted([str(t) for t in smr.index if rz[t] > z_gate])
  3677. winter_low = sorted([str(t) for t in wtr.index if wtr[t] < winter_fail_pct])
  3678. fail_cand = sorted(set(winter_low) & {str(t) for t in (risk_tids or [])})
  3679. gen = d[d['_p'] > 50.0]
  3680. loose = (gen[gen['_ht'] > on_thresh_s].groupby(turbine_col).size()
  3681. / gen.groupby(turbine_col).size() * 100).fillna(0)
  3682. sg = d[d['_p'] > rated_kw * strict_p_frac]
  3683. strict = (sg[sg['_ht'] >= period_s].groupby(turbine_col).size()
  3684. / sg.groupby(turbine_col).size() * 100).fillna(0)
  3685. out = {'caliber': caliber_stamp(period_s=period_s, summer=list(summer), winter=list(winter),
  3686. strict_runstate=f'P>{rated_kw * strict_p_frac:.0f}kW ∧ timeon≥{period_s:.0f}s 整窗 (排启停边界)',
  3687. loose_runstate=f'P>50kW ∧ timeon>{on_thresh_s:.0f}s (仅场级参考, 系统性含边界窗)'),
  3688. 'fleet_summer_duty_pct': round(float(smr.median()), 2),
  3689. 'fleet_winter_duty_pct': round(float(wtr.median()), 2) if len(wtr) else None,
  3690. 'per_turbine': {str(t): {'summer_duty_pct': round(float(smr.get(t, np.nan)), 2),
  3691. 'winter_duty_pct': round(float(wtr.get(t, np.nan)), 2) if t in wtr.index else None,
  3692. 'summer_z': round(float(rz.get(t, np.nan)), 2),
  3693. 'runstate_strict_pct': round(float(strict.get(t, 0.0)), 2)}
  3694. for t in smr.index},
  3695. 'stuckon_cand': stuckon, 'winter_low': winter_low, 'fail_cand': fail_cand,
  3696. 'runstate_strict_top': {str(t): round(float(v), 2)
  3697. for t, v in strict.sort_values(ascending=False).head(5).items() if v > 0},
  3698. 'duty_traj_flagged': {str(t): {ym: round(du * 100, 2)
  3699. for ym, du in zip(hm.loc[hm[turbine_col] == t, '_ym'],
  3700. hm.loc[hm[turbine_col] == t, 'duty'])}
  3701. for t in smr.index if rz[t] > z_gate}}
  3702. if humid_col and humid_col in d.columns:
  3703. hv = pd.to_numeric(d[humid_col], errors='coerce')
  3704. on = d['_ht'] > on_thresh_s
  3705. edges = [-np.inf, *humid_bins, np.inf]
  3706. curve = {}
  3707. for i in range(len(edges) - 1):
  3708. m = (hv >= edges[i]) & (hv < edges[i + 1])
  3709. if m.sum() >= 200:
  3710. lab = f'{edges[i]:.0f}-{edges[i + 1]:.0f}'.replace('-inf', '<' + str(humid_bins[0])).replace('inf', '>' + str(humid_bins[-1]))
  3711. curve[lab] = round(float(on[m].mean() * 100), 1)
  3712. vals = list(curve.values())
  3713. out['humid_on_curve'] = curve
  3714. out['humid_driven'] = bool(len(vals) >= 4 and all(b >= a for a, b in zip(vals, vals[1:])))
  3715. lh = d[hv < low_humid]
  3716. lhd = lh.groupby(turbine_col)['_ht'].sum() / (lh.groupby(turbine_col).size() * period_s) * 100
  3717. out['low_humid_duty_pct'] = {str(t): round(float(v), 2)
  3718. for t, v in lhd.sort_values(ascending=False).head(8).items()}
  3719. if comp_cols and amb_col and amb_col in d.columns:
  3720. idle = d[d['_p'] < 10.0].copy()
  3721. av = pd.to_numeric(idle[amb_col], errors='coerce')
  3722. band = idle[(av >= amb_band[0]) & (av <= amb_band[1])].copy() # 同环温带消 'ON选冷天' 混杂
  3723. cc = [c for c in comp_cols if c in band.columns]
  3724. if cc and len(band) > 1000:
  3725. band['_w'] = band[cc].apply(pd.to_numeric, errors='coerce').median(axis=1) - pd.to_numeric(band[amb_col], errors='coerce')
  3726. resp = {}
  3727. for t, x in band.groupby(turbine_col):
  3728. on_w = x[x['_ht'] > on_thresh_s]['_w']
  3729. off_w = x[x['_ht'] == 0]['_w']
  3730. if len(on_w) > 100 and len(off_w) > 100:
  3731. resp[str(t)] = round(float(on_w.median() - off_w.median()), 2)
  3732. out['idle_on_response_K_ref'] = resp
  3733. out['note'] = ('★ON/OFF 温差≈0 = 恒温控制正常表现, 禁作失效判据 (错判38台戒); 功能判据=控制目标达成率; '
  3734. '卡开=夏duty高z×低湿窗duty×strict整窗运行加热三证; 失效=冬低duty×冷凝风险交叉; '
  3735. '低湿判读前核湿度探头读偏; strict 运行中加热高台=OEM 违规轴单列现场核')
  3736. return out
  3737. # ---------------------------------------------------------------- §4.6 开关量停机 响应比 (如东 U6 回灌 2026-08-08)
  3738. def response_ratio(stop_intervals, signal_intervals, *, ratio_gate=10.0):
  3739. """停机时长/故障信号实际在位时长 比值 — 分辨"真持续故障损失" vs "复位滞后/管理损失" (源 如东 U6 §3.2, n=2 known-answer).
  3740. 对开关量触发的停机型事件 (油位/滤芯/温度开关…), 用**独立信号源** (din 开关实际断开的
  3741. 在位区间) 除 SCADA 连续停机区间:
  3742. ratio ≈ 1 = 信号持续在位 = 真持续性故障 (如东 20# 油位开关连续断开 77.5h ≈ 停机 1:1 = 真缺油/漏点);
  3743. ratio ≫ 1 = 信号仅间歇触发 (阈值边缘抖动) + 人工复位滞后 ⇒ **损失是管理问题非设备问题**
  3744. (如东 18B 开关实际断开 0.51h vs 停机 110.8h = 1:217, 57,922 kWh; 处方=持续性
  3745. 判据/远程复位, 非修设备)。同名故障两台机制可差 152× — 必逐台算, 禁按故障名合并处置。
  3746. ★铁律: signal_intervals 必须来自**与停机判定独立的信号源** (开关量 din 原始在位, 非停机状态字
  3747. 派生), 否则比值恒 1 套套逻辑。ratio_gate 默认 10 只是分层参考 (A/B 判据由调用方按场定, RULE-3)。
  3748. 入参: 区间列表 [(t0, t1), ...] (pd.Timestamp 或可比时间)。
  3749. 返回 {stop_h, signal_h, ratio, classification, n_stop_intervals, n_signal_intervals, note}。
  3750. """
  3751. def _hours(iv):
  3752. tot = 0.0
  3753. for t0, t1 in iv:
  3754. tot += max((pd.Timestamp(t1) - pd.Timestamp(t0)).total_seconds(), 0.0) / 3600.0
  3755. return tot
  3756. sh = _hours(stop_intervals)
  3757. gh = _hours(signal_intervals)
  3758. if sh <= 0:
  3759. return {'stop_h': 0.0, 'signal_h': round(gh, 2), 'ratio': None,
  3760. 'classification': 'no_stop', 'n_stop_intervals': len(stop_intervals),
  3761. 'n_signal_intervals': len(signal_intervals)}
  3762. if gh <= 0:
  3763. return {'stop_h': round(sh, 2), 'signal_h': 0.0, 'ratio': None,
  3764. 'classification': 'signal_absent',
  3765. 'n_stop_intervals': len(stop_intervals), 'n_signal_intervals': len(signal_intervals),
  3766. 'note': '停机窗内独立信号零在位 — 先核信号通道 liveness, 非直接判管理损失'}
  3767. r = sh / gh
  3768. return {'stop_h': round(sh, 2), 'signal_h': round(gh, 2), 'ratio': round(r, 1),
  3769. 'classification': 'latency_dominated' if r > ratio_gate else 'fault_dominated',
  3770. 'n_stop_intervals': len(stop_intervals), 'n_signal_intervals': len(signal_intervals),
  3771. 'note': ('ratio≫1=阈值边缘间歇触发+复位滞后(管理杠杆: 持续性判据/远程复位)' if r > ratio_gate
  3772. else 'ratio≈1=信号持续在位=真持续故障(设备处置线)')}
  3773. # ---------------------------------------------------------------- §4.6 振动谱线四道前提闸 (skill vibration-spectral)
  3774. def spectral_line_gates(spec, dx, hz, *, fleet_med=None, all_line_hz=(), tol_bins=2,
  3775. bw_hz=None, min_struct=3.0, max_drift_bins=2.0,
  3776. peak_hz_by_window=None, min_fleet_struct=3.0, total_energy=None,
  3777. shaft_hz=(), int_order_tol=0.03, max_order=40, harmonic_check_hz=None,
  3778. domain=None, max_peak_offset_bins=1.5, iso_ref_mms=None,
  3779. x_fleet=None, min_x_fleet=3.0, selectivity_samewin=None, min_selectivity=1.5):
  3780. """特征频率判据的四道前提闸 — 不过闸的 x_fleet/选择性/趋势一律不得进下游。
  3781. 根因 (2026-08-14/19 如东 M5, 同一套判据一天内被推翻三次 + 拿到 OEM 表后逮出第四类):
  3782. G1 分辨率: ±tol_bins 窗宽须 < 最近邻特征线间隔/3 (否则窗重叠取到同一峰)
  3783. G2 有线: 峰/邻域本底 >= min_struct (如东 2220 项里 1969 项=89% 在此被拦, 那些位置根本没有线)
  3784. G3 峰位稳: 跨窗峰位漂移 <= max_drift_bins bin (本底型乱跳: 239.2/244.7/241.7/246.4)
  3785. G4 非固有: fleet 中位谱在该处 峰/本底 < min_fleet_struct, 否则该线全场共有(机型固有/结构共振),
  3786. 相对判据在此失效 — 如东 HS转子侧179Hz fleet峰/本底8.5 基率26% 会造13台假阳,
  3787. 而 GenDE内圈135Hz fleet峰/本底1.0 最大/中位80.5 = 真个体缺陷
  3788. G5 非整阶: 实测峰位不得落在任何轴系转频的整数倍上(容差 int_order_tol 相对).
  3789. **轴承特征频率由滚动体几何决定, 必为非整数倍; 齿轮/轴系频率必为整数倍** —
  3790. 落在整数倍上说明归属是轴系/齿轮不是轴承。如东 02# 二级行星"外圈BPFO 20.9Hz"命中
  3791. 6.5×fleet/结构3.3, 但亚bin插值实测峰位五窗 20.970~21.002 = 二级行星架转频(1.053Hz)
  3792. 的 20.0 倍(误差0.005~0.14%), 距 BPFO 20.9 有 2.6bin — ±2bin 搜索窗把整阶线抓成了
  3793. 轴承线。配套: 真轴承缺陷必有 2× 谐波, 02# 的 2×20.9Hz 峰/本底=1.0 完全无谐波
  3794. Args:
  3795. shaft_hz: ★**只传该轴承实际所在的那一根轴**的转频 (Hz) — 用 OEM 表的 shaft_Hz 字段.
  3796. **禁止把全机所有轴都传进来**: G5 判"阶次落整数±tol", 传入轴数越多假阳率越高.
  3797. 纯几何量化 (与任何场的数据无关, 20~300Hz 随机频率 n=200,000 蒙特卡洛):
  3798. 只传本轴 1 根 → 误判整阶 **5.33%**
  3799. 传 2 根 → **11.03%**
  3800. 传 3 根 → **11.43%**
  3801. 传全机 5 根 → **16.67%**
  3802. 实证 (2026-08-21 如东): GenDE 内圈实测峰位 134.56Hz, 其所在轴是 25Hz →
  3803. 134.56/25 = 5.3824 阶 (距整数 0.38, 非整阶, 正确放行); 但若把不相关的 IMS 轴 7.08 也传进来,
  3804. 134.56/7.08 = **19.0057 阶** (距整数 0.0057) → **G5 误杀本场最强的那条结论**.
  3805. 生产脚本 rudong_tcm_oem_scan.py 用 `bb.get('shaft_Hz')` 只取该轴承自己的轴, 是对的.
  3806. int_order_tol: 整阶判定的**绝对阶数**容差 (默认 0.03 阶). ★禁用相对容差 —
  3807. 相对容差在高阶数上等于极宽的绝对容差 (135Hz/1.053Hz=128.2 阶时 2% 相对 = ±2.5 阶,
  3808. 几乎任何频率都会命中某个整数), 会把真轴承线误判成整阶. 自检用例 2 实逮
  3809. max_order: 只在 k <= max_order 时判整阶 (默认 40). 高阶数上相邻整数在频率上只差一个
  3810. shaft_hz, 谱分辨率与谐波衰减都使整阶判据失去意义; 那个量级应直接用 GMF 判
  3811. harmonic_check_hz: 谐波检验用的 2× 频率 (None 则用 2*hz)
  3812. spec: 该台该窗的谱 (1D, 物理单位如 m/s²)
  3813. dx: 谱线间隔 Hz
  3814. hz: 目标特征频率
  3815. fleet_med: fleet 中位谱 (G4 用; None 则跳过 G4 并在 verdict 标注)
  3816. all_line_hz: 该轴承/部件的全部特征频率, 用于 G1 算最近邻间隔
  3817. min_struct: G2 峰/邻域本底门槛 (默认 3.0). ★**统计推导, 不是从任何场的数据调出来的**:
  3818. 纯噪声谱上复高斯幅值服从 Rayleigh, 中位 = 1.1774σ, P(|X| > k·中位) = exp(-(1.1774k)²/2).
  3819. ±tol_bins 窗内取最大 (5 bin) 的假阳率 = 1-(1-p)^5:
  3820. k=2.0 → **27.6%** · k=2.5 → **6.4%** · k=3.0 → **0.97%** · k=3.5 → 0.10% · k=4.0 → 0.008%
  3821. 蒙特卡洛 (纯高斯噪声 n=200,000): k=3.0 实测 **0.989%**, 与解析 0.973% 一致.
  3822. ⇒ 取 3.0 = 窗内假阳率约 1%; 2.0 过松, 4.0 会漏早期弱线.
  3823. ★**防过拟合铁律**: 该值不得因"某场检出率不好看"而调整 — 调整须重做上述统计推导,
  3824. 且**禁止以"对标厂商/第三方检出率"为理由** (厂商口径本身可能错: 如东上气漏检 16# 六个月).
  3825. bw_hz: 邻域半宽 (默认 min(15Hz, 最近邻间隔/2))
  3826. peak_hz_by_window: {窗名: 峰位Hz} — 有则跑 G3
  3827. total_energy: 该测点总振动能量 sum(v**2), 有则算能量占比
  3828. x_fleet / min_x_fleet: ★**G8 fleet 离群门** — 该线相对 fleet 中位的倍数, 门槛默认 3.0.
  3829. 前七闸判的都是"这条线真不真实、是不是个体的", **没有一道问"它比同类高多少"**.
  3830. 根因 (2026-08-21): G7 改自适应后, 新过闸里冒出 x_fleet=1.06 的 (跟全场中位一样,
  3831. 毫无异常含义), 过闸台数飙到 34/37 — 原来那些低 x_fleet 的是被固定 G7=1.5 **偶然**拦住的.
  3832. ★门槛 3.0 **与 G2 同源, 不是调的**: G2 要求"峰比邻域本底高3倍才算有线",
  3833. 同理"比 fleet 中位高3倍才算离群". x_fleet=None 则跳过该闸并在 verdict 标注.
  3834. selectivity_samewin / min_selectivity: ★**G6 同窗选择性门** — 线÷自身宽带的 ×fleet 比, **必须同窗算**.
  3835. ★本函数是**单窗**的, 算不出跨窗中位, 故 G6 需调用方传入 (由生产脚本按窗计算后传).
  3836. 混合口径(线六窗max ÷ 宽带六窗中位)对突发事件台会虚高: 如东 38# 后主轴承内圈 3.10Hz
  3837. 混合 sel=13.26 看着是强线, 同窗逐窗 1.33/0.95/0.94/0.84/0.47 **全 <=1.33**.
  3838. None 则跳过该闸并在 verdict 标注. **[暂行]** 1.5 是经验值, 见退役判据.
  3839. iso_ref_mms: ★G4 批次盲点旁路用的**不依赖 fleet 的绝对锚** (mm/s, 如 ISO 20816 的 7.1).
  3840. G4 命中(fleet 中位谱同处有峰)时: 若该线的速度当量 >= 此锚 ⇒ 仍判 G4_FLEET_INHERENT
  3841. (全场都超绝对线本身就是机型问题); 否则输出 **G4_INHERENT_OR_BATCH** —
  3842. 显式声明"机型固有"与"批次缺陷"在本场数据下**不可区分**, 而不是默认判固有。
  3843. 根因 (2026-08-21 Gemini 独立审): G4 必须先假设 fleet 健康才能用 fleet 判个体,
  3844. 批次性缺陷会在 fleet 中位谱上显现从而被 G4 豁免 ⇒ 对批次缺陷集体失明。
  3845. 该循环**无法用本场数据自证**, 只能显式标不可区分 + 走跨场对照。
  3846. 本场实例: HS转子侧外圈179Hz 13台过前三闸、fleet峰/本底8.5、基率26%,
  3847. 按新规则应判 G4_INHERENT_OR_BATCH 而非确定固有。
  3848. max_peak_offset_bins: ★G7 峰位偏差闸 (默认 **1.5 bin**, 物理推导 + 敏感性验证).
  3849. 敏感性扫描 (本场 146 行): 1.2/1.3→过闸39 · 1.4→42 · **1.5→48** · 1.6→85(+77% 断崖) · 2.0→99。
  3850. ⇒ [1.2,1.5] 是平台, 1.6 起断崖 ⇒ 取**平台右端 1.5** (在物理区间 [1.04,2.0] 内且不进断崖).
  3851. (DeepSeek 独立审判据"1.4→1.6 变化>5% 则物理推导不成立"实测 +102.4%, 质疑成立;
  3852. 故论证由"物理区间取中值"改为"物理区间 ∩ 敏感性平台的右端点"). 窗内最强峰的**实测亚bin峰位**
  3853. 距目标频率超过此值 ⇒ 判 G7_PEAK_OFFSET: 窗里抓到的是**邻线的裙边**, 不是目标线。
  3854. 根因 (2026-08-21 如东 IMS 内圈): OEM 表给 BPFI=151.0Hz, 而该测点上真实存在的是一条
  3855. **全场共有、六窗锁死在 151.609Hz** 的固定线 (34/37 台峰位逐位相同, 漂移 0.001~0.008Hz)。
  3856. 151.0 的 ±2bin 窗 = [150.625, 151.250], 真峰在窗外 2.3 bin ⇒ 窗内测到的
  3857. G2=5.54 全是那条线的裙边。**而 G4 恰好放行**: fleet 中位谱在 151.0 处峰/本底 = **2.99**
  3858. (差 0.01 就到 3.0 门槛), 在真峰 151.609 处是 **23.22**。
  3859. ⇒ **G4 看的是目标频率, G7 看的是实测峰位落在哪** — 两者互补, 缺 G7 时刀刃案例会漏。
  3860. ★阈值 1.5 的推导 (不是拍的): 真线一侧的误差上界 = OEM 表取整 ±0.05Hz (±0.32bin)
  3861. + 轴承线随载荷漂移 0.40~0.72bin (peak_drift_typology 的 LOAD_DRIFT 实测档)
  3862. ≈ **1.04 bin**; 裙边一侧 = 搜索窗 ±tol_bins(默认2), 抓到邻线时峰必落窗边缘 ≈ **2.0 bin**。
  3863. 取中间的 1.5 → 对真线留 44% 余量, 对裙边留 25% 判别距。**换 tol_bins 时须同步重推。**
  3864. domain: 谱域 — 'raw'(原始谱) / 'env'(包络解调谱, 名字含 Env_ 的 shard).
  3865. ★必须显式声明. **包络谱不做速度换算**: 包络谱上 f 处的分量表示
  3866. "以 f 为重复率的冲击串的调制深度", **不是 f 处的正弦振动**, 除以 2πf 得到的
  3867. "mm/s" 没有物理意义, 更不可与原始谱域的 mm/s 或 VDI 3834 烈度限值相比。
  3868. 未声明时仍返回 vel_mms 但附 vel_mms_caveat 字段告警。
  3869. 根因 (2026-08-20 如东, 综合报告线逮, 同一错第2次): 我把 16# GenDE 内圈 135Hz 的
  3870. env850 域 2.602 m/s² 换算成 3.068 mm/s 当作"绝对量也强"的证据, 而**同一条频率
  3871. 在原始谱域只有 0.145 mm/s = 1.2× 中位, VERDICT 是 G2_NO_LINE 根本没过闸**。
  3872. 正确表述是"包络域显著 + 原始谱域不显著" — 这个组合本身就是早期轴承缺陷的
  3873. 预期签名, 支持结论, 不需要靠跨域 mm/s 来撑
  3874. Returns:
  3875. dict: passed(bool) + verdict + 四闸各自结果 + 绝对物理量(abs_ms2/vel_mms/energy_pct)
  3876. """
  3877. import numpy as _np
  3878. n = len(spec)
  3879. i = int(round(hz / dx))
  3880. nb_gaps = [abs(hz - o) for o in all_line_hz if abs(hz - o) > 1e-9]
  3881. gap = min(nb_gaps) if nb_gaps else _np.inf
  3882. win_hz = 2 * tol_bins * dx
  3883. g1 = bool(win_hz < gap / 3)
  3884. if bw_hz is None:
  3885. bw_hz = max(15 * dx, min(15.0, gap / 2 if _np.isfinite(gap) else 15.0))
  3886. def _struct(v):
  3887. if i + tol_bins >= len(v) or i - tol_bins < 1:
  3888. return None
  3889. lo, hi = max(int((hz - bw_hz) / dx), 1), min(int((hz + bw_hz) / dx), len(v) - 1)
  3890. if hi - lo < 10:
  3891. return None
  3892. seg = v[i - tol_bins:i + tol_bins + 1]
  3893. a = float(_np.nanmax(seg))
  3894. pk = (i - tol_bins + int(_np.nanargmax(seg))) * dx
  3895. nbv = _np.concatenate([v[lo:i - tol_bins - 1], v[i + tol_bins + 2:hi]])
  3896. base = float(_np.nanmedian(nbv)) if len(nbv) > 5 else _np.nan
  3897. return {'abs': a, 'peak_hz': pk, 'base': base,
  3898. 'struct': (a / base) if (base and base > 1e-12) else _np.nan}
  3899. st = _struct(spec)
  3900. if st is None:
  3901. return {'passed': False, 'verdict': 'OUT_OF_RANGE', 'G1_resolution_ok': g1,
  3902. 'win_hz': round(win_hz, 4), 'nearest_gap_hz': (round(float(gap), 3) if _np.isfinite(gap) else None)}
  3903. g2 = bool(_np.isfinite(st['struct']) and st['struct'] >= min_struct)
  3904. drift = _np.nan
  3905. if peak_hz_by_window:
  3906. pv = [v for v in peak_hz_by_window.values() if v is not None and _np.isfinite(v)]
  3907. if len(pv) >= 3:
  3908. drift = (max(pv) - min(pv)) / dx
  3909. g3 = bool(not _np.isfinite(drift) or drift <= max_drift_bins)
  3910. fst = _np.nan
  3911. if fleet_med is not None:
  3912. sf = _struct(_np.asarray(fleet_med))
  3913. if sf and _np.isfinite(sf['struct']):
  3914. fst = sf['struct']
  3915. g4 = bool(not _np.isfinite(fst) or fst < min_fleet_struct)
  3916. # G5 整阶排除: 用亚bin插值的实测峰位, 不用标称 hz
  3917. def _subbin(v, k):
  3918. if k < 1 or k + 1 >= len(v):
  3919. return float(k)
  3920. y0, y1, y2 = float(v[k - 1]), float(v[k]), float(v[k + 1])
  3921. den = y0 - 2 * y1 + y2
  3922. return float(k) + (0.0 if abs(den) < 1e-15 else 0.5 * (y0 - y2) / den)
  3923. # 在 ±max(3*tol_bins, 邻线间隔/4) 宽窗内定位主峰 — 真峰可能落在 ±tol_bins 窗外,
  3924. # 那时窗内 argmax 是噪声位置, 直接拿它判整阶会漏 (如东 02# 真峰 21.00 距标称 20.9 有 2.6bin)
  3925. _wb = max(3 * tol_bins, int((gap / 4) / dx) if _np.isfinite(gap) else 3 * tol_bins)
  3926. _lo, _hi = max(i - _wb, 1), min(i + _wb + 1, len(spec) - 1)
  3927. _sv = _np.asarray(spec)
  3928. j = _lo + int(_np.nanargmax(_sv[_lo:_hi])) if _hi > _lo else i
  3929. peak_refined = _subbin(_sv, j) * dx
  3930. int_order_hit = None
  3931. for sh in (shaft_hz or ()):
  3932. if sh and sh > 1e-9:
  3933. k = peak_refined / sh
  3934. if 2 <= k <= max_order and abs(k - round(k)) < int_order_tol:
  3935. int_order_hit = {'shaft_hz': sh, 'order': int(round(k)),
  3936. 'order_err': round(abs(k - round(k)), 4),
  3937. 'implied_hz': round(sh * round(k), 4)}
  3938. break
  3939. g5 = int_order_hit is None
  3940. # ★G7 峰位偏差闸: 实测亚bin峰位是否真的落在目标频率上 (防邻线裙边冒充目标线)
  3941. # ★★2026-08-21 修 (自查 + Gemini③ 追问强信号来源时暴露): 容差必须**按 OEM 表的取整精度自适应**,
  3942. # 不能用固定 1.5 bin。实测如东 OEM 表的取整精度**随频率变化**:
  3943. # 0~10Hz 整数占比 0%(2位小数) · 10~50Hz 43% · 50~100Hz 53% · 100~200Hz **92%** · 200~400Hz **100%**
  3944. # ⇒ 高频线被取整到 1Hz ⇒ 目标频率本身有 **±0.5Hz** 不确定 = **±3.2 bin** (dx=0.15625)
  3945. # 而我原推导假设"取整到 0.1Hz ⇒ ±0.32bin" — **低估 10 倍**, 对高频线系统性误伤。
  3946. # 活案例: HS转子侧外圈 OEM 给 179.00, 实测峰位跨台一致 **178.75**(差 1.6bin),
  3947. # 22 台 G2 = 3~44 (**是真线**), 却被固定 1.5bin 的 G7 误杀。
  3948. # (该线的正确归属是 G4 fleet 固有线 — G7 抢在 G4 前拦了它, 结果对但理由错。)
  3949. _dec = 0
  3950. _sh = f'{hz:.10g}'
  3951. if '.' in _sh:
  3952. _dec = len(_sh.split('.')[1])
  3953. _oem_half_hz = 0.5 * (10.0 ** (-_dec)) # 整数→±0.5Hz · 1位→±0.05 · 2位→±0.005
  3954. _oem_bins = _oem_half_hz / dx if dx > 0 else 0.0
  3955. _eff_tol = max(max_peak_offset_bins, _oem_bins + 0.72) # 0.72bin = 轴承线载荷漂移实测上界
  3956. peak_offset_bins = abs(peak_refined - hz) / dx if dx > 0 else _np.nan
  3957. g7 = not (peak_offset_bins == peak_offset_bins and peak_offset_bins > _eff_tol)
  3958. # 谐波佐证 (不作硬闸, 作字段): 真轴承缺陷应有 2x
  3959. h2 = harmonic_check_hz if harmonic_check_hz else 2 * hz
  3960. harm2 = None
  3961. if h2 / dx + tol_bins + 1 < len(spec):
  3962. k2 = int(round(h2 / dx))
  3963. seg2 = _np.asarray(spec)[k2 - tol_bins:k2 + tol_bins + 1]
  3964. lo3, hi3 = max(int((h2 - bw_hz) / dx), 1), min(int((h2 + bw_hz) / dx), len(spec) - 1)
  3965. if hi3 - lo3 > 10:
  3966. b3 = float(_np.nanmedian(_np.asarray(spec)[lo3:hi3]))
  3967. if b3 > 1e-12:
  3968. harm2 = round(float(_np.nanmax(seg2)) / b3, 2)
  3969. # ★闸优先级 (2026-08-21 调整): 越**具体**的诊断越靠前 —
  3970. # G5"这个峰是轴谐波"比 G7"峰位偏了"信息量大; 若两者同时成立, 报 G5 对现场更有用。
  3971. # ★G4 批次盲点旁路 (2026-08-21 Gemini 独立审逮): G4 必须先假设 fleet 健康才能用 fleet 判个体;
  3972. # 若缺陷是**批次性**的(如基率 26%), 该特征会在 fleet 中位谱上显现 → 反被 G4 判为"机型固有线"豁免
  3973. # ⇒ 对批次缺陷集体失明。这个循环**无法用本场数据自证**, 只能显式声明不可区分。
  3974. # 旁路: G4 命中时不直接判"固有", 先看有没有**不依赖 fleet** 的绝对锚 (ISO 速度当量) 能定性;
  3975. # 无锚 ⇒ 输出 G4_INHERENT_OR_BATCH (机型固有 与 批次缺陷 在本场数据下不可区分)。
  3976. # ★G6 同窗选择性 / G8 fleet 离群 (调用方未传则跳过, 并在 out 标注 skipped)
  3977. g6 = True if selectivity_samewin is None else (float(selectivity_samewin) >= min_selectivity)
  3978. g8 = True if x_fleet is None else (float(x_fleet) >= min_x_fleet)
  3979. g4_label = 'G4_FLEET_INHERENT'
  3980. if not g4:
  3981. _vel = None
  3982. if not (domain or '').lower().startswith('env') and hz > 0:
  3983. _vel = st['abs'] / (2 * _np.pi * hz) * 1000
  3984. if _vel is None or not (iso_ref_mms and _vel >= iso_ref_mms):
  3985. g4_label = 'G4_INHERENT_OR_BATCH'
  3986. # ★闸优先级 (越具体的诊断越靠前) — 2026-08-21 由嵌套三元改为 if/elif:
  3987. # 同样的逻辑, 但不会再数错括号 (加 G8 时已因此报过两次语法错)
  3988. if g1 and g2 and g3 and g4 and g5 and g6 and g7 and g8:
  3989. verdict = 'PASS'
  3990. elif not g1:
  3991. verdict = 'G1_RESOLUTION'
  3992. elif not g2:
  3993. verdict = 'G2_NO_LINE'
  3994. elif not g5:
  3995. verdict = 'G5_INTEGER_ORDER'
  3996. elif not g7:
  3997. verdict = 'G7_PEAK_OFFSET'
  3998. elif not g3:
  3999. verdict = 'G3_PEAK_DRIFT'
  4000. elif not g4:
  4001. verdict = g4_label
  4002. elif not g6:
  4003. verdict = 'G6_SELECTIVITY'
  4004. else:
  4005. verdict = 'G8_NOT_FLEET_OUTLIER'
  4006. out = {'passed': verdict == 'PASS', 'verdict': verdict,
  4007. 'G5_non_integer_order': g5, 'G5_integer_order_hit': int_order_hit,
  4008. 'G6_selectivity_ok': g6, 'G6_skipped': selectivity_samewin is None,
  4009. 'G8_fleet_outlier': g8, 'G8_skipped': x_fleet is None,
  4010. 'G7_peak_on_target': g7, 'G7_peak_offset_bins': (round(float(peak_offset_bins), 3)
  4011. if peak_offset_bins == peak_offset_bins else None),
  4012. 'G7_eff_tol_bins': round(float(_eff_tol), 3), 'G7_oem_decimals': _dec,
  4013. 'peak_hz_refined': round(peak_refined, 4), 'harmonic_2x_struct': harm2,
  4014. 'G1_resolution_ok': g1, 'win_hz': round(win_hz, 4),
  4015. 'nearest_gap_hz': (round(float(gap), 3) if _np.isfinite(gap) else None),
  4016. 'G2_struct': (round(float(st['struct']), 2) if _np.isfinite(st['struct']) else None),
  4017. 'G3_drift_bins': (round(float(drift), 2) if _np.isfinite(drift) else None),
  4018. 'G4_fleet_struct': (round(float(fst), 2) if _np.isfinite(fst) else None),
  4019. 'G4_inherent_line': bool(_np.isfinite(fst) and fst >= min_fleet_struct),
  4020. 'peak_hz': round(st['peak_hz'], 4), 'neighborhood_base': round(st['base'], 8),
  4021. 'abs_ms2': round(st['abs'], 6), 'domain': domain}
  4022. _dom = (domain or '').lower()
  4023. if _dom.startswith('env'):
  4024. out['vel_mms'] = None
  4025. out['vel_mms_note'] = ('包络域不做速度换算 — 该分量是冲击重复率的调制深度, 非该频率的正弦振动; '
  4026. '禁与原始谱域 mm/s 或 VDI 3834 烈度限值比较. 用 abs_ms2 + 同域 fleet 分位定级')
  4027. else:
  4028. out['vel_mms'] = round(st['abs'] / (2 * _np.pi * hz) * 1000, 5)
  4029. if not _dom:
  4030. out['vel_mms_caveat'] = '未声明 domain — 若该谱为包络谱则此 vel_mms 无物理意义, 请传 domain='+repr('env')
  4031. if total_energy and total_energy > 0:
  4032. lo2, hi2 = max(i - tol_bins, 0), min(i + tol_bins + 1, n)
  4033. out['energy_pct'] = round(100.0 * float(_np.sum(_np.asarray(spec)[lo2:hi2] ** 2)) / total_energy, 5)
  4034. out['_caliber'] = (
  4035. f'G1 ±{tol_bins}bin窗宽{win_hz:.3f}Hz vs 最近邻间隔/3; G2 峰/邻域本底(±{bw_hz:.2f}Hz去线)>={min_struct}; '
  4036. f'G3 跨窗峰位漂移<={max_drift_bins}bin; G4 fleet中位谱峰/本底<{min_fleet_struct}; '
  4037. f'G6 同窗选择性>={min_selectivity}(需调用方传, 本函数单窗算不出跨窗中位); '
  4038. f'G8 x_fleet>={min_x_fleet}(与G2同源: 峰比本底3倍 ⇒ 比fleet中位3倍); '
  4039. f'G7 实测亚bin峰位距目标频率<={max_peak_offset_bins}bin (防邻线裙边冒充目标线); '
  4040. f'G5 亚bin实测峰位不落轴系转频整数倍(绝对阶容差{int_order_tol}, 仅判k<={max_order}) — '
  4041. '轴承频率必非整阶, 齿轮/轴系必整阶; ★禁用相对容差(高阶数上等于极宽绝对容差会误伤真轴承线). '
  4042. '★不过闸的 x_fleet/选择性/趋势一律不进下游 (如东实测 G2 拦89%). '
  4043. '★相对判据必配 abs_ms2+vel_mms+energy_pct 才可定级 (13台次"报警"实为万分之几能量占比已撤回). '
  4044. '★★绝对锚必须同域: 包络域(Env_*)不产出 vel_mms — 包络谱 f 处是冲击重复率的调制深度非正弦振动, '
  4045. '跨域比 mm/s 会得到方向相反的结论 (如东16# GenDE 内圈 env850 3.07mm/s 看着最强, '
  4046. '同频率原始谱仅 0.145mm/s=1.2×中位且 G2_NO_LINE 没过闸). '
  4047. '★选择性(线÷宽带)在"缺陷同时抬高线与宽带"(齿面磨损)时方向失效会漏台, 该场景改用绝对幅值+fleet分位')
  4048. return out
  4049. def peak_drift_typology(peak_hz_by_window, dx, *, shaft_hz=(), int_order_tol=0.03,
  4050. lock_bins=0.05, wander_bins=2.0, max_order=40):
  4051. """★峰位漂移三分型 — 用"漂移量"分辨 轴谐波 / 真轴承线 / 本底 (2026-08-20 如东实证 n=6)
  4052. G3 原来只有一个阈值 (漂移>2bin 判本底), 是**二分**。实测发现漂移量本身分三档,
  4053. 且中间那档 (小幅系统性漂移) 正是**真轴承线的正证**, 此前被当成"稳定"一笔带过:
  4054. 漂移 <= lock_bins (默认0.05bin) → 'LOCKED' 峰位锁死
  4055. 频率由轴系几何或电网锁定, 不随载荷变 ⇒ **轴谐波 / 电磁线**, 不是轴承缺陷
  4056. lock_bins < 漂移 <= wander_bins → 'LOAD_DRIFT' 小幅系统性漂移
  4057. 轴承特征频率(BSF/BPFO/BPFI)含接触角项, 随载荷/间隙/温度漂移 ⇒ **真轴承线的正证**
  4058. 漂移 > wander_bins (默认2bin) → 'WANDER' 乱跳 = 本底无线 (= 原 G3 拦下的)
  4059. ★如东实证 (IMS 滚动体 BSF 35.0Hz vs 5×IMS轴 35.40Hz, dx=0.15625, 六窗):
  4060. 轴谐波 09#/19#/12#/31# 漂移 0.0008~0.0079 Hz = **0.005~0.05 bin** 阶次 4.9958
  4061. 轴承线 07#/03# 漂移 0.063~0.113 Hz = **0.40~0.72 bin** 阶次 4.89~4.91
  4062. 两档差**一个数量级**, 干净可分。12# 是全程健康对照台却与 09#/19# 同档 ⇒ 轴谐波的正证。
  4063. ★用法铁律 — 'LOCKED' 单独**不足以**判轴谐波 (一条稳定的轴承线也可能漂移很小):
  4064. 必须与**阶次检验双证**: 阶次落整数(G5) ∧ 峰位锁死 ⇒ 轴谐波。
  4065. 反过来 'LOAD_DRIFT' ∧ 非整阶 ⇒ 支持"真轴承线"。本函数返回 verdict 已做此合取。
  4066. ★与 G3 的关系: G3 是硬闸(拦 WANDER), 本函数是**分型器**(在过了 G3 之后再分 LOCKED/LOAD_DRIFT),
  4067. 两者方向不同, 不要混用同一个阈值。
  4068. [暂行 n=6] — 阈值 lock_bins=0.05 由单场 6 台标定, 跨场用前须复标; 退役判据: 若某场
  4069. 出现"真轴承线漂移<0.05bin"或"轴谐波漂移>0.4bin"的反例, 本三分型失效, 回退到纯阶次判据。
  4070. Args:
  4071. peak_hz_by_window: {窗名: 亚bin峰位Hz} — **必须是亚bin插值峰位**;
  4072. bin 分辨率峰位在低转频轴上太粗 (dx=0.15625Hz 在 7.08Hz 轴上仅 0.022 阶/bin,
  4073. 对 0.03 阶容差不够), 会把轴谐波读成非整阶
  4074. dx: 谱线间隔 Hz
  4075. shaft_hz: 该测点相关的全部轴系转频 (Hz), 用于阶次合取
  4076. lock_bins / wander_bins: 两档边界 (bin)
  4077. Returns:
  4078. dict: drift_hz / drift_bins / drift_class / order_by_shaft / integer_order_hit / verdict
  4079. """
  4080. import numpy as _np
  4081. v = [float(x) for x in (peak_hz_by_window or {}).values() if x == x]
  4082. if len(v) < 3 or dx <= 0:
  4083. return {'drift_class': None, 'verdict': 'INSUFFICIENT_WINDOWS', 'n_win': len(v)}
  4084. drift_hz = float(max(v) - min(v))
  4085. drift_bins = drift_hz / dx
  4086. cls = ('LOCKED' if drift_bins <= lock_bins else
  4087. ('LOAD_DRIFT' if drift_bins <= wander_bins else 'WANDER'))
  4088. pk = float(_np.median(v))
  4089. hit, orders = None, {}
  4090. for sh in (shaft_hz or ()):
  4091. if sh and sh > 1e-9:
  4092. k = pk / sh
  4093. orders[round(float(sh), 4)] = round(float(k), 4)
  4094. if hit is None and 2 <= k <= max_order and abs(k - round(k)) < int_order_tol:
  4095. hit = {'shaft_hz': sh, 'order': int(round(k)),
  4096. 'order_err': round(abs(k - round(k)), 4)}
  4097. if cls == 'WANDER':
  4098. verdict = 'BACKGROUND_NO_LINE'
  4099. elif hit and cls == 'LOCKED':
  4100. verdict = 'SHAFT_HARMONIC' # 双证: 整阶 ∧ 锁死
  4101. elif hit:
  4102. verdict = 'INTEGER_ORDER_BUT_DRIFTS' # 整阶但会漂 — 存疑, 不可自动归任一类
  4103. elif cls == 'LOAD_DRIFT':
  4104. verdict = 'BEARING_LINE_SUPPORTED' # 非整阶 ∧ 载荷漂移 = 真轴承线正证
  4105. else:
  4106. verdict = 'LOCKED_NON_INTEGER' # 锁死但非整阶 — 可能是结构共振/固定源
  4107. return {'drift_hz': round(drift_hz, 5), 'drift_bins': round(drift_bins, 4),
  4108. 'drift_class': cls, 'peak_hz_med': round(pk, 4),
  4109. 'order_by_shaft': orders, 'integer_order_hit': hit,
  4110. 'n_win': len(v), 'verdict': verdict,
  4111. '_caliber': ('漂移<=%.3gbin=LOCKED(轴谐波/电磁, 需与整阶双证) · <=%.3gbin=LOAD_DRIFT'
  4112. '(轴承线正证) · >%.3gbin=WANDER(本底). [暂行 n=6 如东标定]'
  4113. % (lock_bins, wander_bins, wander_bins))}
  4114. def adaptive_baseline_threshold(values, *, k_yellow=3.0, k_red=6.0, min_n=30,
  4115. purge_iter=3, purge_k=5.0, abs_floor=None,
  4116. iso_yellow=None, iso_red=None):
  4117. """★L2 自适应基线阈值 — per-台 per-工况, 中位+k×MAD, 且**基线窗先净化**
  4118. 根因 (2026-08-21 如东 CMS mask.tsv 8,511 条解出): CMS 有完整的自适应机制
  4119. (`Method=StdDev` / `StdDevs=3` / `TargetCount=15`), 基线也**真的采满了**
  4120. (Active 条目中 CurrentCount<TargetCount 的 = 0, RejectCount 全 0),
  4121. **但最终阈值是手填的全场统一固定值** —— `rms_200` 在 6 个功率档 × 38 台上
  4122. Red 全部 2.625 / Yellow 全部 1.875, 92% 的 mask 建于 2017–2018,
  4123. 836 条建于 2017-12-28 08:28 同一分钟。**自适应能力完全浪费。**
  4124. 本函数把那套机制真正跑起来, 并补三处 CMS 没有的:
  4125. 1. **稳健统计**: 中位 + k×MAD 替代 均值 + k×σ。
  4126. 基线窗内若含事件, 均值与 σ 双双被污染, 而中位/MAD 抗污染。
  4127. 2. **★基线窗净化 (最关键的一条)**: 迭代剔除窗内离群点再估基线。
  4128. 实证 —— 如东 20# 初算基线 41.20 是错的, 因为基线窗 (7/10 前) 本身就含
  4129. 7/2~7/8 的发作; 净化后真实基线约 3, 发作时 41~129 = **13~43 倍**,
  4130. 而污染基线只给出 1.0~3.1 倍 ⇒ **倍数被严重低估, 差一个数量级**。
  4131. 3. **绝对下限与 ISO 上限双向夹逼**: 自适应阈值可能在"这台一直很安静"时
  4132. 算得过低 (假阳) 或在"这台一直很吵"时算得过高 (假阴)。
  4133. `abs_floor` 防前者; `iso_yellow/iso_red` (如 ISO 20816 的 7.1/11.0 mm/s)
  4134. 防后者 —— **任何自适应阈值都不得高于绝对标准限值**。
  4135. Args:
  4136. values: 基线窗内的逐次测量值 (1D array-like, 同一 台×测点×判据×工况档)
  4137. k_yellow / k_red: MAD 倍数 (默认 3 / 6; CMS 原用 3σ, MAD≈0.6745σ 故 3MAD 更严)
  4138. min_n: 最小样本数 (默认 30; CMS 用 15 —— 15 个样本估离散度相对误差约 18%)
  4139. purge_iter / purge_k: 净化迭代次数与剔除倍数
  4140. abs_floor: 阈值绝对下限 (物理噪声本底), None 则不设
  4141. iso_yellow / iso_red: 国际标准限值, 自适应阈值不得超过它
  4142. Returns:
  4143. dict: yellow / red / baseline_med / mad / n_used / n_purged /
  4144. purged_frac / capped_by / verdict
  4145. """
  4146. import numpy as _np
  4147. v = _np.asarray([x for x in _np.ravel(values) if x == x], dtype=float)
  4148. if v.size < min_n:
  4149. return {'verdict': 'INSUFFICIENT_BASELINE', 'n_used': int(v.size), 'min_n': min_n,
  4150. 'yellow': None, 'red': None,
  4151. '_caliber': f'基线样本 {v.size} < min_n {min_n} — 不出阈值, 不可回退到全场固定值'}
  4152. kept = v.copy()
  4153. n_purged = 0
  4154. for _ in range(max(0, purge_iter)):
  4155. med = float(_np.median(kept))
  4156. mad = float(_np.median(_np.abs(kept - med))) * 1.4826
  4157. if mad <= 1e-12:
  4158. break
  4159. keep = _np.abs(kept - med) <= purge_k * mad
  4160. if keep.all():
  4161. break
  4162. n_purged += int((~keep).sum())
  4163. kept = kept[keep]
  4164. if kept.size < min_n:
  4165. return {'verdict': 'BASELINE_TOO_CONTAMINATED', 'n_used': int(kept.size),
  4166. 'n_purged': n_purged, 'yellow': None, 'red': None,
  4167. '_caliber': ('净化后样本不足 — 该窗事件占比过高, 说明**这个窗本身就不是常态窗**, '
  4168. '须换窗重建基线, 不可用污染窗的统计量')}
  4169. med = float(_np.median(kept))
  4170. mad = float(_np.median(_np.abs(kept - med))) * 1.4826
  4171. y = med + k_yellow * mad
  4172. r = med + k_red * mad
  4173. capped = []
  4174. if abs_floor is not None:
  4175. if y < abs_floor: y = float(abs_floor); capped.append('yellow_floor')
  4176. if r < abs_floor: r = float(abs_floor); capped.append('red_floor')
  4177. if iso_yellow is not None and y > iso_yellow:
  4178. y = float(iso_yellow); capped.append('yellow_iso')
  4179. if iso_red is not None and r > iso_red:
  4180. r = float(iso_red); capped.append('red_iso')
  4181. frac = n_purged / float(v.size)
  4182. verdict = ('OK' if frac < 0.10 else
  4183. ('OK_BASELINE_HAD_EVENTS' if frac < 0.30 else 'BASELINE_WINDOW_SUSPECT'))
  4184. return {'yellow': round(y, 6), 'red': round(r, 6),
  4185. 'baseline_med': round(med, 6), 'mad': round(mad, 6),
  4186. 'n_used': int(kept.size), 'n_purged': n_purged, 'purged_frac': round(frac, 4),
  4187. 'capped_by': capped, 'verdict': verdict,
  4188. '_caliber': (f'中位{med:.4g}+{k_yellow}/{k_red}×MAD({mad:.4g}); '
  4189. f'净化剔除 {n_purged}/{v.size} ({frac:.1%}, >{purge_k}MAD); '
  4190. f'夹逼 {capped or "无"}. ★基线窗净化实证: 如东20#未净化基线41.20 vs '
  4191. f'净化后约3 ⇒ 发作倍数从1.0~3.1 变 13~43, **差一个数量级**')}
  4192. def vib_verdict_and_writeback(*, gate_result=None, abs_value=None, abs_unit=None,
  4193. energy_pct=None, trend_class=None, iso_yellow=None, iso_red=None,
  4194. data_quality='OK', component=None, mechanism_confirmed=False,
  4195. positive_anchor=False, x_fleet=None, order_of_magnitude=10.0,
  4196. component_class=None, anchors=None, n_evidence_types=1,
  4197. own_turbine_physical=False):
  4198. """★L6 裁决与写回 — 六枚举定级 + "机制未定拒绝命名部件" + 写回优先级
  4199. 把前五层的结果合成一个可交付的裁决。三条硬规则:
  4200. 1. **L0 未通过则短路**: data_quality != 'OK' ⇒ 一律 INSUFFICIENT, 不论谱侧多显著。
  4201. 实证 (如东 27#): Generator_DE 的 iso_rms_vel 16.2× 中位、逐日 0.4↔69.7mm/s 双态,
  4202. 而同测点加速度域 Rms_HP 分位 0.0%(全场最低)、其余七测点全正常 ⇒ 测量链故障。
  4203. 在修好之前该测点**任何**结论(包括"清洁")都不可采信。
  4204. 2. **机制未定则拒绝命名部件**: mechanism_confirmed=False ⇒ 只出"区域宽带/冲击异常",
  4205. component 被强制置空。这不是把话说软, 它**限制错误的传播半径** —
  4206. 命名了滚道, 错的是"换哪个轴承"; 只判区域, 错的最多是"要不要开箱看"。
  4207. 实证: G5 缺失曾让 7 台轴谐波被判"IMS 滚动体缺陷", 而**交付报告零污染**,
  4208. 正因为齿轮箱分册守着这条口径(判级台账只收 5 条频率, 35.0Hz 从未进入)。
  4209. 3. **无正样本锚则封顶在候选级**: positive_anchor=False (全场没有一台经内窥/拆检证实)
  4210. ⇒ 最高只能到"候选", 不得出"定论"。如东现状正是如此。
  4211. 写回优先级 (对应 CMS 的 C4 缺失: YellowAction 全空, 只有 8/1091 有 RedAction):
  4212. RED → 必须写回控制器 (超 ISO 绝对限值 或 定论级)
  4213. YELLOW → **也必须写回** (低优先级码) — 否则慢性劣化永远不上报;
  4214. 如东实证: 2025-01 起 1,239 条黄告警一条没传到 SCADA
  4215. INFO → 只进报表
  4216. Returns: dict(verdict, level, component_named, writeback, reasons)
  4217. """
  4218. reasons = []
  4219. if data_quality != 'OK':
  4220. return {'verdict': 'INSUFFICIENT', 'level': 'A类-数据质量', 'component_named': None,
  4221. 'writeback': 'RED' if data_quality == 'MEASUREMENT_CHAIN_FAULT' else 'YELLOW',
  4222. 'reasons': [f'L0 未通过 ({data_quality}) — 该测点任何结论不可采信, 先修测量链']}
  4223. passed = bool(gate_result.get('passed')) if isinstance(gate_result, dict) else bool(gate_result)
  4224. if not passed:
  4225. v = gate_result.get('verdict') if isinstance(gate_result, dict) else 'GATE_FAIL'
  4226. return {'verdict': '撤回', 'level': None, 'component_named': None, 'writeback': 'INFO',
  4227. 'reasons': [f'未过谱前提闸 ({v}) — 不过闸的 x_fleet/选择性/趋势一律不进下游']}
  4228. over_iso = (abs_value is not None and iso_red is not None and abs_value >= iso_red)
  4229. over_yel = (abs_value is not None and iso_yellow is not None and abs_value >= iso_yellow)
  4230. if abs_value is None:
  4231. reasons.append('★无绝对量 — 相对判据不能单独定级, 降为 参考')
  4232. lvl = '参考'
  4233. elif over_iso:
  4234. lvl = '准定论·预警'; reasons.append(f'超 ISO 红线 ({abs_value}{abs_unit or ""} >= {iso_red})')
  4235. elif over_yel:
  4236. lvl = '候选'; reasons.append(f'超 ISO 黄线 ({abs_value}{abs_unit or ""} >= {iso_yellow})')
  4237. else:
  4238. # ★2026-08-21 补 (端到端跑暴露): 原来只认"持续恶化"判候选, 其余一律"参考" ⇒
  4239. # **包络域没有 ISO 可比时定级完全依赖趋势**, 16# 那条 ×fleet **84.87**、结构度 20.61、
  4240. # 趋势"稳定高位"也只判参考 — 而它有外部佐证(上气月报 2026-07 报"早期损伤")。
  4241. # 补两条, 并把趋势三型都用上:
  4242. # 持续恶化 ⇒ 候选 (在发展)
  4243. # 稳定高位 + 数量级离群 ⇒ 候选·记基线 (长期高但不在发展 — 下窗升破即真信号)
  4244. # 间歇尖峰 ⇒ 候选·查偶发源 (与持续恶化处置完全不同)
  4245. # ★门槛 x_fleet >= 10 的依据: **一个数量级**, 是自然分界不是本场调参
  4246. # (G8 已保证过闸线 >= 3; 从"离群"到"极端离群"取整数量级)。
  4247. _extreme = (x_fleet is not None and x_fleet == x_fleet and x_fleet >= order_of_magnitude)
  4248. if trend_class == '持续恶化':
  4249. lvl = '候选'; reasons.append(f'趋势=持续恶化 (未超/无 ISO 绝对线)')
  4250. elif trend_class == '稳定高位' and _extreme:
  4251. lvl = '候选·记基线'
  4252. reasons.append(f'稳定高位 且 ×fleet={x_fleet:.1f} >= {order_of_magnitude}(一个数量级) — '
  4253. f'长期高但不在发展; **下窗升破即真信号**')
  4254. elif trend_class == '间歇尖峰':
  4255. lvl = '候选·查偶发源'
  4256. reasons.append('间歇尖峰 — 与持续恶化处置不同: 查偶发冲击源/测量瞬态, 不是部件在演进')
  4257. elif _extreme:
  4258. lvl = '候选'
  4259. reasons.append(f'趋势={trend_class} 但 ×fleet={x_fleet:.1f} >= {order_of_magnitude} — 数量级离群仍须跟踪')
  4260. else:
  4261. lvl = '参考'; reasons.append(f'未超/无 ISO 绝对线; 趋势={trend_class}; ×fleet={x_fleet}')
  4262. if energy_pct is not None and energy_pct < 0.01:
  4263. lvl = '参考'; reasons.append(f'★能量占比仅 {energy_pct}% — 相对偏离真实但工程意义不成立')
  4264. # ★2026-08-23 按部件类解封 (替代全局 positive_anchor):
  4265. # anchors 给了 ⇒ anchor_cap 决定最高可达级; 且**解封不只是"不压", 还能升**:
  4266. # cap 允许准定论 ∧ 独立证据类型 ≥3 ∧ (趋势在发展 ∨ 数量级离群) ⇒ 准定论·预警
  4267. # (此前 准定论 只能由 ISO 红线触达 ⇒ 包络域谱线结论永远卡在候选 — 16# 84.87× 即是)
  4268. if anchors is not None or component_class is not None:
  4269. ci = anchor_cap(component_class, anchors, n_evidence_types=n_evidence_types,
  4270. own_turbine_physical=own_turbine_physical, mechanism_confirmed=mechanism_confirmed)
  4271. _dev = trend_class in ('持续恶化', '在升·个体', '持续恶化·高位') or (x_fleet is not None and x_fleet == x_fleet and x_fleet >= order_of_magnitude)
  4272. if str(lvl).startswith('候选') and _AL.get(ci['cap'], 0) >= _AL['准定论·预警'] \
  4273. and n_evidence_types >= 3 and _dev:
  4274. reasons.append(f"★解封升级: {lvl} → 准定论·预警 (锚 tier={ci['tier']}, 独立证据类型 {n_evidence_types}, "
  4275. f"趋势={trend_class}, ×fleet={x_fleet})")
  4276. lvl = '准定论·预警'
  4277. lvl = apply_anchor_cap(lvl, ci, reasons)
  4278. elif not positive_anchor and lvl == '定论':
  4279. lvl = '候选'; reasons.append('★无正样本锚(无内窥/拆检证实台) — 封顶在候选级')
  4280. named = component if mechanism_confirmed else None
  4281. if not mechanism_confirmed:
  4282. reasons.append('★机制未定 — 只判"区域宽带/冲击异常", 不命名保持架/滚动体/外圈/内圈')
  4283. wb = ('RED' if lvl == '准定论·预警' else
  4284. ('YELLOW' if str(lvl).startswith('候选') else 'INFO'))
  4285. return {'verdict': lvl, 'level': lvl, 'component_named': named, 'writeback': wb, 'reasons': reasons}
  4286. # ---------------------------------------------------------------- §4.6 标量振动指标劣化分型 (skill vibration-spectral)
  4287. def vib_scalar_typology(daily, *, baseline_end, late_start, fleet_daily_median=None,
  4288. spike_ratio=8.0, sustain_ratio=2.5, high_ratio=2.5, min_days=10):
  4289. """逐日标量振动指标(Peak/Kurtosis/RMS)的劣化分型 — 分开"持续恶化"与"间歇尖峰"。
  4290. 根因 (2026-08-19 如东 M5): 按末段 max 排名会把间歇尖峰台排在持续恶化台前面。
  4291. 实测 25# 主轴承前 max 496 m/s² 比 38# 的 113 高 4.4 倍, 但 25# 在 43 天里只有 2 天高、
  4292. **逐日中位纹丝不动**(4.65 对基线 4.69); 38# 是 8/7 阶跃后连续 5 天且**中位跟着涨到基线的3.8倍**。
  4293. 两者处置完全不同: 前者查偶发冲击源/测量瞬态, 后者是部件在演进。
  4294. **单一 max 统计量对这个区别完全失明** — 必须同时看 max 与中位的抬升倍数。
  4295. Args:
  4296. daily: DataFrame/dict, index=日期, 需含 'max' 与 'median' 两列 (同一指标的逐日聚合)
  4297. baseline_end: 基线段结束日期 (该日之前为基线)
  4298. late_start: 末段开始日期
  4299. fleet_daily_median: 全场同测点同指标的逐日中位的中位 (标量), 有则判"稳定高位"型
  4300. spike_ratio: 末段max/基线 >= 此值才算尖峰 (默认8)
  4301. sustain_ratio: 末段中位/基线 >= 此值判持续恶化 (默认2.5)
  4302. high_ratio: 全期中位/fleet中位 >= 此值判稳定高位 (默认2.5)
  4303. Returns:
  4304. dict: type ∈ {持续恶化, 间歇尖峰, 稳定高位, 正常}, 各倍数, 高值日数, 以及 _caliber
  4305. """
  4306. import numpy as _np
  4307. import pandas as _pd
  4308. d = _pd.DataFrame(daily) if not isinstance(daily, _pd.DataFrame) else daily.copy()
  4309. if 'max' not in d or 'median' not in d or len(d) < min_days:
  4310. return {'type': 'INSUFFICIENT', 'n_days': len(d),
  4311. 'reason': f'需 max/median 两列且 >= {min_days} 天'}
  4312. idx = _pd.to_datetime(_pd.Series(d.index)).dt.date.values
  4313. b = d[idx < _pd.Timestamp(baseline_end).date()]
  4314. l = d[idx >= _pd.Timestamp(late_start).date()]
  4315. if not len(b) or not len(l):
  4316. return {'type': 'INSUFFICIENT', 'n_days': len(d), 'reason': '基线段或末段为空'}
  4317. base = float(_np.nanmedian(b['max']))
  4318. if not _np.isfinite(base) or base <= 1e-9:
  4319. return {'type': 'INSUFFICIENT', 'n_days': len(d), 'reason': '基线为零或非有限'}
  4320. late_max = float(_np.nanmax(l['max']))
  4321. late_med = float(_np.nanmedian(l['median']))
  4322. all_med = float(_np.nanmedian(d['median']))
  4323. r_max, r_med = late_max / base, late_med / base
  4324. thr = max(4 * base, 15.0)
  4325. n_hi = int((d['max'] >= thr).sum())
  4326. r_fleet = (all_med / fleet_daily_median) if (fleet_daily_median and fleet_daily_median > 1e-9) else _np.nan
  4327. if r_med >= sustain_ratio:
  4328. typ = '持续恶化'
  4329. elif r_max >= spike_ratio:
  4330. typ = '间歇尖峰'
  4331. elif _np.isfinite(r_fleet) and r_fleet >= high_ratio:
  4332. typ = '稳定高位'
  4333. else:
  4334. typ = '正常'
  4335. return {'type': typ, 'n_days': len(d), 'baseline_max': round(base, 3),
  4336. 'late_max': round(late_max, 3), 'late_median': round(late_med, 3),
  4337. 'ratio_max': round(r_max, 2), 'ratio_median': round(r_med, 2),
  4338. 'n_high_days': n_hi, 'all_median': round(all_med, 3),
  4339. 'ratio_vs_fleet': (round(float(r_fleet), 2) if _np.isfinite(r_fleet) else None),
  4340. '_caliber': (
  4341. f'基线=<{baseline_end} 逐日max的中位; 末段=>={late_start}. '
  4342. f'持续恶化: 末段中位/基线>={sustain_ratio} (病情在演进); '
  4343. f'间歇尖峰: 末段max/基线>={spike_ratio} 但中位未抬 (偶发冲击/测量瞬态, 查同时刻工况); '
  4344. f'稳定高位: 全期中位/fleet中位>={high_ratio} (变化型判据看不见, 需绝对水平闸). '
  4345. '★禁止只按 max 排名定优先级 — 如东实测 25#(max 496, 中位抬升1.0) 会排在 '
  4346. '38#(max 113, 中位抬升3.8) 前面, 而只有 38# 是真在恶化. '
  4347. '★三型可并存: 同台可既稳定高位又持续恶化(如东 23# 前轴承 fleet 5.2倍且逐窗中位 25.0->43.4)')}
  4348. # ---------------------------------------------------------------- §4.6 冲击的结构响应判据 (skill vibration-spectral)
  4349. def impact_ringing_shift(waveforms, dx, *, n_impacts=5, ring_ms=1.5,
  4350. f_lo=500.0, f_hi=20000.0, shift_tol=0.25, min_sep_pts=200,
  4351. exclude_top=10, min_bg=30):
  4352. """冲击激发的振铃频率是否迁移 — 判"结构响应变了" vs "只是激励变强"。
  4353. 物理: 冲击近似宽频激励, 响应频率由**结构固有模态**决定, 与冲击强度无关。
  4354. ⇒ 幅值涨而振铃频率不动 = 同一传递路径, 激励变强 (不足以证明部件损伤);
  4355. 振铃频率迁移 = 参与振动的质量/刚度改变 = 结构响应本身变了 (强证据)。
  4356. ★该判据**不需要任何特征频率**, 因此不受轴承几何/OEM表/谱分辨率等前提的影响。
  4357. ★★2026-08-20 重大修正 (原实现有采样偏差, 曾导致我给出错误结论):
  4358. "取最大 N 个冲击求振铃" 会被**少数极端事件主导**。冲击能量足够大时会激发大质量低频模态,
  4359. 这是**激励非线性**不是结构改变。如东 38# 实证:
  4360. 最大5个冲击 振铃 2.69 kHz → 我据此判"结构响应改变" **错**
  4361. 全部185个冲击分档: 小/中/大 档均 10.07~11.42 kHz
  4362. **排除最大10个后的背景冲击(n=172) 振铃 10.75 kHz, 而事件前是 11.42 kHz — 几乎没变**
  4363. ⇒ 38# 结构并未改变, 只是叠加了少数能量极大的冲击(峰值15.70, 成簇, 簇内间隔12.5~20.3ms)
  4364. **反证**: 20# 发作时最大冲击峰值 19.39 **比 38# 的 15.70 还大**, 振铃却仍是 18.80 kHz
  4365. ⇒ 幅值不是唯一因素, 但也正因如此, 单看极端冲击无法分离"结构"与"激励"
  4366. **⇒ 结构改变的判据必须用 `bg_ring_hz`(排除极端后的背景振铃), 不是 `ring_hz`**
  4367. 实证 (如东 M5 2026-08-20):
  4368. 38# 后主轴承 事件前 11.4 kHz → 事件中 **2.7 kHz** (降4.2倍) = 低频模态被激发,
  4369. 原本只"听"到局部安装座(小质量→高频), 事件时整个轴承座/机架参与(大质量→低频) ⇒ 结构变了
  4370. 20# 后主轴承 发作 18.8~19.5 kHz 而**平静日同为 19.5 kHz** ⇒ 只是激励变强, 结构未变
  4371. (该台幅值涨6.7倍、峭度3229全场最高, 但振铃零迁移 ⇒ 机制判 INSUFFICIENT)
  4372. Args:
  4373. waveforms: {窗标签: 1D波形}. 至少两个窗 (基线窗 + 待判窗)
  4374. dx: 采样间隔(秒)
  4375. n_impacts: 每窗取最大的 n 个冲击求振铃主频中位
  4376. ring_ms: 从冲击峰起取多长一段做振铃频谱
  4377. f_lo/f_hi: 振铃搜索频带 (Hz)
  4378. shift_tol: 相对迁移阈值 — |f/f_ref - 1| >= shift_tol 判迁移
  4379. min_sep_pts: 冲击之间的最小间隔(采样点), 防重复取同一冲击
  4380. Returns:
  4381. dict: per_window {窗: {ring_hz, peak_abs, n_used}}, shifted(bool),
  4382. ref_window, max_shift_ratio, verdict, _caliber
  4383. """
  4384. import numpy as _np
  4385. out = {}
  4386. for lab, v in waveforms.items():
  4387. v = _np.asarray(v, dtype=float)
  4388. if v.size < 64:
  4389. continue
  4390. a = _np.abs(v - _np.median(v))
  4391. order = _np.argsort(a)[::-1]
  4392. used, rings = [], []
  4393. for i in order:
  4394. if any(abs(int(i) - u) < min_sep_pts for u in used):
  4395. continue
  4396. used.append(int(i))
  4397. w = v[int(i):min(len(v), int(i) + int(ring_ms / 1000.0 / dx))]
  4398. if w.size < 32:
  4399. continue
  4400. W = _np.abs(_np.fft.rfft(w * _np.hanning(w.size)))
  4401. ff = _np.fft.rfftfreq(w.size, dx)
  4402. m = (ff > f_lo) & (ff < f_hi)
  4403. if not m.any():
  4404. continue
  4405. rings.append(float(ff[m][int(_np.argmax(W[m]))]))
  4406. if len(rings) >= n_impacts:
  4407. break
  4408. # 背景振铃: 排除最大 exclude_top 个冲击后的众多小冲击 — 这才反映结构常态
  4409. bg_rings = []
  4410. excl = used[:exclude_top]
  4411. thr_bg = _np.median(a) + 6 * 1.4826 * _np.median(_np.abs(a - _np.median(a)))
  4412. i2 = 1
  4413. while i2 < len(a) - 1 and len(bg_rings) < 400:
  4414. if a[i2] > thr_bg and a[i2] >= a[i2 - 1] and a[i2] > a[i2 + 1]:
  4415. if not any(abs(i2 - e) < 2 * min_sep_pts for e in excl):
  4416. w = v[i2:min(len(v), i2 + int(ring_ms / 1000.0 / dx))]
  4417. if w.size >= 32:
  4418. W = _np.abs(_np.fft.rfft(w * _np.hanning(w.size)))
  4419. ff = _np.fft.rfftfreq(w.size, dx)
  4420. mm = (ff > f_lo) & (ff < f_hi)
  4421. if mm.any():
  4422. bg_rings.append(float(ff[mm][int(_np.argmax(W[mm]))]))
  4423. i2 += min_sep_pts // 2
  4424. else:
  4425. i2 += 1
  4426. if rings:
  4427. out[lab] = {'ring_hz': float(_np.median(rings)),
  4428. 'bg_ring_hz': (float(_np.median(bg_rings)) if len(bg_rings) >= min_bg else None),
  4429. 'n_bg': len(bg_rings),
  4430. 'ring_all_hz': [round(r, 1) for r in rings],
  4431. 'peak_abs': float(_np.max(a)), 'n_used': len(rings)}
  4432. if len(out) < 2:
  4433. return {'per_window': out, 'shifted': None, 'verdict': 'INSUFFICIENT',
  4434. 'reason': '需 >=2 个窗才能判迁移'}
  4435. labs = list(out)
  4436. ref = min(labs, key=lambda k: out[k]['peak_abs']) # 幅值最小者作基线窗
  4437. # ★优先用背景振铃判结构改变; 背景样本不足才退回 ring_hz 并降级 verdict
  4438. use_bg = all(out[k].get('bg_ring_hz') for k in labs)
  4439. fld = 'bg_ring_hz' if use_bg else 'ring_hz'
  4440. fr = out[ref][fld]
  4441. ratios = {k: out[k][fld] / fr for k in labs if k != ref}
  4442. mx = max(ratios.values(), key=lambda r: abs(r - 1.0)) if ratios else 1.0
  4443. shifted = abs(mx - 1.0) >= shift_tol
  4444. amp_ratio = max(out[k]['peak_abs'] for k in labs) / max(out[ref]['peak_abs'], 1e-12)
  4445. return {'per_window': out, 'ref_window': ref, 'basis': fld,
  4446. 'ring_ratio_by_window': {k: round(v, 3) for k, v in ratios.items()},
  4447. 'max_shift_ratio': round(float(mx), 3), 'shifted': bool(shifted),
  4448. 'amp_ratio_max': round(float(amp_ratio), 2),
  4449. 'verdict': (('结构响应改变' if shifted else '仅激励变强') if use_bg
  4450. else ('结构改变嫌疑(背景样本不足, 仅极端冲击口径)' if shifted else '仅激励变强(背景样本不足)')),
  4451. '_caliber': (
  4452. f'每窗取最大{n_impacts}个冲击, 自峰起{ring_ms}ms 段的 {f_lo:.0f}~{f_hi:.0f}Hz 主频中位; '
  4453. f'基线窗=峰值最小者; 迁移阈 |比值-1|>={shift_tol}. '
  4454. '★"仅激励变强"**不足以支撑部件损伤定性** — 如东20#幅值涨6.7倍/峭度3229全场最高但振铃零迁移, 机制判INSUFFICIENT. '
  4455. '★振铃频率下降通常=参与振动的质量变大或刚度下降(局部安装座→整个轴承座/机架). '
  4456. '★采样率须足够: 振铃周期至少4个采样点, 否则时域看似毛刺(如东19kHz@40960Hz仅约2点/周期, 必须靠频谱判)')}
  4457. def bearing_end_symmetry(daily_front, daily_rear, *, thresh=None, base_end=None, min_days=10):
  4458. """前后端(或成对测点)的发作对称性 — 判外部共模 vs 单部件局部。
  4459. 根因 (如东 M5 2026-08-19, 台风"巴威"共模事件): 外部激励(台风/涌浪/并网瞬态)会**同时**
  4460. 抬高同一部件的前后两端; 单部件缺陷只抬高一端。该判据不需要任何特征频率或波形。
  4461. 实证: 台风共模6台(08#/15#/19#/33#/34#/36#)前后峭度**都高**(33~405);
  4462. 20# 后端峭度3229 而前端仅106(30倍差)且四窗纹丝不动(39.3/40.7/40.7/39.5) ⇒ 单端 ⇒ 局部;
  4463. 16# 前后**同时**阶跃(1.91/1.61)但全场其余台抬升中位0.99 ⇒ 虽双端但**全场唯一** ⇒ 仍是该台整机变化非共模。
  4464. ★双端 != 必然共模 — 必须再看**同期全场有几台同样变**, 只有一台就不是共模。
  4465. Args:
  4466. daily_front / daily_rear: Series 或 {日期: 值}, 同一指标的逐日聚合(建议用 max)
  4467. thresh: 发作判定阈; None 则用 max(15, 4×基线中位)
  4468. base_end: 基线段结束日期 (该日之前算基线); None 则用前 1/3 天数
  4469. Returns:
  4470. dict: n_front / n_rear / both_days / type / _caliber
  4471. """
  4472. import numpy as _np
  4473. import pandas as _pd
  4474. f = _pd.Series(daily_front).dropna(); r = _pd.Series(daily_rear).dropna()
  4475. if len(f) < min_days or len(r) < min_days:
  4476. return {'type': 'INSUFFICIENT', 'reason': f'需每端 >= {min_days} 天'}
  4477. def _base(s):
  4478. if base_end is not None:
  4479. b = s[_pd.to_datetime(_pd.Series(s.index)).dt.date.values < _pd.Timestamp(base_end).date()]
  4480. else:
  4481. b = s.iloc[:max(len(s) // 3, 3)]
  4482. return float(_np.median(b)) if len(b) else float(_np.median(s))
  4483. bf, br = _base(f), _base(r)
  4484. tf = thresh if thresh is not None else max(15.0, 4 * bf)
  4485. tr = thresh if thresh is not None else max(15.0, 4 * br)
  4486. hf = set(f[f >= tf].index); hr = set(r[r >= tr].index)
  4487. both = hf & hr
  4488. nf, nr, nb = len(hf), len(hr), len(both)
  4489. if nf == 0 and nr == 0:
  4490. typ = '无发作'
  4491. elif nb >= 0.6 * max(nf, nr) and min(nf, nr) >= 2:
  4492. typ = '双端同步(需查全场是否同日→共模)'
  4493. elif nf >= 2 and nr <= 1:
  4494. typ = '★单端(前)=局部'
  4495. elif nr >= 2 and nf <= 1:
  4496. typ = '★单端(后)=局部'
  4497. else:
  4498. typ = '零星'
  4499. # ★配套: 前后端相关性 (独立于"发作"的判据) — 正常时两端测同一刚性结构应高度相关
  4500. corr = None
  4501. try:
  4502. j = f.index.intersection(r.index)
  4503. if len(j) >= min_days:
  4504. corr = float(_np.corrcoef(f.loc[j].values, r.loc[j].values)[0, 1])
  4505. except Exception:
  4506. corr = None
  4507. return {'n_front': nf, 'n_rear': nr, 'both_days': nb, 'end_corr': (round(corr, 3) if corr is not None else None),
  4508. 'base_front': round(bf, 3), 'base_rear': round(br, 3),
  4509. 'thresh_front': round(tf, 3), 'thresh_rear': round(tr, 3), 'type': typ,
  4510. '_caliber': (
  4511. '发作阈 = max(15, 4×基线中位) per 端; 双端同步 = 共同发作日 >= 0.6×较多一端. '
  4512. '★双端同步只是**共模嫌疑**, 必须再查"同日全场有几台同样发作" — '
  4513. '如东07-12 有15个测点跨8台同日 = 台风共模; 而16#前后同时阶跃但全场其余台抬升中位0.99 = 非共模. '
  4514. '★基线窗内若本身含发作, 基线会被自身异常污染 — 如东20#初算基线41.20(窗内含7/2~7/8发作), '
  4515. '真实基线约3, 倍数被低估13倍. **设基线窗前必须先看窗内有无事件**. '
  4516. '★统一阈值对高基线台失效: 如东23#常态逐日中位28.42, "Peak>=15共39天"只是"它一直这么高", '
  4517. '与20#的13天不可横比 — 高基线台须改用相对自身基线的阈. '
  4518. '★配套字段 end_corr = 前后端逐次配对相关: 如东全场中位0.935(p10 0.649), '
  4519. '而四台问题机组全部落在低端(38#=0.311 20#=0.506 23#=0.710 16#=0.865) — '
  4520. '**某端出现局部异常会冒出另一端没有的成分, 相关随之下降**, 这是独立于"发作次数"的判据; '
  4521. '38# 同台内 事件前波形相关0.9924 -> 事件中0.6417 = 时变证据')}
  4522. # ---------------------------------------------------------------- §4.6 轴系谐波比 (台内线间比例型判据; skill vibration-spectral)
  4523. def shaft_harmonic_ratios(spec, dx, shaft_hz, *, orders=(1, 2, 3), tol_bins=2,
  4524. bw_hz=8.0, misalign_r2=0.5, looseness_r3=0.5, min_base_ratio=3.0):
  4525. """轴系转频谐波 1×/2×/3× 的**台内比值** — 不对中/松动类判据。
  4526. ★为何单独立一类 (2026-08-20 如东实证, 用户追问"为什么之前的分析没暴露"后补):
  4527. 我此前整套谱线判据都是"线 vs fleet"或"线 vs 自身宽带"型, **缺"线 vs 同台另一条线"型**。
  4528. 不对中的判据恰恰是后者 — 判的是 2×/1× 的**比例**, 不是某条线是否突出。
  4529. 三层原因导致它被漏掉:
  4530. (a) 源头: OEM 特征频率表是**轴承频率表**(如东72个频率全是BPFI/BPFO/BSF/轴频),
  4531. 不含转频谐波 ⇒ 扫描列表从源头就没有这一类
  4532. (b) 闸: 转频谐波**必然全场共有**(如东97%的台2×线峰/本底>=3), 送进 `spectral_line_gates`
  4533. 必被 G4(机型固有线) 拦下 — 实测 34#/26#/02# 全部 G4_FLEET_INHERENT (fleet结构度34.86)
  4534. (c) 判据类型: 我的体系找"异常突出的线", 而这里要看"正常线之间的比例"
  4535. ⇒ **对转频谐波禁用 spectral_line_gates**, 用本函数。
  4536. 实证 (如东高速轴发电机侧, FFT_250_Tr, w0707):
  4537. 全场 1× 峰/本底中位101.7 · 2× 中位32.0 · 3× 中位6.8; **2×/1× 全场中位 0.20** = 正常
  4538. ★34# 2×/1×=0.98 (2×几乎等于1×) = 平行不对中典型
  4539. ★26# 2×/1×=0.89 且 3×比21.6(全场中位6.8) = 角度+平行复合嫌疑
  4540. 对照 02#=0.13 · 30#=0.10 · 04#=0.15 · 20#=0.22 均正常
  4541. ★与包络域的2×调制不是一回事: 原始谱2×线=该频率的**正弦振动**(本判据);
  4542. 包络2×调制=**高频冲击以该频率重复**。如东实测两者 r=+0.137 几乎无关, 混用会得出错误机制。
  4543. **本函数必须喂原始谱(FFT_*), 不能喂包络谱(Env_*)。**
  4544. Args:
  4545. spec: 原始谱(非包络), 1D
  4546. dx: 谱线间隔 Hz
  4547. shaft_hz: 该轴的转频 (order-tracked 谱上通常是标称值, 如 HSS=25.0)
  4548. orders: 要算的谐波次数
  4549. bw_hz: 邻域本底的半宽
  4550. misalign_r2: 2×/1× 超此值提示不对中 (经典阈 0.5)
  4551. looseness_r3: 3×/1× 超此值提示松动/复合
  4552. Returns:
  4553. dict: amp_by_order / ratio_to_base(峰/邻域本底) / r2_over_1 / r3_over_1 / flags / _caliber
  4554. """
  4555. import numpy as _np
  4556. v = _np.asarray(spec, dtype=float)
  4557. amp, rb = {}, {}
  4558. for k in orders:
  4559. hz = shaft_hz * k
  4560. i = int(round(hz / dx))
  4561. if i + tol_bins + 1 >= len(v) or i - tol_bins < 1:
  4562. continue
  4563. lo, hi = max(int((hz - bw_hz) / dx), 1), min(int((hz + bw_hz) / dx), len(v) - 1)
  4564. if hi - lo < 8:
  4565. continue
  4566. a = float(_np.nanmax(v[i - tol_bins:i + tol_bins + 1]))
  4567. nb = _np.concatenate([v[lo:i - tol_bins - 1], v[i + tol_bins + 2:hi]])
  4568. base = float(_np.nanmedian(nb)) if len(nb) > 4 else _np.nan
  4569. amp[k] = a
  4570. rb[k] = (a / base) if (base and base > 1e-12) else _np.nan
  4571. if 1 not in amp or amp[1] <= 1e-12:
  4572. return {'verdict': 'INSUFFICIENT', 'reason': '1×线缺失或为零, 比值无意义',
  4573. 'amp_by_order': amp, 'ratio_to_base': rb}
  4574. # ★前提门: 1× 线本身必须存在 — 宽带抬升台上所有线都被自身本底淹没, 比值是噪声比噪声
  4575. if not (_np.isfinite(rb.get(1, _np.nan)) and rb[1] >= min_base_ratio):
  4576. return {'verdict': 'INSUFFICIENT',
  4577. 'reason': f'1×线 峰/邻域本底 {rb.get(1)} < {min_base_ratio} — 该台1×线不成立(常见于宽带抬升台), 比值无意义',
  4578. 'amp_by_order': {k: round(x, 5) for k, x in amp.items()},
  4579. 'ratio_to_base': {k: (round(x, 2) if _np.isfinite(x) else None) for k, x in rb.items()},
  4580. 'r2_over_1': None, 'r3_over_1': None, 'flags': []}
  4581. r2 = amp.get(2, _np.nan) / amp[1]
  4582. r3 = amp.get(3, _np.nan) / amp[1]
  4583. flags = []
  4584. if _np.isfinite(r2) and r2 >= misalign_r2:
  4585. flags.append('不对中嫌疑(2×/1×高)')
  4586. if _np.isfinite(r3) and r3 >= looseness_r3:
  4587. flags.append('松动/复合嫌疑(3×/1×高)')
  4588. return {'amp_by_order': {k: round(x, 5) for k, x in amp.items()},
  4589. 'ratio_to_base': {k: (round(x, 2) if _np.isfinite(x) else None) for k, x in rb.items()},
  4590. 'r2_over_1': (round(float(r2), 3) if _np.isfinite(r2) else None),
  4591. 'r3_over_1': (round(float(r3), 3) if _np.isfinite(r3) else None),
  4592. 'flags': flags, 'verdict': (' '.join(flags) if flags else '轴系谐波比正常'),
  4593. '_caliber': (
  4594. f'幅值取 ±{tol_bins}bin 峰; 邻域本底取 ±{bw_hz}Hz 去线后中位. '
  4595. f'2×/1× >= {misalign_r2} 提示不对中; 3×/1× >= {looseness_r3} 提示松动/复合. '
  4596. '★必须喂**原始谱**不能喂包络谱 — 原始谱2×线=正弦振动, 包络2×调制=冲击重复率, '
  4597. '如东实测两者 r=+0.137 几乎无关. '
  4598. '★禁止对转频谐波用 spectral_line_gates — 它们必然全场共有, 会被 G4(机型固有线)拦掉; '
  4599. '本判据看的是**台内线间比例**不是线是否突出. '
  4600. '★阈值无绝对锚时结论只到候选, 且应与温度/对中记录等独立轴交叉 — '
  4601. '如东 34#/26# 振动侧提示不对中但发电机DE温升(37.22/38.46K)低于全场中位39.37K, 温度侧不支持. '
  4602. f'★前提门: 1×线 峰/邻域本底须 >= {min_base_ratio} 否则判 INSUFFICIENT — '
  4603. '宽带抬升台上所有线都被自身本底淹没, 比值是噪声比噪声 '
  4604. '(如东17# 1×比仅1.3、2×比1.0, 却算出2×/1×=0.781 假阳; 该台是宽带抬升型宽带75×fleet)')}
  4605. # ══════════════════════════════════════════════════════════════════════════════
  4606. # ★ 六窗趋势五规则 + 分型 (2026-08-22 如东时域/密标量趋势实证 → 落库)
  4607. # 此前只活在 scripts/rudong_{timedomain,scalar}_trend.py / rudong_band_select.py 里 ⇒ "如东跑通了" ≠ "模型建成了"
  4608. # 每条规则对应一个实逮的错 (见各 docstring), 自检 tests/test_trend_rules.py 逐例复现
  4609. # ══════════════════════════════════════════════════════════════════════════════
  4610. def require_same_band(bands, *, name='band'):
  4611. """★混功率档机器闸 — 跨窗比值函数的输入必须单一工况档, 否则抛异常 (不是警告).
  4612. Why: 如东同一坑踩三次 — 33# 135Hz 载荷依赖 / 35#28# 低转速伪影 / 26# 主轴承混档 3.12× 假"在升"
  4613. (分档后 HI 1.01× / MID 0.73×, 全部来自功率档构成变化)。纪律写了三次没用, 要机器闸。
  4614. 用法: 在任何 first/last、端段、变化比函数入口调 require_same_band(df[band_col])。
  4615. """
  4616. import numpy as _np
  4617. vals = _np.asarray(list(bands), dtype=object)
  4618. vals = vals[[v == v and v is not None for v in vals]]
  4619. u = set(map(str, vals))
  4620. if len(u) > 1:
  4621. raise ValueError(f'混档: {name} 有 {len(u)} 个取值 {sorted(u)} — 跨窗比值必须同档 (如东 26# 混档 3.12× 假在升)')
  4622. return True
  4623. def rated_speed_guard(rpm, *, rpm_min=1500.0):
  4624. """★转速守卫 — 同功率档内仍按额定转速区过滤, 返回布尔掩码.
  4625. Why: 二级分层实测 (如东 w0127, 扣台效应后): 0-1600kW 档内 GeneratorRPM 对 Rms_HP 残差 ρ=0.89,
  4626. ≥1601 档 0.2~0.5 ⇒ 厂家的功率档太宽, 档内振动仍随转速走; 六窗跨冬夏转速构成会变, 不守则把
  4627. 转速构成读成趋势。rpm_min=1500 = 额定 1600 的 94%, 只比满速区。
  4628. """
  4629. import numpy as _np
  4630. r = _np.asarray(rpm, dtype=float)
  4631. return _np.isfinite(r) & (r >= rpm_min)
  4632. def ringing_sentinel(ring_khz, *, floor_khz=1.0, tol=0.05):
  4633. """★振铃哨兵 — 背景振铃搜索下限附近的值 = 该快照没捕到振铃 (冲击缺席), 转 NaN 而非当作频率.
  4634. Why: 如东 21# 后主轴承 2026-07-23 10:20 一条快照 ring=1.02kHz (搜索下限 1.0k), 同窗 1h 前 11.84k,
  4635. 六窗谱形 9~13k 比值 1.03~1.10 稳 ⇒ 单快照冲击缺席; 不转 NaN 会造出 11.4→1.0 的假"在降" (排 11/11)。
  4636. """
  4637. import numpy as _np
  4638. r = _np.asarray(ring_khz, dtype=float).copy()
  4639. r[(r <= floor_khz * (1 + tol))] = _np.nan
  4640. return r
  4641. def end_segment_ratio(values_by_window, *, k=2):
  4642. """★端段对比 — 前 k 窗中位 / 后 k 窗中位 的比, 不用首末两点, 不用斜率.
  4643. Why (memory slope-shares-median-dilution-blindspot): 沿 X 取中位会稀释只在一段发作的变化, Theil-Sen 同样;
  4644. 首末两点则把单快照当趋势 (如东时域 81% 格单快照: 19# 主轴承 kurt 38→800 排1 实为 w0724 一窗台风尖峰)。
  4645. 端段取 2 窗中位各压一半快照噪声。k 自动降到 1 当窗数 <4。
  4646. 返回 (first, last, ratio, n_used);任一端无有效值返回 NaN。
  4647. """
  4648. import numpy as _np
  4649. v = _np.asarray([x for x in values_by_window if x == x and x is not None], dtype=float)
  4650. if len(v) < 2:
  4651. return _np.nan, _np.nan, _np.nan, len(v)
  4652. kk = k if len(v) >= 2 * k else 1
  4653. a, b = float(_np.median(v[:kk])), float(_np.median(v[-kk:]))
  4654. return a, b, (b / a if a > 0 else _np.nan), len(v)
  4655. def paired_change_rank(first_by_unit, last_by_unit):
  4656. """★变化的中位数 (不是中位数的变化) — 逐台配对比值 → fleet 变化的中位数 + 每台排名 k/N.
  4657. Why (memory median-of-changes-not-change-of-median): 如东 08# 用"中位数的变化"得 +12.3% 而"变化的中位数"
  4658. +52.7%, 差 4 倍, 把 fleet 共模读成个体恶化 → 撤回。判"这台在恶化"必用逐台配对变化 + 排名。
  4659. 输入: 两个 {unit: value} 映射 (或 Series)。返回 dict(unit → dict(ratio, rank, N)) 与 fleet_med_change。
  4660. """
  4661. import numpy as _np
  4662. f = dict(first_by_unit); l = dict(last_by_unit)
  4663. ratio = {u: l[u] / f[u] for u in f if u in l and f[u] and f[u] > 0 and l[u] == l[u] and l[u] is not None}
  4664. ratio = {u: r for u, r in ratio.items() if _np.isfinite(r)}
  4665. if not ratio:
  4666. return {}, _np.nan
  4667. # ★并列名次取"竞争名次·最差" (method='max'): 5 台并列最高 ⇒ 各得 5 不是 1。
  4668. # 理由: 并列上升的一串台是共模不是个体; 对 "rank<=3" 的在升门保守。
  4669. # (接线回归实逮: n_imp6 一串 ratio=1.0 的台被按出现顺序给了 1,2,3… = 假"排 1")
  4670. N = len(ratio); fmc = float(_np.median(list(ratio.values())))
  4671. vals = _np.array(list(ratio.values()))
  4672. out = {u: dict(ratio=float(ratio[u]), rank=int((vals >= ratio[u]).sum()), N=N) for u in ratio}
  4673. return out, fmc
  4674. def band_stability(struct_by_band_by_window, *, win_frac=0.5, min_win=3):
  4675. """★带选择稳定性 — 多候选解调带取最佳时, 用"跨窗胜出稳定性"压多重比较假阳, 不抬阈值.
  4676. Why: 扫 ~15 个候选带取 max, 15 个噪声抽样的最大值天然高于单次 ⇒ G2≥3 被"多试几次"绕过。
  4677. 真信号在最佳带里每窗都赢; 噪声的最佳带逐窗乱跳。胜出带须在 ≥win_frac 的窗里排第一。
  4678. (如东全扫 942 条→过 G2 仅 35→再过排名稳定 Kendall τ≥0.6 只剩 6 条可给推荐)
  4679. 输入: {band: {window: struct}}。返回 dict(best, win_frac, n_win, stable)。
  4680. """
  4681. import numpy as _np
  4682. wins = {}
  4683. for band, d in struct_by_band_by_window.items():
  4684. for w, v in (d or {}).items():
  4685. try:
  4686. v = float(v)
  4687. except (TypeError, ValueError):
  4688. continue
  4689. if _np.isfinite(v):
  4690. wins.setdefault(w, {})[band] = v
  4691. if len(wins) < min_win:
  4692. return dict(best=None, win_frac=_np.nan, n_win=len(wins), stable=False)
  4693. cnt = {}
  4694. for w, d in wins.items():
  4695. b = max(d, key=lambda kk: d[kk]); cnt[b] = cnt.get(b, 0) + 1
  4696. best = max(cnt, key=lambda kk: cnt[kk]); frac = cnt[best] / len(wins)
  4697. return dict(best=best, win_frac=round(frac, 3), n_win=len(wins), stable=bool(frac >= win_frac))
  4698. def trend_typology(*, ratio, rank, N, fleet_med_change, level_x_fleet,
  4699. rise_ratio=1.5, rise_rank=3, rise_vs_fleet=1.4, fall_ratio=0.67, high_level=2.5,
  4700. n_windows_above=None, moderate_level=1.5, persist_n=3):
  4701. """★六窗趋势分型 — 在升·个体 / 持续恶化·高位 / 稳定高位 / 在降 / 平.
  4702. 在升须三条同时: 端段比 ≥1.5 ∧ 排名 ≤3 ∧ 比 fleet 变化的中位数高 ≥1.4× (第三条防共模: 全场一起涨不算个体)。
  4703. 高位 = 六窗中位 / fleet 中位 ≥2.5 (skill 稳定高位门)。
  4704. Why 排名门: 如东 04#/05# 齿轮箱端段比 2.0~2.5× 但水平 0.6~1.0× = 从低处回到 fleet, 不是恶化 — 靠 level 与 rank 合判。
  4705. """
  4706. import numpy as _np
  4707. if not all(_np.isfinite([ratio, rank, N, fleet_med_change, level_x_fleet])):
  4708. return 'INSUFFICIENT'
  4709. rising = ratio >= rise_ratio and rank <= rise_rank and (ratio / fleet_med_change) >= rise_vs_fleet
  4710. falling = ratio <= fall_ratio and rank >= N - 2
  4711. high = level_x_fleet >= high_level
  4712. if rising and high:
  4713. return '持续恶化·高位'
  4714. if rising:
  4715. return '在升·个体'
  4716. if high:
  4717. return '稳定高位'
  4718. # ★中等持续·观察 (2026-08-26 历史锚回测回灌, 回测器路径2): level 落 [moderate_level, high_level) 即
  4719. # 1.5~2.5 之间 ∧ 该指标跨 >=persist_n 窗都 >=moderate_level。Why: 09# (内窥确诊 HS 轴承**轻微**损伤,
  4720. # tier=physical_in_window 运行中未换) Peak@MID 跨四窗 1.61~1.74×fleet 位次 6-9 — **rank<=3 与 level>=2.5
  4721. # 两个门都够不着**, 旧版直接判"平"完全忽略。回测该路径单独 precision 0.417 > 锚台基率 0.316; 命中率 0.56→0.64。
  4722. # ⇒ 损伤程度决定信号强度: 轻微损伤给中等持续信号而非爆发式高倍数, 排名类判据天然筛不出。
  4723. # n_windows_above=None (不传) 时行为与旧版完全一致 = 向后兼容, 调用方自愿接入。
  4724. # ⚠ 本档是**观察级非报警级**: 回测假阳中 14#/30#/31#/10# 落在 skill cms-tcm-swt4000 §2 子群指纹
  4725. # {14,17,24,30,31}/{09,10,14,30,31} (疑齿轮箱变体/批次, 相对判据对子群系统性报高) — 用前先查 BOM/序列号。
  4726. if falling:
  4727. return '在降'
  4728. # ★次序: 本档必须在 falling **之后** — 一台可能水平仍在 1.5~2.5 但正在快速下降(ratio<=0.67 ∧ 排名末位),
  4729. # 那是"正在恢复"应判在降; 放在 falling 之前会截胡, 把恢复中的台误报成观察对象 (2026-08-26 接线时自逮)
  4730. if (n_windows_above is not None and _np.isfinite(n_windows_above)
  4731. and moderate_level <= level_x_fleet < high_level and n_windows_above >= persist_n):
  4732. return '中等持续·观察'
  4733. return '平'
  4734. # ══════════════════════════════════════════════════════════════════════════════
  4735. # ★ 倒谱域齿轮判据 — 厂商 (Brüel & Kjær Vibro TCM) `Indicator_*` 的精确等价物 + 扩展 (2026-08-22 如东实证)
  4736. # 复现: Indicator_X = 倒谱在 [q0 - Δ/2, q0 + Δ/2] 窗内的 **L2 范数** √Σc² (Δ = 记录 X-axisDelta, 全宽非半宽),
  4737. # 谱种按齿轮级: 行星级 Cep_FFT_5_110 / 二级·中速 Cep_FFT_75_830 / 高速级 Cep_FFT_600_1000
  4738. # 如东 w0811 9 齿轮 × 4 测点逐记录 Spearman ρ = 0.974~1.000, 厂商值/窗RMS 的比 = √窗bin数 (HSP 4.2/HSW 7.8/2R 11.5/1P 9.7)
  4739. # ⇒ 同序且同尺度 = 精确等价。峰值/峰-本底/峰÷本底 口径都只到 ρ 0.5~0.9
  4740. # 物理: 齿轮局部损伤 ⇒ 啮合谐波两侧出现以该齿轮轴频为间距的边带簇 ⇒ 倒谱在 quefrency = 1/轴频 处聚成一根拉姆谐波
  4741. # 厂商只做 per 台 per 档自适应绝对阈; 这里加: 结构度 (窗 RMS / 邻域 RMS) · 2q0 拉姆谐波 · fleet 相对 · 端段趋势
  4742. # ══════════════════════════════════════════════════════════════════════════════
  4743. VENDOR_CEPSTRAL_GEARS = {
  4744. # name: (q0_s, width_s, shard, shaft_hz, 含义) — q0/width 取自如东 TCM 记录 X-axisOffset/X-axisDelta
  4745. 'HSP': (0.0400, 0.006, 'Cep_FFT_600_1000_Tr', 25.00, '高速级小齿轮 (HSS 轴)'),
  4746. 'PW': (0.0415, 0.007, 'Cep_FFT_600_1000_Tr', 24.10, '高速级 (24.1Hz, 含义待厂商确认)'),
  4747. 'HSW': (0.1415, 0.023, 'Cep_FFT_600_1000_Tr', 7.067, '高速级大齿轮 (中速轴)'),
  4748. '2S': (0.0555, 0.009, 'Cep_FFT_75_830_Tr', 18.02, '二级行星 太阳轮'),
  4749. '2R': (0.3165, 0.051, 'Cep_FFT_75_830_Tr', 3.160, '二级行星 内齿圈'),
  4750. '2P': (0.3920, 0.062, 'Cep_FFT_75_830_Tr', 2.551, '二级行星 行星轮'),
  4751. '1S': (0.2960, 0.048, 'Cep_FFT_5_110_Tr', 3.378, '一级行星 太阳轮'),
  4752. '1R': (1.1975, 0.191, 'Cep_FFT_5_110_Tr', 0.835, '一级行星 内齿圈'),
  4753. '1P': (1.8030, 0.288, 'Cep_FFT_5_110_Tr', 0.555, '一级行星 行星轮'),
  4754. }
  4755. def cepstral_gear_indicator(cep, dq, q0, width, *, bg_mult=3.0, rahmonic=True, max_window_frac=0.05):
  4756. """倒谱齿轮指标 — 厂商精确等价 (窗 RMS) + 结构度 + 2q0 拉姆谐波.
  4757. cep : 倒谱 (quefrency 轴从 0 起, 步长 dq, 无量纲)
  4758. q0 : 目标 quefrency (s) = 1/齿轮轴频; width: 全窗宽 (s), 即厂商 X-axisDelta
  4759. 返回 dict(indicator, bg_rms, struct, rahm2, i_lo, i_hi); 窗出界返回 NaN 字段。
  4760. struct = indicator / 邻域 RMS (邻域 = 窗两侧各 bg_mult 倍窗宽, 去掉窗本身) — 厂商没有的"有没有真拉姆谐波"闸,
  4761. 同 G2 (峰/本底) 的精神; rahm2 = 2q0 处同法窗 RMS / 其邻域 (真齿轮调制有二次拉姆谐波)。
  4762. """
  4763. import numpy as _np
  4764. c = _np.asarray(cep, dtype=float); n = len(c)
  4765. lo = int(_np.floor((q0 - width / 2) / dq)); hi = int(_np.ceil((q0 + width / 2) / dq)) + 1
  4766. nan = dict(indicator=_np.nan, bg_rms=_np.nan, struct=_np.nan, rahm2=_np.nan, i_lo=lo, i_hi=hi)
  4767. if lo < 2 or hi > n or hi - lo < 2:
  4768. return nan
  4769. w = c[lo:hi]; ind = float(_np.sqrt(_np.nansum(w ** 2))) # ★L2 范数 = 厂商精确尺度 (= RMS×√n)
  4770. rms = float(_np.sqrt(_np.nanmean(w ** 2)))
  4771. # ★结构度 (窗RMS/邻域RMS, G2 精神) 只在**几何有效**时算 (2026-08-23 PENDING 核实逮):
  4772. # 厂商给低速齿轮的窗本身占 quefrency 轴 10~15% (1P: 0.288s/2.5s), 3 倍窗宽邻域占 69% 且撞轴端;
  4773. # 去趋势 (7 倍窗宽滑动中位) 对这种窗等于整条轴, 修不了 — 1P/2P struct 仍 0.55/0.61, 2R 反而 1.48。
  4774. # "峰/本底" 对窗宽 >5% 轴长的量本身没意义 ⇒ 判 NaN + struct_note, 不硬算。
  4775. # 去趋势保留 (对窄窗去掉倒谱随 quefrency 的衰减), 改用 scipy C 实现 (python 循环 43 万条 >1h)。
  4776. frac = (hi - lo) / n
  4777. span = int(round(bg_mult * (hi - lo)))
  4778. valid = frac <= max_window_frac and (lo - span) >= 2 and (hi + span) <= n
  4779. out = dict(indicator=ind, window_rms=rms, struct=_np.nan, rahm2=_np.nan, i_lo=lo, i_hi=hi,
  4780. window_frac=round(frac, 4),
  4781. struct_note=None if valid else f'窗占轴 {100*frac:.1f}% 或邻域出界 ⇒ 峰/本底无意义, 不算')
  4782. if not valid:
  4783. return out
  4784. try:
  4785. from scipy.ndimage import median_filter as _mf
  4786. k = max(5, int(round(7 * (hi - lo)))) | 1
  4787. d = c - _mf(c, size=min(k, n | 1), mode='nearest')
  4788. except Exception:
  4789. d = c
  4790. def _struct(a, b):
  4791. if a - span < 2 or b + span > n or b - a < 2:
  4792. return _np.nan
  4793. wd = d[a:b]; nb = _np.r_[d[a - span:a], d[b:b + span]]
  4794. s_w = float(_np.sqrt(_np.nanmean(wd ** 2))); s_b = float(_np.sqrt(_np.nanmean(nb ** 2)))
  4795. return s_w / s_b if s_b > 0 else _np.nan
  4796. out['struct'] = _struct(lo, hi)
  4797. if rahmonic:
  4798. lo2 = int(_np.floor((2 * q0 - width / 2) / dq)); hi2 = int(_np.ceil((2 * q0 + width / 2) / dq)) + 1
  4799. out['rahm2'] = _struct(lo2, hi2)
  4800. return out
  4801. # ══════════════════════════════════════════════════════════════════════════════
  4802. # ★ 正样本锚 按部件类解封 (2026-08-23) — 替代全局布尔 positive_anchor
  4803. # 背景: 我此前写"38 台零内窥零拆检"并把封顶做成全局开关 — 错。台账有 6 台内窥镜 HS 轴承点蚀、2 台金相发电机内圈剥落、
  4804. # 4 台 FAG 主轴点蚀、9# 2026-01 内窥镜 (窗内) ⇒ 锚是**按部件类**存在的, 封顶也必须按部件类。
  4805. # 登记表 = reference/<场>/positive_anchors.json (数据不是代码; 每条带 tier/日期/证据出处)。
  4806. # ══════════════════════════════════════════════════════════════════════════════
  4807. ANCHOR_LEVELS = ('参考', '候选·记基线', '候选', '准定论·预警', '定论')
  4808. def line_energy_anchor(share_med, share_over_p90, *, min_share=0.01, min_over_p90=10.0, weak_over_p90=3.0):
  4809. """★同域线能量占比 = 谱线定级的绝对锚 (2026-08-23, 如东 48 条过闸线实证).
  4810. ×fleet 的分母是 fleet 中位谱在该频率的本底 — fleet 无此线时倍数天然巨大, 不是损伤量;
  4811. 原始谱域 135Hz 区全场 p90 占比 8.2% = 机型固有成分, 只看 ×fleet 会把 20#/34#/26# 当缺陷。
  4812. share_med : 该线 (±2 bin 功率 / 全谱功率) 六窗逐台中位
  4813. share_over_p90 : share_med / 同 shard fleet p90 (同域同 shard 才可比; 宽带 shard 会稀释占比)
  4814. 返回 dict: status ∈ {'ANCHOR_OK', 'WEAK', 'MID'} + cap (定级上限 或 None) + reason
  4815. ANCHOR_OK: share ≥ min_share ∧ over_p90 ≥ min_over_p90 → 允许解封至 准定论
  4816. WEAK : over_p90 < weak_over_p90 → 封顶 候选·记基线 (相对偏离真实但绝对量在 fleet 分布内)
  4817. MID : 其余 → 封顶 候选 (不得准定论)
  4818. 实证: 16#/33# GenDE 内圈 env 域 占比 2.6~6.2% / 10.9~175×p90 = ANCHOR_OK; 03# GenDE 1.5%/6.2× = MID;
  4819. 14#/31# HS 发电机侧 BSF 0.01~0.02% / 1.1~2.4× = WEAK (×fleet 16~21 全是分母效应);
  4820. 换件后对照 02#/24#/27# 六窗 0.04~0.07% = fleet 水平。
  4821. """
  4822. import math as _m
  4823. if share_med is None or share_over_p90 is None or not (share_med == share_med) or not (share_over_p90 == share_over_p90):
  4824. return dict(status='UNKNOWN', cap=None, reason='无能量占比 (shard 无谱或频率出界)')
  4825. if share_over_p90 < weak_over_p90:
  4826. return dict(status='WEAK', cap='候选·记基线',
  4827. reason=f'★绝对量微弱: 线能量占比 {100*share_med:.3f}% 仅 {share_over_p90:.1f}× 同shard fleet p90 — ×fleet 高因分母≈本底, 封顶 候选·记基线')
  4828. if share_med >= min_share and share_over_p90 >= min_over_p90:
  4829. return dict(status='ANCHOR_OK', cap=None, reason=f'绝对锚达标: 占比 {100*share_med:.2f}% = {share_over_p90:.1f}× fleet p90')
  4830. return dict(status='MID', cap='候选', reason=f'★绝对锚未达标 (占比 {100*share_med:.2f}%, {share_over_p90:.1f}×p90; 需 ≥{100*min_share:.0f}% ∧ ≥{min_over_p90:.0f}×) — 不得准定论')
  4831. def apply_line_energy_anchor(level, anchor_info, reasons, levels=ANCHOR_LEVELS):
  4832. """把 line_energy_anchor 的 cap 施加到定级上 (只降不升); 返回 (level, reasons)."""
  4833. cap = anchor_info.get('cap')
  4834. if cap is None: return level, reasons
  4835. base = str(level).split('·')[0] if str(level) != '候选·记基线' else '候选·记基线'
  4836. def rank(x):
  4837. x = str(x)
  4838. if x in levels: return levels.index(x)
  4839. return levels.index(x.split('·')[0]) if x.split('·')[0] in levels else -1
  4840. if rank(level) > rank(cap):
  4841. return cap, list(reasons) + [anchor_info['reason']]
  4842. return level, reasons
  4843. _AL = {v: i for i, v in enumerate(ANCHOR_LEVELS)}
  4844. def component_class_of(sensor):
  4845. """测点名 → 部件类 (锚按此匹配)."""
  4846. s = str(sensor)
  4847. if s.startswith('Gear_HS'): return 'gearbox_hs_bearing'
  4848. if s.startswith('Gear_IMS'): return 'gearbox_ims_bearing'
  4849. if s.startswith('Gear_planet'): return 'gearbox_planetary'
  4850. if s.startswith('Generator'): return 'generator_bearing'
  4851. if s.startswith('Main_bearing'): return 'main_bearing'
  4852. return 'unknown'
  4853. def anchor_cap(component_class, anchors=None, *, n_evidence_types=1, own_turbine_physical=False,
  4854. mechanism_confirmed=False, min_types_for_historical=3):
  4855. """该部件类在本场的**最高可达定级** + 理由.
  4856. 规则 (可证伪, 写进模型文档 §13.2 B9):
  4857. own_turbine_physical ∧ mechanism_confirmed → 定论 (本台拆检且机制对上)
  4858. own_turbine_physical → 准定论·预警 (本台有实物但圈侧/机制未对上, 拒绝命名)
  4859. tier physical_in_window 存在 → 准定论·预警
  4860. tier physical_historical 存在 ∧ 独立证据类型 ≥3 → 准定论·预警 (失效模式本场有实物先例, 但无 CMS 谱↔实物配对 ⇒ 以证据多样性补)
  4861. tier physical_historical 存在 ∧ 类型 <3 → 候选 (+ "本场有实物先例" 标注)
  4862. 只有 closed_loop / 无锚 → 候选
  4863. 确诊 (定论) 永远要求本台拆检 — 任何锚都不能替代。
  4864. 返回 dict(cap, tier, reason, anchors_n)
  4865. """
  4866. al = [a for a in (anchors or []) if a.get('component_class') == component_class]
  4867. tiers = {a.get('tier') for a in al}
  4868. n = len(al)
  4869. if own_turbine_physical and mechanism_confirmed:
  4870. return dict(cap='定论', tier='own_turbine', reason='本台实物证实且机制对上', anchors_n=n)
  4871. if own_turbine_physical:
  4872. return dict(cap='准定论·预警', tier='own_turbine', reason='本台实物证实, 但圈侧/机制未对上 ⇒ 不命名, 不到定论', anchors_n=n)
  4873. if 'physical_in_window' in tiers:
  4874. return dict(cap='准定论·预警', tier='physical_in_window',
  4875. reason=f'同部件类在数据窗内有实物锚 ({n} 条登记)', anchors_n=n)
  4876. if 'physical_historical' in tiers:
  4877. if n_evidence_types >= min_types_for_historical:
  4878. return dict(cap='准定论·预警', tier='physical_historical',
  4879. reason=f'同部件类有历史实物锚 ({n} 条) 且独立证据类型 {n_evidence_types} ≥ {min_types_for_historical}', anchors_n=n)
  4880. return dict(cap='候选', tier='physical_historical',
  4881. reason=f'同部件类有历史实物锚但独立证据类型仅 {n_evidence_types} < {min_types_for_historical} ⇒ 候选 (本场有实物先例)', anchors_n=n)
  4882. if 'closed_loop' in tiers:
  4883. return dict(cap='候选', tier='closed_loop', reason=f'只有诊断→换件闭环 ({n} 条), 无拆检文字 ⇒ 不解封', anchors_n=n)
  4884. return dict(cap='候选', tier='none', reason='本场该部件类无实物锚', anchors_n=0)
  4885. def apply_anchor_cap(level, cap_info, reasons):
  4886. """把 anchor_cap 应用到 L6 的 level: 高于 cap 的压到 cap; 候选级且有实物先例的加标注."""
  4887. lv = level
  4888. if lv in _AL and _AL[lv] > _AL[cap_info['cap']]:
  4889. reasons.append(f"★锚封顶: {lv} → {cap_info['cap']} ({cap_info['reason']})")
  4890. lv = cap_info['cap']
  4891. elif cap_info['tier'] in ('physical_historical', 'physical_in_window') and str(lv).startswith('候选'):
  4892. reasons.append(f"(本场该部件类有实物先例: tier={cap_info['tier']}, {cap_info['anchors_n']} 条)")
  4893. return lv
  4894. # ══════════════════════════════════════════════════════════════════════════════
  4895. # ★ L0 A1 三源合判 — "无记录" 三子型 (2026-08-23 如东 31#/18# 实证 → 落代码)
  4896. # 只看 CMS 最后时间戳分不开: (a) 停机无数据 (CMS 只在档内闭合才测 = 正确) / (b) 可达但不采集 (31# 8h, 风机发电中)
  4897. # / (c) 主机离线 (18#: No Communication + 表 crashed)。厂商看门狗对 (b) 延迟 35h。
  4898. # ══════════════════════════════════════════════════════════════════════════════
  4899. def a1_blind_subtype(*, last_cms_ts, now, running_spans=(), syslog_events=(), cycle_min=30.0,
  4900. gap_cycles=2, offline_types=('No Communication', 'Crashed Site Server Tables',
  4901. 'M-System Data retrival Warning')):
  4902. """CMS 采集盲区子型裁决.
  4903. last_cms_ts : 该台该测点最后一条 CMS 实测时间 (datetime)
  4904. now : 数据末端 / 判定时刻
  4905. running_spans : [(t0, t1), ...] 风机运行 (发电) 时段, 来自 SCADA/fastlog 运行态见证; 空 = 无见证
  4906. syslog_events : [(t0, t1, type), ...] CMS syslog 事件
  4907. 返回 dict(subtype, gap_h, running_unobserved_h, offline_h, reason)
  4908. subtype ∈ {'正常', '停机无数据', '可达但不采集', '主机离线', '无记录·无运行态见证'}
  4909. """
  4910. import datetime as _dt
  4911. gap_h = (now - last_cms_ts).total_seconds() / 3600 if last_cms_ts is not None else float('inf')
  4912. if gap_h <= gap_cycles * cycle_min / 60:
  4913. return dict(subtype='正常', gap_h=gap_h, running_unobserved_h=0.0, offline_h=0.0, reason=f'最后记录距末端 {gap_h:.1f}h ≤ {gap_cycles} 周期')
  4914. t_gap0 = last_cms_ts if last_cms_ts is not None else None
  4915. # 离线时长: syslog 离线类事件与盲区的交集
  4916. off = 0.0
  4917. for (a, b, ty) in syslog_events:
  4918. if ty in offline_types and t_gap0 is not None:
  4919. lo, hi = max(a, t_gap0), min(b or now, now)
  4920. if hi > lo: off += (hi - lo).total_seconds() / 3600
  4921. # 运行中未观测时长: 运行时段与盲区的交集
  4922. run = 0.0
  4923. for (a, b) in running_spans:
  4924. if t_gap0 is None: continue
  4925. lo, hi = max(a, t_gap0), min(b, now)
  4926. if hi > lo: run += (hi - lo).total_seconds() / 3600
  4927. if off >= gap_cycles * cycle_min / 60:
  4928. return dict(subtype='主机离线', gap_h=gap_h, running_unobserved_h=run, offline_h=off,
  4929. reason=f'syslog 离线类事件覆盖盲区 {off:.1f}h')
  4930. if run >= gap_cycles * cycle_min / 60:
  4931. return dict(subtype='可达但不采集', gap_h=gap_h, running_unobserved_h=run, offline_h=off,
  4932. reason=f'盲区内风机运行 {run:.1f}h (≥{gap_cycles} 周期) 而无 CMS 记录且无离线事件 ⇒ 机侧采集停摆')
  4933. if running_spans:
  4934. return dict(subtype='停机无数据', gap_h=gap_h, running_unobserved_h=run, offline_h=off,
  4935. reason='盲区内风机基本未运行 ⇒ CMS 只记停机档属正常行为')
  4936. return dict(subtype='无记录·无运行态见证', gap_h=gap_h, running_unobserved_h=0.0, offline_h=off,
  4937. reason='无 SCADA/fastlog 运行态见证, 三子型不可分 ⇒ 只能报 INSUFFICIENT, 不得写成"停机"或"故障"')
  4938. def shared_component_gate(coh_med, group_delay_us, phase_resultant_R, *, coh_shared=0.98, gd_max_us=5.0, r_min=0.999, coh_independent=0.5):
  4939. """共享成分污染门 (Codex 回传 §5-1 落库, 2026-08-23 如东 TCM 争议裁决 D1).
  4940. 判两只同步通道在某频带内是否为"同一路信号" (测量链耦合/电气拾取), 该带不得用于机械源定位:
  4941. SHARED = 相干中位 >= coh_shared ∧ |群延迟| <= gd_max_us ∧ 相位合成向量 R >= r_min
  4942. (如东 WTG12/38 主轴承前后 8-13 kHz: 相干 0.984-0.999, 群延迟 -1.16..-1.33 µs, R>=0.9998;
  4943. 两测点相距 >=1 m, 结构传播 >=200 µs ⇒ 零群延迟与机械传播不相容)
  4944. INDEPENDENT = 相干中位 < coh_independent
  4945. PARTIAL = 其余 (如东 WTG23 同带: 相干 0.59-0.69, 滞后 +4 µs, 相位恒 -22°)
  4946. 只看相干幅值不能区分固定结构传递与串扰 (Codex §6-1); 必须同时有相位/群延迟。
  4947. 返回 dict(status, usable_for_localization, reason)。
  4948. """
  4949. import math
  4950. vals = [coh_med, group_delay_us, phase_resultant_R]
  4951. if any(v is None or (isinstance(v, float) and math.isnan(v)) for v in vals):
  4952. return {'status': 'UNKNOWN', 'usable_for_localization': False, 'reason': '输入缺失 (相干/群延迟/相位R 任一为空)'}
  4953. if coh_med >= coh_shared and abs(group_delay_us) <= gd_max_us and phase_resultant_R >= r_min:
  4954. return {'status': 'SHARED', 'usable_for_localization': False,
  4955. 'reason': f'相干 {coh_med:.3f}>={coh_shared} ∧ |群延迟| {abs(group_delay_us):.2f}µs<={gd_max_us} ∧ R {phase_resultant_R:.4f}>={r_min}: 同一路信号, 该带不用于定位'}
  4956. if coh_med < coh_independent:
  4957. return {'status': 'INDEPENDENT', 'usable_for_localization': True, 'reason': f'相干 {coh_med:.3f}<{coh_independent}: 两路独立'}
  4958. return {'status': 'PARTIAL', 'usable_for_localization': True,
  4959. 'reason': f'相干 {coh_med:.3f} 介于 {coh_independent}-{coh_shared} 或 群延迟/相位未满足共享判据: 部分共享, 定位须剔除共享带并报占比'}
  4960. def bimodal_shape(x, *, fleet_iqr_median=None, span_iqr_max=2.0, iqr_ratio_min=2.5,
  4961. min_n=200, corroboration=None):
  4962. """双态(间歇) vs 单峰(整体偏移) 判别 —— **不需要基线切点**, 补 vib_scalar_typology 的盲区。
  4963. 为什么另起一个: `vib_scalar_typology` 是**变化型**判据 (末段 vs 基线), 对"一直就这样"的
  4964. 稳态双态完全失明 (memory change-detector-blind-to-steady-bias)。而"间歇发作"与"渐进退化"
  4965. 的现场措辞完全不同 —— 按"查渐进退化"下单, 现场大概率在正常态测一遍回报"未见异常"。
  4966. 判据用**分布形状**, 两个量都 scale-free (对振动残差、温度残差同样适用):
  4967. · IQR / fleet_IQR中位 —— 离散度是否异常大
  4968. · span/IQR = (p95-p05)/IQR —— **尾巴长度**。参考值 (可解析推导, 自检里断言):
  4969. 正态 (2*1.6449σ)/(1.3490σ) = 2.4386
  4970. 均匀 0.9/0.5 = 1.8
  4971. 两点质量 (完美双态) -> 1.0
  4972. **低值 = 质量在两端、中间不长尾** ⇒ 双态候选; 高值 = 单峰带长尾。
  4973. ★低 span/IQR 是**必要非充分**: 双态给低值, 但重中心的单峰、被 clip/量化压过的序列
  4974. 同样给低值。所以 verdict 只在**另有独立正证**时才升到"双态", 否则停在候选 ——
  4975. `corroboration` 传入那条独立证据的说明 (如"热态占空比逐月 8%->60% 而幅度恒定":
  4976. 占空比变而幅度不变, 本身就是比 span/IQR 更硬的双态正证)。
  4977. Args:
  4978. x: 一维样本 (残差/指标值), NaN 自动剔除
  4979. fleet_iqr_median: 全场同指标 IQR 的中位; 给了才出 iqr_ratio 与"离散异常"判
  4980. span_iqr_max: span/IQR 低于此值算"尾巴短" (默认 2.0, 介于均匀 1.8 与正态 2.44 之间)
  4981. iqr_ratio_min: IQR/fleet中位 高于此值算"离散异常大"
  4982. corroboration: 独立正证的一句话说明; None = 无 ⇒ verdict 封顶在 候选
  4983. Returns:
  4984. dict: verdict ∈ {双态, 双态候选, 单峰, INSUFFICIENT}, span_iqr/iqr/iqr_ratio, _caliber
  4985. [暂行 n=1场 · 零阳性对照] 门槛由古城+昌平坳 49 台一轮标定 (2026-09-03 温度侧, 跨会话回灌),
  4986. **无拆检证实的双态台**。⇒ 只作筛查, 不用于给机组定级 (skill vibration-spectral "无正样本时")。
  4987. 退役/复标判据: 出现"实证双态台 span/IQR > 2.0"或"实证单峰台 < 1.8"即失效, 须重标。
  4988. """
  4989. import numpy as _np
  4990. v = _np.asarray(x, dtype=float).ravel()
  4991. v = v[_np.isfinite(v)]
  4992. if v.size < min_n:
  4993. return {'verdict': 'INSUFFICIENT', 'n': int(v.size),
  4994. 'reason': f'样本 {v.size} < {min_n}'}
  4995. q05, q25, q75, q95 = (float(_np.percentile(v, q)) for q in (5, 25, 75, 95))
  4996. iqr = q75 - q25
  4997. if iqr <= 1e-12:
  4998. return {'verdict': 'INSUFFICIENT', 'n': int(v.size), 'iqr': iqr,
  4999. 'reason': 'IQR≈0 (常数或量化死通道) — 形状判据不适用'}
  5000. span_iqr = (q95 - q05) / iqr
  5001. iqr_ratio = (iqr / fleet_iqr_median) if fleet_iqr_median else None
  5002. short_tail = span_iqr < span_iqr_max
  5003. wide = (iqr_ratio is not None and iqr_ratio >= iqr_ratio_min)
  5004. if short_tail and wide:
  5005. verdict = '双态' if corroboration else '双态候选'
  5006. elif short_tail or wide:
  5007. verdict = '双态候选'
  5008. else:
  5009. verdict = '单峰'
  5010. return {'verdict': verdict, 'n': int(v.size), 'iqr': iqr, 'span_iqr': span_iqr,
  5011. 'iqr_ratio': iqr_ratio, 'p05': q05, 'p95': q95,
  5012. 'short_tail': short_tail, 'wide_spread': wide,
  5013. 'corroboration': corroboration,
  5014. '_caliber': (f'span/IQR={span_iqr:.2f} (正态2.44/均匀1.80/双点1.00), '
  5015. + (f'IQR={iqr:.3g} 为 fleet 中位的 {iqr_ratio:.2f}×' if iqr_ratio
  5016. else 'fleet_iqr_median 未给, 仅报形状')
  5017. + ('; 有独立正证' if corroboration else
  5018. '; **无独立正证 ⇒ 封顶在候选** (低 span/IQR 必要非充分)')),
  5019. '_retire': '[暂行 n=1场 零阳性对照] 只作筛查, 不给机组定级'}
  5020. _MADZ_CACHE = {}
  5021. def madz_null_rate(n, z, *, n_mc=200_000, seed=0, two_sided=True):
  5022. """fleet MAD-z 判据在**有限机群规模 n** 上的真实尾概率 (实验 J, 2026-09-07)。
  5023. ★为什么需要这个函数: `z = (x − median) / (1.4826·MAD)` 常被当作近似标准正态读, 于是
  5024. "z≥2 ⇒ 4.55% 假阳" / "z≥3 ⇒ 0.27%" 被用来估基率。**这在小机群上是错的, 且方向是反的
  5025. (低估假阳, 不是高估)** —— MAD 本身是从同一批 n 个样本估出来的噪声量, 其抽样波动把比值的
  5026. 尾巴抬厚 (同 t 分布相对正态)。**纯 iid 正态数据**下实测 (n_mc=2e5):
  5027. n P(|z|≥2)/正态 P(|z|≥3)/正态
  5028. 12 1.78× 8.62×
  5029. 20 1.51× 5.22×
  5030. 38 1.29× 2.98×
  5031. 80 1.14× 1.85×
  5032. 200 1.06× 1.32×
  5033. 1000 1.01× 1.06×
  5034. ⇒ **判据阈值的名义假阳率只在大机群上成立**; 12 台的场用 z≥3 出榜, 真实假阳是名义的 8.6 倍。
  5035. ⇒ 与 `fleet_two_component_split` 的 n_ref 前提同源: 小参考池不只是"污染"问题, **z 的刻度本身就偏**。
  5036. ⚠ 本函数只给"估计量自身"的那一份偏差 (数据 iid 正态)。真实机群的偏差分布还更厚尾 (台间异质 +
  5037. 真异常台污染) —— 如东实测置换零模型 z≥2 为 1.67×、z≥3 为 7.1×, 其中 1.29×/2.98× 由本函数解释,
  5038. 余下 1.29×/2.4× 来自数据本身。**要拿场级真基率必须跑置换零模型**, 本函数是它的可移植下界。
  5039. 返回 P(|z| ≥ z) (two_sided=False 则为单侧 P(z ≥ z))。
  5040. """
  5041. key = (int(n), float(z), int(n_mc), int(seed), bool(two_sided))
  5042. if key in _MADZ_CACHE:
  5043. return _MADZ_CACHE[key]
  5044. if n < 3:
  5045. raise ValueError(f'madz_null_rate: n={n} < 3, MAD 无意义 (必需输入不静默兜底)')
  5046. rng = np.random.default_rng(seed)
  5047. hit = 0
  5048. done = 0
  5049. block = max(1, min(n_mc, int(2e7 // max(n, 1))))
  5050. while done < n_mc:
  5051. m = min(block, n_mc - done)
  5052. X = rng.standard_normal((m, n))
  5053. med = np.median(X, axis=1, keepdims=True)
  5054. mad = 1.4826 * np.median(np.abs(X - med), axis=1, keepdims=True)
  5055. zz = (X - med) / np.where(mad > 0, mad, np.nan)
  5056. hit += int(np.nansum(np.abs(zz) >= z) if two_sided else np.nansum(zz >= z))
  5057. done += m
  5058. r = hit / (n_mc * n)
  5059. _MADZ_CACHE[key] = r
  5060. return r
  5061. def fleet_madz_base_rate(n_turbine, n_comparison, z=2.0, *, two_sided=True, **kw):
  5062. """榜单基率: n_comparison 次 fleet MAD-z 比较下, 纯噪声期望命中数 (**用 madz_null_rate 而非正态**)。
  5063. 返回 {expected_madz, expected_normal_WRONG, inflation, note}。`expected_normal_WRONG` 只为对照 ——
  5064. 它是常见的错算法 (拿正态尾概率乘比较数), 实测系统性**低估**基率, 名字里带 WRONG 防被误引。
  5065. """
  5066. from math import erf, sqrt
  5067. p_norm = (1 - erf(z / sqrt(2))) if two_sided else 0.5 * (1 - erf(z / sqrt(2)))
  5068. p_mad = madz_null_rate(n_turbine, z, two_sided=two_sided, **kw)
  5069. return {'expected_madz': n_comparison * p_mad,
  5070. 'expected_normal_WRONG': n_comparison * p_norm,
  5071. 'inflation': (p_mad / p_norm) if p_norm else float('nan'),
  5072. 'p_madz': p_mad, 'n_turbine': int(n_turbine), 'z': float(z),
  5073. 'note': ('仅含估计量自身偏差 (iid 正态假设); 台间异质与真异常污染会再抬高 → '
  5074. '场级真基率须跑置换零模型, 本值是其可移植下界')}
  5075. def _grand_chart(gser, h0_window, test_start, *, mu1_k=1.0, run_alpha=0.05,
  5076. min_h0_n=30, min_test_n=30, note=None):
  5077. """共模序列 g_t 的时序判据 (fleet_two_component_split 与 nbm_residual 共用, 防两处判据漂移)。
  5078. H0 冻结自 h0_window (**应与测试窗同历月**, 否则季节错配会把共模图点亮)。
  5079. 返回 (grand_dict, h0_dict|None); grand['verdict'] 三态见 fleet_two_component_split docstring。
  5080. """
  5081. from wind_analytics.health_assessment.mset_sprt import fit_h0, sprt
  5082. note = note if note is not None else []
  5083. h0_lo, h0_hi, t_lo = pd.Timestamp(h0_window[0]), pd.Timestamp(h0_window[1]), pd.Timestamp(test_start)
  5084. base = gser[(gser.index >= h0_lo) & (gser.index < h0_hi)].dropna().values
  5085. test = gser[gser.index >= t_lo].dropna().values
  5086. if len(base) < min_h0_n or len(test) < min_test_n:
  5087. return {'decision': 'insufficient', 'verdict': 'INSUFFICIENT', 'n_alarm': 0, 'candidate': False,
  5088. 'reason': f'H0 {len(base)} / 测试 {len(test)} 样本不足'}, None
  5089. h0 = fit_h0(base)
  5090. if 'note' in h0:
  5091. return {'decision': 'insufficient', 'verdict': 'INSUFFICIENT', 'n_alarm': 0,
  5092. 'candidate': False, 'reason': h0['note']}, None
  5093. sr = sprt(test, mu_0=h0['mu_0'], sigma_0=h0['sigma_0'],
  5094. mu_1=h0['mu_0'] + mu1_k * h0['sigma_0'], phi=h0['phi'],
  5095. method='whiten', persist_k=2, run_alpha=run_alpha)
  5096. grand = {'decision': sr.decision, 'n_alarm': len(sr.alarm_indices),
  5097. 'candidate': bool(sr.candidate), 'n_eff': int(sr.n_eff),
  5098. 'decorr_ok': bool(sr.decorr_ok), 'sigma_0_K': float(h0['sigma_0']),
  5099. 'phi': float(h0['phi']), 'mu1_K': float(mu1_k * h0['sigma_0'])}
  5100. # ★失效必须落在显式状态, 不许降级成"另一个合法状态" (memory parse-failure-read-as-positive-result 第三类):
  5101. # 共模量近随机游走 (φ→1) 时白化把电平阶跃压成 (1−φ)Δ → 真有批次漂也返回 'no_change',
  5102. # 与"干净阴性"外观完全一样, 调用方读不出差别。故不可信的阴性单独判 INSUFFICIENT。
  5103. if sr.candidate:
  5104. grand['verdict'] = 'batch_shift_candidate'
  5105. elif not sr.decorr_ok:
  5106. grand['verdict'] = 'INSUFFICIENT'
  5107. grand['reason'] = (f'共模序列 φ={h0["phi"]:.3f} 白化后残余自相关超容忍 → 电平阶跃被压成 '
  5108. f'{(1 - h0["phi"]) * 100:.1f}%, 阴性不可信 (非"未见异常")')
  5109. note.append('⚠ decorr_ok=False: 阴性判 INSUFFICIENT 而非 no_change; run_alpha 的 ARL 上界亦不成立')
  5110. else:
  5111. grand['verdict'] = 'no_change'
  5112. return grand, h0
  5113. # ------------------------------------------------- 两成分分解: 共模图 (fleet-relative 缺失的那半张)
  5114. def fleet_two_component_split(df, *, comp_col, p_col, amb_col, turbine_col, time_col,
  5115. fit_window, h0_window, test_start,
  5116. curtail_col=None, gen_min=None, freq='D',
  5117. z_flag=2.0, mu1_k=1.0, run_alpha=0.05,
  5118. min_fit_n=60, min_h0_n=30, min_test_n=30):
  5119. """MSP 两成分分解 (Runger/Alt/Montgomery 1996; Epprecht/Barbosa/Simões 2011 doi:10.1590/S0103-65132011005000022)。
  5120. 把 per 台 NBM 残差 r_it 拆成 **共模 g_t = median_i r_it** 与 **单台偏差 d_it = r_it − g_t**, 两条各配一张图:
  5121. · deviation 图 (d): 横截面 MAD-z, = 现行 fleet-relative 生产语义 (windscada/subsys/temp_nbm.py::registry,
  5122. discriminators.nbm_residual)。**本函数不替代它们**, 只把它算在同一次分解里以保证两图同源可比。
  5123. · grand-mean 图 (g): 时序 SPRT (health_assessment.mset_sprt.fit_h0 + sprt, H0 冻结自独立标定窗)。
  5124. **这是原先缺的那半张** —— 共模量此前只作为回归量 (nbm_residual 的 Tmed) 被吸收后丢弃, 从不被监控。
  5125. ★为什么必须两张: deviation 图对"全场同步平移"**逐台逐值精确不变**(d = r − median(r), 全体加 Δ 则 d 不变),
  5126. 故批次退化在它上面**必然全绿** —— 这是设计属性不是 bug (memory relative-criterion-batch-blindness 的正式版:
  5127. SPC 称之为"滤掉共模", 风电语境称之为"批次盲")。补救只能靠另加共模图, 不能靠调 deviation 图的阈值。
  5128. 2026-09-07 实验 K 如东 38 台 × 4 温度通道实测: 全场注入 +1σ 阶跃, deviation z **max|Δ|=0.00e+00**(4 通道全部),
  5129. 而共模图 4/4 报警; 单台注入 +1σ 则反过来 —— 该台 z 从 +0.48~+1.77 抬到 +3.73~+10.03, 其余 37 台 max|Δz|≤0.118,
  5130. 共模图 3/4 不动。两图正交, 互不替代。
  5131. ⚠ 前置① 限电必须先剥 (SOP 模块0.1): curtail_col 给定则剔除该列 >0 的行。如东实测 **41.8% 发电行处于降功率态**,
  5132. 不剥 → 小时粒度下 3/4 通道**零注入即误报**(GeOil/GenBeG/HSGen 共模图 null 报警), 剥后 3/4 转 no_change。
  5133. ⚠ 前置② 粒度: 小时粒度共模序列 φ≈0.76~0.91, 白化后残余 AC 多数超容忍 (decorr_ok=False) → **默认日粒度**
  5134. (freq='D', 实测 φ 降到 0.29~0.49)。decorr_ok=False 时 run_alpha 的 ARL 上界不成立, 报警数不可当已标定读。
  5135. ⚠ 前置③ **共模图不能单独定因**: 全场同步上移既可能是批次设备退化, 也可能是场级参考量变化 (环温传感器族偏、
  5136. 场址/微气象变、控制版本全场升级、限电制度切换)。**本图给不出区分**, 单靠它最高只能到 准定论·预警,
  5137. 升定论须外部绝对锚 (memory anchor-validation-shear-tautology-trap)。
  5138. ★common_frac = grand_sd0 / resid_sd = 共模图灵敏度的天花板诊断。若残差散布主要是各台独立噪声, 38 台取中位
  5139. 应把它压到 ≈1/√38≈0.16; 如东实测 **0.60~0.92** ⇒ 散布的 60~92% 本身就是共模 (模型没解释掉的天气/工况),
  5140. **中位数几乎没降噪** ⇒ **加台数买不到共模灵敏度**, 只能靠把物理模型做准。common_frac 越接近 1,
  5141. 共模图越钝, 越该先改模型而不是调阈值。
  5142. ★grand['verdict'] 三态 (不许把不可信阴性混进 no_change): 'batch_shift_candidate' / 'no_change' /
  5143. 'INSUFFICIENT'(样本不足, 或共模量近随机游走 φ→1 致白化把电平阶跃压没 —— 此时阴性只是"测不出", 非"没有")。
  5144. 返回 {grand:{...sprt..., verdict}, dev:{z, flagged, n_flagged, n_total}, common_frac, resid_sd_K,
  5145. grand_sd0_K, n_turbine, curtail_stripped_frac, freq, note:[...]}。
  5146. """
  5147. need = [comp_col, p_col, amb_col, turbine_col, time_col]
  5148. miss = [c for c in need if c not in df.columns]
  5149. if miss:
  5150. raise KeyError(f'fleet_two_component_split 缺必需列: {miss}') # 必需输入禁静默 .get
  5151. d = df[need + ([curtail_col] if curtail_col else [])].copy()
  5152. d[time_col] = pd.to_datetime(d[time_col])
  5153. d = d.dropna(subset=need)
  5154. note = []
  5155. if gen_min is not None:
  5156. d = d[d[p_col] > gen_min]
  5157. strip_frac = None
  5158. if curtail_col:
  5159. if curtail_col not in df.columns:
  5160. raise KeyError(f'curtail_col={curtail_col} 不在 df (限电剥离是硬前置, 不静默跳过)')
  5161. n0 = len(d)
  5162. d = d[d[curtail_col].fillna(0) <= 0]
  5163. strip_frac = float(1 - len(d) / n0) if n0 else None
  5164. note.append(f'限电剥离 {strip_frac:.1%} 行 (SOP 模块0.1)')
  5165. else:
  5166. note.append('⚠ 未剥限电 (curtail_col=None): 共模图零注入误报风险, 结论最高 INSUFFICIENT')
  5167. d['_t'] = d[time_col].dt.floor(freq)
  5168. g = d.groupby([turbine_col, '_t'])[[p_col, amb_col, comp_col]].median().reset_index()
  5169. fit_lo, fit_hi = pd.Timestamp(fit_window[0]), pd.Timestamp(fit_window[1])
  5170. res = []
  5171. for tid, gs in g.groupby(turbine_col):
  5172. f = gs[(gs['_t'] >= fit_lo) & (gs['_t'] < fit_hi)]
  5173. if len(f) < min_fit_n:
  5174. note.append(f'{tid}: 拟合窗样本 {len(f)}<{min_fit_n}, 剔出')
  5175. continue
  5176. X = np.c_[np.ones(len(f)), f[p_col].values, f[amb_col].values]
  5177. beta, *_ = np.linalg.lstsq(X, f[comp_col].values, rcond=None)
  5178. Xa = np.c_[np.ones(len(gs)), gs[p_col].values, gs[amb_col].values]
  5179. res.append(pd.DataFrame({turbine_col: tid, '_t': gs['_t'].values,
  5180. 'r': gs[comp_col].values - Xa @ beta}))
  5181. if len(res) < 3:
  5182. return {'grand': {'decision': 'insufficient', 'verdict': 'INSUFFICIENT'}, 'dev': None, 'common_frac': None,
  5183. 'note': note + [f'可建模台数 {len(res)}<3, 共模量无意义']}
  5184. r = pd.concat(res)
  5185. gser = r.groupby('_t')['r'].median().rename('g')
  5186. r = r.join(gser, on='_t')
  5187. r['d'] = r['r'] - r['g']
  5188. # --- 共模图 (grand-mean): 走共用 helper (nbm_residual 同调, 判据不许两处各写一份)
  5189. t_lo = pd.Timestamp(test_start) # deviation 图下方仍需
  5190. grand, h0 = _grand_chart(gser, h0_window, test_start, mu1_k=mu1_k, run_alpha=run_alpha,
  5191. min_h0_n=min_h0_n, min_test_n=min_test_n, note=note)
  5192. # --- deviation 图 (生产语义横截面 MAD-z; 对全场平移逐值不变)
  5193. t = r[r['_t'] >= t_lo]
  5194. dev = t.groupby(turbine_col)['d'].mean()
  5195. mad = float(1.4826 * np.median(np.abs(dev - dev.median())))
  5196. z = (dev - dev.median()) / max(mad, 0.3)
  5197. resid_sd = float(r['r'].std())
  5198. grand_sd0 = float(h0['sigma_0']) if h0 and 'note' not in h0 else None
  5199. cf = (grand_sd0 / resid_sd) if (grand_sd0 and resid_sd) else None
  5200. if cf is not None and cf > 0.5:
  5201. note.append(f'common_frac={cf:.2f}>0.5: 散布主要是共模, 中位数几乎未降噪 ⇒ 加台数买不到灵敏度, 先改物理模型')
  5202. return {'grand': grand,
  5203. 'dev': {'z': {str(k): round(float(v), 3) for k, v in z.items()},
  5204. 'flagged': sorted(str(k) for k in z[z.abs() >= z_flag].index),
  5205. 'n_flagged': int((z.abs() >= z_flag).sum()), 'n_total': int(len(z))},
  5206. 'common_frac': cf, 'resid_sd_K': round(resid_sd, 3),
  5207. 'grand_sd0_K': round(grand_sd0, 3) if grand_sd0 else None,
  5208. 'n_turbine': int(r[turbine_col].nunique()),
  5209. 'curtail_stripped_frac': strip_frac, 'freq': freq, 'note': note}