| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633 |
- # -*- coding: utf-8 -*-
- """discriminators.py — 稳定判别器纯函数库 (Batch2 部分增量, 2026-06-16).
- 抽自已跨多场实跑的 per-farm 脚本 (RV-2: 搬真逻辑不改判据), 给 5 个分析 skill 配真引擎。
- 本批只抽 **物理/统计扎实、跨场同机制、变体面已收敛** 的 4 个 (用户 + Gemini/DeepSeek 裁决"部分增量"):
- curtail_vs_fault_sync — 限电vs故障 同步性三证 源 scripts/jtxq_analysis.py:36-51 SOP §4.3 n=16
- mast_usability — 测风塔可用性 (切变/风向/月ratio) 源 scripts/gbs_mast.py SOP §4.5
- ntf_correction — 机舱风/自由来流 NTF + 空间代表性 源 scripts/jtxq_mast.py:99 SOP §4.5
- nbm_residual — 温度 NBM 残差 (pooled OLS + MAD-z + EWMA) 源 scripts/litloop_r4_temp_nbm.py SOP §4.6 n=3
- yaw_column_usability — 对风角列可用性 (per机任意零点 artifact 拦截) 源 cq 昌平坳 SOP §4.4① n=2 [2026-06-17 增]
- 变体面未收敛的 (per-OEM 状态/限功率码解码 90012/910021、glitchy 内置可用率、setpoint 节流/binding) 仍 per-farm, 不抽
- (2026-06-17 复核: builtin_avail_verify 变体面仍未收敛=不抽; curtailment_setpoint_split n=1[暂行]<§0.3 准入线=不抽)。
- 全为纯函数: 入 DataFrame/array + 列名, 出结构化 dict; 无文件 I/O / 无 print。
- """
- import numpy as np
- import pandas as pd
- from .analysis_kit import theil_sen # 原子层稳健斜率 (禁在此重造; 见 analysis_kit docstring)
- __all__ = ['leading_edge_shape_screen', 'curtail_vs_fault_sync', 'offline_vs_curtail_split', 'mast_usability', 'ntf_correction', 'nbm_residual', 'ewma_exceed',
- 'near_equal_columns', 'power_channel_scale_check', 'channel_swap_screen', 'yaw_column_usability', 'availability_calibers',
- 'fleet_sector_devz', 'matched_load_overheat', 'vibration_trend', 'order_track_1p', 'naive_fft_1p',
- 'blade_imbalance_1p', 'mcsa_feasibility_gate', 'mcsa_method_skeleton', 'oil_quality_gate',
- 'cooling_efficiency', 'pitch_stick', 'cp_operating_peak', 'reference_channel_liveness',
- 'idle_zero_check', 'node_minus_sink_dt', 'dende_spread', 'phase_imbalance',
- 'daily_thermal_table', 'thermal_chain_align', 'gearbox_bearing_multichannel',
- 'matched_load_sink_dt', 'lubrication_pump_duty_split', 'thermal_beta_family',
- 'heater_actuator_screen', 'response_ratio', 'spectral_line_gates',
- 'vib_scalar_typology', 'impact_ringing_shift', 'bearing_end_symmetry',
- 'shaft_harmonic_ratios', 'fleet_two_component_split',
- 'madz_null_rate', 'fleet_madz_base_rate']
- # ---------------------------------------------------------------- §4.7 气动 Cp (运行段取峰, 止低风 P/v³ 伪影)
- 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):
- """运行段功率系数峰 (SOP §4.7; 止低风 P/v³ 数值爆点伪影 = memory cp-lowwind-pv3-artifact)。
- Cp=P/(0.5·ρ·A·v³) 在 ws≲4 分母趋零 → 数值冲 0.7-0.8 假"超 Betz", 是纯数值伪影非真超物理。
- 本函数**只取运行段 ws≥ws_min** 逐 bin 中位 + clip 0<Cp<0.593, **禁 max-over-all-bins**(会抓低风爆点)。
- 偏高(>hi_thresh, 高于现代机典型~0.47)= 风速读低**嫌疑·候选**(主证须用相对出力, 非 Cp 绝对值)。物理。
- 入: ws/p_kw 同长 array; rho 现场空气密度 kg/m³ (RULE-1 修正, 禁标准密度裸算); area 扫风面积 m²。
- 出 dict: {cp_curve {ws_bin: cp}, cp_peak, peak_ws, n_lowwind_artifact (剔的 ws<ws_min 且>Betz 点数),
- verdict ∈ {Cp偏高·读低嫌疑, Cp正常, 数据不足}, note}。凉水泉 2109 实证: 原"超物理极限"=此伪影。
- """
- ws = np.asarray(ws, float)
- p = np.asarray(p_kw, float)
- m = np.isfinite(ws) & np.isfinite(p) & (ws > 0) & (p > 0)
- ws, p = ws[m], p[m]
- if len(ws) < 20:
- return {'cp_curve': {}, 'cp_peak': None, 'peak_ws': None,
- 'n_lowwind_artifact': 0, 'verdict': '数据不足', 'note': f'有效点 n={len(ws)}<20'}
- cp_all = p * 1000.0 / (0.5 * rho * area * ws ** 3)
- n_art = int(np.sum((ws < ws_min) & (cp_all > 0.593))) # 低风伪影点 (诊断用): ws<ws_min 且 cp>Betz
- op = (ws >= ws_min) & (ws < ws_max) & (cp_all > 0) & (cp_all < 0.593) # 运行段 + clip Betz
- if int(op.sum()) < 10:
- return {'cp_curve': {}, 'cp_peak': None, 'peak_ws': None,
- 'n_lowwind_artifact': n_art, 'verdict': '数据不足', 'note': '运行段有效 bin<10'}
- wb = ws[op] // bin_w * bin_w
- s = pd.Series(cp_all[op]).groupby(wb).median()
- cp_peak = float(s.max())
- peak_ws = float(s.idxmax())
- verdict = 'Cp偏高·读低嫌疑' if cp_peak > hi_thresh else 'Cp正常'
- return {'cp_curve': {float(k): float(v) for k, v in s.items()},
- 'cp_peak': cp_peak, 'peak_ws': peak_ws, 'n_lowwind_artifact': n_art, 'verdict': verdict,
- 'note': f'运行段 ws≥{ws_min} 峰 {cp_peak:.3f}@{peak_ws:.1f}m/s; '
- f'剔低风伪影点 {n_art} (ws<{ws_min} 且>Betz = P/v³ 爆点, 非真超物理)'}
- # ---------------------------------------------------------------- §4.3 限电同步性
- def _longest_zero_block_days(times, p_abs, p_zero_kw, max_gap_days=2.0):
- """最长**数据连续** |P|<p_zero_kw 段的 wall-clock 跨度(天). times/p_abs 已按时间排序的 numpy array。
- 起止在 z(零功率)边沿; **额外在相邻行间隔 > max_gap_days 处断段** —— 否则稀疏/缺测台(几行零功率横跨
- 数月、中间大段无数据)会把"缺数据"误报成"连续离线块"(独立Critic 2026-07-07 逮的 latent 稳健性洞:
- langmushan 真数据块 99.6% 行覆盖·最大内隙 1 天=真连续, 但他场未必)。返回 (best_days, start_ts, end_ts)。
- NaT 时戳先剔 (无法锚定时长; langmushan 每台 144 个 NaT 若不剔 → span=NaT 污染 max, 误把 A30 297天块判 nan)。
- """
- times = np.asarray(times)
- p_abs = np.asarray(p_abs, dtype=float)
- valid = ~pd.isna(times)
- if not valid.all():
- times, p_abs = times[valid], p_abs[valid]
- n = len(times)
- if n == 0:
- return 0.0, None, None
- z = (p_abs < p_zero_kw) # NaN 功率 → False (未知≠已知零, 不并入零块)
- if not z.any():
- return 0.0, None, None
- gap = np.diff(times) / np.timedelta64(1, 'D') # 相邻行间隔(天), 长 n-1
- gap_prev = np.concatenate(([np.inf], gap)) # 行 i 之前的间隔
- gap_next = np.concatenate((gap, [np.inf])) # 行 i 之后的间隔
- prev_z = np.concatenate(([False], z[:-1]))
- next_z = np.concatenate((z[1:], [False]))
- # 段起 = 零行 ∧ (前行非零 ∨ 前隔过大); 段止 = 零行 ∧ (后行非零 ∨ 后隔过大)
- starts = np.flatnonzero(z & (~prev_z | (gap_prev > max_gap_days)))
- ends = np.flatnonzero(z & (~next_z | (gap_next > max_gap_days)))
- spans = (times[ends] - times[starts]) / np.timedelta64(1, 'D')
- k = int(np.argmax(spans))
- return float(spans[k]), times[starts[k]], times[ends[k]]
- def offline_vs_curtail_split(df, *, time_col, turbine_col, p_col, gen_speed_col,
- curtail_col=None, ws_col=None, ws_min=4.0,
- p_zero_kw=20.0, gen_offline_rpm=50.0, gen_turn_rpm=500.0,
- offline_frac_thresh=0.5, zero_block_days_thresh=30.0, max_gap_days=2.0):
- """限电候选桶: 「机组离线/停机」vs「真节流限电」分辨 + 连续零块时长闸 (SOP §4.3; 解 §0.9 限电三义混淆)。
- 朗目山铁证 (2026-07-07 独立Critic clean-room 翻案): 把"限电候选桶"(有风却 P~0) 判电网弃风前必过两闸——
- ① 离线占比: 桶内 |generator_speed| < gen_offline_rpm (发电机静止) 的占比 = 机组彻底离线/停机。
- 发电机不转 → **物理上不可能是"节流限电"**(节流 = 发电机在转、功率被外部指令压低)。真限电签名 =
- gen 转(≥gen_turn_rpm)但 P~0。离线占比 > offline_frac_thresh → 判"离线/停机主导·非节流弃风", 须辨
- 停机型限电(同步性 §4.3) vs 故障 vs 投产前离线, 成因 INSUFFICIENT, **不出节流弃风¥**。
- ② 连续零块: per台最长连续 P~0 (|P|<p_zero_kw) 块 > zero_block_days_thresh 天 → 电网调度物理上造不出
- 连续数月的零 (调度限电是小时级偶发态) → **决定性**判"投产前离线/长停"(cause INSUFFICIENT), 与同步/散布无关。
- 朗目山实证: 桶内 84% gen=0 离线 (真限电仅 0.6%); A30 连续 297 天零块 (健康台 A01 仅 12 天); 18/33 台
- Sep-Oct 才首并 → 前稿"弃风26%/37.5GWh ROI"误判, 实为投产前离线 (数字全对、故事讲反)。物理: 发电机静止 ≠ 节流。
- ⚠ 边界: 离线占比闸(①)**不足以**独立否定停机型电网限电(同步短停也 gen=0) → ① 只降级+禁节流¥、不否定;
- 连续零块闸(②)才是"非电网"的决定性证据(数月连续零 sync 也不可能是调度)。n=1 [暂行] (langmushan; §0.9 铁律锚)。
- 桶定义: (|p_col| < p_zero_kw) [∧ ws_col ≥ ws_min 若给] [∧ curtail_col > 0.5 若给]。
- 入长表; p_col 有功(kW); gen_speed_col 发电机转速(rpm, 可负→取 abs); time_col 时戳; turbine_col 台号。
- 出 dict: {n_bucket, offline_frac, true_curtail_frac, ambiguous_frac, max_zero_block_days,
- worst_zero_block_turbine, n_turbines_long_offline, turbines_long_offline, verdict, cause, evidence};
- verdict ∈ {疑投产前离线/长停(非弃风), 离线/停机主导·非节流弃风, 真节流限电候选, 桶空/无候选}。
- """
- p_abs_all = pd.to_numeric(df[p_col], errors='coerce').abs()
- gen = pd.to_numeric(df[gen_speed_col], errors='coerce').abs()
- bucket = (p_abs_all < p_zero_kw)
- if ws_col and ws_col in df:
- bucket = bucket & (pd.to_numeric(df[ws_col], errors='coerce') >= ws_min)
- if curtail_col and curtail_col in df:
- bucket = bucket & (pd.to_numeric(df[curtail_col], errors='coerce') > 0.5)
- n_bucket = int(bucket.sum())
- # ② 最长连续零块 per 台 (wall-clock; 与桶无关, 覆盖全序列) — 决定性"非电网"证据
- per_turb_block = {}
- sub = df[[time_col, turbine_col, p_col]].copy()
- sub['_pabs'] = pd.to_numeric(sub[p_col], errors='coerce').abs()
- sub['_t'] = pd.to_datetime(sub[time_col], errors='coerce')
- for tid, g in sub.groupby(turbine_col, sort=False):
- g = g.sort_values('_t')
- best, _, _ = _longest_zero_block_days(g['_t'].to_numpy(), g['_pabs'].to_numpy(), p_zero_kw,
- max_gap_days=max_gap_days)
- per_turb_block[str(tid)] = round(best, 1)
- max_zero_block_days = max(per_turb_block.values()) if per_turb_block else 0.0
- worst_turb = max(per_turb_block, key=per_turb_block.get) if per_turb_block else None
- turbines_long_offline = sorted([t for t, dd in per_turb_block.items() if dd > zero_block_days_thresh])
- n_long = len(turbines_long_offline)
- if n_bucket == 0:
- # 桶空但仍可能有长零块(全零台 ws 缺失场景) → 决定性②仍先判
- if max_zero_block_days > zero_block_days_thresh:
- ev = (f'⚠ 限电候选桶空(无"有风却P~0"), 但 per台最长连续零块 {max_zero_block_days:.0f}天@{worst_turb} '
- f'(>{zero_block_days_thresh:.0f}天闸, {n_long}台超闸) = 电网调度造不出连续数月零 → 投产前离线/长停。')
- return {'n_bucket': 0, 'offline_frac': None, 'true_curtail_frac': None, 'ambiguous_frac': None,
- 'max_zero_block_days': round(max_zero_block_days, 1), 'worst_zero_block_turbine': worst_turb,
- 'n_turbines_long_offline': n_long, 'turbines_long_offline': turbines_long_offline,
- 'verdict': '疑投产前离线/长停(非弃风)', 'cause': 'INSUFFICIENT_投产vs停役vs弃风(须业主并网台账+停机工单)',
- 'evidence': ev}
- return {'n_bucket': 0, 'offline_frac': None, 'true_curtail_frac': None, 'ambiguous_frac': None,
- 'max_zero_block_days': round(max_zero_block_days, 1), 'worst_zero_block_turbine': worst_turb,
- 'n_turbines_long_offline': n_long, 'turbines_long_offline': turbines_long_offline,
- 'verdict': '桶空/无候选', 'cause': None, 'evidence': '限电候选桶(有风却P~0)为空且无长零块'}
- # ① 桶内离线占比 (gen 静止) vs 真节流签名 (gen 转但 P~0)
- gen_b = gen[bucket]
- valid = gen_b.notna()
- offline_frac = float((gen_b[valid] < gen_offline_rpm).mean()) if bool(valid.any()) else None
- true_curtail_frac = float((gen_b[valid] >= gen_turn_rpm).mean()) if bool(valid.any()) else None
- ambiguous_frac = (round(max(0.0, 1.0 - offline_frac - true_curtail_frac), 3)
- if (offline_frac is not None and true_curtail_frac is not None) else None)
- of_s = f'{offline_frac:.0%}' if offline_frac is not None else 'NA'
- tc_s = f'{true_curtail_frac:.1%}' if true_curtail_frac is not None else 'NA'
- result = {'n_bucket': n_bucket,
- 'offline_frac': round(offline_frac, 3) if offline_frac is not None else None,
- 'true_curtail_frac': round(true_curtail_frac, 3) if true_curtail_frac is not None else None,
- 'ambiguous_frac': ambiguous_frac, 'max_zero_block_days': round(max_zero_block_days, 1),
- 'worst_zero_block_turbine': worst_turb, 'n_turbines_long_offline': n_long,
- 'turbines_long_offline': turbines_long_offline}
- # 决定性②: 连续数月零块 = 非电网 (sync 也救不了) → 投产前离线/长停
- if max_zero_block_days > zero_block_days_thresh:
- result.update({
- 'verdict': '疑投产前离线/长停(非弃风)',
- 'cause': 'INSUFFICIENT_投产vs停役vs弃风(须业主并网台账+停机工单)',
- 'evidence': (f'⚠ 限电候选桶 n={n_bucket}: 离线占比 {of_s}(gen<{gen_offline_rpm:.0f}rpm 静止) vs 真节流'
- f'签名(gen≥{gen_turn_rpm:.0f}转但P~0) {tc_s}; **最长连续零块 {max_zero_block_days:.0f}天@'
- f'{worst_turb}** (>{zero_block_days_thresh:.0f}天闸; {n_long}台超闸={turbines_long_offline[:6]}'
- f'{"..." if n_long > 6 else ""}). 调度造不出连续数月零 → 投产前离线/长停, 成因INSUFFICIENT, '
- f'撤弃风¥ (须业主并网台账定投产vs停役vs弃风)。')})
- return result
- # ①: 离线占比高但无长块 → 停机/离线主导(非节流弃风); 须辨停机型限电(同步性)vs故障 → 禁节流¥, 不擅判投产
- if offline_frac is not None and offline_frac > offline_frac_thresh:
- result.update({
- 'verdict': '离线/停机主导·非节流弃风',
- 'cause': 'INSUFFICIENT_停机型限电vs故障停机vs离线(须同步性§4.3+业主台账)',
- 'evidence': (f'⚠ 限电候选桶 n={n_bucket}: 离线占比 {of_s} > 闸{offline_frac_thresh:.0%}(gen静止=彻底停机) '
- f'vs 真节流签名(gen转P~0) {tc_s}; 最长连续零块 {max_zero_block_days:.0f}天(≤闸, 非投产前离线)。'
- f'发电机静止≠节流 → 非节流弃风, 须辨停机型电网限电(同步性)vs故障停机, 禁用节流模型套弃风¥。')})
- return result
- # 真节流签名: gen 转但 P~0, 无长块, 离线占比低 → 可按节流限电候选走同步性三证
- result.update({
- 'verdict': '真节流限电候选', 'cause': None,
- 'evidence': (f'限电候选桶 n={n_bucket}: 离线占比 {of_s} ≤ 闸{offline_frac_thresh:.0%} 且真节流签名(gen转P~0) '
- f'{tc_s}; 最长连续零块 {max_zero_block_days:.0f}天 ≤ 闸{zero_block_days_thresh:.0f}天 → '
- f'非投产前离线, 可按节流限电候选走同步性三证 (curtail_vs_fault_sync)。')})
- return result
- def curtail_vs_fault_sync(df, *, time_col, turbine_col, curtail_col,
- freq_col=None, gen_col=None, month_col=None,
- sync_thresh=0.5, n_total=None, ws_col=None, p_col=None,
- gen_speed_col=None, offline_frac_thresh=0.5, zero_block_days_thresh=30.0,
- max_gap_days=2.0):
- """限电 vs 故障 同步性判别 (SOP §4.3 三证, 源 jtxq_analysis). 只覆盖**停机型**限电。
- 三证: ①同步台数比例(≥半数台同时处于 curtail 态的窗占比) ②电网频率偏移(curtail vs 发电)
- ③季节集中(月度 curtail 率峰/均)。 同步=电网限电 / 散布=per机故障。
- df: 长表, curtail_col 为 [0,1] 处于限电候选态的占比/标志 (>0.5 视为在限电态)。
- gen_speed_col/p_col (可选, 强烈建议): 给定则跑**离线vs节流 + 连续零块守卫** (offline_vs_curtail_split;
- 解 §0.9 限电三义混淆): generator 静止=离线/停机(物理上不可能是节流) vs gen转但P≈0=真节流;
- per台最长连续零块 > zero_block_days_thresh 天 = 调度造不出的连续数月零 → **决定性**判投产前离线/长停,
- 不出弃风; 离线占比高但块短 → 附 offline_caution (禁用节流模型套¥), 不擅改同步裁决。n=1 [暂行] 朗目山。
- ws_col/p_col (可选, 强烈建议): 给定则跑**功率压制守卫** —— 真停机型限电必在同风速段把功率压到
- 显著低于非限电态; 若限电标志在匹配风速 bin 不压功率 = setpoint 标签 artifact (n=2: 唐龙 CURT 阈过高
- + 阜山 pset∈{2000,2050,2080} 均满发) → INSUFFICIENT, 不报散布故障/电网限电。
- 返回 dict: {n_curtail_windows, sync_fraction, median_simul, n_total, verdict, freq_offset_hz,
- season_peak_ratio, corroboration_votes, evidence};
- verdict ∈ {电网同步限电, **电网限电(部分调度)**, per机故障(散布), 无限电候选,
- **INSUFFICIENT(疑部分调度,旁证不足)**, **INSUFFICIENT(限电vs故障不可分)**,
- 疑误派生(限电信号常年on), 限电信号存疑(未压功率)}。
- ★2026-07-26: verdict 从"纯 sync_frac>=0.5 单门"升级为**多证阶梯**。旧版盲区(可靠性卡④注入
- + 如东 P5 真场坐实): 电网只调度 <50% 机组的部分限电被误判 per机故障 -> 去追不存在的故障。
- 新增两档"部分调度"与"INSUFFICIENT", **允许说不知道而非强行二选一**。
- """
- piv = df.pivot_table(index=time_col, columns=turbine_col, values=curtail_col, aggfunc='mean')
- ntot = int(n_total or piv.shape[1])
- in_c = piv > 0.5
- n_simul = in_c.sum(axis=1)
- busy = n_simul[n_simul > 0]
- if len(busy) == 0:
- return {'n_curtail_windows': 0, 'sync_fraction': None, 'verdict': '无限电候选',
- 'n_total': ntot, 'evidence': '无窗处于 curtail 态'}
- sync_frac = float((busy >= ntot / 2.0).mean()) # ≥半数台同时
- median_simul = float(busy.median())
- freq_offset = None
- if freq_col and gen_col and freq_col in df and gen_col in df:
- fq_c = df.loc[df[curtail_col] > 0.5, freq_col].median()
- fq_g = df.loc[df[gen_col] > 0.5, freq_col].median()
- if pd.notna(fq_c) and pd.notna(fq_g):
- freq_offset = float(fq_g - fq_c)
- season_peak = None
- mcol = month_col
- if mcol is None and pd.api.types.is_datetime64_any_dtype(df[time_col]):
- mser = df[time_col].dt.month
- elif mcol and mcol in df:
- mser = df[mcol]
- else:
- mser = None
- if mser is not None:
- mrate = df.assign(_m=mser).groupby('_m')[curtail_col].mean()
- if len(mrate) and mrate.mean() > 0:
- season_peak = float(mrate.max() / mrate.mean()) # 峰/均, >2 = 季节集中
- # 常年-on 守卫 (n=唐龙一期 tl_clean CURT=2100>铭牌设定~1944 → 88%正常次额定运行被误标限电):
- # 真电网限电是偶发态; curtail 占 >50% 全时几乎必是误派生(派生阈值高于铭牌设定/把正常次额定当限电)
- # → 不判电网限电, 标疑误派生 (corr(P,ws) 高也是 tell, 但此处仅用占比, 简单鲁棒)。
- curtail_duty = float((pd.to_numeric(df[curtail_col], errors='coerce') > 0.5).mean())
- if curtail_duty > 0.5:
- ev = (f'⚠ 限电信号占 {curtail_duty:.0%} 全时 = 不可能是真电网限电(应偶发态); 疑 curtail 派生阈值高于'
- f'铭牌功率设定(把正常次额定运行误标限电); 同步窗占比 {sync_frac:.1%} 无意义。须核 curtail 派生口径。')
- return {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
- 'median_simul': median_simul, 'n_total': ntot, 'verdict': '疑误派生(限电信号常年on)',
- 'curtail_duty': round(curtail_duty, 3), 'freq_offset_hz': freq_offset,
- 'season_peak_ratio': season_peak, 'evidence': ev}
- # 离线 vs 节流 + 连续零块守卫 (n=1 langmushan; 解 §0.9 限电三义混淆):
- # 判"限电/弃风"前必分 generator 静止(离线/停机, 物理上不可能是节流) vs gen转但P~0(真节流)。
- # 决定性②(per台连续数月零块) → 覆盖同步/散布, 判投产前离线/长停; ①(离线占比高但短块) → 附 caution 禁节流¥, 不擅改同步裁决。
- _offline_caution = None
- if gen_speed_col and p_col and gen_speed_col in df and p_col in df:
- split = offline_vs_curtail_split(
- df, time_col=time_col, turbine_col=turbine_col, p_col=p_col, gen_speed_col=gen_speed_col,
- curtail_col=curtail_col, ws_col=ws_col, offline_frac_thresh=offline_frac_thresh,
- zero_block_days_thresh=zero_block_days_thresh, max_gap_days=max_gap_days)
- if split['verdict'] == '疑投产前离线/长停(非弃风)':
- return {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
- 'median_simul': median_simul, 'n_total': ntot, 'verdict': '疑投产前离线/长停(非弃风)',
- 'curtail_duty': round(curtail_duty, 3), 'offline_frac': split['offline_frac'],
- 'true_curtail_frac': split['true_curtail_frac'], 'max_zero_block_days': split['max_zero_block_days'],
- 'n_turbines_long_offline': split['n_turbines_long_offline'], 'cause': split['cause'],
- 'freq_offset_hz': freq_offset, 'season_peak_ratio': season_peak, 'evidence': split['evidence']}
- if split['verdict'] == '离线/停机主导·非节流弃风':
- _offline_caution = (f"⚠离线占比 {split['offline_frac']:.0%} 高(gen静止=停机非节流); 同步裁决维持但弃风损失"
- f"须先剥离离线台+业主台账, 禁用节流模型套¥ ({split['cause']})")
- # 功率压制守卫 (n=2: 唐龙 CURT 阈过高 + 阜山 pset 标签{2000/2050/2080}均满发):
- # 真停机型限电必在"同风速"下把功率压到显著低于非限电态; 若限电标志在匹配中风速段不压功率
- # = setpoint 标签 artifact (pset 多个正常值都满发), 非真限电 → INSUFFICIENT, 不报"散布故障/电网限电"。
- if ws_col and p_col and ws_col in df and p_col in df:
- # 只在**发电态**比 (停机台 P=0∧pset=0→curt=True 会污染 curt 中位, 假装"压功率"→守卫漏判)
- dch = df[df[gen_col] > 0.5] if (gen_col and gen_col in df) else df
- cur = pd.to_numeric(dch[curtail_col], errors='coerce') > 0.5
- ws = pd.to_numeric(dch[ws_col], errors='coerce')
- pw = pd.to_numeric(dch[p_col], errors='coerce')
- caps = []
- for lo in range(6, 12): # 中风段: 功率随风变, 真限电会显著压低
- m = (ws >= lo) & (ws < lo + 1)
- pc = pw[m & cur].median(); pf = pw[m & ~cur].median()
- if (m & cur).sum() >= 100 and (m & ~cur).sum() >= 100 and pd.notna(pc) and pd.notna(pf):
- caps.append(float(pf - pc)) # >0 = 限电态被压低
- if caps:
- cap_kw = float(np.median(caps))
- if cap_kw <= 40: # kW; 中风段真停机限电应压数百 kW
- ev = (f'⚠ 限电标志在匹配中风速段未压低功率(中位差 {cap_kw:+.0f}kW ≤ 阈40) = setpoint 标签 artifact '
- f'(非真停机限电; pset 多个正常值都满发); 同步窗占比 {sync_frac:.1%} 无意义。须核 curtail 派生口径或用真限电信号。')
- return {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
- 'median_simul': median_simul, 'n_total': ntot, 'verdict': '限电信号存疑(未压功率)',
- 'curtail_duty': round(curtail_duty, 3), 'power_cap_kw': round(cap_kw, 1),
- 'freq_offset_hz': freq_offset, 'season_peak_ratio': season_peak, 'evidence': ev}
- # ---- verdict: 多证阶梯 (2026-07-26 升级; 旧版是纯 sync_frac>=0.5 单门) ----
- # 旧版盲区 (可靠性卡④ 注入实测 + 如东 P5 真场坐实): 电网只调度 <50% 机组的**部分限电**
- # (现实极常见) 被误判 'per机故障(散布)' -> 去追不存在的故障 / 把限电欠发当机组缺陷。
- # 注入: grid_frac 0.2/0.3/0.4 -> 100% 误判; 如东真场 sync 25.3% 却确有台账证明的真限电。
- # 修法 = 多证融合 + **允许输出"证据不足"而非强行二选一**:
- # sync 成立 -> 直接判 (保原行为, 不翻 jtxq 59% 等已有锚点)
- # sync 不成立 -> 看 median_simul(绝对同时台数) / season_peak(季节集中) / freq_offset(过频) 投票
- # 阈值依据: 可靠性卡④ 注入表 —— 部分电网调度 median_simul 4~8 台, 真散布故障仅 ~2 台;
- # 3 台是**模糊带** (如东实测 3.0), 故 simul 单独不足以判, 须旁证。
- votes, vote_ev = 0, []
- if median_simul >= 4:
- votes += 1; vote_ev.append(f'同时台数 {median_simul:.0f}≥4(部分电网调度量级, 真散布故障≈2)')
- if season_peak is not None and season_peak > 3:
- votes += 2; vote_ev.append(f'季节峰/均 {season_peak:.1f}×>3(强集中)')
- elif season_peak is not None and season_peak > 2:
- votes += 1; vote_ev.append(f'季节峰/均 {season_peak:.1f}×>2')
- if freq_offset is not None and abs(freq_offset) >= 0.02:
- votes += 1; vote_ev.append(f'频率偏移 {freq_offset:+.3f}Hz(电网态)')
- if sync_frac >= sync_thresh:
- verdict = '电网同步限电'
- elif votes >= 2 and median_simul >= 3:
- verdict = '电网限电(部分调度)'
- elif votes >= 1 and median_simul >= 3:
- verdict = 'INSUFFICIENT(疑部分调度,旁证不足)'
- elif votes == 0 and median_simul <= 2:
- verdict = 'per机故障(散布)'
- else:
- verdict = 'INSUFFICIENT(限电vs故障不可分)'
- ev = (f'同步窗占比 {sync_frac:.1%} (中位同时 {median_simul:.0f}/{ntot}台); '
- + (f'频率偏移 {freq_offset:+.3f}Hz; ' if freq_offset is not None else '')
- + (f'季节峰/均 {season_peak:.1f}×; ' if season_peak is not None else '')
- + (f'旁证票 {votes} [{"; ".join(vote_ev)}]' if vote_ev else f'旁证票 {votes}')
- + (f' || {_offline_caution}' if _offline_caution else ''))
- out = {'n_curtail_windows': int(len(busy)), 'sync_fraction': sync_frac,
- 'median_simul': median_simul, 'n_total': ntot, 'verdict': verdict, 'curtail_duty': round(curtail_duty, 3),
- 'freq_offset_hz': freq_offset, 'season_peak_ratio': season_peak,
- 'corroboration_votes': votes, 'evidence': ev}
- if _offline_caution:
- out['offline_caution'] = _offline_caution
- return out
- # ---------------------------------------------------------------- §4.4 偏航列可用性
- def yaw_column_usability(df, *, turbine_col, yaw_err_col, spread_thresh=30.0,
- max_abs_thresh=45.0, min_per_turbine=200, min_within_iqr=1.0):
- """对风角/偏航误差列可用性 (SOP §4.4①, 源 cq 昌平坳, n=2). 防 风向−机舱位置 的 per机任意零点 artifact。
- 真 native 对风角: 各台控制到≈0, per台中位聚集近0 (标定 offset ±数°); 派生 wind_dir−nacelle 编码器
- 零点任意 → per机中位散布大 (昌平坳 66° 乱跳)。判: ①inter-turbine 中位极差 > spread_thresh = 任意
- 零点 artifact ②任一台 |中位| > max_abs_thresh = 可疑 → usable=False → 偏航判据走【INSUFFICIENT】。
- 不做角度 wrap (wrap 会掩盖 0-360 误用), 保守偏 INSUFFICIENT。
- 返回 {usable, reason, n_turbines, inter_turbine_spread, max_abs_median, per_turbine_median}。
- """
- d = df.dropna(subset=[turbine_col, yaw_err_col])
- vals = pd.to_numeric(d[yaw_err_col], errors='coerce')
- # 退化列守卫 (n=怀朔 内蒙): 真 vane 对风角是连续量, 几乎不会精确=0; 大比例精确0 = 缺测填0/sentinel,
- # per机中位被零膨胀拉成 0 → 误判"完美对风/定论". 此类列无真对风信号 → INSUFFICIENT 非"对中健康"。
- frac_zero = float((vals == 0).mean()) if vals.notna().any() else 1.0
- if frac_zero > 0.5:
- nt = int((vals.groupby(d[turbine_col]).count() >= min_per_turbine).sum())
- return {'usable': None,
- 'reason': f'退化列: {frac_zero:.0%} 值精确=0 (零膨胀/sentinel, 非真对风角); 需 native vane 列或现场独立量',
- 'n_turbines': nt, 'inter_turbine_spread': None, 'max_abs_median': None,
- 'per_turbine_median': {}, 'frac_zero': round(frac_zero, 3)}
- med = vals.groupby(d[turbine_col]).median()
- cnt = vals.groupby(d[turbine_col]).count()
- med = med[(cnt >= min_per_turbine) & med.notna()]
- if len(med) < 2:
- return {'usable': None, 'reason': f'有效台<2 (per台需≥{min_per_turbine}样本)',
- 'n_turbines': int(len(med)), 'inter_turbine_spread': None,
- 'max_abs_median': None, 'per_turbine_median': {}}
- # 台内离散度门 (2026-07-16 jtxq实证): 真 native vane 对风角台内 IQR~10°(连续量); 零填/塌缩列台内 IQR≈0.
- # frac_zero 门查 raw-row 精确0占比, spread 门查任意零点(大极差) —— 但"per台中位塌缩≈0 + 极小极差 + IQR≈0"
- # 的退化列(jtxq 10min 层缺对风角度→伪列: 28/30台中位≈0.00 spread0.28°)两门都溜过 → 被误判"完美对风定论".
- # 靠台内离散拦: per台 IQR 中位 < min_within_iqr = 无真对风散布信号 → INSUFFICIENT(非对中健康).
- grp = vals.groupby(d[turbine_col])
- iqr = (grp.quantile(0.75) - grp.quantile(0.25)).reindex(med.index)
- med_iqr = float(iqr.median())
- if med_iqr < min_within_iqr:
- return {'usable': None,
- 'reason': f'退化列(台内离散): per台IQR中位 {med_iqr:.2f}°<{min_within_iqr}° = 无真对风散布(零填/塌缩伪列, 常见=输入层缺对风角度), 非"完美对风"; 需 native vane 列或现场独立量',
- 'n_turbines': int(len(med)), 'inter_turbine_spread': None, 'max_abs_median': None,
- 'per_turbine_median': {}, 'median_within_iqr': round(med_iqr, 3)}
- spread = float(med.max() - med.min())
- max_abs = float(med.abs().max())
- arbitrary, extreme = spread > spread_thresh, max_abs > max_abs_thresh
- usable = not (arbitrary or extreme)
- reason = (f'per机中位极差 {spread:.1f}°>{spread_thresh:.0f}° = 任意零点 artifact (非真对风角)' if arbitrary
- else (f'某台中位 |{max_abs:.1f}°|>{max_abs_thresh:.0f}° 可疑' if extreme
- else f'per机中位聚集 (极差 {spread:.1f}°), 列可用'))
- return {'usable': usable, 'reason': reason, 'n_turbines': int(len(med)),
- 'inter_turbine_spread': round(spread, 2), 'max_abs_median': round(max_abs, 2),
- 'median_within_iqr': round(med_iqr, 2),
- 'per_turbine_median': {str(k): round(float(v), 2) for k, v in med.items()}}
- # ---------------------------------------------------------------- §4.5 测风塔可用性
- def _ang_diff(a, b):
- d = np.abs((a - b + 180.0) % 360.0 - 180.0)
- return d
- def mast_usability(mast_long, *, time_col, height_col, ws_col, wd_col=None,
- heights=None, dir_max_deg=30.0, ratio_band=0.15):
- """测风塔可用性预检 (SOP §4.5, 源 gbs_mast/jtxq_mast)。schemas mast_usability.json 键。
- ①切变物理性: 各层中位风速单调增高 ∧ α∈[0.05,0.45] (α=polyfit(ln h, ln v))。
- ②双高度风向一致: 顶/底层风向角差中位 < dir_max_deg。
- ③月 ratio 稳定: (顶/底层风速比) 月度 max-min < ratio_band。
- 任一硬 FAIL → verdict=FAIL (唐珍 80m<10m / 风向44.6° / 月ratio0.74 = 塔坏)。
- 返回 {shear_physical, shear_alpha, monotonic, direction_consistency, direction_diff_deg,
- monthly_ratio, monthly_ratio_spread, verdict, layer_medians}。
- """
- piv = mast_long.pivot_table(index=time_col, columns=height_col, values=ws_col, aggfunc='mean')
- hs = list(heights) if heights else sorted(h for h in piv.columns
- if isinstance(h, (int, float, np.integer, np.floating)))
- v = piv[hs].median().values.astype(float)
- mono = bool(np.all(np.diff(v) > 0))
- with np.errstate(all='ignore'):
- alpha = float(np.polyfit(np.log(np.asarray(hs, float)), np.log(v), 1)[0]) if (v > 0).all() else np.nan
- shear_ok = bool(mono and np.isfinite(alpha) and 0.05 < alpha < 0.45)
- dir_ok, dir_diff = None, None
- if wd_col:
- wpiv = mast_long.pivot_table(index=time_col, columns=height_col, values=wd_col, aggfunc='mean')
- if hs[0] in wpiv and hs[-1] in wpiv:
- dd = _ang_diff(wpiv[hs[-1]], wpiv[hs[0]]).median()
- if pd.notna(dd):
- dir_diff = float(dd)
- dir_ok = bool(dir_diff < dir_max_deg)
- ratio_ok, ratio_spread = None, None
- if pd.api.types.is_datetime64_any_dtype(piv.index):
- r = (piv[hs[-1]] / piv[hs[0]]).replace([np.inf, -np.inf], np.nan)
- mr = r.groupby(piv.index.month).median().dropna()
- if len(mr) >= 2:
- ratio_spread = float(mr.max() - mr.min())
- ratio_ok = bool(ratio_spread < ratio_band)
- verdict = 'PASS' if (shear_ok and dir_ok is not False and ratio_ok is not False) else 'FAIL'
- return {'shear_physical': shear_ok, 'shear_alpha': alpha, 'monotonic': mono,
- 'direction_consistency': dir_ok, 'direction_diff_deg': dir_diff,
- 'monthly_ratio': ratio_ok, 'monthly_ratio_spread': ratio_spread,
- 'verdict': verdict, 'layer_medians': {h: round(float(x), 2) for h, x in zip(hs, v)}}
- # ---------------------------------------------------------------- §4.5 NTF + 空间代表性
- def ntf_correction(nacelle_ws, freestream_ws, *, turbine=None, sector=None,
- reads_low_thresh=0.97, rep_band=(0.9, 1.1)):
- """机舱风/自由来流 NTF + 空间代表性 (SOP §4.5, 源 jtxq_mast)。
- NTF = median(机舱ws / 自由来流ws); <reads_low_thresh = 机舱读低 (解释绝对达成虚高)。
- 给 turbine → per机 NTF; 各机比 ∉ rep_band 的台占多 → 空间代表性差, 场级绝对达成只报区间。
- 给 sector → 分扇区 NTF, 极差 > 0.1 = 方向加权混合非机型 NTF (jtxq 0.794 戒)。
- 返回 {ntf_median, reads_low, n, per_turbine_range, frac_in_rep_band, per_sector_range,
- spatial_representative, caveat}。
- """
- nac = np.asarray(nacelle_ws, float)
- free = np.asarray(freestream_ws, float)
- m = np.isfinite(nac) & np.isfinite(free) & (free > 0)
- ratio = nac[m] / free[m]
- ntf = float(np.median(ratio)) if ratio.size else np.nan
- per_t_range = frac_in = None
- if turbine is not None:
- tb = pd.Series(np.asarray(turbine)[m], name='t')
- pt = pd.Series(ratio).groupby(tb.values).median()
- if len(pt):
- per_t_range = float(pt.max() - pt.min())
- frac_in = float(((pt >= rep_band[0]) & (pt <= rep_band[1])).mean())
- per_s_range = None
- if sector is not None:
- sb = pd.Series(np.asarray(sector)[m])
- ps = pd.Series(ratio).groupby(sb.values).median()
- if len(ps) >= 2:
- per_s_range = float(ps.max() - ps.min())
- # 空间代表性: 各机比多在 [0.9,1.1] 且扇区极差≤0.1
- rep = None
- if frac_in is not None:
- rep = bool(frac_in >= 0.8 and (per_s_range is None or per_s_range <= 0.1))
- return {'ntf_median': ntf, 'reads_low': bool(np.isfinite(ntf) and ntf < reads_low_thresh),
- 'n': int(ratio.size), 'per_turbine_range': per_t_range, 'frac_in_rep_band': frac_in,
- 'per_sector_range': per_s_range, 'spatial_representative': rep,
- 'caveat': '单 mast 空间代表性 caveat; 绝对达成率非空间代表则只报区间 (SOP §4.5)'}
- # ---------------------------------------------------------------- §4.5 机群相对功率一致性 (控扇区)
- def fleet_sector_devz(df, *, turbine_col, power_col, ws_ref_col, sector_col,
- bin_ms=0.5, min_cell=10, min_turb=8, resp_kw=(75.0, 1425.0), spread_floor=8.0):
- """机群相对功率一致性 — 控风向扇区 (SOP §4.5; 源 jtxq勘误/gbs/wjw 实证 n=3).
- 单 mast 方向代表性 artifact: 仅按 ws-bin 池化比机群中位 → 单 mast 在某扇区系统偏估来流, 该扇区全场假高/假亏;
- 当某台能量权重落该偏差扇区, 被误读成 per 机欠发 (jtxq DJ23 池化 devz-2.07 假【预警】, 控扇区后 -0.67 落回正常).
- 修: 在 (ws_ref bin × 风向扇区) 内比机群**中位数**, 方向偏差扇区内共模消掉. devz=(台中位−群中位)/(1.4826·MAD).
- ⚠ 仅消**方向**偏差; 远 mast 的**距离/选址梯度**(wjw 57D塔, sc_devz~资源proxy R²0.47)须额外资源proxy去混.
- ⚠ ws_ref 须独立量(测风塔自由来流), 非机舱风(self_ref); 密度归一后传入. 结论上限【预警】(机组级禁定论).
- ⚠⚠ sc_devz 灵敏度 (_jtxq/_wjw_resid_sensitivity 注入实证, 2026-06-18): median-over-cells 对**均匀**欠发盲 —
- 注入 12% 均匀亏损仅 devz≈-0.66 (低风场 per-cell 大 MAD 稀释: 多数低风 cell 的 d% 只几 kW < spread_floor), 检出地板 >12%/None。
- 能量加权% 敏感但健康本底散布 ±15-37% (单塔空间代表残差) 淹没之; 资源proxy去混残差 (王家湾 resid_z, R²高场) 仅收紧基线,
- 不放大均匀小亏信号 → 仅捕**粗大多扇区**欠发 (W006 级 ~30%, resid_z-3.4), 对 ≤~12% 均匀亏不恢复灵敏。
- → 本函数 = **粗大欠发检测器**, 非 subtle 检测器; "无 sc_devz 预警" = "无粗大偏离"(**大盲区**),
- 须**逐场注入校准检出地板** + magnitude(sc_dev_kw) + 跨场/历史 prior, **非 sc_devz 单阈当筛查**。
- 返回 {per_turbine: {tid: {sc_devz, sc_dev_kw, n_cells, n_sec, persist_below, persist_above}},
- n_qualified_cells, caveat}.
- """
- def _mad(s):
- s = np.asarray(s, float)
- return float(1.4826 * np.median(np.abs(s - np.median(s))))
- a = df[[ws_ref_col, sector_col, turbine_col, power_col]].dropna().copy()
- a.columns = ['ws', 'sec', 'tid', 'P']
- a['wb'] = np.round(a['ws'] / bin_ms) * bin_ms
- g = a.groupby(['wb', 'sec', 'tid'], as_index=False).agg(pm=('P', 'median'), nn=('P', 'size'))
- g = g[g['nn'] >= min_cell]
- b = g.groupby(['wb', 'sec']).agg(fleet=('pm', 'median'), nt=('pm', 'size'), sp=('pm', _mad)).reset_index()
- b = b[(b['nt'] >= min_turb) & (b['fleet'] >= resp_kw[0]) & (b['fleet'] <= resp_kw[1])]
- b['sp'] = b['sp'].clip(lower=spread_floor)
- g = g.merge(b[['wb', 'sec', 'fleet', 'sp']], on=['wb', 'sec'], how='inner')
- g['dev'] = g['pm'] - g['fleet']; g['devz'] = g['dev'] / g['sp']
- out = {}
- for tid, d in g.groupby('tid'):
- out[tid] = {'sc_devz': round(float(d['devz'].median()), 2),
- 'sc_dev_kw': round(float(np.average(d['dev'], weights=d['nn'])), 1),
- 'n_cells': int(len(d)), 'n_sec': int(d['sec'].nunique()),
- 'persist_below': round(float((d['devz'] < -1).mean()), 2),
- 'persist_above': round(float((d['devz'] > 1).mean()), 2)}
- # ⭐检测下限(解析, 源 gbs): 均匀欠发达 devz 门所需 % = k·(cell_MAD/cell_P 中位). 抽自各farm手搓→canonical自动带.
- cell_mad = float(b['sp'].median()) if len(b) else float('nan')
- cell_p = float(b['fleet'].median()) if len(b) else float('nan')
- warn_pct = round(2 * cell_mad / cell_p * 100, 1) if cell_p else None # devz=-2(预警)所需均匀欠发%
- ref_pct = round(cell_mad / cell_p * 100, 1) if cell_p else None # devz=-1(参考)
- return {'per_turbine': out, 'n_qualified_cells': int(len(b)),
- 'detection_floor_pct': {'warn_devz2': warn_pct, 'ref_devz1': ref_pct, 'cell_mad_kw': round(cell_mad, 1),
- 'note': '均匀欠发须≥此%才达对应 devz 门(=2×/1× cell_MAD/cell_P 中位); '
- '<此% 此法盲 → "无预警"=检测下限内无可检出 ≠ 确认健康(细缺陷走振动/温度轴)'},
- 'caveat': '控扇区消方向偏差; 距离梯度须额外资源proxy去混; ws_ref须独立量(非机舱self_ref); 上限【预警】; '
- '⚠sc_devz对均匀欠发盲=粗大检测器非subtle(见 detection_floor_pct, 通常数十%): 无预警=无粗大偏离(大盲区), 须 magnitude+prior'}
- # ---------------------------------------------------------------- §4.6 温度 NBM 残差
- def ewma_exceed(resid, lam=0.2):
- """残差 EWMA 超 3σ_ewma 时间占比 (持续性, 源 litloop_r4)。"""
- r = pd.Series(resid).dropna()
- if len(r) < 200:
- return np.nan
- sig = r.std()
- e = r.ewm(alpha=lam).mean()
- sig_e = sig * np.sqrt(lam / (2 - lam))
- return float((np.abs(e) > 3 * sig_e).mean())
- # ---------------------------------------------------------------- §1.1③/§4.6c 参考通道 per-台存活守卫
- def reference_channel_liveness(s, *, min_nunique=10, max_stuck_frac=0.5, min_std=1e-3,
- valid_range=None, min_n=500, round_decimals=1):
- """参考/被排名通道 **per-台** 存活校验 (SOP §1.1③ / §4.6c 列 liveness 先验的机器实现)。
- 用途: dT/matched-load/NBM 类判据拿某通道 (舱外温度/环温/塔底 做温差或回归基线; 或被排名的部件温本身)
- 前, 必先确认该通道在**这台**机上活着。死/冻结的参考会把"参考坏"伪装成"部件热" —— 06淄川
- F0810001022 戒: 舱外温度 2024.08 起冻在 -0.1°C (整窗 97.8% 行恒值 + 偶发 ±300°C 野值), 致
- dT=变流器冷却水−舱外 假登顶 rank1/24, 而原始变流器冷却水实为 fleet 中位 (41°C, rank12/24, 跨载荷平),
- 被误报"变流器冷却水异常 onset 2024.09" —— 见 memory sensor_reference_liveness_trap。
- ⚠ 强于 §4.6c 旧"铁律①"(nuniq>1 ∧ 有方差): 半程冻结+野值的传感器在**整窗**上 nuniq=297、std=6.1
- (皆非零) → 旧规则放行; **stuck_frac (单一众数值占比) 才是决定性判据** (本例 0.978 vs 健康全场 ≤0.017)。
- nunique/std 仅作 corroborate (野值会把二者顶高 → 单看必漏)。
- 判据 (任一触发 = 非存活 dead):
- ① stuck_frac > max_stuck_frac —— 卡在单一众数值 (冻结/坐死; **主判, 抗野值**)
- ② nunique < min_nunique —— 近常数列 (corroborate)
- ③ std < min_std —— 无方差死列 (corroborate)
- valid_range 给定时先把物理域外读数 (野值/哨兵) 置 NaN 再统计 (野值不算"活", 但也不进众数分母)。
- 入: s (单台某通道 Series); 出 dict:
- {live(True/False/None), reason, stuck_frac, stuck_value, nunique, std, n, n_valid}。
- live=None = 有效样本不足 (n_valid<min_n) → 调用方按 INSUFFICIENT 处置 (既不当存活也不当死)。
- """
- v = pd.to_numeric(s, errors='coerce')
- if valid_range is not None:
- lo, hi = valid_range
- v = v.where((v >= lo) & (v <= hi))
- v = v.dropna()
- n_valid = int(v.size)
- base = {'stuck_frac': None, 'stuck_value': None, 'nunique': None, 'std': None,
- 'n': int(getattr(s, 'size', len(s))), 'n_valid': n_valid}
- if n_valid < min_n:
- return {'live': None, 'reason': f'有效样本不足(n_valid={n_valid}<{min_n})', **base}
- r = v.round(round_decimals)
- vc = r.value_counts()
- stuck_frac = float(vc.iloc[0] / n_valid)
- stuck_value = float(vc.index[0])
- nunique = int(r.nunique())
- std = float(v.std())
- fails = []
- if stuck_frac > max_stuck_frac:
- fails.append(f'卡值{stuck_value:g}占比{stuck_frac:.1%}>{max_stuck_frac:.0%}(冻结)')
- if nunique < min_nunique:
- fails.append(f'nunique={nunique}<{min_nunique}(近常数)')
- if std < min_std:
- fails.append(f'std={std:.3g}<{min_std:g}(无方差)')
- live = not fails
- return {'live': live, 'reason': ('alive' if live else '; '.join(fails)),
- 'stuck_frac': round(stuck_frac, 3), 'stuck_value': round(stuck_value, 3),
- 'nunique': nunique, 'std': round(std, 4), 'n': base['n'], 'n_valid': n_valid}
- def _theil_sen(y):
- """Theil-Sen 斜率 (中位 pairwise, 抗季节抖/单点尖峰; x=等距月序)。"""
- n = len(y)
- sl = [(y[j] - y[i]) / (j - i) for i in range(n) for j in range(i + 1, n)]
- return float(np.median(sl)) if sl else 0.0
- def residual_trend(resid, time, *, min_month_n=500, min_months=5, recent_months=6,
- slope_thresh=1.5, level_thresh=5.0):
- """per-台残差时间轨迹 → 发展性检测 (SOP §4.6; 补"年/窗聚合 z 埋没发展性故障", 太阳山 W091 教训).
- 年 resid_mean→z 把"前静默+后爆发"平均掉 → 发展性故障年 z 低被埋 (W091 年 z=2.34 最弱却逼近报警)。
- ★关键: 斜率用**近窗(末 recent_months 月)**非全序列 —— 全序列 Theil-Sen 仍被前平坦期稀释(W091 全序列
- 1.14°/月 <阈, 近6月 ~+4.5 才逮), 是年 z 稀释病搬到月分辨率的翻版。
- 月分箱(剔 n<min_month_n 薄月防端点 artifact, W091 末月 n=112 不算数); developing = 近窗斜率≥slope_thresh
- ∧ 末月残差≥level_thresh ∧ 近半均>早半(排恒偏/季节回落)。与端点-n 陷阱互补相反(一防薄端点当趋势/一防厚聚合埋趋势)。
- resid/time: 该台逐行残差 + 时间。返回 {slope_recent, slope_full, last_resid, n_months, developing, monthly}。
- """
- s = pd.DataFrame({'r': pd.to_numeric(resid, errors='coerce'),
- 't': pd.to_datetime(time, errors='coerce')}).dropna()
- if s.empty:
- return {'slope_recent': None, 'slope_full': None, 'last_resid': None,
- 'n_months': 0, 'developing': False, 'monthly': []}
- s['ym'] = s['t'].dt.to_period('M')
- g = s.groupby('ym')['r'].agg(med='median', n='size').reset_index()
- g = g[g['n'] >= min_month_n].sort_values('ym') # 剔薄月(端点防护)
- monthly = [(str(r.ym), round(float(r.med), 2), int(r.n)) for r in g.itertuples(index=False)]
- if len(g) < min_months:
- return {'slope_recent': None, 'slope_full': None,
- 'last_resid': (monthly[-1][1] if monthly else None),
- 'n_months': len(g), 'developing': False, 'monthly': monthly}
- y = g['med'].to_numpy(float)
- recent = y[-recent_months:]
- slope_full = _theil_sen(y)
- slope_recent = _theil_sen(recent) if len(y) >= 3 else slope_full # ★近窗斜率
- last_resid = float(y[-1])
- half = len(y) // 2
- recent_gt_early = float(np.mean(y[half:])) > float(np.mean(y[:half])) + level_thresh / 2
- # ★末月须近近窗峰值: 真持续发展=峰在末端(W091 末月=峰); 中段尖峰后回落=峰在中段(平阴A11 末月0.59×峰)剔除
- recent_max = float(np.max(recent))
- near_peak = last_resid >= 0.8 * recent_max if recent_max > 0 else True
- developing = bool(slope_recent >= slope_thresh and last_resid >= level_thresh
- and recent_gt_early and near_peak)
- return {'slope_recent': round(slope_recent, 2), 'slope_full': round(slope_full, 2),
- 'last_resid': round(last_resid, 2), 'recent_max': round(recent_max, 2), 'n_months': len(g),
- 'developing': developing, 'monthly': monthly}
- def nbm_residual(df, *, comp_col, p_col, rotor_col=None, turbine_col, time_col,
- amb_col=None, gen_mask=None, z_thresh=2.0, min_per_turbine=1000,
- amb_fallback_cols=None, amb_valid_range=(-40.0, 55.0),
- amb_neighbor_median=False, amb_neighbor_dev_max=2.0,
- trend_gate=True, trend_slope_thresh=1.5, trend_level_thresh=5.0,
- cold_soak_ref_col=None, cold_soak_rotor_col=None,
- common_fit_window=None, common_h0_window=None, common_test_start=None,
- common_freq='D'):
- """温度 NBM 残差第二道 (SOP §4.6, 源 litloop_r4_temp_nbm)。
- 全场 pooled OLS `comp ~ 1 + P [+ rotor] + Tmed [+ amb]` (Tmed=同时刻全场该部件中位温, 吸收季节/环温共模),
- per 台残差均值 → fleet MAD-z; z≥z_thresh 升候选 (绝对温度需另判, 防 C 系基线低污染)。
- rotor_col=None: 无转速场 (如 max/min/avg 结构) 用 P+Tmed (P 已主导载荷, 略弱但可用)。
- gen_mask: 可选布尔 Series (发电态过滤, 如 f_gen>0.8)。
- ⚠ 参考通道存活守卫 (§1.1③/§4.6c, 2026-06-28 加, 06淄川 F0810001022 戒): amb_col 做回归特征前
- **逐台**经 reference_channel_liveness 验活。某台 amb 冻结/坐死 (如舱外温度卡 -0.1°C) → 先试
- amb_fallback_cols (舱内/塔底环境温度) 顶替; 无活备选则该台**整体剔出拟合** (冻结参考不可建模 + 污染
- pooled β) 并入 ref_dead = INSUFFICIENT (而非凭坏参考出假"偏热"候选)。amb_valid_range 先剔物理域外野值。
- amb_neighbor_median (§4.6d, 2026-07-14 海装/神木双实证 + 注入验证): True → 环温回归量弃各台自身读数,
- 统一用**同时刻健康邻机 amb 中位** (健康池 = liveness live ∧ |台amb−场中位|中位 ≤ amb_neighbor_dev_max)。
- 动机: 各台自身 amb 受安装位/机舱排热污染不一 (海装 19/32 台 |偏|>2℃, 明阳 8/33) → 逐台 amb 引入台间
- 伪差 (amb 虚高压残差把真热台藏住, 海装 A02; amb 全死台被 INSUFFICIENT 而实为最热, 海装 A19)。统一场参考
- 后台间残差严格可比, amb 死台也可建模 (不再剔出)。健康池 <3 台 → 响亮退回逐台模式 (note 警告)。
- 局限① (2026-07-14 双模型审 Gemini): 无法区分设备故障 vs 持续局地微气象 — 升定论必现场量。
- 局限② (>半数共模盲区, DP test 逮): 池准入以场中位为锚 → **>50% 台同向偏置时中位随污染走**,
- 偏置台 dev≈0 反入池、干净台反被剔 (与 fleet 相对法同款天花板, 不另设检测) — 环温这种场级气象量
- 的>半数同向硬件偏置在物理上罕见 (安装位差异是台间随机非共模), 但知边界。
- ★★ **批次盲 (2026-09-07 实验 K 接线)**: 本判据的 z 是 **fleet 相对**量 —— per 台残差均值减 fleet 中位后
- 取 MAD-z。全场同步平移时 z **逐值不变** (如东 38 台×4 通道实测 max|Δz|=0.00e+00) ⇒ **批次退化必然全绿**。
- 故返回值**恒含 `batch_blind`**: `flagged==[]` 只能读作"无台显著偏离机群", **不可读作"机群健康"**。
- 补救 = 共模图: 传 common_fit_window/common_h0_window/common_test_start 三窗 → 对 `_Tmed`(全场中位部件温)
- 扣除 P/环温后的序列跑时序 SPRT (共用 `_grand_chart`, 与 `fleet_two_component_split` 同一判据), 结果入
- `common`, 并把 batch_blind.common_chart_ran 置 True。**不传三窗 = 共模轴未测**, 此时 batch_blind 会明说。
- ⚠ common_h0_window **应与测试窗同历月** (季节错配会把共模图点亮); 粒度默认日级 (小时粒度 φ≈0.85 不合格)。
- ⚠ 共模图**不能单独定因** (批次退化 vs 场级参考量变化不可分) ⇒ 单靠它最高 准定论·预警。
- 返回 dict: {per_turbine: [{tid, resid_mean, resid_std, z, ewma_exc, n} ...排序], flagged: [tid...],
- batch_blind: {...}, common: {...}|None,
- n_total, ref_dead:[{tid,reason}...], ref_fallback:[{tid,col}...],
- amb_mode: 'neighbor'|'per_turbine'|None, amb_neighbor_excluded:[{tid,dev|reason}...]}。
- """
- # ★批次盲声明恒建 (含早返回路径): 调用方不必判断 key 是否存在
- batch_blind = {
- 'axis': 'deviation (fleet-relative)',
- 'blind_to': '全场同步平移 (批次退化 / 场级参考量变化)',
- 'why': 'z = per台残差均值 − fleet中位 后取 MAD-z; 全体同移则 z 逐值不变 (实测 max|Δz|=0.00e+00)',
- 'not_a_threshold_issue': True,
- 'common_chart_ran': False,
- 'read_as': '⚠ flagged 为空 ≠ 机群健康, 只等于"无台显著偏离机群"; 共模轴**未测**',
- }
- d = df if gen_mask is None else df[gen_mask]
- rotor = [rotor_col] if rotor_col else []
- # Tmed = 同时刻全场该部件中位温
- d = d.dropna(subset=[comp_col, p_col, turbine_col, time_col] + rotor).copy()
- # §1.1③/§4.6c 参考通道 per-台存活守卫: amb 冻结会把"参考坏"伪装成"部件热" (F0810001022 戒)
- ref_dead, ref_fallback = {}, {}
- amb_feat = None
- amb_mode = None
- neighbor_excluded = []
- if amb_col and amb_col in d.columns:
- amb_feat = '_amb_eff'
- d[amb_feat] = pd.to_numeric(d[amb_col], errors='coerce')
- d.loc[~d[amb_feat].between(*amb_valid_range), amb_feat] = np.nan # 物理域外野值剔
- if amb_neighbor_median:
- # §4.6d 邻机参考: 健康池 = liveness ∧ 偏差谱 |dev|≤dev_max
- fmed = d.groupby(time_col)[amb_feat].median()
- dev = (d[amb_feat] - d[time_col].map(fmed))
- per_dev = dev.groupby(d[turbine_col]).median()
- pool = []
- for tid, g in d.groupby(turbine_col):
- lv = reference_channel_liveness(g[amb_col], valid_range=amb_valid_range, min_n=min_per_turbine)
- dv = per_dev.get(tid)
- if lv['live'] is not True:
- neighbor_excluded.append({'tid': str(tid), 'reason': lv['reason']})
- elif dv is None or pd.isna(dv) or abs(dv) > amb_neighbor_dev_max:
- neighbor_excluded.append({'tid': str(tid), 'dev': (None if dv is None or pd.isna(dv) else round(float(dv), 2))})
- else:
- pool.append(tid)
- if len(pool) >= 3:
- ref = d[d[turbine_col].isin(pool)].groupby(time_col)[amb_feat].median()
- d[amb_feat] = d[time_col].map(ref) # 所有台统一场参考 (amb 死台也可建模)
- amb_mode = 'neighbor'
- else: # 守护失败必响亮: 池太小退回逐台, 不静默
- neighbor_excluded.append({'tid': '_POOL_TOO_SMALL_', 'reason': f'健康amb池仅{len(pool)}台(<3), 退回逐台模式'})
- amb_neighbor_median = False
- if not amb_neighbor_median:
- amb_mode = 'per_turbine'
- fbs = [c for c in (amb_fallback_cols or []) if c in d.columns]
- for tid, g in d.groupby(turbine_col):
- lv = reference_channel_liveness(g[amb_col], valid_range=amb_valid_range, min_n=min_per_turbine)
- if lv['live'] is True:
- continue
- used = next((fb for fb in fbs
- if reference_channel_liveness(g[fb], valid_range=amb_valid_range,
- min_n=min_per_turbine)['live'] is True), None)
- if used:
- d.loc[g.index, amb_feat] = pd.to_numeric(d.loc[g.index, used], errors='coerce')
- ref_fallback[str(tid)] = used
- else:
- ref_dead[str(tid)] = lv['reason']
- if ref_dead: # 死参考台整体剔出拟合 → 入 ref_dead (INSUFFICIENT), 不出假候选
- d = d[~d[turbine_col].astype(str).isin(ref_dead)]
- rd = [{'tid': t, 'reason': r} for t, r in ref_dead.items()]
- rf = [{'tid': t, 'col': c} for t, c in ref_fallback.items()]
- med = d.groupby(time_col)[comp_col].median().rename('_Tmed')
- d = d.join(med, on=time_col)
- feats = [p_col] + rotor + ['_Tmed'] + ([amb_feat] if amb_feat else [])
- d = d.dropna(subset=feats)
- if len(d) < 5000:
- return {'per_turbine': [], 'flagged': [], 'n_total': 0, 'note': '样本不足(<5000)',
- 'batch_blind': batch_blind, 'common': None, 'ref_dead': rd, 'ref_fallback': rf, 'amb_mode': amb_mode, 'amb_neighbor_excluded': neighbor_excluded}
- Xa = np.column_stack([np.ones(len(d))] + [d[f].values.astype(float) for f in feats])
- beta, *_ = np.linalg.lstsq(Xa, d[comp_col].values.astype(float), rcond=None)
- d['_resid'] = d[comp_col].values.astype(float) - Xa @ beta
- agg = d.groupby(turbine_col).agg(resid_mean=('_resid', 'mean'), resid_std=('_resid', 'std'),
- n=('_resid', 'size')).reset_index().rename(columns={turbine_col: 'tid'})
- agg = agg[agg.n >= min_per_turbine]
- if agg.empty:
- return {'per_turbine': [], 'flagged': [], 'n_total': 0, 'note': 'per台样本不足',
- 'batch_blind': batch_blind, 'common': None, 'ref_dead': rd, 'ref_fallback': rf, 'amb_mode': amb_mode, 'amb_neighbor_excluded': neighbor_excluded}
- mad = (agg.resid_mean - agg.resid_mean.median()).abs().median() * 1.4826
- agg['z'] = (agg.resid_mean - agg.resid_mean.median()) / max(mad, 1e-6)
- ewma_map = {str(t): ewma_exceed(g.sort_values(time_col)['_resid'])
- for t, g in d.groupby(turbine_col) if len(g) >= min_per_turbine}
- agg['ewma_exc'] = agg['tid'].astype(str).map(ewma_map)
- # 时间趋势门 (SOP §4.6, W091 教训): 逐台残差轨迹 → developing (补年 z 埋没发展性故障)
- trend_map = ({str(t): residual_trend(g['_resid'], g[time_col],
- slope_thresh=trend_slope_thresh, level_thresh=trend_level_thresh)
- for t, g in d.groupby(turbine_col) if len(g) >= min_per_turbine}
- if trend_gate else {})
- agg = agg.sort_values('z', ascending=False)
- rows = [{'tid': str(r.tid), 'resid_mean': round(float(r.resid_mean), 2),
- 'resid_std': round(float(r.resid_std), 2), 'z': round(float(r.z), 2),
- 'ewma_exc': (None if pd.isna(r.ewma_exc) else round(float(r.ewma_exc), 3)),
- 'n': int(r.n), 'trend': trend_map.get(str(r.tid))} for r in agg.itertuples(index=False)]
- flagged = [r['tid'] for r in rows if r['z'] >= z_thresh]
- # developing = 时间门命中但年 z 未必过 (被年聚合埋的发展性台, 单列出防漏)
- developing = [r['tid'] for r in rows if (r.get('trend') or {}).get('developing')]
- # ★闭环: developing 命中自动跑冷浸审 (平陆20#教训 —— developing残差 传感器漂移与真故障同形)
- # 冷浸 rotor 独立于回归 rotor_col (回归可不含转速, 但冷浸必须 rotor 检停机)
- csr = cold_soak_rotor_col or rotor_col
- developing_adjudicated = {}
- if developing and cold_soak_ref_col and csr and cold_soak_ref_col in df.columns and csr in df.columns:
- cs = cold_soak_offset(df, comp_col=comp_col, ref_col=cold_soak_ref_col, rotor_col=csr,
- turbine_col=turbine_col, time_col=time_col, p_col=p_col)
- cs_map = {r['tid']: r for r in cs.get('per_turbine', [])}
- cs_flag = set(cs.get('flagged', []))
- cs_avail = bool(cs.get('per_turbine'))
- for tid in developing:
- det = cs_map.get(tid)
- if not cs_avail or det is None:
- verdict = 'INSUFFICIENT' # 无长停机/该台无冷浸样本 → 判不了
- elif tid in cs_flag:
- verdict = 'sensor_suspect' # 静止仍偏(尤其晚>早) → 传感器漂移嫌疑, 非真发热
- else:
- verdict = 'real_candidate' # 冷浸态不偏 → developing 更可能真部件
- developing_adjudicated[tid] = {'cold_soak_verdict': verdict,
- 'cold_recent': (det or {}).get('cold_recent'),
- 'dev_offset': (det or {}).get('dev_offset')}
- for r in rows: # 回填进 per_turbine.trend
- if r['tid'] == tid and isinstance(r.get('trend'), dict):
- r['trend']['cold_soak'] = developing_adjudicated[tid]
- elif developing:
- for tid in developing:
- developing_adjudicated[tid] = {'cold_soak_verdict': 'not_checked',
- 'note': '缺 cold_soak_ref_col/rotor_col → developing 未过冷浸审, 须人工跑'}
- # ---- 共模图接线 (实验 K): 判据正本 = _grand_chart, 此处只备料不另写判据 ----
- common, cnote = None, []
- if common_fit_window and common_h0_window and common_test_start:
- fl = d.groupby(time_col).agg(_tm=('_Tmed', 'first'), _p=(p_col, 'median'))
- if amb_feat:
- fl['_a'] = d.groupby(time_col)[amb_feat].median()
- fl.index = pd.to_datetime(fl.index)
- fl = fl.resample(common_freq).median().dropna()
- flo, fhi = pd.Timestamp(common_fit_window[0]), pd.Timestamp(common_fit_window[1])
- f = fl[(fl.index >= flo) & (fl.index < fhi)]
- if len(f) < 60:
- common = {'decision': 'insufficient', 'verdict': 'INSUFFICIENT', 'candidate': False,
- 'reason': f'共模拟合窗样本 {len(f)}<60'}
- else:
- cols = ['_p'] + (['_a'] if '_a' in fl.columns else [])
- X = np.c_[np.ones(len(f)), f[cols].values]
- beta, *_ = np.linalg.lstsq(X, f['_tm'].values, rcond=None)
- Xa = np.c_[np.ones(len(fl)), fl[cols].values]
- gser = pd.Series(fl['_tm'].values - Xa @ beta, index=fl.index)
- common, _ = _grand_chart(gser, common_h0_window, common_test_start, note=cnote)
- batch_blind['common_chart_ran'] = True
- batch_blind['read_as'] = (
- f'共模轴已测: verdict={common.get("verdict")}. '
- + {'batch_shift_candidate': '★全场同步漂移候选 — deviation 轴看不见它, 勿因 flagged 为空判健康',
- 'no_change': 'flagged 为空 + 共模无变 ⇒ 可读作"该窗未见机群级异常"(仍非绝对健康: 共模图不能与场级参考量变化区分)',
- 'INSUFFICIENT': '⚠ 共模轴阴性**不可信** (样本不足或 φ→1 致阶跃被白化压没), 不等于"没有批次漂"'}
- .get(common.get('verdict'), ''))
- if cnote:
- batch_blind['common_note'] = cnote
- return {'per_turbine': rows, 'flagged': flagged, 'developing': developing,
- 'developing_adjudicated': developing_adjudicated, 'n_total': len(rows),
- 'batch_blind': batch_blind, 'common': common,
- 'ref_dead': rd, 'ref_fallback': rf, 'amb_mode': amb_mode, 'amb_neighbor_excluded': neighbor_excluded}
- # ---------------------------------------------------------------- §1.2 CT-2 跨列全等/别名
- def near_equal_columns(df, cols=None, rel_tol=0.01, min_frac=0.99, min_overlap=500,
- corr_thresh=0.9999, slope_tol=0.02, intercept_tol=0.5):
- """CT-2 跨列全等/线性别名检测 (SOP §1.2, 双模型评审 2026-06-16). **WARN 级, 非 hard-fail**.
- 防别名/重名列被当两路独立证据(假冗余"多源印证", 如金华山 t_mainbrg≡t_genbrg 99.99%)。
- 两路检测(双模型评审): ① strict 近等 frac(|a-b|/avg<rel_tol)≥min_frac
- ② linear 别名 corr≈1 ∧ OLS slope≈1 ∧ intercept≈0 (抓同传感器不同标定 b=a+5/b=1.01a, 纯近等判据漏的)。
- 跳过真死列(std≈0)与时间列; 命名提示独立物理点(DE/NDE/_1/_2/L/R/相)→标低置信"需现场确认"。
- 别名也可能是硬件冗余设计 → 输出候选供人工结合点位表确认, 不作报告作废。
- 返回 [{col_a, col_b, kind, equal_frac, corr, note}] (按 equal_frac 降序)。
- """
- import re
- num = df.select_dtypes(include='number')
- cand = [c for c in (cols or list(num.columns)) if c in num.columns]
- time_re = re.compile(r'(^t\d*$|time|timestamp|^ts$|日期|时间)', re.I)
- indep_re = re.compile(r'(_de\b|_nde\b|_[12]\b|_[lr]\b|相[uvw]|[uvw]相|north|south|前|后|左|右)', re.I)
- live = [c for c in cand if not time_re.search(str(c))
- and int(num[c].notna().sum()) > min_overlap and float(num[c].std(skipna=True) or 0) > 1e-9]
- out = []
- for i in range(len(live)):
- ai = num[live[i]]
- for j in range(i + 1, len(live)):
- ca, cb = live[i], live[j]
- m = ai.notna() & num[cb].notna()
- if int(m.sum()) < min_overlap:
- continue
- a = ai[m].to_numpy(float)
- b = num[cb][m].to_numpy(float)
- denom = (np.abs(a) + np.abs(b)) / 2.0 + 1e-9
- eq_frac = float(np.mean(np.abs(a - b) / denom < rel_tol))
- sa, sb = a.std(), b.std()
- corr = float(np.corrcoef(a, b)[0, 1]) if sa > 0 and sb > 0 else 0.0
- kind = None
- if eq_frac >= min_frac:
- kind = 'near_equal'
- elif corr > corr_thresh:
- slope, intercept = np.polyfit(a, b, 1)
- if abs(slope - 1) < slope_tol and abs(intercept) < intercept_tol * (sa + 1e-9):
- kind = 'linear_alias'
- if kind:
- indep = bool(indep_re.search(str(ca)) or indep_re.search(str(cb)))
- out.append({'col_a': ca, 'col_b': cb, 'kind': kind,
- 'equal_frac': round(eq_frac, 4), 'corr': round(corr, 5),
- 'note': ('命名提示独立物理点→现场确认(可能真相近非别名)' if indep
- else '疑似同源复制→勿当两路独立证据')})
- return sorted(out, key=lambda r: -r['equal_frac'])
- # ---------------------------------------------------------------- §1.2 CT-3 功率通道相对缩放 mis-scale 守卫
- def power_channel_scale_check(df, *, grid_col, gen_col, turbine_col=None,
- gen_floor_kw=100.0, misscale_gate=0.5, normal_band=(0.85, 1.15),
- const_iqr_gate=0.10, min_pts=200):
- """功率通道相对缩放 mis-scale 守卫 (SOP §1.2 CT-3, **WARN 级 DQ, 非 hard-fail**; [暂行] n=1 海装 09肥城, 2026-06-28).
- 业主端 CT 变比/单位/导出缩放配置错 → 某功率列被整体压成真值的 1/k (海装 09肥城 A11/A12: k≈5.16,
- 电网有功 max 424kW vs 发电机有功 2150kW)。这种**纯比例**错读不越物理上限、不触 range/IQR/stuck 清洗门,
- 却污染一切功率分档分析 —— 实证 A12 转速比假 WATCH = 用被压电网功率分档把高功率点挤进低档错配速度区
- (scripts/sop_adversarial/feicheng_a12_rotratio_cleanpower_rerun.py: gen/rot 偏 +48%→+0.02% 翻案)。
- 本守卫: 发电区 (gen_col>gen_floor_kw, 用**参照功率**门避免被压列自污) 比 median(grid/gen);
- 偏离 1.0 即标 mis-scale, 给 implied_scale=1/ratio 与 ratio 的 IQR/median (恒定性: ≤const_iqr_gate
- = 纯常数缩放, 支持常数矫正; 否则非常数 → 须查别因不可裸 ×k)。
- turbine_col 给定 → per 台逐查 (机群里只**部分**台 mis-scale 是常态: 海装 33 台仅 2 台)。
- ⚠ 命中处置 = 该台该列**禁用于功率分档**, 改用干净的同义功率列 (发电机有功/有功功率); 既有干净原生列时
- 优先换列 (a) 而非反推 CT 变比 ×k (b)。grid_col≡gen_col(传同一列)→ratio≈1 不误报。
- ⚠ [暂行] n=1: 仅海装 1 场实证, < §0.3 跨场准入线, 暂不进 §4 canon 判据面; 但机制 (CT变比/单位/导出缩放)
- farm-agnostic, 跨场复现到 n≥2 再升 canon。
- 入: df (单台或多台长表); grid_col 待查功率列; gen_col 参照功率列 (发电机有功, 须干净); turbine_col 可选。
- 出 dict: {per_turbine:{tid:{ratio_median, implied_scale, ratio_iqr_over_med, constant_scale,
- grid_max, gen_max, n_gen, verdict}}, misscaled:[tid...], n_checked, n_misscaled, verdict, note};
- verdict ∈ {mis-scale, 正常, 可疑(部分偏), 样本不足}。
- """
- if grid_col not in df.columns or gen_col not in df.columns:
- return {'per_turbine': {}, 'misscaled': [], 'n_checked': 0, 'n_misscaled': 0,
- 'verdict': '样本不足', 'note': f'缺列 grid={grid_col in df.columns} gen={gen_col in df.columns}'}
- def _one(sub):
- grid = pd.to_numeric(sub[grid_col], errors='coerce')
- gen = pd.to_numeric(sub[gen_col], errors='coerce')
- m = grid.notna() & gen.notna() & (gen > gen_floor_kw) # 发电区门用参照功率 (被压列不自污)
- n_gen = int(m.sum())
- rec = {'ratio_median': None, 'implied_scale': None, 'ratio_iqr_over_med': None,
- 'constant_scale': None, 'grid_max': (float(grid.max()) if grid.notna().any() else None),
- 'gen_max': (float(gen.max()) if gen.notna().any() else None), 'n_gen': n_gen,
- 'verdict': '样本不足'}
- if n_gen < min_pts:
- return rec
- r = (grid[m] / gen[m]).replace([np.inf, -np.inf], np.nan).dropna()
- if len(r) < min_pts:
- return rec
- med = float(r.median())
- iqr = float(r.quantile(0.75) - r.quantile(0.25))
- rec['ratio_median'] = round(med, 4)
- rec['ratio_iqr_over_med'] = round(iqr / med, 4) if med else None
- rec['implied_scale'] = round(1.0 / med, 4) if med else None
- rec['constant_scale'] = bool(rec['ratio_iqr_over_med'] is not None
- and rec['ratio_iqr_over_med'] <= const_iqr_gate)
- if med < misscale_gate:
- rec['verdict'] = 'mis-scale'
- elif normal_band[0] <= med <= normal_band[1]:
- rec['verdict'] = '正常'
- else:
- rec['verdict'] = '可疑(部分偏)'
- return rec
- per = {}
- if turbine_col and turbine_col in df.columns:
- for tid, sub in df.groupby(turbine_col):
- per[str(tid)] = _one(sub)
- else:
- per['_all'] = _one(df)
- misscaled = sorted(t for t, r in per.items() if r['verdict'] == 'mis-scale')
- suspect = [t for t, r in per.items() if r['verdict'] == '可疑(部分偏)']
- checked = [t for t, r in per.items() if r['verdict'] != '样本不足']
- if misscaled:
- verdict = 'mis-scale'
- elif suspect:
- verdict = '可疑(部分偏)'
- elif checked:
- verdict = '正常'
- else:
- verdict = '样本不足'
- scales = [per[t]['implied_scale'] for t in misscaled if per[t]['implied_scale']]
- note = (f"{len(misscaled)}/{len(checked)} 台 {grid_col} 被压 (median(grid/gen)<{misscale_gate}); "
- f"implied k≈{np.median(scales):.2f}; 处置=换用干净功率列做功率分档" if misscaled
- else f"{len(checked)} 台 {grid_col} 相对 {gen_col} 缩放正常 (~1.0)")
- return {'per_turbine': per, 'misscaled': misscaled, 'n_checked': len(checked),
- 'n_misscaled': len(misscaled), 'verdict': verdict, 'note': note}
- # ---------------------------------------------------------------- §1.2 CT-4 孪生温度通道互换筛
- def channel_swap_screen(df, pairs, *, turbine_col, gen_mask=None, min_per_turbine=1000,
- swap_win_ratio=0.5, anti_min=2.0, swap_abs_max=3.0):
- """孪生温度通道互换(接线/映射 swap)筛 (SOP §1.2 CT-4, **WARN 级 DQ, 非 hard-fail**;
- [暂行] n=2 场 2026-07-06: mawang 450102036 (DE/NDE, 原 ⭐确证经此翻案为镜像) +
- wangjiawan W006/026/024; hefeng #18/#19 = 共热负样本, 未误报).
- 成对温度通道(发电机 DE/NDE 轴承、_a/_b 等)被现场接线或点位映射**互换** → 某台一端逆机群
- 基线偏热、另一端对称偏冷(反对称)。误当"局部单端真发热/强特异"会派现场核**健康**部件
- (mawang 036 原裁决即此坑)。**判据 = swap-match**: 交换该台两通道后是否落回机群(swap 残差 ≪ no-swap 残差)。
- ⚠ **基线方向从机群实测, 禁假设哪端更热** (wangjiawan DE>NDE / mawang·hefeng NDE>DE, 相反!)。
- ⚠ **共热(两端同向偏)= 真部件/子系统热, 非 swap** (hefeng 三相+双轴承齐热=真); 单通道无配对=免疫。
- ⚠ **仅出候选供点位表确认, 不作废/不改数据** (WARN; 单手段不终结推翻确证级裁决 — 见 memory
- scada-residual-claim-discipline)。基线无方向(|fleet_d|<σ, 如对称三相)→ 跳过(swap 不可辨)。
- pairs: [(col_a, col_b), ...] 已知物理配对通道 (调用方给, 不猜)。
- gen_mask: 可选发电态布尔 Series (温度须发电态比)。
- swap_win_ratio: swap残差 < swap_win_ratio × no_swap残差 才算"交换更优"。
- anti_min: 该台 (a−b) 距机群中位 ≥ anti_min 稳健σ 且**反号** 才算真反对称(非噪声)。
- swap_abs_max: swap残差绝对上限(°C), 落机群噪声内才算 clean swap。
- 返回 [{turbine, pair, d, fleet_d, anti_sigma, swap_resid, no_swap_resid, ratio, verdict}]
- (仅 swap 嫌疑入候选, 按 |anti_sigma| 降序)。
- """
- d = df if gen_mask is None else df[gen_mask]
- out = []
- for ca, cb in pairs:
- if ca not in d.columns or cb not in d.columns:
- continue
- sub = d.dropna(subset=[ca, cb, turbine_col])
- g = sub.groupby(turbine_col)
- n = g.size()
- keep = n[n >= min_per_turbine].index
- if len(keep) < 5: # 机群太小无法立基线
- continue
- a = g[ca].median().loc[keep] # per 台 中位
- b = g[cb].median().loc[keep]
- diff = a - b
- m_d = float(diff.median()) # 机群基线 (稳健, 中位抗少数 swap)
- s_d = max(float((diff - m_d).abs().median()) * 1.4826, 1e-6)
- if abs(m_d) < s_d: # 基线无方向 (对称对, swap 不可辨) → 跳过
- continue
- fleet_a = float(a.median()); fleet_b = float(b.median())
- for t in keep:
- dt = float(diff[t])
- if np.sign(dt) == np.sign(m_d) or abs(dt - m_d) < anti_min * s_d:
- continue # 非反对称 (含共热/正常) → 不入候选
- no_swap = max(abs(float(a[t]) - fleet_a), abs(float(b[t]) - fleet_b))
- swap = max(abs(float(b[t]) - fleet_a), abs(float(a[t]) - fleet_b))
- if swap < swap_win_ratio * no_swap and swap <= swap_abs_max:
- out.append({'turbine': str(t), 'pair': f'{ca}|{cb}',
- 'd': round(dt, 2), 'fleet_d': round(m_d, 2),
- 'anti_sigma': round((dt - m_d) / s_d, 2),
- 'swap_resid': round(float(swap), 2),
- 'no_swap_resid': round(float(no_swap), 2),
- 'ratio': round(float(swap / max(no_swap, 1e-6)), 3),
- 'verdict': 'swap嫌疑(待点位表确认)'})
- return sorted(out, key=lambda r: -abs(r['anti_sigma']))
- # ---------------------------------------------------------------- §4.1 模块1.1 可用率多口径
- def availability_calibers(df, *, ws_col, p_col, rated, cutin=None, cutout=25.0,
- gen_frac=0.02, pba_min_op=500):
- """可用率多口径 A_WIL + A_PBA (SOP §4.1 模块1.1; 源 litloop_r1_availability, RV-2 搬真逻辑不改判据).
- A_WIL (DNV wind-in-limits) = 发电时间 / 风在[cutin,cutout]内时间 — 损失因子用此 (非全时段 A_op)。
- A_PBA proxy (IEC 61400-26) = E_actual / E_potential, 潜在=该台自身运行态分bin中位曲线
- (停机段机舱风转移函数不同 → caveat, 标参考)。
- A_op (运行口径) 因状态源 per 场变体 (官方码/状态派生/OEM内置) **不在此抽**, 留 per-farm 计算后传入比对
- (§0.3 变体面未收敛)。零发电×有风红旗 同理不在此抽: **两函数的实现均已在本模块落地** —— builtin_avail_verify 见 §4.3 内置可利用率列(先验真), zero_gen_redflag 见同批(n≥2 提级); 本处仅说明"不入 A_op 抽取面、留 per-farm 传入比对"。
- cutin=None 时经验派生 (最低风速bin中 ≥50% 样本发电的bin下缘, 缺则 3.0); gen_frac: 发电态门=P>gen_frac·rated。
- 返回 {cutin, a_wil, n_wil, a_pba}; a_* 为 NaN 表样本不足。
- """
- d = df.dropna(subset=[ws_col, p_col])
- gen_thr = gen_frac * rated
- if cutin is None: # 经验切入风速 (同源 derive_cutin)
- lab = pd.cut(d[ws_col], np.arange(0, 10.25, 0.25))
- frac = d.groupby(lab, observed=True)[p_col].apply(lambda s: (s > gen_thr).mean())
- ok = frac[frac >= 0.5]
- cutin = float(ok.index[0].left) if len(ok) else 3.0
- inlim = d[(d[ws_col] >= cutin) & (d[ws_col] <= cutout)] # A_WIL (同源 wil_availability)
- if len(inlim) == 0:
- a_wil, n_wil = float('nan'), 0
- else:
- a_wil, n_wil = float((inlim[p_col] > gen_thr).mean()), int(len(inlim))
- op = d[d[p_col] > gen_thr] # A_PBA proxy (同源 pba_proxy)
- if len(op) < pba_min_op:
- a_pba = float('nan')
- else:
- bins_p = np.arange(0, 26.5, 0.5)
- dd = d.assign(_bin=pd.cut(d[ws_col], bins_p))
- curve = op.assign(_bin=pd.cut(op[ws_col], bins_p)).groupby('_bin', observed=True)[p_col].median()
- pot = dd['_bin'].map(curve).astype(float).where(dd[ws_col] >= cutin, 0.0) # 切入下潜在=0
- act = dd[p_col].clip(lower=0)
- pot = np.maximum(pot, act) # 潜在≥实发 (防曲线低估>100%)
- a_pba = float(act.sum() / pot.sum()) if pot.sum() > 0 else float('nan')
- return {'cutin': round(float(cutin), 2), 'a_wil': a_wil, 'n_wil': n_wil, 'a_pba': a_pba}
- # ================================================================ ④ 部件健康 (3.1-3.6, loop训练硬化 2026-06-20)
- # 本批 7 函数抽自跨场实跑 bespoke (RV-2 搬真逻辑不改判据); 数据受限模块 (3.3 MCSA/3.4 油液) = 诚实数据门, 禁假"健康"。
- # 合成已知真值见 scripts/test_discriminators.py。
- # ---------------------------------------------------------------- §4.6 模块3.1 matched-load 双判 (NBM 盲区兜底)
- def matched_load_overheat(df, *, comp_cols, p_col, turbine_col, time_col, rated,
- gen_mask=None, p_lo_frac=0.30, p_hi_frac=0.95, bin_kw=100.0,
- min_cell=30, min_bins=3, dt_z_thresh=3.0, dt_abs_floor=2.0,
- hot_dev_c=1.5, cluster_day_frac=0.5, cluster_hot_z=2.5,
- min_cluster_days=30, comp_valid_range=(-40.0, 200.0)):
- """温度 matched-load 双判 — NBM 盲区兜底 (SOP §4.6/模块3.1, 源 lsq_temp_matchedload, 凉水泉 n=1暂行)。
- ⭐ nbm_residual 是机群**极值**检测器: 中度(+4-6°C)/多部件/簇状升温被宽 fleet 残差散布稀释成
- MAD-z<2 → 漏检 (凉水泉 2208F 戒: 齿油/主轴/高速轴各 +4°C, 单部件 NBM z<2, 险被'更先进判别器'erase)。
- 本函数 = 其【兜底】(非替代): 不靠残差散布, 直接看同载荷温差 + 簇状, 逮 NBM 极值盲区。
- 双路 (任一升=候选, 与 nbm_residual 互补不重复):
- ① ΔT-z 路径 (逮单部件强升温, 2205F/2109F 型): 同功率 bin (p_lo_frac~p_hi_frac×rated, bin_kw 分箱)
- 内 per台部件温中位 − fleet 同 bin 中位 = dev; 跨多 bin 取中位 ΔT (同功率比, 排载荷混杂);
- fleet MAD-z≥dt_z_thresh ∧ |ΔT|≥dt_abs_floor。 (z≥3 故意严于 nbm z≥2: 本=corroborating 第二判)
- ② 簇状路径 (逮中度多部件, 2208F 型): per台 per日 部件日中位偏热>hot_dev_c 的部件数;
- (≥3部件齐热 ∧ 占比≥cluster_day_frac) ∨ (≥2部件 ∧ 其一 ΔT-z≥cluster_hot_z ∧ 占比≥cluster_day_frac+0.1)。
- 阈值 RULE-3 依据: hot_dev_c=1.5°C=日中位噪声带上沿; dt_z_thresh=3 (±3σ-class, 严于 NBM);
- dt_abs_floor=2°C 防小散布场 z 虚高; 实测 fleet ΔT MAD 1.8-3.3°C (n=凉水泉)。
- comp_cols: 部件温列名 list (齿油/主轴承/高速轴/发电机轴承等); rated: 铭牌功率 kW;
- gen_mask: 可选发电态布尔 Series。⚠ ΔT=相对 fleet (非 OEM 绝对阈), 上限【预警】候选。
- ⚠ 被排名通道存活守卫 (§4.6c, 2026-06-28 加): 每个部件温列**逐台**经 reference_channel_liveness 验活,
- 冻结/坐死的列 (comp_valid_range 先剔野值) 把该台从 ΔT 排名与簇状计数中剔出 (入 dead_channel),
- 防"传感器坏"伪装"部件热" (同 nbm_residual 的 amb 守卫, 06淄川 F0810001022 戒)。
- 返回 dict: {per_turbine: {tid: {components:{col:{dT,z}}, n_hot_components, cluster_day_frac_ge2/ge3,
- max_dt_c, max_dt_z, flag, reason}}, flagged:[tid...按 max_dt_z 降序], n_components,
- n_power_bins_used, dead_channel:[{comp,tids}...], caveat}。
- """
- d = df if gen_mask is None else df[gen_mask]
- comp_cols = [c for c in comp_cols if c in d.columns]
- if not comp_cols or p_col not in d.columns or turbine_col not in d.columns:
- return {'per_turbine': {}, 'flagged': [], 'n_components': 0, 'note': '无有效部件温列/功率列'}
- p_all = pd.to_numeric(d[p_col], errors='coerce')
- band = d[(p_all >= p_lo_frac * rated) & (p_all <= p_hi_frac * rated)].copy()
- if band.empty:
- return {'per_turbine': {}, 'flagged': [], 'n_components': 0, 'note': '匹配功率带内无样本'}
- band['_pb'] = np.round(pd.to_numeric(band[p_col], errors='coerce') / bin_kw) * bin_kw
- # 路径①: 同功率 bin ΔT (fleet 同 bin 中位为基线, 跨 bin 取中位 → 排载荷混杂) + per部件 MAD-z
- comp_dt, comp_z, dead_channel = {}, {}, {}
- for c in comp_cols:
- sub = band.dropna(subset=[c, '_pb', turbine_col])
- if sub.empty:
- continue
- # §4.6c per-台被排名通道存活守卫: 冻结的部件温会把"传感器坏"伪装成 ΔT 离群 → 剔出排名+簇状
- dead = {str(t) for t, gc in sub.groupby(turbine_col)
- if reference_channel_liveness(gc[c], valid_range=comp_valid_range, min_n=200)['live'] is False}
- if dead:
- dead_channel[c] = sorted(dead)
- sub = sub[~sub[turbine_col].astype(str).isin(dead)]
- if sub.empty:
- continue
- g = sub.groupby(['_pb', turbine_col])[c].agg(med='median', nn='size').reset_index()
- g = g[g['nn'] >= min_cell]
- if g.empty:
- continue
- fl = g.groupby('_pb')['med'].median().rename('fleet')
- g = g.merge(fl, on='_pb')
- g['dev'] = g['med'] - g['fleet']
- nb = g.groupby(turbine_col)['_pb'].nunique()
- dt = g.groupby(turbine_col)['dev'].median()
- dt = dt[nb >= min_bins] # 须跨≥min_bins 个功率 bin (单 bin 不算)
- if dt.empty:
- continue
- comp_dt[c] = dt
- m = dt.median()
- mad = (dt - m).abs().median() * 1.4826
- comp_z[c] = (dt - m) / max(mad, 1e-6)
- # 路径②: 多部件同热天占比 (簇状; per时刻 fleet 中位为基线 → per日中位偏热部件数)
- cl_frac3, cl_frac2 = {}, {}
- if len(comp_cols) >= 2:
- gg = d.copy()
- dev_cols = []
- for c in comp_cols:
- fl = gg.groupby(time_col)[c].transform('median')
- gg['_dv_' + c] = pd.to_numeric(gg[c], errors='coerce') - fl
- dev_cols.append('_dv_' + c)
- gg['_day'] = pd.to_datetime(gg[time_col]).dt.date
- for tid, s in gg.groupby(turbine_col):
- # 死通道 (路径①守卫所逮) 不进簇状计数: 高位冻结的部件温会假装"天天偏热"
- cols_ok = [dc for dc in dev_cols if str(tid) not in dead_channel.get(dc[4:], ())]
- if not cols_ok:
- continue
- s = s.dropna(subset=cols_ok)
- if s.empty:
- continue
- byday = s.groupby('_day')[cols_ok].median()
- if len(byday) < min_cluster_days: # 天数不足 → 不出簇状判 (防短窗假占比)
- continue
- n_hot = (byday > hot_dev_c).sum(axis=1)
- cl_frac3[str(tid)] = float((n_hot >= 3).mean())
- cl_frac2[str(tid)] = float((n_hot >= 2).mean())
- all_t = set()
- for dt in comp_dt.values():
- all_t |= {str(x) for x in dt.index}
- all_t |= set(cl_frac2)
- out, flagged = {}, []
- for tid in sorted(all_t):
- comps, max_z, max_dt, hot_zs = {}, -np.inf, -np.inf, []
- for c in comp_cols:
- if c in comp_dt and tid in comp_dt[c].index:
- v = float(comp_dt[c].loc[tid]); z = float(comp_z[c].loc[tid])
- comps[c] = {'dT': round(v, 1), 'z': round(z, 2)}
- max_z = max(max_z, z); max_dt = max(max_dt, v)
- if v > hot_dev_c:
- hot_zs.append(z)
- n_hot = len(hot_zs)
- f3, f2 = cl_frac3.get(tid), cl_frac2.get(tid)
- dt_flag = (max_z >= dt_z_thresh) and (max_dt >= dt_abs_floor)
- cluster_flag = ((n_hot >= 3 and f3 is not None and f3 >= cluster_day_frac) or
- (n_hot >= 2 and max(hot_zs or [-9]) >= cluster_hot_z
- and f2 is not None and f2 >= cluster_day_frac + 0.1))
- is_flag = bool(dt_flag or cluster_flag)
- rs = []
- if dt_flag:
- rs.append(f'单部件ΔT-z≥{dt_z_thresh:g}(maxΔT{max_dt:+.1f}°C,z{max_z:+.1f})')
- if cluster_flag:
- rs.append(f'簇状{n_hot}部件同热{(f3 if n_hot>=3 else f2):.0%}天(每部件>{hot_dev_c:g}°C)')
- out[tid] = {'components': comps, 'n_hot_components': n_hot,
- 'cluster_day_frac_ge2': (None if f2 is None else round(f2, 2)),
- 'cluster_day_frac_ge3': (None if f3 is None else round(f3, 2)),
- 'max_dt_c': (None if max_dt == -np.inf else round(max_dt, 1)),
- 'max_dt_z': (None if max_z == -np.inf else round(max_z, 2)),
- 'flag': is_flag, 'reason': ('; '.join(rs) if rs else '无升温签名')}
- if is_flag:
- flagged.append(tid)
- flagged.sort(key=lambda t: -(out[t]['max_dt_z'] or -9))
- dch = [{'comp': c, 'tids': ts} for c, ts in dead_channel.items()]
- return {'per_turbine': out, 'flagged': flagged, 'n_components': len(comp_dt),
- 'n_power_bins_used': int(band['_pb'].nunique()), 'dead_channel': dch,
- 'caveat': 'matched-load=NBM极值检测器盲区兜底(逮中度+4-6°C/多部件/簇状, 凉水泉2208F戒); '
- 'ΔT=相对fleet同功率bin(非OEM绝对阈), 上限【预警】候选; 绝对失控须OEM定值核; '
- '⚠盲点(Critic): 某台功率带与全场完全不重叠(孤立高/低bin)→ΔT路径fleet基线=自身→致盲(false-neg), '
- '须 fleet 功率带重叠才有效(同风况机组通常重叠); '
- '§4.6c 守卫: 冻结/坐死的部件温列已逐台剔出排名+簇状(见 dead_channel)'}
- # ---------------------------------------------------------------- §4.6 R5 模块3.2 振动趋势 (level 粗筛)
- def _theil_sen_slope(x, y):
- """Theil-Sen 月度斜率 (中位 pairwise slope, 抗离群月; 源 R5 趋势, 比 OLS 稳)。"""
- x = np.asarray(x, float); y = np.asarray(y, float)
- n = len(x)
- if n < 3:
- return np.nan
- 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]]
- return float(np.median(sl)) if sl else np.nan
- def vibration_trend(df, *, vib_cols, turbine_col, time_col,
- z_thresh=2.0, min_per_turbine=1000, min_months=4,
- min_nunique=30, slope_frac_thresh=0.03, persist_frac=0.6,
- mad_floor_frac=0.02):
- """振动 level 趋势第二道 — 分测点 fleet null MAD-z + 月度爬升斜率 + 持续性 (SOP §4.6 R5, 模块3.2).
- 源 scripts/litloop_r5_vib_survey.py (RV-2 搬真逻辑不改判据)。**10min RMS/std level = 粗筛, verdict 上限【预警】**。
- 物理铁律 (必 honor):
- ① **禁跨测点混池**: 主轴承/齿轮箱/发电机/塔筒 物理量级差数倍, 混池→fleet 中位落某簇 + MAD 撑大/缩小
- → 整通道系统偏移成假 z (实证: 3 低幅通道 +1 高幅健康塔通道混池 → 塔台假 z≈15; per 通道则 |z|≈2 中性)。
- ② **无频带/测点标定元数据 → 绝对 ISO 10816/20816 区界 INSUFFICIENT** (10min 奈奎斯特仅 0.03-0.5Hz, 做不了频谱)
- → 只出相对 level-trend, 不套区界表; 部件级 (BPFO/BPFI/啮合/1P) 须 CMS 高频频谱 (留下一轮)。
- ③ **退化/近死振动列守卫** (R5 tl3/hl near-dead): nunique<min_nunique 或 p95<量化步长 = 近死列 → INSUFFICIENT。
- ④ **趋势爬升须持续性门** (单事件≠趋势): 月度中位 Theil-Sen 斜率正 ∧ 后半段多数月>基线+半幅 才算"在涨"。
- 阈值 RULE-3: fleet null MAD-z>z_thresh(=2, 非 magic RMS≥6 —— R5 明记 RMS≥6 是未标定绝对值无意义);
- slope_frac=每月涨>fleet中位幅5% 才显著; persist_frac=0.6。flagged = z>阈 ∨ (显著正斜率 ∧ 持续)。
- vib_cols: 单列名 str 或 测点列名 list (每列=一测点通道, 各自成池)。
- 返回 dict: {per_channel:{测点:{guard, n_turbines, per_turbine:[{tid,level_median,z,slope_per_month,
- slope_frac,persist,n_months,n,climbing}], flagged}}, flagged:[(channel,tid)], verdict, ceiling, caveat}。
- """
- cols = [vib_cols] if isinstance(vib_cols, str) else list(vib_cols)
- per_channel = {}
- flagged_all = []
- any_usable = False
- for col in cols:
- if col not in df.columns:
- per_channel[col] = {'guard': 'missing', 'n_turbines': 0, 'per_turbine': [], 'flagged': [],
- 'reason': f'列 {col} 不存在'}
- continue
- d = df.dropna(subset=[col, turbine_col, time_col]).copy()
- s = pd.to_numeric(d[col], errors='coerce')
- d = d.assign(_v=s).dropna(subset=['_v'])
- # ③ 退化/近死列守卫 (nunique 粗 + p95<量化步长). 量化步长 = 最小正相邻差 (排重后)。
- nunq = int(d['_v'].nunique())
- uvals = np.unique(d['_v'].to_numpy())
- qstep = float(np.min(np.diff(uvals))) if uvals.size >= 2 else np.inf
- p95 = float(d['_v'].quantile(0.95))
- if nunq < min_nunique or (np.isfinite(qstep) and p95 < qstep):
- per_channel[col] = {
- 'guard': 'near_dead', 'n_turbines': 0, 'per_turbine': [], 'flagged': [],
- 'reason': (f'退化/近死列: nunique={nunq}(<{min_nunique}) 或 p95={p95:.4g}<量化步长{qstep:.4g} '
- f'(缺测填常数/sentinel/量化太粗, 无真振动信号) → 不出假 z, 标 INSUFFICIENT'),
- 'nunique': nunq, 'p95': round(p95, 4)}
- continue
- # ① per 测点通道各自 fleet 中位 + MAD-z (绝不跨通道混池)
- lev = d.groupby(turbine_col)['_v'].median()
- cnt = d.groupby(turbine_col)['_v'].count()
- lev = lev[(cnt >= min_per_turbine) & lev.notna()]
- if len(lev) < 2:
- per_channel[col] = {'guard': 'few_turbines', 'n_turbines': int(len(lev)),
- 'per_turbine': [], 'flagged': [],
- 'reason': f'有效台<2 (per台需≥{min_per_turbine}样本) → 无 fleet null, INSUFFICIENT'}
- continue
- any_usable = True
- fleet_med = float(lev.median())
- mad_raw = float(1.4826 * (lev - lev.median()).abs().median())
- # MAD 地板 (Critic 逮: 原 min_dev_frac 硬截断会 mask 真中度escalation; 改floor MAD 分母, 同 fleet_sector_devz
- # spread_floor 思想): tight-fleet MAD→tiny 时 floor 到 mad_floor_frac·fleet中位 → z 不爆炸但不硬截真信号。
- mad = max(mad_raw, mad_floor_frac * abs(fleet_med), 1e-9)
- # ④ 月度爬升斜率 + 持续性 (per 台自比, 免标定; 单事件≠趋势)
- rows = []
- for tid, g in d.groupby(turbine_col):
- if len(g) < min_per_turbine:
- continue
- gm = (g.set_index(pd.to_datetime(g[time_col]))['_v']
- .resample('MS').median().dropna())
- n_months = int(len(gm))
- slope = slope_frac = np.nan
- persist = climbing = False
- if n_months >= min_months:
- xm = np.arange(n_months)
- slope = _theil_sen_slope(xm, gm.values) # level / month
- base = float(gm.iloc[:max(1, n_months // 3)].median()) # 前 1/3 月基线
- amp = float(gm.max() - gm.min())
- slope_frac = (slope / fleet_med) if fleet_med > 0 else np.nan
- if amp > 0:
- thr = base + 0.5 * amp
- half = gm.iloc[n_months // 2:]
- persist = bool((half > thr).mean() >= persist_frac)
- climbing = bool(np.isfinite(slope_frac) and slope_frac >= slope_frac_thresh and persist)
- z = float((lev[tid] - fleet_med) / mad) if tid in lev.index else np.nan
- rows.append({'tid': str(tid), 'level_median': round(float(lev.get(tid, np.nan)), 4),
- 'z': (None if not np.isfinite(z) else round(z, 2)),
- 'slope_per_month': (None if not np.isfinite(slope) else round(slope, 5)),
- 'slope_frac': (None if not np.isfinite(slope_frac) else round(slope_frac, 4)),
- 'persist': persist, 'n_months': n_months,
- 'n': int(len(g)), 'climbing': climbing})
- rows.sort(key=lambda r: (-(r['z'] if r['z'] is not None else -9)))
- # flag = z≥阈 (z 已用 floored MAD, tight-fleet 不爆炸) ∨ 趋势爬升(slope+持续门). 不再硬截 level 偏差 (会mask真中度).
- flg = [r['tid'] for r in rows
- if (r['z'] is not None and r['z'] >= z_thresh) or r['climbing']]
- # 检测下限透明 (同 fleet_sector_devz detection_floor): 达 z 阈所需 level 偏 fleet 中位 % (mad被floor时=阈×floor)
- det_floor_pct = round(z_thresh * mad / abs(fleet_med) * 100, 1) if fleet_med else None
- per_channel[col] = {'guard': 'ok', 'n_turbines': int(len(lev)), 'fleet_median': round(fleet_med, 4),
- 'mad': round(mad, 4), 'mad_floored': bool(mad > mad_raw + 1e-12),
- 'detection_floor_pct': det_floor_pct, 'per_turbine': rows, 'flagged': flg}
- flagged_all += [(col, t) for t in flg]
- if not any_usable:
- verdict = 'INSUFFICIENT(退化列/样本/<2台)'
- elif flagged_all:
- verdict = '振动 level 离群/爬升候选(预警)'
- else:
- verdict = '各测点 level 平稳'
- return {'per_channel': per_channel, 'flagged': flagged_all, 'verdict': verdict, 'ceiling': '预警',
- 'caveat': ('分测点各自 fleet null(禁混池); 10min level=粗筛 verdict上限【预警】; '
- '无频带/测点标定→绝对 ISO 区界 INSUFFICIENT(奈奎斯特0.03-0.5Hz, 不套区界表); '
- '部件级(BPFO/BPFI/啮合/1P)须 CMS 高频频谱')}
- # ---------------------------------------------------------------- §4.1 模块3.2b 叶片不平衡 1P 阶次跟踪
- def order_track_1p(accel, rpm, fs, *, order=1.0, detrend=True, min_cycles=4.0, samples_per_rev=64):
- """振动加速度按瞬时转速**阶次跟踪**提 1P (=order) 幅值 (源 blade_imb_chainB_ordertrack.order_amp,
- RV-2 搬真逻辑; 升级: rpm 随时间变 → 角域重采样, 锁 1P 为常数阶次, 规避固定频率 FFT 低频泄漏 artifact)。
- 为什么必阶次跟踪 (铁律②): 转速变 → 1P 时域频率漂移; 固定频率 FFT 在一个 bin 收不全漂移的能量 → 1P 被低估/糊。
- 角域重采样 (按累积转角等角度抽样) 把"每转一周期"固定成常数阶次, 不平衡 1P 落单一阶次 bin, 与转速变化无关。
- 算法: ① 重力/DC 自标定 (deg2 detrend 扣重力 ~9.8m/s² + 慢漂); ② 瞬时相位 φ(t)=2π·order·∫(rpm/60)dt,
- 按 φ 均匀重采样 (每转 samples_per_rev 点); ③ 角域 1P = 每转整 1 周期 → 谐波回归 (2×2 正规方程直解)。
- 转速近恒定 (cv<2%) 退化单频时域回归 (与 bespoke order_amp 逐位等价)。
- 入: accel (1d array), rpm (标量或同长 array, r/min), fs (Hz)。
- 出: dict {amp_1p, n_rev, mean_rpm, rpm_cv, method, note}; amp_1p=NaN 表样本/转速不足。
- """
- a = np.asarray(accel, float)
- a = a[np.isfinite(a)]
- n = a.size
- if n < 64 or fs <= 0:
- return {'amp_1p': np.nan, 'n_rev': 0.0, 'mean_rpm': np.nan, 'rpm_cv': np.nan,
- 'method': None, 'note': '样本不足(<64)或 fs 非法'}
- t = np.arange(n) / float(fs)
- dur = t[-1] if n > 1 else 0.0
- rpm_arr = np.asarray(rpm, float)
- if rpm_arr.ndim == 0:
- rpm_t = np.full(n, float(rpm_arr))
- else:
- rpm_arr = rpm_arr[np.isfinite(rpm_arr)]
- if rpm_arr.size == 0:
- return {'amp_1p': np.nan, 'n_rev': 0.0, 'mean_rpm': np.nan, 'rpm_cv': np.nan,
- 'method': None, 'note': 'rpm 全 NaN'}
- rt = np.linspace(0.0, dur, rpm_arr.size)
- rpm_t = np.interp(t, rt, rpm_arr)
- mean_rpm = float(np.mean(rpm_t))
- if mean_rpm <= 0:
- return {'amp_1p': np.nan, 'n_rev': 0.0, 'mean_rpm': mean_rpm, 'rpm_cv': np.nan,
- 'method': None, 'note': '转速≤0 (停机; CMS 0RPM 假标签须先 join 真转速)'}
- rpm_cv = float(np.std(rpm_t) / mean_rpm)
- rev = np.concatenate([[0.0], np.cumsum((rpm_t[:-1] + rpm_t[1:]) / 2.0 / 60.0) / fs])
- n_rev = float(rev[-1])
- if n_rev < min_cycles: # 1P 周期数不足 → marginal, 拒 (zyx 短窗戒)
- return {'amp_1p': np.nan, 'n_rev': round(n_rev, 2), 'mean_rpm': round(mean_rpm, 2),
- 'rpm_cv': round(rpm_cv, 4), 'method': None,
- 'note': f'仅 {n_rev:.1f} 转 < min_cycles={min_cycles} (1P 估计 marginal)'}
- x = a - np.polyval(np.polyfit(t, a, 2), t) if detrend else a - a.mean()
- def _harm_amp(sig, phase): # 谐波回归 2×2 正规方程直解 (源 order_amp)
- c, s = np.cos(phase), np.sin(phase)
- a11, a12, a22 = float(c @ c), float(c @ s), float(s @ s)
- b1, b2 = float(c @ sig), float(s @ sig)
- det = a11 * a22 - a12 * a12
- if det <= 1e-12 or not np.isfinite(det):
- return 0.0
- A = (a22 * b1 - a12 * b2) / det
- B = (a11 * b2 - a12 * b1) / det
- return float(np.hypot(A, B))
- if rpm_cv < 0.02: # 转速近恒定 → 单频时域回归 (= bespoke order_amp)
- f1 = order * mean_rpm / 60.0
- amp = _harm_amp(x, 2 * np.pi * f1 * t)
- method = 'single_freq_regression'
- else: # 转速变 → 角域重采样锁阶次 (规避漂移泄漏)
- n_grid = max(int(n_rev * samples_per_rev), 16)
- rev_u = np.linspace(0.0, n_rev, n_grid)
- x_ang = np.interp(rev_u, rev, x)
- amp = _harm_amp(x_ang, 2 * np.pi * order * rev_u)
- method = 'angular_resample'
- return {'amp_1p': float(amp), 'n_rev': round(n_rev, 2), 'mean_rpm': round(mean_rpm, 2),
- 'rpm_cv': round(rpm_cv, 4), 'method': method,
- 'note': f'阶次跟踪 1P 幅值 (重力 detrend={detrend}, {method})'}
- def naive_fft_1p(accel, rpm, fs, *, order=1.0, tol=0.15):
- """对照: 固定频率 FFT 在 mean_rpm 处取 1P (=铁律②的 artifact 路径; 仅供测试/教学对比, 非判据)。
- 用 mean(rpm) 算单一 f1, 在 ±tol 带内取 FFT 峰。转速变时 1P 频率漂移 → 能量糊到带外/邻 bin,
- 此值系统偏低 → 演示"为什么不能用固定频率 FFT"。源 blade_imb_cms_1p_verify.harmonics。
- """
- a = np.asarray(accel, float); a = a[np.isfinite(a)]
- rpm_arr = np.asarray(rpm, float)
- mean_rpm = float(np.nanmean(rpm_arr)) if rpm_arr.size else np.nan
- if a.size < 64 or not np.isfinite(mean_rpm) or mean_rpm <= 0:
- return np.nan
- x = a - a.mean()
- win = np.hanning(x.size)
- A = 2 * np.abs(np.fft.rfft(x * win)) / win.sum()
- fr = np.fft.rfftfreq(x.size, 1 / float(fs))
- f1 = order * mean_rpm / 60.0
- m = (fr > f1 * (1 - tol)) & (fr < f1 * (1 + tol))
- return float(A[m].max()) if m.any() else np.nan
- def blade_imbalance_1p(captures, *, fs, order=1.0, z_thresh=2.0, min_captures=3,
- pitch_p95_by_blade=None, collective_tol=0.5,
- gravity_compensated=False, gravity_amp=9.81, gravity_tol_frac=0.30):
- """叶片不平衡 1P 谐波判别 — **重力感知门 + fleet null z** (SOP §4.1 模块3.2b).
- ⚠⚠ 重力 1P 铁律 (2026-06-20 独立 Critic 逮, 改写自首版): 旋转坐标系下叶根/挥舞/摆振向加速度的**重力
- 分量本身就是 1P** (g·sin(转角), 每转一周期)。order_track_1p 的 deg2 detrend 只去 DC/慢漂, **去不掉
- 1P 振荡** → 原始挥舞/摆振向通道的 1P 幅值 ≈ 1g(~9.8), 是**重力非不平衡**。真不平衡 1P 与重力 1P 同频
- 矢量相加 → 不知重力相位无法分离 (反相时真故障被抵消成低 1P, 假装健康)。源 bespoke
- blade_imb_chainB_ordertrack 已明示"1P死"(AC耦合/高通传感器 → 真不平衡 1P 在通带下, 只剩重力), 转用
- modal-z。故本函数 **默认 raw 通道 → 重力主导 → INSUFFICIENT** (诚实, 非假不平衡)。
- 判据:
- • gravity_compensated=False (默认, raw 挥舞/摆振向): 若 fleet 中位 1P 幅值落在 gravity_amp±gravity_tol_frac
- 内 → verdict='INSUFFICIENT(重力主导1P)', **不出不平衡 flag**; 路由 = modal-z(源 chainB) 或 切向/已补偿通道。
- 即便不在 1g 附近, raw 通道 1P 仍含未知重力相位 → 仍 INSUFFICIENT(不可信), 标 caveat。
- • gravity_compensated=True (调用方断言: 切向/扭矩向通道, 或已矢量扣除平衡参考窗重力相量): 才跑 fleet null
- MAD-z, **双侧**(高 z=不平衡候选; 极低 z/幅值近0=疑死/低增益通道, 非健康), 上限【预警】, 定论须现场叶检。
- ① 集距死前置: pitch_p95_by_blade 三叶 p95 极差<collective_tol=集距 → pitch_dead_indicator=True (桨距判不出, 走振动)。
- ② order_track_1p 阶次跟踪 (转速变→角域重采样, 非固定 FFT)。 ③ CMS '0RPM' 假标签: rpm≤0/不足 → 弃 capture。
- 入: captures — list of {'turbine', 'accel', 'rpm', 'fs'?}; gravity_compensated 见上。
- 出: dict {verdict, per_turbine:[{tid,amp_1p_median,z,side,n_cap,mean_rpm}], flagged, fleet_median, fleet_mad,
- n_turbines, gravity_dominated, gravity_compensated, pitch_dead_indicator, caveat}。
- """
- pitch_dead = None
- if pitch_p95_by_blade:
- vals = [float(v) for v in pitch_p95_by_blade.values() if v is not None and np.isfinite(v)]
- if len(vals) >= 2:
- pitch_dead = bool((max(vals) - min(vals)) < collective_tol)
- by_turb = {}
- for cap in captures:
- f = float(cap.get('fs', fs))
- r = order_track_1p(cap.get('accel'), cap.get('rpm'), f, order=order)
- if r['method'] is None or not np.isfinite(r['amp_1p']): # ③ 0RPM/marginal capture 弃
- continue
- by_turb.setdefault(cap.get('turbine'), []).append((r['amp_1p'], r['mean_rpm']))
- rows = []
- for tid, lst in by_turb.items():
- amps = np.array([a for a, _ in lst], float)
- if len(amps) < min_captures: # per台捕获不足 → 不入 fleet (诚实)
- continue
- rows.append({'tid': str(tid), 'amp_1p_median': float(np.median(amps)),
- 'n_cap': int(len(amps)), 'mean_rpm': round(float(np.mean([m for _, m in lst])), 1)})
- base = {'per_turbine': rows, 'flagged': [], 'fleet_median': None, 'fleet_mad': None,
- 'n_turbines': len(rows), 'gravity_compensated': gravity_compensated,
- 'pitch_dead_indicator': pitch_dead}
- if len(rows) < 2:
- return {**base, 'verdict': 'INSUFFICIENT(有效台<2)', 'gravity_dominated': None,
- 'caveat': f'有效台<2 (per台需≥{min_captures}捕获); 叶片级 1P 须 CMS 高频振动 + 真转速 join'}
- amp_all = np.array([r['amp_1p_median'] for r in rows], float)
- fleet_med = float(np.median(amp_all))
- grav_dom = bool(abs(fleet_med - gravity_amp) <= gravity_tol_frac * gravity_amp)
- for r in rows:
- r['amp_1p_median'] = round(r['amp_1p_median'], 4)
- # ⚠ 默认 raw 通道: 1P 不可信 → INSUFFICIENT (诚实)。两条 raw-通道致盲机制 (zyx 实证, memory item4):
- # ① 摆振向(edgewise): 重力 1P 主导 (g·sinθ≈1g, deg2 去不掉) → fleet 1P≈1g (grav_dom)。
- # ② 挥舞向(flapwise): 重力投影弱(1P 小), 但传感器标定不一/bimodal → 跨台绝对 1P 极差巨大(uncal), 不可比。
- # 两者真不平衡都与重力同频矢量相加(反相被抵消假健康) → raw 一律拒, 路由 modal-z / 切向补偿通道。
- if not gravity_compensated:
- amps_pos = [r['amp_1p_median'] for r in rows if r['amp_1p_median'] and r['amp_1p_median'] > 0]
- xt_ratio = (max(amps_pos) / min(amps_pos)) if len(amps_pos) >= 2 and min(amps_pos) > 0 else None
- uncal = bool(xt_ratio is not None and xt_ratio > 5) # 跨台 1P >5× = 传感器标定不一/bimodal
- gcav = ('⚠ raw 通道 1P 不可作叶片不平衡: '
- + (f'摆振向重力主导(fleet 1P={fleet_med:.2f}≈1g, 重力=1P deg2去不掉); ' if grav_dom else '')
- + (f'挥舞向跨台 1P 极差 {xt_ratio:.0f}×=传感器标定不一/bimodal, 绝对1P跨台不可比; ' if uncal else f'fleet 中位 1P={fleet_med:.3f}; ')
- + '真不平衡与重力同频矢量相加(反相被抵消假健康)。路由 modal-z(源 chainB "1P死") 或 切向/补偿通道(gravity_compensated=True)。')
- if pitch_dead:
- gcav = '⚠ 集距口径(桨距判不出叶片不平衡); ' + gcav
- return {**base, 'verdict': 'INSUFFICIENT(raw通道1P不可信:重力/未标定)', 'gravity_dominated': grav_dom,
- 'cross_turbine_1p_ratio': (round(xt_ratio, 1) if xt_ratio else None), 'uncalibrated': uncal,
- 'fleet_median': round(fleet_med, 4), 'caveat': gcav}
- # gravity_compensated=True: 才跑 fleet null MAD-z (双侧)
- mad = float(1.4826 * np.median(np.abs(amp_all - fleet_med)))
- for r in rows:
- z = float((r['amp_1p_median'] - fleet_med) / max(mad, 1e-9))
- r['z'] = round(z, 2)
- r['side'] = ('high_imbalance' if z >= z_thresh else ('low_suspect_dead' if z <= -z_thresh else 'normal'))
- rows.sort(key=lambda x: x['z'], reverse=True)
- flagged = [r['tid'] for r in rows if r['side'] == 'high_imbalance']
- low_suspect = [r['tid'] for r in rows if r['side'] == 'low_suspect_dead']
- cav = ('已补偿/切向通道 fleet null MAD-z 双侧; 高 z=不平衡候选(上限【预警】, 定论须现场叶检质量配平/气动外形); '
- '极低 z/幅值近0=疑死/低增益通道(非健康, 须核传感器); 阶次跟踪扣转速; CMS 0RPM 须先 join 真转速')
- if low_suspect:
- cav = f'⚠ 低 1P 疑死/低增益通道台 {low_suspect} (非健康, 须核传感器); ' + cav
- if pitch_dead:
- cav = '⚠ 集距口径(桨距判不出, 已转振动 1P); ' + cav
- return {**base, 'verdict': ('叶片1P不平衡候选(预警)' if flagged else '已补偿通道 1P fleet 中位(无离群)'),
- 'flagged': flagged, 'fleet_median': round(fleet_med, 4), 'fleet_mad': round(mad, 4),
- 'gravity_dominated': grav_dom, 'caveat': cav}
- # ---------------------------------------------------------------- §4.1 模块3.3 MCSA 数据维度门
- # MCSA 须 kHz 级原始电流波形捕特征边带的物理依据 (RULE-3, 奈奎斯特): 电流频谱需分辨电频基波 f1(~50Hz) 叠
- # 机械故障调制边带 (转子断条 2sf 贴基波 / 气隙偏心偏离几十~上百Hz / 轴承特征调制到 f1±数百Hz / PWM 2~4kHz)。
- # 取保守上界 f_max≈650Hz → 奈奎斯特 fs≥1300Hz; 含谐波分辨+抗混叠裕度(~5×) fs≥~3kHz。门阈默认 2000Hz 偏保守;
- # 10min(1/600Hz)/秒级(1Hz) 聚合层远不足 → 不可做 MCSA(数据维度不够), 显式 ≠ "电气健康"(SOP §0.2)。
- _MCSA_KHZ_FLOOR_HZ = 2000.0
- def mcsa_feasibility_gate(*, sampling_hz=None, columns=None, column_sampling_hz=None,
- electrical_params=None, khz_floor_hz=_MCSA_KHZ_FLOOR_HZ,
- waveform_col_hint=('current_wave', 'i_wave', 'stator_current_raw',
- 'iu_raw', 'iv_raw', 'iw_raw', '电流波形')):
- """MCSA 可行性门 — 诚实数据维度门 (SOP §4.1 模块3.3; RULE-3 奈奎斯特, n=物理). 无 IO。
- 判**能不能做 MCSA**, 不做 MCSA 本身。MCSA 须 kHz 级原始定子电流波形 (转子断条 2sf/气隙偏心/轴承特征
- 边带靠电流频谱高频特征定位, 奈奎斯特要求 fs 覆盖最高特征边带)。SCADA 聚合层只有功率/电流**均值**
- (10min=1/600Hz, 秒级=1Hz) → 无 kHz 波形 → MCSA 不可做, **显式 ≠ "电气健康"** (数据没覆盖故障频带)。
- 入: sampling_hz (标量, 最快通道采样率) 或 columns+column_sampling_hz (取电流类列最高采样率比阈);
- electrical_params (须 极对数 ∧ (槽数|滑差), 缺则即便有 kHz 波形也定位不了特征频率)。
- 返回 dict (保守偏 INSUFFICIENT): {feasible, verdict, reason, required_data, method_when_feasible,
- max_sampling_hz, khz_floor_hz, has_electrical_params}。
- """
- required = ['kHz 级原始定子电流波形 (三相, fs≥~数 kHz; 非 10min/秒级均值)',
- '电气设计参数: 极对数(pole_pairs) + 槽数(slots) 或 滑差(slip) (特征频率定位须)',
- '转速/滑差通道 (2sf 边带与轴承阶次定位须)']
- method_when_feasible = ('mcsa_method_skeleton(): 定子电流 FFT/包络谱 → 转子断条 2sf 边带 / '
- '气隙偏心边带 / 轴承特征频率; 滑差+极对数定位特征频率。**待 kHz 数据, 未首跑**。')
- cand_hz = []
- if sampling_hz is not None and np.isfinite(sampling_hz):
- cand_hz.append(float(sampling_hz))
- if columns and column_sampling_hz:
- cur_hz = [float(column_sampling_hz[c]) for c in columns
- if c in column_sampling_hz and np.isfinite(column_sampling_hz.get(c, np.nan))
- and any(h in str(c).lower() for h in waveform_col_hint)]
- if cur_hz:
- cand_hz.append(max(cur_hz))
- else:
- all_hz = [float(v) for v in column_sampling_hz.values() if np.isfinite(v)]
- if all_hz:
- cand_hz.append(max(all_hz))
- max_hz = max(cand_hz) if cand_hz else None
- ep = electrical_params or {}
- has_pp = any(k in ep and ep[k] is not None for k in ('pole_pairs', 'p', '极对数'))
- has_loc = any(k in ep and ep[k] is not None for k in ('slots', 'slip', 's', '槽数', '滑差'))
- has_ep = bool(has_pp and has_loc)
- base = {'max_sampling_hz': max_hz, 'khz_floor_hz': float(khz_floor_hz),
- 'has_electrical_params': has_ep, 'required_data': required,
- 'method_when_feasible': method_when_feasible}
- if max_hz is None:
- return {**base, 'feasible': False, 'verdict': 'MCSA 可行性不可判(未提供采样率/列)',
- 'reason': '未提供 sampling_hz 或 (columns+column_sampling_hz); 无法判数据是否含 kHz 波形。'}
- if max_hz < khz_floor_hz:
- return {**base, 'feasible': False, 'verdict': 'MCSA 不可做(数据维度不够)',
- 'reason': (f'最快采样率 {max_hz:g}Hz < kHz 门阈 {khz_floor_hz:g}Hz(奈奎斯特覆不住 2sf/偏心/'
- f'轴承特征边带, RULE-3): SCADA 聚合层只有功率/电流均值, 无 kHz 原始波形 → '
- f'MCSA 不可做(数据维度不够), **明确 ≠ "电气健康"**(数据没覆盖故障频带, 非"看过没隐患")。')}
- if not has_ep:
- return {**base, 'feasible': False, 'verdict': 'MCSA 不可做(无电气设计参数)',
- 'reason': (f'采样率 {max_hz:g}Hz≥{khz_floor_hz:g}Hz 满足 kHz 维度, 但缺电气设计参数'
- f'(须极对数∧(槽数|滑差)定位 2sf/偏心/轴承特征频率) → 暂不可做; 补参数即可行。')}
- return {**base, 'feasible': True, 'verdict': 'MCSA feasible (待首跑)',
- 'reason': (f'采样率 {max_hz:g}Hz≥{khz_floor_hz:g}Hz 满足 kHz 维度 ∧ 电气设计参数齐 → '
- f'可做 MCSA; 走 method skeleton。**campaign 实际无此数据, 未首跑**。')}
- def mcsa_method_skeleton(*, stator_current, fs, pole_pairs, line_freq=50.0,
- slip=None, rotor_rpm=None, bearing_geom=None):
- """MCSA 方法骨架 (定子电流频谱诊断) — **待 kHz 高频数据, 未首跑; 占位骨架, 勿当已验证**。
- 数据维度门 (mcsa_feasibility_gate) 通过后才走此。诊断三族: ①转子断条 2sf 边带 f=f1·(1±2k·s)
- ②气隙偏心边带 f=f1·[1±k·(1−s)/p] ③轴承特征频率调制 f1±m·f_b。定位须滑差 s + 极对数 p (轴承须几何)。
- **campaign 无 kHz 电流波形, 从未首跑 → 禁直接调用产出结论 (未验证 claim)**。
- """
- raise NotImplementedError(
- 'mcsa_method_skeleton 待 kHz 级原始电流波形数据首跑 + 校准, 当前 campaign 无此数据 → '
- '禁产出结论 (未验证)。先过 mcsa_feasibility_gate, 有 kHz 波形再实现频谱/边带提取。')
- # ---------------------------------------------------------------- §3.4 模块3.4 齿轮箱油液 (条件门)
- def oil_quality_gate(df=None, *, columns=None, oil_sensor_cols=None, oil_temp_cols=None,
- time_col=None, turbine_col=None, slope_window=30, min_points=300,
- iso4406_warn_jump=2, water_ppm_spec=None):
- """齿轮箱油液 条件门 (SOP 模块 3.4, 条件性). 诚实路由, **禁假"油液正常"**.
- campaign 多数场条件受限 (无在线油液传感/无化验报告, knowledge_base 只有油液文献 PDF=文献非实测)。
- SCADA 给不出油质定论 — 本函数只做诚实条件门 + 路由, 三档:
- (a) 有颗粒计数(ISO 4406)/水分 ppm 在线传感列 → 趋势 + 突变检测, verdict 至【准定论·预警】。
- (b) 只有油温/油压 (无油质传感) → 冷却/堵塞 proxy 非油质 → 路由 3.5 冷却, **禁判油质** (郭家店 a8 形态)。
- (c) 都无 → 【INSUFFICIENT】N/A (不强建=反膨胀), **禁**"油液正常/无隐患"。
- 定论须 offline 化验 (颗粒/水分/粘度/元素谱定位磨损部件) — SCADA 永远给不出。
- RULE-3: 颗粒 ISO 4406:1999 等级跳≥2 档 (每档~×2 浓度); 水分 ppm 规格上限 (DL/T 1096 类); 油温只 proxy。
- 返回 dict: {channel_class: 'oil_sensor'|'temp_proxy_only'|'none', verdict, reason, routed_to, action,
- (oil_sensor 分支另带) trends}。verdict ∈ SOP §0.2 六枚举。
- """
- sensor = [c for c in (oil_sensor_cols or []) if df is None or c in df.columns]
- temp = [c for c in (oil_temp_cols or []) if df is None or c in df.columns]
- if not sensor and not temp: # (c) 都无 → INSUFFICIENT N/A
- return {'channel_class': 'none', 'verdict': '【INSUFFICIENT】',
- 'reason': ('无油液在线传感(颗粒计数/水分)且无油温/油压通道, 无取样化验报告 → 油质状态不可知。'
- '**非"油液正常/无隐患"** (反膨胀: 无传感无化验不强建本模块)。'),
- 'routed_to': None,
- 'action': 'N/A — 不强建。如需油质结论须加装在线油液传感(颗粒/水分) 或 取样送 offline 化验。'}
- if temp and not sensor: # (b) 只油温/油压 → proxy, 路由 3.5
- return {'channel_class': 'temp_proxy_only',
- 'verdict': '【INSUFFICIENT】(油质); 油温=proxy非油质, 路由 3.5 冷却',
- 'reason': (f'仅有油温/油压通道 ({", ".join(map(str, temp))}), 无颗粒计数/水分传感。'
- '油温/油压是**冷却/堵塞 proxy 非油质** (温升多为负荷驱动 artifact, 须负荷归一防因果反置)。'
- '**禁**据此判油质劣化或"油液正常"。'),
- 'routed_to': '3.5 冷却系统 (负荷归一温升 + ΔT/效能)',
- 'action': '油温/油压趋势交 3.5 冷却分析 (防把负荷驱动温升误当油液问题)。油质定论仍须 offline 化验。'}
- trends = {} # (a) 有油质传感 → 趋势 + 突变
- if df is not None and time_col and time_col in df.columns:
- def _trend(s):
- s = pd.to_numeric(s, errors='coerce').dropna()
- if len(s) < min_points:
- return {'slope_per_window': None, 'last_vs_baseline': None,
- 'jump_flag': None, 'n': int(len(s)), 'note': f'样本<{min_points}, 趋势不可靠'}
- base = float(s.iloc[:max(slope_window, len(s) // 4)].median())
- last = float(s.iloc[-slope_window:].median()) if len(s) >= slope_window else float(s.median())
- x = np.arange(len(s), dtype=float)
- slope = float(np.polyfit(x, s.values.astype(float), 1)[0]) * slope_window
- return {'slope_per_window': round(slope, 4), 'baseline': round(base, 3),
- 'last_window': round(last, 3), 'last_vs_baseline': round(last - base, 3), 'n': int(len(s))}
- grp = df.sort_values(time_col)
- iters = ([(None, grp)] if not (turbine_col and turbine_col in df.columns)
- else list(grp.groupby(turbine_col)))
- for col in sensor:
- per = {}
- for tid, g in iters:
- tr = _trend(g[col])
- jf = None
- lv = tr.get('last_vs_baseline')
- if lv is not None:
- if 'iso' in str(col).lower() or '4406' in str(col) or '颗粒' in str(col):
- jf = bool(lv >= iso4406_warn_jump)
- elif water_ppm_spec is not None and tr.get('last_window') is not None:
- jf = bool(tr['last_window'] > water_ppm_spec)
- tr['jump_flag'] = jf
- per[str(tid) if tid is not None else '_all'] = tr
- trends[col] = per
- any_jump = any(t.get('jump_flag') for col in trends.values() for t in col.values())
- verdict = '【准定论·预警】' if any_jump else '【候选】'
- reason = ('在线油液传感 (颗粒计数 ISO 4406 / 水分 ppm) 趋势'
- + ('检出突变/超规格 → 油质劣化候选 (升【预警】); ' if any_jump else '未见突变 → 单方法单窗信号; ')
- + '部件级/磨损定位**仍须 offline 化验** (元素谱定位磨损部件), SCADA 在线趋势上限【预警】不下根因。')
- return {'channel_class': 'oil_sensor', 'verdict': verdict, 'reason': reason, 'routed_to': None,
- 'action': '颗粒/水分预警台 → 取样送 offline 化验 (颗粒/水分/粘度/元素谱) 确诊 + 定位磨损部件 (齿/轴承)。',
- 'trends': trends}
- # ---------------------------------------------------------------- §4.1 模块3.5 冷却系统效能
- def cooling_efficiency(df, *, temp_col, p_col, turbine_col, time_col,
- ws_col=None, amb_col=None, rated=None,
- p_bin_kw=100.0, min_cell=200, min_per_turbine=1000,
- z_thresh=2.0, abs_temp_floor=2.0, causal_ws_lo=8.0, causal_ws_hi=12.0,
- causal_margin_kw=50.0, causal_margin_frac=0.03, near_rated_frac=0.90,
- release_bins=8, mono_min_frac=0.75, comp_valid_range=(-40.0, 200.0)):
- """冷却系统效能判别 (SOP §4.1 模块3.5, 源 gjd a8_cooling 探索, n=1[条件性], RV-2 搬真逻辑不改判据).
- 回答冷却衰减否, 但**必先排因果反置**: 功率↑→发热→冷却水温↑ = 负荷驱动温升, NOT 高温→降容。三层
- (郭家店 1338MWh→真197MWh / 21%高估 教训 + memory temp_derate_overtemp_verification):
- A. 因果方向守卫 (ws_col 给定时): 同风速 bin 高冷却温组 vs 低温组功率中位。真降容=高温伴低功率;
- 负荷驱动 artifact=高温组功率**反而更高** → causal='load_driven'。极端高温@高风仍近额定 = 坐实非降容。
- **禁在 load_driven 下报"冷却衰减致降容"**。
- B. 释放曲线分型: gen 态按部件温分位 bin, per-bin 功率上界 p95 随温**单调上升** = 冷油保护低温冷启
- (冬季集中, 真降容但正常保护) 非过热; 高温侧无功率被压 = 非高温降容。
- C. 真冷却衰减候选 (防漏检, 卡明写): 同功率 bin (扣负荷消因果) 各台冷却温偏移 → fleet MAD-z; z≥阈
- 且系统偏热 = 冷却候选 (散热器/水泵/风扇)。与 A/B 正交: A 防误当衰减, C 防漏真衰减。
- 无流量/进出水温列 → 不算 ΔT, 只比同负荷温水平。真过热确诊须 30 秒实温 vs 告警阈 (F级155°C, 留下一轮)。
- 阈值 RULE-3: 同功率 bin MAD-z≥2 (RULE-4); 因果方向=功率↑→温升(物理); 释放曲线单调; 禁 magic number。
- ⚠ 被排名通道存活守卫 (§4.6c, 2026-06-28 加): temp_col **逐台**经 reference_channel_liveness 验活,
- 冻结/坐死的台 (comp_valid_range 先剔野值) 从同功率偏移排名中剔出 (入 dead_channel), 防"传感器坏"
- 伪装"冷却偏热" (06淄川 F0810001022 戒: 舱外温度冻结同因致 dT 类判据假阳, 此处护冷却水温自身)。
- 返回 dict: {causal, causal_detail, hi_minus_lo_kw, near_rated_at_high_temp, cooling_type,
- release_monotonic_frac, release_curve, per_turbine, flagged, n_total, verdict, evidence, dead_channel}。
- """
- d = df.dropna(subset=[temp_col, p_col, turbine_col, time_col]).copy()
- T = pd.to_numeric(d[temp_col], errors='coerce')
- P = pd.to_numeric(d[p_col], errors='coerce')
- d = d.assign(_T=T, _P=P).dropna(subset=['_T', '_P'])
- if len(d) < 2000:
- return {'causal': None, 'cooling_type': None, 'per_turbine': [], 'flagged': [],
- 'n_total': 0, 'verdict': 'INSUFFICIENT', 'evidence': '样本不足(<2000)', 'dead_channel': []}
- gen = d[d['_P'] > 0]
- if len(gen) < 1000:
- gen = d
- causal, causal_detail, hi_lo, near_rated = None, None, None, None
- if ws_col and ws_col in d:
- ws = pd.to_numeric(gen[ws_col], errors='coerce')
- g = gen.assign(_ws=ws).dropna(subset=['_ws'])
- band = g[(g['_ws'] >= causal_ws_lo) & (g['_ws'] < causal_ws_hi)]
- if len(band) >= 2 * min_cell:
- tmed = band['_T'].median()
- hi = band[band['_T'] > tmed]['_P']; lo = band[band['_T'] <= tmed]['_P']
- if len(hi) >= min_cell and len(lo) >= min_cell:
- hi_lo = float(hi.median() - lo.median())
- # 因果阈按 rated 归一 (RULE-3, Critic 逮: 50kW 绝对阈跨机型不可比 — 6MW 的 0.83% vs 1.5MW 的 3.3%)
- margin = causal_margin_frac * rated if rated else causal_margin_kw
- causal = 'load_driven' if hi_lo > margin else 'temp_limits_power'
- causal_detail = (f'同 ws[{causal_ws_lo:.0f},{causal_ws_hi:.0f}) 高冷却温组 P_med−低温组 P_med '
- f'= {hi_lo:+.0f}kW (>{margin:.0f}={causal_margin_frac:.0%}rated=高温伴高功率→负荷驱动温升, 非降容)')
- if rated:
- hot = g[(g['_T'] >= g['_T'].quantile(0.95)) & (g['_ws'] >= causal_ws_hi)]
- if len(hot) >= min_cell:
- near_rated = bool(hot['_P'].median() >= near_rated_frac * rated)
- cooling_type, mono_frac, rel_curve = None, None, None
- try:
- qb = pd.qcut(gen['_T'], q=release_bins, duplicates='drop')
- rc = gen.groupby(qb, observed=True).agg(p_ceil=('_P', lambda s: float(s.quantile(0.95))),
- t_mid=('_T', 'median'), n=('_P', 'size'))
- rc = rc[rc['n'] >= min_cell]
- if len(rc) >= 4:
- ceils = rc['p_ceil'].values
- ups = np.sum(np.diff(ceils) > 0)
- mono_frac = float(ups / max(len(ceils) - 1, 1))
- rel_curve = {round(float(t), 1): round(float(p), 0)
- for t, p in zip(rc['t_mid'].values, ceils)}
- cooling_type = 'cold_start_release' if mono_frac >= mono_min_frac else 'no_temp_release'
- except (ValueError, IndexError):
- pass
- gen = gen.assign(_pb=(np.round(gen['_P'] / p_bin_kw) * p_bin_kw))
- cell_med = gen.groupby('_pb')['_T'].transform('median')
- gen = gen.assign(_dT=gen['_T'] - cell_med)
- agg = (gen.groupby(turbine_col).agg(temp_offset=('_dT', 'mean'), n=('_dT', 'size'))
- .reset_index().rename(columns={turbine_col: 'tid'}))
- agg = agg[agg['n'] >= min_per_turbine]
- # §4.6c 被排名通道存活守卫: 冻结的冷却水温会把"传感器坏"伪装成同功率偏热 → 剔出排名 (F0810001022 戒)
- dead_channel = sorted({str(t) for t, gt in gen.groupby(turbine_col)
- if reference_channel_liveness(gt['_T'], valid_range=comp_valid_range,
- min_n=min_per_turbine)['live'] is False})
- if dead_channel:
- agg = agg[~agg['tid'].astype(str).isin(dead_channel)]
- rows, flagged = [], []
- if not agg.empty:
- mad = float((agg['temp_offset'] - agg['temp_offset'].median()).abs().median() * 1.4826)
- agg['z'] = (agg['temp_offset'] - agg['temp_offset'].median()) / max(mad, 1e-6)
- agg = agg.sort_values('z', ascending=False)
- rows = [{'tid': str(r.tid), 'temp_offset': round(float(r.temp_offset), 2),
- 'z': round(float(r.z), 2), 'n': int(r.n)} for r in agg.itertuples(index=False)]
- # 绝对温度地板 (同 matched_load dt_abs_floor / vibration mad_floor): C层 z 分母无 floor → tight-fleet
- # 亚度温差被放大成 z≥2 假阳 → 候选还须 temp_offset≥abs_temp_floor(=2°C, <2°C 冷却衰减无运维意义)。
- flagged = [r['tid'] for r in rows if r['z'] >= z_thresh and r['temp_offset'] >= abs_temp_floor]
- # ⚠ flagged (C层真衰减候选) 始终先 surface (Critic 2026-06-20 逮: 原 load_driven 分支在检查 flagged 前 return
- # → 顶层 verdict 掩盖真衰减台, 违反卡"勿因防高估而漏检"). 整体因果/冷启作为限定语并报, 不吞掉个体候选。
- if flagged:
- top = rows[0]
- ctx = ('负荷驱动温升(整体)' if causal == 'load_driven'
- else ('低温冷启保护(整体)' if cooling_type == 'cold_start_release' else None))
- verdict = (f'{ctx} + {len(flagged)}台同功率偏热候选' if ctx else '冷却衰减候选')
- ev = (f'C层: 同功率bin(扣负荷消因果)内 {len(flagged)}台冷却温系统性偏高 top {top["tid"]} '
- f'{top["temp_offset"]:+.1f}°C(z={top["z"]}); 散热器/水泵/风扇检修候选(真过热须30秒实温 vs F级155°C). '
- + (causal_detail + '. ' if causal_detail else ''))
- elif causal == 'load_driven':
- verdict = '负荷驱动温升(非冷却衰减)'
- ev = (causal_detail or '') + (f'; 极端高温@高风 P_med 仍近额定({near_rated_frac:.0%}rated)' if near_rated else '') \
- + ' → 因果反置守卫: 不报"冷却衰减致降容"(郭家店 1338MWh→真197MWh 教训)'
- elif cooling_type == 'cold_start_release':
- verdict = '低温冷启保护(正常降容)'
- ev = (f'释放曲线随温单调升 (升幅占比 {mono_frac:.0%}≥{mono_min_frac:.0%}): {rel_curve} '
- f'= 冷油保护低温冷启 (暖机后释放, 冬季集中), 真降容但正常保护非过热')
- elif rows:
- verdict = '同功率温度fleet中位(正常)'
- ev = f'同功率 bin 内各台温偏移皆 |z|<{z_thresh} (max |z|={max(abs(r["z"]) for r in rows):.1f}); 无系统性偏热'
- else:
- verdict = 'INSUFFICIENT'
- ev = f'per 台样本不足(需≥{min_per_turbine}) 或无有效功率 bin'
- return {'causal': causal, 'causal_detail': causal_detail, 'hi_minus_lo_kw': hi_lo,
- 'near_rated_at_high_temp': near_rated, 'cooling_type': cooling_type,
- 'release_monotonic_frac': mono_frac, 'release_curve': rel_curve,
- 'per_turbine': rows, 'flagged': flagged, 'n_total': len(rows),
- 'verdict': verdict, 'evidence': ev, 'dead_channel': dead_channel}
- # ---------------------------------------------------------------- §4.1 3.6 + §4.6 卡桨筛查
- def pitch_stick(df, *, turbine_col, time_col, blade_cols=None,
- pitch_col=None, blade_id_col=None, moving_col=None,
- rate_thresh=0.5, collective_spread_deg=0.5, z_thresh=2.0,
- abs_floor_deg=1.0, abs_floor_mad_k=3.0, lag_consist_min=0.6,
- min_move_samples=200, min_move_range_deg=3.0, min_turbines=4):
- """变桨卡桨(stick)筛查 — 运动段三叶同步性 + 速率均衡 MAD-z (SOP §4.1 3.6 + §4.6 卡滞决策树).
- 源 (RV-2 搬真逻辑不改判据): scripts/zyx_pitch_stick_sec.py + zyx_pitch_stick.py. n=1 [zyx F29/F35].
- 仅做**秒级**卡桨筛查 (10min 层平均掉运动细节)。
- ⭐⭐ 能力边界 (诚实): SCADA 只筛**卡死(stick)**, 筛不出**渐进磨损(wear)** —— wear≠stick。zyx OEM 确诊
- F29/F35 轴承 Fe 磨损但 SCADA 三叶同步/速率/(电机电流死列) 全正常 = double-null。渐进磨损确诊**必油脂化验**。
- 陷阱: ② 变桨电机电流=死列, 桨距给定镜像实际 → 直接判据废 → 用运动段三叶同步性(非静态/非给定)。
- ③ 集距死(三桨同值, per-row 极差≈0) → 不可分 → INSUFFICIENT。 ④ 必在运动段比同步性。
- 算法 per 台: 运动段 = moving_col 或 |Δβ|/Δt>rate_thresh 派生; ① 三叶同步性 = 运动段 per-row 极差 p95
- (用 p95 非中位: 真卡滞常间歇, 中位被健康段冲淡); ② 速率均衡 = per 叶速率中位 → lag_blade 离群叶;
- fleet MAD-z (RULE-4): sync_spread 跨台 MAD-z = 主轴; 卡滞候选 = sync_z≥z_thresh ∧ (sync_spread−
- fleet中位)≥abs_floor_deg (绝对地板拦健康场 MAD→0 微 z 假阳)。<min_turbines 台跳 fleet z。
- 入: 宽表 blade_cols=[叶1,叶2,叶3] 或 长表 pitch_col+blade_id_col; moving_col 可选。
- 返回 dict: {per_turbine:[{tid,sync_spread_deg,rate_imbalance,lag_blade,rate_by_blade,n_move,
- sync_z,imb_z,verdict,note}], flagged, collective_pitch, n_turbines, wear_blind_caveat, caveat}。
- """
- WEAR_CAVEAT = ('⚠ SCADA 只筛卡死(stick), 对渐进磨损(wear)盲: zyx F29/F35 OEM确诊轴承Fe磨损但'
- '三叶同步/速率/(电机电流死列)全正常=double-null。渐进磨损确诊须油脂取样化验'
- '(offline lab, Fe/Cu/水分/基础油), 非可选。')
- if blade_cols:
- d = df.dropna(subset=[turbine_col, time_col]).copy()
- bcols = list(blade_cols)
- elif pitch_col and blade_id_col:
- d = df.dropna(subset=[turbine_col, time_col, blade_id_col, pitch_col]).copy()
- piv = d.pivot_table(index=[turbine_col, time_col], columns=blade_id_col,
- values=pitch_col, aggfunc='mean')
- bcols = [f'_b{c}' for c in piv.columns]
- piv.columns = bcols
- d = piv.reset_index()
- else:
- raise ValueError('须给 blade_cols=[叶1,叶2,叶3] 或 (pitch_col + blade_id_col)')
- if len(bcols) < 3:
- return {'per_turbine': [], 'flagged': [], 'collective_pitch': False, 'n_turbines': 0,
- 'wear_blind_caveat': WEAR_CAVEAT, 'caveat': f'三叶不全 (仅 {len(bcols)} 叶) → INSUFFICIENT'}
- for c in bcols:
- d[c] = pd.to_numeric(d[c], errors='coerce')
- d = d.dropna(subset=bcols)
- row_spread_all = d[bcols].max(axis=1) - d[bcols].min(axis=1) # ③ 集距死守卫
- if len(row_spread_all) and float((row_spread_all <= collective_spread_deg).mean()) > 0.98:
- return {'per_turbine': [], 'flagged': [], 'collective_pitch': True,
- 'n_turbines': int(d[turbine_col].nunique()), 'wear_blind_caveat': WEAR_CAVEAT,
- 'caveat': (f'集距死: {float((row_spread_all <= collective_spread_deg).mean()):.0%} 样本三叶'
- f'极差 ≤{collective_spread_deg}° (三桨同值/集距控制) → per叶不可分 → 卡桨筛查 '
- f'INSUFFICIENT。须秒级独立桨距通道 (集距场转 1P 谐波/振动, 见 blade_imbalance)。')}
- recs = []
- for tid, g in d.groupby(turbine_col):
- g = g.sort_values(time_col)
- B = g[bcols].to_numpy(float)
- if moving_col and moving_col in g:
- mv = g[moving_col].to_numpy(bool)
- rate = np.abs(np.diff(B, axis=0))
- mv_rate = mv[1:]
- else:
- rate = np.abs(np.diff(B, axis=0))
- mv_rate = rate.max(axis=1) > rate_thresh
- if mv_rate.sum() < min_move_samples:
- recs.append({'tid': str(tid), 'sync_spread_deg': None, 'rate_imbalance': None,
- 'lag_blade': None, 'rate_by_blade': None, 'n_move': int(mv_rate.sum()),
- 'sync_z': None, 'imb_z': None, 'verdict': 'INSUFFICIENT(运动段不足)',
- 'note': f'运动段样本 {int(mv_rate.sum())} < {min_move_samples}'})
- continue
- mv_rows = np.r_[False, mv_rate] | np.r_[mv_rate, False]
- mv_rows = mv_rows[:len(B)]
- Bm = B[mv_rows]
- # 运动段净幅守卫 (Critic 逮: rate_thresh 被 AR(1) 噪声尖峰击穿→静止噪声台被当"运动"假阳).
- # 真变桨在运动段会真实扫过角度范围; 纯噪声只在小带抖 → 桨距幅<阈 = 疑噪声非真运动 → INSUFFICIENT。
- move_range = float(Bm.max() - Bm.min()) if Bm.size else 0.0
- if move_range < min_move_range_deg:
- recs.append({'tid': str(tid), 'sync_spread_deg': None, 'rate_imbalance': None,
- 'lag_blade': None, 'rate_by_blade': None, 'n_move': int(mv_rate.sum()),
- 'sync_z': None, 'imb_z': None, 'verdict': 'INSUFFICIENT(疑噪声非真运动)',
- 'note': f'运动段桨距幅 {move_range:.1f}°<{min_move_range_deg}° (疑噪声尖峰被当运动段, 非真变桨)'})
- continue
- spread = (Bm.max(axis=1) - Bm.min(axis=1))
- sync_spread = float(np.percentile(spread, 95)) if spread.size else float('nan')
- rate_mv = rate[mv_rate]
- rate_by_blade = np.median(rate_mv, axis=0)
- rmax = float(rate_by_blade.max()); rmin = float(rate_by_blade.min())
- imb = float((rmax - rmin) / rmax) if rmax > 1e-9 else 0.0
- # lag 一致性 (替 median-rate 确认, Critic 逮后改): 高 spread 行里**同一叶**持续当离群的占比.
- # 真卡死(即便间歇): 冻结叶在冻结窗里恒为离群 → 一致性高(~1.0); 三叶同噪健康台: 离群叶逐行随机 → ~0.33.
- # (median 每叶速率washes out 间歇冻结的 75% 正常运动, 故不用 rate_imbalance 当闸, 只留作 info。)
- hi = spread >= np.percentile(spread, 95) if spread.size else np.zeros(len(Bm), bool)
- lag_consistency = 0.0
- if hi.any():
- dev = np.abs(Bm[hi] - np.median(Bm[hi], axis=1, keepdims=True)) # (H,3)
- counts = np.bincount(np.argmax(dev, axis=1), minlength=3) # 每高spread行最离群叶 → 计数
- lag_blade = int(np.argmax(counts) + 1)
- lag_consistency = float(counts.max() / counts.sum())
- else:
- lag_blade = int(np.argmin(rate_by_blade) + 1)
- recs.append({'tid': str(tid), 'sync_spread_deg': round(sync_spread, 3),
- 'rate_imbalance': round(imb, 3), 'lag_blade': lag_blade,
- 'lag_consistency': round(lag_consistency, 3),
- 'rate_by_blade': [round(float(x), 3) for x in rate_by_blade],
- 'n_move': int(mv_rate.sum()), 'sync_z': None, 'imb_z': None,
- 'verdict': None, 'note': ''})
- valid = [r for r in recs if r['sync_spread_deg'] is not None]
- if len(valid) >= min_turbines:
- def _madz(vals):
- v = np.asarray(vals, float)
- med = np.median(v)
- mad = np.median(np.abs(v - med)) * 1.4826
- return (v - med) / mad if mad > 1e-9 else np.zeros_like(v)
- sync_vals = [r['sync_spread_deg'] for r in valid]
- sync_med = float(np.median(sync_vals))
- sync_mad = float(np.median(np.abs(np.asarray(sync_vals) - sync_med)) * 1.4826)
- # 绝对地板按真实噪声本底自适应 (Critic 逮: abs_floor=1° < 实测健康噪声 p95~1.4°, 是按确定性合成定的偏低).
- # 取 max(abs_floor_deg, k·fleet_MAD) → 健康噪声大时地板随之抬高, 不被单台噪声台击穿。
- eff_floor = max(abs_floor_deg, abs_floor_mad_k * sync_mad)
- sz = _madz(sync_vals)
- iz = _madz([r['rate_imbalance'] for r in valid])
- for r, a, b in zip(valid, sz, iz):
- r['sync_z'] = round(float(a), 2)
- r['imb_z'] = round(float(b), 2)
- # 卡滞 = 同步极差离群(主轴) ∧ 绝对超本底 ∧ **lag 一致性确认**(Critic 逮 名实不符: 原只 sync 单轴,
- # 分不出"一叶冻结"vs"三叶同噪同步"). lag_consistency = 高spread行里同一叶持续离群占比: 真卡死~1.0,
- # 同噪健康台~0.33 → ≥lag_consist_min 确认是单叶滞后非随机噪声 (对间歇卡死稳, median-rate 会washes out)。
- stick = ((a >= z_thresh) and ((r['sync_spread_deg'] - sync_med) >= eff_floor)
- and r['lag_consistency'] >= lag_consist_min)
- r['verdict'] = '卡滞候选' if stick else '同步健康'
- if stick:
- r['note'] = (f'运动段三叶同步极差离群 (sync_z={r["sync_z"]}, 超中位 '
- f'{r["sync_spread_deg"] - sync_med:+.2f}°≥地板{eff_floor:.2f}°) ∧ lag一致性确认 '
- f'({r["lag_consistency"]:.0%}同叶离群≥{lag_consist_min:.0%}); '
- f'冻结/滞后叶={r["lag_blade"]}; 须变桨系统检查+取脂送检')
- flagged = [r['tid'] for r in valid if r['verdict'] == '卡滞候选']
- zcaveat = f'地板={eff_floor:.2f}°(max(1°,3·MAD)); 卡滞须 sync离群∧绝对超本底∧lag一致性 三确认(防同噪健康台假阳)。'
- else:
- for r in valid:
- r['verdict'] = '单台(无fleet基线)'
- r['note'] = f'有效台<{min_turbines}, 无 fleet MAD-z 基线 → 只给原始指标, 不出卡滞定论'
- flagged = []
- zcaveat = f'有效台 {len(valid)}<{min_turbines}: 无 fleet z 基线, 不出 flagged (须跨台 null 分布, RULE-4)。'
- recs.sort(key=lambda r: (r['sync_z'] is None, -(r['sync_z'] or -99)))
- return {'per_turbine': recs, 'flagged': flagged, 'collective_pitch': False,
- 'n_turbines': len(valid), 'wear_blind_caveat': WEAR_CAVEAT,
- 'caveat': ('运动段三叶同步性(极差p95)+速率均衡 fleet MAD-z; 仅筛卡死(stick), 渐进磨损(wear)盲须化验; '
- '卡死签名须卡死叶冻结窗叠被指令运动窗(否则盲); 集距死场不可分→INSUFFICIENT; '
- '须秒级独立桨距三叶通道。' + zcaveat)}
- # ================================================================ 长清 loop 提级 (2026-06-28; [暂行]→firm 落地)
- # 三函数抽自长清专项复证 (静偏 §4.4⑥⑦ n=6 / 分机型 §4.5+§4.6 n=2 / 时长口径 §5 D4 长清). RV-2 搬真逻辑.
- # ---------------------------------------------------------------- §4.4 ⑥⑦ 静偏: offset vs scatter + 自指守卫
- def static_yaw_assessment(df, *, turbine_col, yaw_err_col, power_col=None, ws_col=None,
- rated=None, gen_frac=0.05, min_per_turbine=200,
- offset_warn=3.0, robust_z=3.0, ci_z=1.96,
- cos2_ws_lo=6.0, cos2_ws_hi=9.0):
- """静态偏航评估 (SOP §4.4 ⑥⑦; 源 datang-crosscheck 五场 + 长清复证, n=6).
- 分两件事别混: **offset (per台中位 = vane 系统偏置)** vs **scatter (per台 std = 跟踪质量/湍流)**。
- - vane 中位≈0 = 自指, 只证控制器跟随 vane, **禁下"对风良好"[定论]** → self_ref_insufficient=True (须 dome-fit/cos² 复核)。
- - 真静偏候选 = |offset| 跨台 robust-z≥robust_z ∧ |offset|>offset_warn ∧ offset 的 SE-CI 排除 0 (⑦ 峰CI门 SE 近似)。
- - scatter 离群 (std robust-z 高) = 湍流/松控 (watch), **非机械偏航病**, 不与 offset 候选混。
- - 有 power+ws 时给 cos² 损失旁证: 候选台中风段功率随 |yaw_err| 是否按 cos² 掉; 无掉 = vane 标定偏非真气流失配。
- 实证: 长清 25 台静偏全亚度 (中位 0.17°/MAD 0.18°, 最大 A12 0.54° z2.1 / A20 0.53° z2.0, 无 z≥3),
- A12/A20 排名靠前是 scatter(std 11.5°/11.3° z>3) 非 offset, 无 cos² 损失 → 全场无真静偏, A12/A20 仅 watch。
- 返回 {per_turbine, self_ref_insufficient, fleet_offset_median, fleet_offset_mad,
- static_bias_candidates, scatter_candidates, cos2_loss_pct, verdict}。
- """
- d = df.dropna(subset=[turbine_col, yaw_err_col]).copy()
- d['_y'] = pd.to_numeric(d[yaw_err_col], errors='coerce')
- if power_col is not None and rated is not None:
- d = d[pd.to_numeric(d[power_col], errors='coerce') > gen_frac * rated] # 发电态 (排 idle/偏航激活)
- g = d.dropna(subset=['_y']).groupby(turbine_col)['_y']
- stat = g.agg(['median', 'std', 'count'])
- stat = stat[stat['count'] >= min_per_turbine]
- if len(stat) < 2:
- return {'per_turbine': [], 'self_ref_insufficient': None, 'fleet_offset_median': None,
- 'fleet_offset_mad': None, 'static_bias_candidates': [], 'scatter_candidates': [],
- 'cos2_loss_pct': {}, 'verdict': f'有效台<2 (per台需≥{min_per_turbine})'}
- fleet_med = float(stat['median'].median())
- mad = float((stat['median'] - fleet_med).abs().median()) or 1e-9
- scat_med = float(stat['std'].median())
- scat_mad = float((stat['std'] - scat_med).abs().median()) or 1e-9
- recs = []
- for tid, row in stat.iterrows():
- off, sd, n = float(row['median']), float(row['std']), int(row['count'])
- off_z = (off - fleet_med) / (1.4826 * mad)
- scat_z = (sd - scat_med) / (1.4826 * scat_mad)
- se_med = 1.2533 * sd / np.sqrt(n) # 中位标准误 ≈ 1.2533·σ/√n
- ci_lo, ci_hi = off - ci_z * se_med, off + ci_z * se_med
- ci_excl0 = not (ci_lo <= 0 <= ci_hi)
- is_bias = (abs(off_z) >= robust_z) and (abs(off) > offset_warn) and ci_excl0
- is_scatter = scat_z >= robust_z
- kind = '静偏候选' if is_bias else ('散布离群(湍流/松控·watch)' if is_scatter else '正常')
- recs.append({'tid': str(tid), 'offset_deg': round(off, 3), 'scatter_std_deg': round(sd, 2), 'n': n,
- 'offset_robust_z': round(float(off_z), 2), 'scatter_robust_z': round(float(scat_z), 2),
- 'offset_ci': [round(float(ci_lo), 3), round(float(ci_hi), 3)], 'kind': kind})
- recs.sort(key=lambda r: -abs(r['offset_robust_z']))
- bias = [r['tid'] for r in recs if r['kind'] == '静偏候选']
- scatter = [r['tid'] for r in recs if r['kind'].startswith('散布')]
- max_abs_off = max(abs(r['offset_deg']) for r in recs)
- self_ref = (len(bias) == 0) and (max_abs_off <= offset_warn)
- cos2 = {}
- if power_col is not None and ws_col is not None and bias:
- ww = pd.to_numeric(d[ws_col], errors='coerce')
- band = (ww >= cos2_ws_lo) & (ww <= cos2_ws_hi)
- for tid in bias:
- sub = d[(d[turbine_col].astype(str) == tid) & band]
- if len(sub) < 50:
- cos2[tid] = None; continue
- ye = sub['_y'].abs(); pw = pd.to_numeric(sub[power_col], errors='coerce')
- base, hi = pw[ye < 1.0].median(), pw[(ye >= 2.0) & (ye < 6.0)].median()
- cos2[tid] = (None if (pd.isna(base) or pd.isna(hi) or base <= 0)
- else round(float(hi / base - 1.0) * 100, 1)) # <0=有损失(真失配); ≈0=vane偏
- if self_ref:
- verdict = (f'全场 |offset|≤{offset_warn:.0f}° (最大 {max_abs_off:.2f}°), 无 vane 系统偏置; '
- f'但 vane 中位≈0 自指 → 禁"对风良好"[定论], 须 dome-fit/cos² 复核')
- elif bias:
- verdict = f'静偏候选 {bias} (offset 离群+CI排0); cos²损失%={cos2 or "未算"}'
- else:
- verdict = f'无静偏候选; 散布离群 {scatter} = 湍流/松控 watch (非机械偏航病)'
- return {'per_turbine': recs, 'self_ref_insufficient': self_ref,
- 'fleet_offset_median': round(fleet_med, 3), 'fleet_offset_mad': round(mad, 3),
- 'static_bias_candidates': bias, 'scatter_candidates': scatter,
- 'cos2_loss_pct': cos2, 'verdict': verdict}
- # ---------------------------------------------------------------- §4.5/§4.6/§1.2 齿比聚类 → 机型变体 (查收资表先于"编码器artifact")
- def gear_ratio_model_clusters(df, *, turbine_col, gen_rpm_col, rotor_rpm_col, power_col=None,
- rated=None, gen_frac=0.30, rotor_min=3.0, rotor_max=25.0,
- min_per_turbine=200, outlier_pct=2.0, rotor_dia_ref=None):
- """齿比(gen/rotor)聚类识别同场混入的不同机型/叶轮变体 (SOP §4.5 分机型 + §4.6 转速比型内对照 + §1.2; 长清A17, n=2).
- **顺序硬门 (纠错教训)**: 单台齿比/转速离群, 判"编码器 PPR config artifact / rescale" 前 **必先核官方收资表机型** ——
- 同 OEM 同容量同场可能混不同转子直径变体。**机型差异铁证 = 转子直径比 ≈ 齿比比 ∧ 同设计叶尖速** (大转子配大齿比落同 gen rpm)。
- 实证: 长清 A17 齿比 124.69 vs 余 24 台 117.84 (官方 W2000-105=117.91); 收资表 A17=W2000-111(111m) 余 W2000-105(105m);
- 111/105=1.057 ≈ 124.69/117.91=1.058; A17 14.46rpm×111 ≈ fleet 15.33×105 ≈ 84m/s 同叶尖速 → 机型差异非编码器偏 →
- **不可 rescale 归一** (×1.058 会推到 89m/s 超设计 6%, 把正确读数搞坏)。gen rpm fleet-normal 而 rotor 系统低 = 大转子真低转速。
- rotor_dia_ref={anchor:直径} 给则反推离群台疑似直径 = base × 齿比比。
- 返回 {per_turbine, fleet_ratio_median, n_turbines, outliers, verdict}。
- """
- d = df.dropna(subset=[turbine_col, gen_rpm_col, rotor_rpm_col]).copy()
- gen = pd.to_numeric(d[gen_rpm_col], errors='coerce')
- rot = pd.to_numeric(d[rotor_rpm_col], errors='coerce')
- mask = rot.between(rotor_min, rotor_max) & (gen > 0)
- if power_col is not None and rated is not None:
- mask &= pd.to_numeric(d[power_col], errors='coerce') > gen_frac * rated
- d = d[mask].assign(_r=(gen / rot)[mask])
- g = d.dropna(subset=['_r']).groupby(turbine_col)['_r']
- med, cnt = g.median(), g.count()
- med = med[cnt >= min_per_turbine]
- if len(med) < 2:
- return {'per_turbine': {}, 'fleet_ratio_median': None, 'n_turbines': int(len(med)),
- 'outliers': [], 'verdict': f'有效台<2 (per台需≥{min_per_turbine})'}
- fleet_med = float(med.median())
- rel = (med - fleet_med) / fleet_med * 100.0
- outliers = []
- for tid, r in med.items():
- relpct = float((r - fleet_med) / fleet_med * 100.0)
- if abs(relpct) > outlier_pct:
- rr = float(r / fleet_med)
- e = {'tid': str(tid), 'ratio': round(float(r), 2), 'rel_pct': round(relpct, 2),
- 'ratio_ratio_vs_fleet': round(rr, 4),
- 'suspect': '不同机型/叶轮变体 (先核收资表机型, 非编码器artifact; 转子直径比≈齿比比+同叶尖速=铁证; 禁rescale归一)'}
- if rotor_dia_ref:
- e['inferred_rotor_dia_m'] = round(float(next(iter(rotor_dia_ref.values()))) * rr, 1)
- outliers.append(e)
- if not outliers:
- verdict = f'齿比紧带 (中位 {fleet_med:.2f}, 极差 {float(rel.max() - rel.min()):.1f}%), 全场同机型, 无变体离群'
- else:
- verdict = (f'{len(outliers)} 台齿比离群(>{outlier_pct:.0f}%): {[o["tid"] for o in outliers]} → '
- f'**先核收资表机型** (疑不同转子直径变体, 非编码器artifact, 禁rescale归一)')
- return {'per_turbine': {str(k): round(float(v), 2) for k, v in med.items()},
- 'fleet_ratio_median': round(fleet_med, 2), 'n_turbines': int(len(med)),
- 'outliers': outliers, 'verdict': verdict}
- # ---------------------------------------------------------------- §5 D4/§4.6 故障 计数 vs 真时长口径 (穿越码膨胀)
- def _parse_dt_ms(s):
- """解析 'YYYY-MM-DD HH:MM:SS:fff' (末位:为毫秒) 等时戳; 退回标准 to_datetime。"""
- s2 = s.astype(str).str.strip().str.replace(
- r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):(\d+)$', r'\1.\2', regex=True)
- return pd.to_datetime(s2, errors='coerce')
- def fault_duration_caliber(df, *, code_col, activate_col, reset_col, desc_col=None,
- short_event_s=120.0, valid_max_h=24.0, top_gap=2.0, top_n=5):
- """故障台账 计数 vs 真时长 口径 (SOP §5 D4 + §4.6; 源 长清 90027, n=1).
- 计数 Top ≠ 真问题: 单次极短(<short_event_s)的高频穿越/瞬时码 (如高偏航误差穿越), 每次独立计数 → 计数膨胀;
- 真口径 = Σ(reset−activate) 时长。is_count_artifact = 单次中位<short_event_s ∧ 计数排名 top_n 内 ∧ 时长排名远差(≥计数排名×top_gap)。
- 实证: 长清 90027 高偏航误差穿越 计数 126552(48.4% #1) → 真时长 2102h(2.8%, 计数#1→描述级时长#3), 每次中位 54.5s,
- 偏航族(含软启动器等)膨胀 5.77x; 时长真 #1#2 = SCADA 手停(4889h)/湍流保护(4300h) (非偏航)。
- 返回 {per_code(按时长降序), count_top, duration_top, count_artifacts, n_events}。
- """
- d = df.copy()
- dur_s = (_parse_dt_ms(d[reset_col]) - _parse_dt_ms(d[activate_col])).dt.total_seconds()
- ok = dur_s.between(0, valid_max_h * 3600)
- d = d.assign(_dur=dur_s)[ok]
- if len(d) == 0:
- return {'per_code': [], 'count_top': [], 'duration_top': [], 'count_artifacts': [],
- 'n_events': 0, 'reason': '无可解析时长事件 (查时戳格式/双时戳列)'}
- desc_map = {}
- if desc_col is not None:
- desc_map = d.groupby(code_col)[desc_col].agg(
- lambda s: s.astype(str).mode().iloc[0] if len(s.dropna()) else '').to_dict()
- g = d.groupby(code_col)['_dur']
- agg = pd.DataFrame({'count': g.size(), 'dur_h': g.sum() / 3600.0,
- 'median_s': g.median(), 'p95_s': g.quantile(0.95)})
- tot_c, tot_h = agg['count'].sum(), agg['dur_h'].sum()
- agg['count_rank'] = agg['count'].rank(ascending=False, method='min').astype(int)
- agg['dur_rank'] = agg['dur_h'].rank(ascending=False, method='min').astype(int)
- recs = []
- for code, row in agg.iterrows():
- c_share = row['count'] / tot_c * 100 if tot_c else 0.0
- h_share = row['dur_h'] / tot_h * 100 if tot_h else 0.0
- artifact = (row['median_s'] < short_event_s and row['count_rank'] <= top_n
- and row['dur_rank'] >= row['count_rank'] * top_gap)
- recs.append({'code': str(code), 'desc': str(desc_map.get(code, '')),
- 'count': int(row['count']), 'dur_h': round(float(row['dur_h']), 1),
- 'median_event_s': round(float(row['median_s']), 1),
- 'count_share_pct': round(float(c_share), 1), 'dur_share_pct': round(float(h_share), 1),
- 'count_rank': int(row['count_rank']), 'dur_rank': int(row['dur_rank']),
- 'is_count_artifact': bool(artifact),
- 'inflation_x': round(float(c_share / h_share), 2) if h_share > 0 else None})
- by_c = sorted(recs, key=lambda r: -r['count'])
- by_h = sorted(recs, key=lambda r: -r['dur_h'])
- return {'per_code': by_h, 'count_top': [r['code'] for r in by_c[:top_n]],
- 'duration_top': [r['code'] for r in by_h[:top_n]],
- 'count_artifacts': [r['code'] for r in recs if r['is_count_artifact']],
- 'n_events': int(len(d))}
- # ================================================================ n≥2 待实现批 提级 (2026-06-28 续; 昨晚训练)
- # ---------------------------------------------------------------- §4.3 内置可利用率列 优先但先验真 (glitchy报0却满发; n=3)
- def builtin_avail_verify(df, *, avail_col, p_col, turbine_col, rated, gen_frac=0.02,
- unavail_frac=0.5, glitch_tol=0.02, min_per_turbine=200):
- """内置可利用率列 优先但先验真 (SOP §4.3; 源 #10/#16, n=3).
- 内置 avail 列是 OEM 自算 ground truth (优于粗状态派生: 后者把降额/待机变体误当停机, 0.919 vs 真 100),
- 但 glitchy 列会报"不可用"却满发 (t30/47 avail≈0 而 P~2100kW) → 必交叉发电态验真:
- 发电态(P>gen_frac·rated)行里被内置列标"不可用"(avail≤unavail_frac·标度)的占比 > glitch_tol → glitchy, use_builtin=False 退状态派生。
- avail 自动判 0-1 / 0-100 标度。返回 {per_turbine, glitchy_turbines, fleet_use_builtin}。
- """
- d = df.dropna(subset=[turbine_col, avail_col, p_col]).copy()
- a = pd.to_numeric(d[avail_col], errors='coerce'); p = pd.to_numeric(d[p_col], errors='coerce')
- scale = 100.0 if a.dropna().quantile(0.95) > 1.5 else 1.0
- unavail_lvl = unavail_frac * scale
- d = d.assign(_a=a, _gen=(p > gen_frac * rated), _unav=(a <= unavail_lvl))
- recs = []
- for tid, g in d.groupby(turbine_col):
- if len(g) < min_per_turbine:
- continue
- n_gen = int(g['_gen'].sum())
- frac = float((g['_gen'] & g['_unav']).sum() / n_gen) if n_gen else 0.0
- glitchy = frac > glitch_tol
- recs.append({'tid': str(tid), 'avail_builtin_mean': round(float(g['_a'].mean() / scale), 4),
- 'gen_while_unavail_frac': round(frac, 4), 'n_gen': n_gen,
- 'glitchy': glitchy, 'use_builtin': not glitchy})
- recs.sort(key=lambda r: -r['gen_while_unavail_frac'])
- glitchy = [r['tid'] for r in recs if r['glitchy']]
- return {'per_turbine': recs, 'glitchy_turbines': glitchy, 'fleet_use_builtin': len(glitchy) == 0,
- 'caveat': f'glitchy(发电态被内置列标不可用>{glitch_tol:.0%})→退状态派生; 全clean→内置列优先(避粗状态把降额/待机误停机)'}
- # ---------------------------------------------------------------- §4.3 零发电×风在限内 交叉红旗 (状态口径失真; n=2)
- def zero_gen_redflag(df, *, ws_col, p_col, turbine_col, rated, cutin=3.0, cutout=25.0,
- gen_frac=0.02, time_col=None, a_op=None, gap_pp=0.10,
- zero_run_days=14, min_per_turbine=200):
- """零发电×风在限内 交叉红旗 (SOP §4.3; 源 唐珍B8/jtxq DJ24, n=2).
- per台 A_op(状态派生可用率, 外传) − A_WIL(风在限内发电率) > gap_pp(10pp) 或 连续≥zero_run_days 天 P≈0∧ws≥cutin
- → 状态派生口径失真 (状态列谎报/功率通道死/长期人工停机) → 改报 WIL 口径并标 [需O&M核], 禁进 fleet 中位头条。
- (唐珍 B8: A_op0.79/A_WIL0.29 四月零发电有风, 状态报 75-96% 可用)。a_op={tid:值} 可选; time_col 给则算最长连续零发有风天。
- 返回 {per_turbine, redflag_turbines}。
- """
- d = df.dropna(subset=[turbine_col, ws_col, p_col]).copy()
- ws = pd.to_numeric(d[ws_col], errors='coerce'); p = pd.to_numeric(d[p_col], errors='coerce')
- d = d.assign(_inlim=ws.between(cutin, cutout), _gen=(p > gen_frac * rated))
- recs = []
- for tid, g in d.groupby(turbine_col):
- if len(g) < min_per_turbine:
- continue
- inlim = g[g['_inlim']]
- a_wil = float(inlim['_gen'].mean()) if len(inlim) else float('nan')
- aop = (a_op or {}).get(str(tid), (a_op or {}).get(tid))
- gap = (float(aop) - a_wil) if (aop is not None and pd.notna(a_wil)) else None
- max_run = None
- if time_col is not None and time_col in g.columns:
- gg = g.assign(_t=pd.to_datetime(g[time_col], errors='coerce')).dropna(subset=['_t'])
- day = gg.assign(_d=gg['_t'].dt.date).groupby('_d').agg(
- zero=('_gen', lambda s: (~s).mean()), wind=('_inlim', 'mean'))
- bad = ((day['zero'] > 0.8) & (day['wind'] > 0.3)).astype(int)
- run = mx = 0
- for b in bad:
- run = run + 1 if b else 0
- mx = max(mx, run)
- max_run = int(mx)
- redflag = bool((gap is not None and gap > gap_pp) or (max_run is not None and max_run >= zero_run_days))
- recs.append({'tid': str(tid), 'a_wil': round(a_wil, 4) if pd.notna(a_wil) else None,
- 'a_op': round(float(aop), 4) if aop is not None else None,
- 'gap_pp': round(gap, 4) if gap is not None else None,
- 'max_zero_gen_days': max_run, 'redflag': redflag})
- recs.sort(key=lambda r: -((r['gap_pp'] or 0)))
- return {'per_turbine': recs, 'redflag_turbines': [r['tid'] for r in recs if r['redflag']],
- 'caveat': f'红旗台改报 A_WIL 口径 + 标[需O&M核], 禁进 fleet 中位; gap>{gap_pp:.0%} 或 连续≥{zero_run_days}天零发有风'}
- # ---------------------------------------------------------------- §4.8 变桨电机超温·活动依赖守卫 (真卡涩 vs 加性零偏; n=9)
- def pitch_motor_temp_activity_guard(df, *, motor_temp_col, pitch_col, turbine_col, time_col,
- p_col, gen_rpm_col=None, amb_col=None, fleet_temp_col=None,
- pitch_rate_active=0.3, parked_p=5.0, parked_rpm=10.0,
- dt_floor=2.0, min_per_turbine=300):
- """变桨电机超温 活动依赖守卫 (SOP §4.8; 源 和风法跨9场, n=9: 7 REFUTE + 威海真 + 阜山锁底).
- 真机构卡涩/润滑/轴承劣化产热只在变桨电机做功(变桨)时出现 → idle dT≈0 → active dT 骤升 → 对桨距速度 dose-response 单调升。
- 反之 ①全活动档恒定温偏 ②停机(gen_rpm<parked_rpm ∧ P<parked_p)≈运行同温 ③冷季放大(corr(dT,amb)<0) 任一 = standing/加性零偏
- = 传感(PT100)/接线/接触电阻/待机加热电气故障 非机构。dT = motor_temp − (fleet_temp_col 或 同时刻全场中位)。
- 返回 per台 {dt_active, dt_parked, dose_response, cold_corr, kind}; kind∈{真卡涩候选, 加性零偏, 加热器锁底/standing, 待查, 正常}。
- """
- d = df.dropna(subset=[turbine_col, motor_temp_col, pitch_col, p_col]).copy()
- d['_T'] = pd.to_numeric(d[motor_temp_col], errors='coerce')
- d['_pitch'] = pd.to_numeric(d[pitch_col], errors='coerce')
- d['_p'] = pd.to_numeric(d[p_col], errors='coerce')
- d['_amb'] = pd.to_numeric(d[amb_col], errors='coerce') if amb_col and amb_col in d.columns else np.nan
- if fleet_temp_col is not None:
- d['_dT'] = d['_T'] - pd.to_numeric(d[fleet_temp_col], errors='coerce')
- else:
- d['_dT'] = d['_T'] - d.groupby(time_col)['_T'].transform('median') # 同时刻全场中位基线
- recs = []
- for tid, g in d.groupby(turbine_col):
- g = g.sort_values(time_col)
- if len(g) < min_per_turbine:
- continue
- rate = g['_pitch'].diff().abs()
- active = rate > pitch_rate_active
- if gen_rpm_col and gen_rpm_col in g.columns:
- parked = (pd.to_numeric(g[gen_rpm_col], errors='coerce') < parked_rpm) & (g['_p'] < parked_p)
- else:
- parked = g['_p'] < parked_p
- dt_active = float(g['_dT'][active].median()) if active.sum() > 20 else np.nan
- dt_parked = float(g['_dT'][parked].median()) if parked.sum() > 20 else np.nan
- dose = np.nan
- m = active & g['_dT'].notna() & rate.notna()
- if m.sum() > 50 and rate[m].std() > 0:
- dose = float(np.corrcoef(rate[m], g['_dT'][m])[0, 1])
- cold_corr = np.nan
- ma = g['_dT'].notna() & g['_amb'].notna()
- if ma.sum() > 50 and g['_amb'][ma].std() > 0:
- cold_corr = float(np.corrcoef(g['_amb'][ma], g['_dT'][ma])[0, 1])
- kind = '正常'
- if pd.notna(dt_active) and dt_active > dt_floor:
- if pd.notna(dt_parked) and dt_parked > dt_active - 1.0:
- kind = '加性零偏(传感/接线/待机加热)'
- elif pd.notna(cold_corr) and cold_corr < -0.2:
- kind = '加热器锁底/standing'
- elif pd.notna(dose) and dose > 0.2 and (pd.isna(dt_parked) or dt_parked < dt_floor):
- kind = '真卡涩候选'
- else:
- kind = '待查(活动偏热但dose/parked不全)'
- recs.append({'tid': str(tid), 'dt_active': round(dt_active, 2) if pd.notna(dt_active) else None,
- 'dt_parked': round(dt_parked, 2) if pd.notna(dt_parked) else None,
- 'dose_response': round(dose, 2) if pd.notna(dose) else None,
- 'cold_corr': round(cold_corr, 2) if pd.notna(cold_corr) else None, 'kind': kind})
- recs.sort(key=lambda r: -((r['dt_active'] if r['dt_active'] is not None else -99)))
- return {'per_turbine': recs,
- 'stick_candidates': [r['tid'] for r in recs if r['kind'] == '真卡涩候选'],
- 'sensor_refuted': [r['tid'] for r in recs if '零偏' in r['kind'] or 'standing' in r['kind']],
- 'caveat': '10min电流多场恒0→守卫只温度单腿, 真信号止于【预警】(须秒级电流波形+绝缘); 加热器锁底须pitch-WORK耦合+位置跟随滞后+floor检测补门'}
- # ---------------------------------------------------------------- §4.6 温度伪报四类证伪分类 (默认REFUTED, 决定闸=载荷依赖; n=6)
- def temp_sensor_falsify(df, *, comp_col, ref_col, p_col, turbine_col, rated,
- ceiling=155.0, gen_frac=0.10, parked_p=5.0, idle_dt_tol=1.5,
- slope_mult=1.05, min_per_turbine=300):
- """温度伪报四类证伪分类法 (SOP §4.6; 默认 REFUTED, 决定闸=载荷依赖检验双向都验; 源跨6场, n=6).
- NBM 裸 z 最大者多为传感器伪影。dt = comp − ref (ref=配对通道/同时刻全场中位/环温)。四类:
- ①**加性零偏**: 温-温 slope≈1 ∧ 怠速/停机 dt 不归零 (真摩擦/铜损热应趋零) ∧ dt 不随载升 (海勒斯 a≈+13℃满发z塌+0.9)。
- ②**乘性增益**: 温-温回归 slope>slope_mult (福山×1.10/义渡口A19×1.15) ∧ 怠速 dt 仍偏正。
- ③**钉天花板/railed**: 绝缘上限(ceiling)钉死 ∧ 停机态占比高(无电流不可能 I²R) (肥城A2 8月railed 50%停机态)。
- 决定闸: **加性偏随载升=真过热(I²R, 夏邱A39 +1.9→+7.2℃) / 全段恒定加性偏=零偏 REFUTE**。
- 返回 per台 {tt_slope, idle_dt, load_rise, railed_frac, kind}; kind∈{加性零偏, 乘性增益, 钉天花板railed, 真过热候选, 待查}。
- """
- d = df.dropna(subset=[turbine_col, comp_col, ref_col, p_col]).copy()
- d['_c'] = pd.to_numeric(d[comp_col], errors='coerce')
- d['_r'] = pd.to_numeric(d[ref_col], errors='coerce')
- d['_p'] = pd.to_numeric(d[p_col], errors='coerce')
- d['_dt'] = d['_c'] - d['_r']
- recs = []
- for tid, g in d.groupby(turbine_col):
- if len(g) < min_per_turbine:
- continue
- gen = g['_p'] > gen_frac * rated
- parked = g['_p'] < parked_p
- tt_slope = np.nan
- sub = g[gen & g['_c'].notna() & g['_r'].notna()]
- if len(sub) > 50 and sub['_r'].std() > 0:
- tt_slope = float(np.polyfit(sub['_r'], sub['_c'], 1)[0])
- idle_dt = float(g['_dt'][parked].median()) if parked.sum() > 20 else np.nan
- load_rise = np.nan
- if gen.sum() > 50 and g['_p'][gen].std() > 0:
- load_rise = float(np.polyfit(g['_p'][gen], g['_dt'][gen], 1)[0] * rated) # 全载 dt 变化估计
- railed = g['_c'] >= (ceiling - 1.0)
- railed_frac = float(railed.mean())
- railed_parked = float((railed & parked).sum() / max(int(railed.sum()), 1))
- kind = '待查'
- if railed_frac > 0.05 and railed_parked > 0.3:
- kind = '钉天花板railed(共享采集/停机态钉死)'
- elif pd.notna(tt_slope) and tt_slope > slope_mult and pd.notna(idle_dt) and idle_dt > idle_dt_tol:
- kind = '乘性增益(传感器)'
- elif pd.notna(idle_dt) and idle_dt > idle_dt_tol and (pd.isna(load_rise) or abs(load_rise) < 1.0):
- kind = '加性零偏(传感/接线)'
- elif pd.notna(idle_dt) and idle_dt <= idle_dt_tol and pd.notna(load_rise) and load_rise > 2.0:
- kind = '真过热候选(加性偏随载升I²R)'
- recs.append({'tid': str(tid), 'tt_slope': round(tt_slope, 3) if pd.notna(tt_slope) else None,
- 'idle_dt': round(idle_dt, 2) if pd.notna(idle_dt) else None,
- 'load_rise': round(load_rise, 2) if pd.notna(load_rise) else None,
- 'railed_frac': round(railed_frac, 3), 'kind': kind})
- recs.sort(key=lambda r: -((r['idle_dt'] if r['idle_dt'] is not None else -99)))
- return {'per_turbine': recs,
- 'real_overheat_candidates': [r['tid'] for r in recs if r['kind'].startswith('真过热')],
- 'sensor_refuted': [r['tid'] for r in recs if any(k in r['kind'] for k in ('零偏', '乘性', 'railed'))],
- 'caveat': '决定闸=载荷依赖(加性偏随载升=真I²R过热 / 全段恒定=零偏REFUTE); ref须先过liveness; 真过热候选上限【预警】须OEM绝对阈交叉'}
- # ---------------------------------------------------------------- §4.6 部件温度判据家族 (热源模型 T=T_sink+Q·R, Batch3, 2026-07-02)
- # 源: 郭家店发电机(2026-06-29~30)+齿轮箱(2026-07-01~02)双部件深挖 per-farm 脚本 (gjd_gen/gearbox_full_diagnose,
- # gjd_thermal_daily/chains); 判据细则 docs/部件温度诊断判据SOP.md; DP 锚 B11/A6/A18/A19/A13/A23/A8。
- def _load_band(df, p_col, rated, lo_frac, hi_frac):
- p = pd.to_numeric(df[p_col], errors='coerce')
- return df[(p >= lo_frac * rated) & (p <= hi_frac * rated)]
- def idle_zero_check(df, *, comp_col, p_col, turbine_col, rpm_col=None, amb_col=None,
- thermal_mass='small', p_max=5.0, rpm_max=1.0, bias_c=3.0,
- amb_bin_c=5.0, min_idle_n=50, valid_range=(-40.0, 200.0)):
- """idle 停机归零判据 — 加性探头偏检测, **必过热质量适用门** (SOP §4.6 idle 门; 部件温度SOP §2/§3)。
- 原理: 停机态 Q≈0 → T≈T_热汇; 某台停机态仍比同状态机群偏 |dev|≥bias_c = 加性探头偏 (④传感器)。
- ★热质量门 (郭家店 A19 戒): 齿轮箱热质量大, 停机后油/轴承滞热, "停机偏高"=热滞留非探头偏 —
- A19 曾被 idle 误判"高速/低速加性", 改轴承-油ΔT 才归位整箱热。thermal_mass='large' → **拒跑**,
- 返回 applicable=False + alternative 指向 node_minus_sink_dt + 现场RTD (教训焊进函数签名)。
- ★ambient 分箱 (辉腾梁 T31 戒): idle 时机群环温散度大 (冷机位 −20~−25℃) 淹没零偏 → raw idle 假阴;
- amb_col 给定时按环温带 (amb_bin_c) 分箱后组内比 (T31 raw z−0.25 → 分箱后 −6.33 显形)。
- DP 锚: A23 冷却水 idle+7.4℃ (加性, 先校再评散热器) / A19 拒跑 / T31 分箱显形。
- 出: {applicable, per_turbine:{tid:{idle_dev,n_idle}}, flagged(按|dev|降序), caveat}。
- """
- if thermal_mass == 'large':
- return {'applicable': False,
- 'reason': '大热质量部件(齿轮箱类) idle 失灵: 停机滞热=热滞留非探头偏 (郭家店 A19 戒)',
- 'alternative': 'node_minus_sink_dt (轴承-油ΔT 一致性) + 现场 RTD 交叉'}
- if thermal_mass != 'small':
- return {'applicable': False, 'reason': "thermal_mass 须 'small'|'large'"}
- p = pd.to_numeric(df[p_col], errors='coerce')
- m = p < p_max
- if rpm_col is not None and rpm_col in df.columns:
- m &= pd.to_numeric(df[rpm_col], errors='coerce') < rpm_max
- d = df[m].copy()
- v = pd.to_numeric(d.get(comp_col), errors='coerce')
- lo, hi = valid_range
- d = d[(v >= lo) & (v <= hi)]
- if d.empty:
- return {'applicable': True, 'per_turbine': {}, 'flagged': [], 'note': '无停机态有效样本'}
- cv = pd.to_numeric(d[comp_col], errors='coerce')
- if amb_col is not None and amb_col in d.columns:
- ab = pd.to_numeric(d[amb_col], errors='coerce')
- d = d[ab.notna()].copy()
- cv = pd.to_numeric(d[comp_col], errors='coerce')
- d['_ab'] = (pd.to_numeric(d[amb_col], errors='coerce') / amb_bin_c).round() * amb_bin_c
- d['_dev'] = cv - d.groupby('_ab')[comp_col].transform('median')
- else:
- d['_dev'] = cv - float(cv.median())
- per = {}
- for tid, g in d.groupby(turbine_col):
- if len(g) < min_idle_n:
- continue
- per[str(tid)] = {'idle_dev': round(float(g['_dev'].median()), 2), 'n_idle': int(len(g))}
- flagged = sorted((t for t, r in per.items() if abs(r['idle_dev']) >= bias_c),
- key=lambda t: -abs(per[t]['idle_dev']))
- return {'applicable': True, 'per_turbine': per, 'flagged': flagged,
- 'caveat': 'idle 只挡加性; 乘性 idle≈0 挡不住须现场RTD; amb 分箱剥停机态环温散度 (辉腾梁T31戒)'}
- def node_minus_sink_dt(df, *, node_col, sink_col, p_col, turbine_col, rated,
- second_ref_col=None, dt_gate=4.0, sink_bias_c=5.0,
- p_lo_frac=0.50, p_hi_frac=0.98, min_n=200, valid_range=(-40.0, 200.0)):
- """局部摩擦判据 = 节点−即时热汇 ΔT (SOP §4.6 ΔT 家族; 部件温度SOP §0/§3)。
- 原理: 即时热汇消共模, ΔT=该节点自身产热。实例: 齿箱 轴承−油 ≥dt_gate(4℃)=该轴承真摩擦
- (高速/低速分); 发电机 绕组−冷却水 分 Q/R。主轴承脂润滑无即时热汇通道 → 勿用本函数, 走双分箱
- (matched_load_overheat)。
- ★参考先"活"再"准" (A13 戒): sink 逐台过 reference_channel_liveness (挡死/冻结); **liveness
- 挡不住读偏** — A13 油温读低 −10℃ 致轴承-油ΔT 虚高 +20.4 假登顶。second_ref_col (冷却水/环温)
- 给定时核 per台 (sink−second_ref) 相对机群偏离, dev≤−sink_bias_c (sink 读低) 或 ≥+sink_bias_c →
- sink_suspect, 该台 verdict=INSUFFICIENT (先校 sink 探头; 行动紧迫度≠证据强度)。
- DP 锚: A19 双轴承-油ΔT +11/+17=整箱 / A10 +4.2 / A13 油温读低→INSUFFICIENT。
- 出: {per_turbine:{tid:{dt, sink_live, sink_dev, verdict}}, flagged, insufficient, fleet_dt_median}。
- """
- band = _load_band(df, p_col, rated, p_lo_frac, p_hi_frac).dropna(subset=[node_col, sink_col])
- if band.empty:
- return {'per_turbine': {}, 'flagged': [], 'insufficient': [], 'note': '匹配功率带内无样本'}
- per, dts = {}, {}
- for tid, g in band.groupby(turbine_col):
- if len(g) < min_n:
- continue
- tid = str(tid)
- live = reference_channel_liveness(g[sink_col], valid_range=valid_range, min_n=min(min_n, 200))
- nd = pd.to_numeric(g[node_col], errors='coerce')
- sk = pd.to_numeric(g[sink_col], errors='coerce')
- dt = float((nd - sk).median())
- rec = {'dt': round(dt, 2), 'sink_live': live['live'], 'sink_dev': None, 'verdict': '正常'}
- if live['live'] is False:
- rec['verdict'] = f'INSUFFICIENT(即时热汇通道死: {live["reason"]})'
- else:
- if second_ref_col is not None and second_ref_col in g.columns:
- rf = pd.to_numeric(g[second_ref_col], errors='coerce')
- rec['sink_dev'] = float((sk - rf).median())
- per[tid] = rec
- dts[tid] = dt
- continue
- per[tid] = rec
- if dts and second_ref_col is not None:
- sd = pd.Series({t: per[t]['sink_dev'] for t in dts if per[t]['sink_dev'] is not None})
- if not sd.empty:
- fleet_sd = float(sd.median())
- for t in sd.index:
- dev = sd[t] - fleet_sd
- per[t]['sink_dev'] = round(dev, 2)
- if abs(dev) >= sink_bias_c:
- per[t]['verdict'] = (f'INSUFFICIENT(sink读偏嫌疑 {dev:+.1f}℃, 先校 {sink_col} 探头再评 '
- f'{node_col}; A13戒)')
- fleet_dt = float(pd.Series(dts).median()) if dts else None
- for t, dt in dts.items():
- over = dt - fleet_dt if fleet_dt is not None else np.nan
- per[t]['dt_over_fleet'] = round(over, 2) if pd.notna(over) else None
- # 候选门 = 超 fleet 中位 ≥ dt_gate (非裸绝对阈: 设计签名机型固定阈会假阳, EN156 NDE>DE 戒)
- if per[t]['verdict'] == '正常' and pd.notna(over) and over >= dt_gate:
- per[t]['verdict'] = f'候选(节点−热汇ΔT 超fleet中位 {over:+.1f}℃≥{dt_gate}=真摩擦嫌疑→CMS)'
- flagged = sorted((t for t in per if per[t]['verdict'].startswith('候选')),
- key=lambda t: -(per[t].get('dt_over_fleet') or 0.0))
- insufficient = [t for t in per if per[t]['verdict'].startswith('INSUFFICIENT')]
- return {'per_turbine': per, 'flagged': flagged, 'insufficient': insufficient,
- 'fleet_dt_median': round(fleet_dt, 2) if fleet_dt is not None else None,
- 'caveat': ('ΔT=相对判据上限【预警】(超peer口径, 防设计签名固定阈假阳); 绝对 dt 同报供 OEM 阈交叉; '
- '齿箱高速/低速分列各跑; 主轴承(无即时热汇)勿用本函数走双分箱')}
- def dende_spread(df, *, de_col, nde_col, p_col, turbine_col, rated,
- spread_gate=5.0, idle_p_max=5.0, idle_bias_c=3.0,
- hi_frac=(0.60, 0.98), lo_frac=(0.10, 0.35), min_n=200):
- """配对轴承 DE−NDE 张开符号判据 + 单端探头污染守卫 (SOP §4.6; 部件温度SOP §3)。
- 张开消共模 (同台同环境, Simpson 免疫): 高载张开 >>+spread_gate = DE 局部病 / <<−spread_gate =
- NDE 局部病 / ≈0 = 非局部 (整体偏暖或传感器)。rise = 高载张开 − 低载张开 (真摩擦随载升)。
- ★污染守卫 (A18 戒): 单端探头偏会撑高张开 — **idle 张开 |≥idle_bias_c| = 单端探头嫌疑**, verdict
- 加"两端探头都验"(A18 = DE 真热 idle+1.3 + NDE 探头读低 idle−4.1 撑高张开 → 确诊DE+坏NDE探头)。
- DP 锚: B11 +7.7(DE) / A6 −12.8(NDE) / A18 探头污染。
- 出: {per_turbine:{tid:{spread_hi, spread_lo, rise, spread_idle, cls}}, de_flag, nde_flag, probe_suspect}。
- """
- p = pd.to_numeric(df[p_col], errors='coerce')
- hi = df[(p >= hi_frac[0] * rated) & (p <= hi_frac[1] * rated)]
- lo = df[(p >= lo_frac[0] * rated) & (p <= lo_frac[1] * rated)]
- idle = df[p < idle_p_max]
- per = {}
- for tid, g in hi.groupby(turbine_col):
- if len(g) < min_n:
- continue
- tid = str(tid)
- sp = pd.to_numeric(g[de_col], errors='coerce') - pd.to_numeric(g[nde_col], errors='coerce')
- s_hi = float(sp.median())
- gl = lo[lo[turbine_col].astype(str) == tid]
- s_lo = float((pd.to_numeric(gl[de_col], errors='coerce')
- - pd.to_numeric(gl[nde_col], errors='coerce')).median()) if len(gl) >= 30 else np.nan
- gi = idle[idle[turbine_col].astype(str) == tid]
- s_id = float((pd.to_numeric(gi[de_col], errors='coerce')
- - pd.to_numeric(gi[nde_col], errors='coerce')).median()) if len(gi) >= 30 else np.nan
- rise = s_hi - s_lo if pd.notna(s_lo) else np.nan
- if s_hi >= spread_gate:
- cls = 'DE局部'
- elif s_hi <= -spread_gate:
- cls = 'NDE局部'
- else:
- cls = '非局部(整体或传感器)'
- if pd.notna(s_id) and abs(s_id) >= idle_bias_c:
- cls += f'+单端探头嫌疑(idle张开{s_id:+.1f}, 两端探头都验; A18戒)'
- per[tid] = {'spread_hi': round(s_hi, 2), 'spread_lo': round(s_lo, 2) if pd.notna(s_lo) else None,
- 'rise': round(rise, 2) if pd.notna(rise) else None,
- 'spread_idle': round(s_id, 2) if pd.notna(s_id) else None, 'cls': cls}
- return {'per_turbine': per,
- 'de_flag': sorted((t for t, r in per.items() if r['cls'].startswith('DE局部')),
- key=lambda t: -per[t]['spread_hi']),
- 'nde_flag': sorted((t for t, r in per.items() if r['cls'].startswith('NDE局部')),
- key=lambda t: per[t]['spread_hi']),
- 'probe_suspect': [t for t, r in per.items() if '探头嫌疑' in r['cls']],
- 'caveat': '张开定局部+端别; 真摩擦应 rise>0; ≈0双高须回 matched_load/双分箱判整体或传感器'}
- def phase_imbalance(df, *, phase_cols, p_col, turbine_col, rated, imb_gate=6.0,
- idle_p_max=5.0, idle_tol=3.0, p_lo_frac=0.50, p_hi_frac=0.98, min_n=200):
- """绕组三相平衡判据 + idle 分流 (探头 vs 真 I²R) (SOP §4.6; 部件温度SOP §3)。
- 匹配载荷相间极差 healthy≈0 (郭家店全场中位 2.2℃); imb_load > imb_gate → idle 分流:
- **idle 仍偏 (≥idle_tol)** = 该相 RTD 探头偏 (A3 W相 / A17 V相);
- **idle 平 载带偏** = 真 I²R 不平衡 → 查相电流/功率因数 (A23 U相 峰120℃ / B3 U相)。
- 出: {per_turbine:{tid:{imb_load, imb_idle, worst_phase, cls}}, electrical, probe}。
- """
- band = _load_band(df, p_col, rated, p_lo_frac, p_hi_frac)
- p = pd.to_numeric(df[p_col], errors='coerce')
- idle = df[p < idle_p_max]
- per = {}
- for tid, g in band.groupby(turbine_col):
- if len(g) < min_n:
- continue
- tid = str(tid)
- med = {c: float(pd.to_numeric(g[c], errors='coerce').median()) for c in phase_cols}
- imb = max(med.values()) - min(med.values())
- worst = max(med, key=lambda c: abs(med[c] - float(np.median(list(med.values())))))
- gi = idle[idle[turbine_col].astype(str) == tid]
- if len(gi) >= 30:
- med_i = {c: float(pd.to_numeric(gi[c], errors='coerce').median()) for c in phase_cols}
- imb_i = max(med_i.values()) - min(med_i.values())
- else:
- imb_i = np.nan
- if imb <= imb_gate:
- cls = '均衡'
- elif pd.notna(imb_i) and imb_i >= idle_tol:
- cls = f'{worst}相RTD探头嫌疑(idle仍偏{imb_i:.1f}℃; A3 W相型)'
- elif pd.notna(imb_i):
- cls = f'{worst}相真I²R不平衡嫌疑(idle平带载偏→查相电流/功率因数; A23 U相型)'
- else:
- cls = f'{worst}相偏(无idle样本, 分流不了→现场)'
- per[tid] = {'imb_load': round(imb, 2), 'imb_idle': round(imb_i, 2) if pd.notna(imb_i) else None,
- 'worst_phase': worst, 'cls': cls}
- return {'per_turbine': per,
- 'electrical': [t for t, r in per.items() if 'I²R' in r['cls']],
- 'probe': [t for t, r in per.items() if '探头' in r['cls']],
- 'caveat': '峰温另对绝对阈(F级155/报警130); 真I²R须相电流/功率因数电气确认'}
- def daily_thermal_table(df, *, comp_cols, p_col, turbine_col, time_col, gen_p_min=10.0,
- valid_range=(-40.0, 200.0)):
- """逐日热底表 — 热链纪律的数据底座 (SOP §4.6 同系统条; 郭家店 daily_thermal.parquet 模式)。
- 日偏离 = 台**发电态**日均温 − 同日机群日中位 (per 通道)。温度专项场把本表列入 §0.8
- required_outputs; 任一天可追溯"偏离是否先于跳闸、维修后是否回落"。
- 出: DataFrame [date, tid, channel, dev, t_mean, n] (长表; 调用方自行落 parquet)。
- """
- p = pd.to_numeric(df[p_col], errors='coerce')
- d = df[p >= gen_p_min].copy()
- d['_day'] = pd.to_datetime(d[time_col]).dt.date
- lo, hi = valid_range
- out = []
- for c in comp_cols:
- if c not in d.columns:
- continue
- v = pd.to_numeric(d[c], errors='coerce')
- sub = d[(v >= lo) & (v <= hi)][[turbine_col, '_day', c]].dropna()
- g = sub.groupby([turbine_col, '_day'])[c].agg(t_mean='mean', n='size').reset_index()
- fl = g.groupby('_day')['t_mean'].median().rename('fleet')
- g = g.merge(fl, on='_day')
- g['dev'] = g['t_mean'] - g['fleet']
- for r in g.itertuples(index=False):
- out.append((getattr(r, '_day', r[1]), str(r[0]), c, round(float(r.dev), 2),
- round(float(r.t_mean), 2), int(r.n)))
- return pd.DataFrame(out, columns=['date', 'tid', 'channel', 'dev', 't_mean', 'n'])
- def thermal_chain_align(daily_dev, faults, *, system_map, fault_time_col, fault_tid_col,
- fault_code_col, dev_gate=6.0, trips_gate=10, silent_trips_max=2):
- """温度健康链 同系统对齐 + 真联动判据 (SOP §4.6 热链纪律; 用户 2026-06-30 纠错机器化)。
- 四维链 (偏离→故障→维修→损失) 只可**同系统**连: system_map = {通道名(或其子串): [同系统故障码前缀]}
- (发电机轴承↔'70021'/'70022' · 发电机冷却↔'70036'/'70065' · 齿箱↔'80')。跨系统按台聚合=伪联动
- (A3 发电机DE偏离+15℃ 配 齿箱跳闸126次, 峰谷不同步=独立链) — 本函数**按构造禁止**: 每通道只统计
- 映射到的同系统码, 不映射的码不进该通道的账。
- 真联动 = 同系统 ∧ 同月 ∧ 月均偏离≥dev_gate ∧ 月跳闸≥trips_gate (A8 冷却水+16.7℃↔同月19跳)。
- **有码不跳 = 静默前兆是正常链态** (发电机轴承 70021×3/70022×1, 越90/95℃阈才跳) → silent 列表
- (max月均偏离≥dev_gate ∧ 窗内同系统跳闸≤silent_trips_max), 非缺数据。
- 入: daily_dev = daily_thermal_table 输出 (date/tid/channel/dev); faults = 事件表 (time/tid/code)。
- 出: {monthly:[{tid,channel,month,dev_mean,trips}], linkages, silent, caveat}。
- """
- dd = daily_dev.copy()
- dd['month'] = pd.to_datetime(dd['date']).dt.to_period('M').astype(str)
- ft = faults.copy()
- ft['month'] = pd.to_datetime(ft[fault_time_col]).dt.to_period('M').astype(str)
- ft['code_s'] = ft[fault_code_col].astype(str)
- ft['tid_s'] = ft[fault_tid_col].astype(str)
- monthly, linkages, silent = [], [], []
- gm = dd.groupby(['tid', 'channel', 'month'])['dev'].mean().reset_index()
- for (tid, ch), g in gm.groupby(['tid', 'channel']):
- prefixes = None
- for key, pfx in system_map.items():
- if key in ch:
- prefixes = [str(x) for x in pfx]
- break
- sub_f = ft[ft['tid_s'] == str(tid)]
- if prefixes is not None:
- sub_f = sub_f[sub_f['code_s'].str.startswith(tuple(prefixes))]
- else:
- sub_f = sub_f.iloc[0:0] # 通道无映射码 → 零账, 不借别系统的码
- trips_by_m = sub_f.groupby('month').size()
- total_trips = int(trips_by_m.sum())
- max_dev = float(g['dev'].max())
- for r in g.itertuples(index=False):
- tr = int(trips_by_m.get(r.month, 0))
- monthly.append({'tid': str(tid), 'channel': ch, 'month': r.month,
- 'dev_mean': round(float(r.dev), 2), 'trips': tr})
- if float(r.dev) >= dev_gate and tr >= trips_gate:
- linkages.append({'tid': str(tid), 'channel': ch, 'month': r.month,
- 'dev_mean': round(float(r.dev), 2), 'trips': tr, 'kind': '真联动(同系统同月)'})
- if max_dev >= dev_gate and total_trips <= silent_trips_max and prefixes is not None:
- silent.append({'tid': str(tid), 'channel': ch, 'max_month_dev': round(max_dev, 2),
- 'total_trips': total_trips, 'kind': '静默前兆(有码不跳=正常链态, 非缺数据)'})
- return {'monthly': monthly, 'linkages': linkages, 'silent': silent,
- 'caveat': '跨系统伪联动按构造禁止(每通道只记映射码); 维修/损失维接同表时同样只许同系统'}
- # ---------------------------------------------------------------- §4.6 齿轮箱轴承退化 多通道印证决策树 (跨4场沉淀 2026-07-06 /loop)
- def gearbox_bearing_multichannel(*, vib=None, temp=None, log=None):
- """齿轮箱轴承退化 多通道印证决策树 (SOP §4.6 同名条; 诺木洪4#/元宝山18#/淄川#008/威海苘山08#).
- 把振动(独立CMS/第三方) + 温度(SCADA) + 日志 的证据按决策树聚合 → verdict + data_tier + cross_check。
- 纯逻辑聚合器(不读df; 各通道证据先由 nbm_residual/vibration_trend/OCR 等产出后传入)。
- vib : {'source': 'independent_cms'|'third_party'|'none', 'confirmed': bool, 'part': str, 'step_aligned': bool}
- source=诊断上限(独立CMS真波形 > 第三方PDF > 无); confirmed=振动是否确诊该部件; step_aligned=是否有事件对齐step。
- temp : {'channel_available': bool, 'abs_z': float, 'power_matched': bool, 'delta_rising': bool, 'oil_temp_low': bool, 'sync_rising': bool}
- channel_available=SCADA有无对应部件温度通道; abs_z=绝对温度fleet-z; power_matched=是否功率bin匹配后仍成立; oil_temp_low=油温是否偏低(A13陷阱)。
- log : {'event': str|None, 'time_aligned': bool} 齿箱/润滑故障事件 + 是否与振动/温度时间对齐。
- """
- vib = vib or {}; temp = temp or {}; log = log or {}
- src = vib.get('source', 'none')
- tier = {'independent_cms': 'A_独立CMS真波形', 'third_party': 'C_第三方PDF', 'none': 'B_仅SCADA温度'}.get(src, 'B_仅SCADA温度')
- R = [] # reasoning
- # ④ 油温读低陷阱前置提示: ΔT大 + 油温低 → 必用绝对温度+功率匹配复核 (A13/18#/08#)
- oil_trap = bool(temp.get('oil_temp_low')) and not temp.get('power_matched', False)
- if temp.get('oil_temp_low'):
- R.append('油温偏低(A13陷阱): 轴承-油ΔT大先查油温读低, 用轴承绝对温度+功率匹配复核')
- # ③ 印证强度定 verdict
- # 三源一致(独立CMS振动确诊+step对齐 + 温度同步升 + 日志时间对齐) = 预警最强
- if src == 'independent_cms' and vib.get('confirmed') and vib.get('step_aligned') \
- and temp.get('sync_rising') and log.get('time_aligned'):
- verdict, cross = '预警', '三源一致(振动step+温度同步+日志对齐)'
- R.append('独立CMS振动确诊+事件step对齐, 温度同步升, 日志时间对齐 → 三源印证最强')
- # 振动确诊 + 无对应温度通道 → 以振动为准 (淄川#008 中速轴无中速温度通道)
- elif vib.get('confirmed') and not temp.get('channel_available', True):
- verdict, cross = '候选·预警', '以振动为准(SCADA无对应温度通道)'
- R.append('振动确诊该部件但SCADA无对应温度通道 → 以振动为准, 勿因温度不印证否定(早期损伤振动先于温度)')
- # 振动确诊 + 有温度通道但绝对温度系统性最低(温度反证) → 查通道(疑传感器偏低不可用)
- elif vib.get('confirmed') and temp.get('channel_available', True) and temp.get('abs_z', 0.0) <= -2.0:
- verdict, cross = '削弱·查通道', '温度反证→查通道(绝对温度系统性最低=疑传感器偏低不可用, 既不印证也不否定)'
- R.append('振动报该部件但SCADA温度全场最低(z≤-2): 若功率匹配后仍系统性最低=传感器偏低、通道不可用')
- # 温度单通道强(无振动源, SCADA轴承绝对温度真高+功率匹配+ΔT升) = 候选·预警 (元宝山18#)
- elif src == 'none' and temp.get('abs_z', 0.0) >= 2.0 and temp.get('power_matched') and temp.get('delta_rising'):
- verdict, cross = '候选·预警', '温度单通道强(轴承绝对温度真高+功率匹配+ΔT升)'
- R.append('无振动源, SCADA轴承绝对温度fleet-z≥2且功率匹配确认(排低负荷/油温伪影)+ΔT月度升 → 温度单通道候选')
- # 温度候选未坐实(abs_z≥2但未功率匹配 or 油温陷阱未复核)
- elif temp.get('abs_z', 0.0) >= 2.0 and oil_trap:
- verdict, cross = '候选·待坐实', '温度候选但油温陷阱未复核(须绝对温度+功率匹配)'
- R.append('轴承-油ΔT/残差高但油温偏低且未功率匹配复核 → 待坐实(防A13油温读低伪影)')
- else:
- verdict, cross = '参考', '无足够多通道证据(单弱信号或已证伪)'
- return {
- 'verdict': verdict,
- 'data_tier': tier,
- 'cross_check': cross,
- 'oil_temp_trap': oil_trap,
- 'reasoning': R,
- 'caveat': '第三方CMS非独立(RV-2)必标注+SCADA交叉; 同台持续稳, 跨月绝对评级抖动(厂商口径)忽略. 部件级定论须现场油样/内窥.',
- }
- def cold_soak_offset(df, *, comp_col, ref_col, rotor_col, turbine_col, time_col,
- p_col=None, rs_stop=0.5, p_stop=20.0, settle_rows=36,
- min_n=100, min_split=30, offset_thresh=5.0, recent_frac=0.5,
- near_frac=0.8, min_month_rows=30, min_month_turbines=10):
- """冷浸物理测(等平衡+时间split): 转子停 comp−ref 偏移 → 传感器偏置 vs 真发热杀手判据 (平陆20# 教训, SOP §4.6).
- 物理第一原则: 转子停(rs<rs_stop [∧ |P|<p_stop]) = 该部件**零摩擦生热** → comp(轴承)绝不可能比 ref(油浴/配对)热;
- **静止态仍异常偏 = 加性传感器偏置(非真热)**, 分"真发热 vs 传感器"最硬物理判据, 优先级高于绝对/ΔT/载荷。
- ★两个必须(平陆20#实证, 缺一给假象):
- ①**等平衡**: 只取连续停≥settle_rows(默认36=6h)后 —— brief停残余热污染(20#未平衡冷浸-油+11°/平衡后仅+2.6°假象);
- ②**时间split**(近期vs早期): 发展性传感器漂移 pooled 会被早期正常稀释(20# pooled平衡+2.6° 掩盖 晚期+14.2°/早期+1.5°)。
- 用途: developing/偏热候选升"真故障"前**必过此测** —— 静止(尤其晚期)仍偏 = 传感器嫌疑, 换目标修传感器非抢部件。
- 返回 {per_turbine:[{tid, cold_offset, cold_recent, cold_early, dev_offset, n}...], fleet_median_recent,
- flagged:[|晚期偏fleet|>阈 ∨ dev_offset(|晚|>|早|=发展性漂移, **双向**)], note}。
- direction: 'high'(读高型=报警阈被蒙蔽) / 'low'(读低型=真超温时保护延迟) — 两向都入修单, 后果不同。
- split_quality:{n_months, **n_months_usable**(该月有≥min_month_turbines 台各≥min_month_rows 样本),
- max_month_share, early/recent_calendar_months, month_overlap, verdict, **tripped**(全部命中档), **near**(逼近档)}
- — verdict != 'ok' ⇒ **early/recent 及其差值只可作筛, 不得作趋势判据**。
- per_turbine.month_shape_ratio: 月间sd/月内sd — **<1 ⇒ 该台月序列的形状(趋势/阶跃/双态)不可读**,
- None=可用月<4。可读性前置, 非异常判据, 全场高命中属预期(健康台本无月尺度结构)。
- near_miss:[未过阈但达 near_frac(默认0.8)x阈 的台] — **交付名单必须与 flagged 一起给**, 否则「差一点」的台整个消失。
- ⚠**early/recent 只能读「有没有变」, 不能读「怎么变」, 且读之前必先扣 fleet 逐月共模**
- (2026-09-03 神木远景 66 台 365 天实证): ①**fleet 自己有季节性** —— 冷态 Δ 的 fleet 月中位在
- [+0.92, +2.91] 摆动, 幅度 1.99℃, 与个体信号同量级 ⇒ 不扣共模会把季节读成个体漂移
- (实测一台 raw 晚−早 +0.64 看似上升, 扣 fleet 同月中位后 13 个月全平 = 纯季节, 趋势轴假阳性)。
- ②**逐月样本数极不均** —— 实测四台的月样本数极差 23x~276x(某台 7 个月中一个月占 552 样本、
- 其余 2~116) ⇒ early/recent 的 pooled 值由"哪几个月碰巧有多少冷浸样本"支配, 该台的
- 晚−早 大部分是**权重伪影**而非信号。③**两个点区分不了 ramp / 阶跃 / 双态** —— 三者都能给出同一个
- 晚−早。⇒ 判"发展性漂移"必须**看逐月形状**(并行线实证: Theil-Sen 报 4 台漂移, 查形状后 3 台改判为
- 阶跃或双态)。本函数的 early/recent 是**筛子不是判据**。
- ⚠**cold_offset(pooled) 禁与别法的 pooled 直接比**: 被测量随时间变时, 两种采样的 pooled 之差
- 第一解释是**采样时间分布之差**, 不是物理也不是污染 (实测: 冷浸 pooled −30.1 vs 最冷箱 −16.4 差 13.7,
- 逐月对拍却一致到 ≤1.2 —— 全部来自月份权重)。**跨法对拍必先配时间(逐月/逐段)**; 本函数的 early/recent
- 就是为此而设, 比 pooled 优先用。反之「两法一致」只证被测量稳, 不证方法对。
- """
- d = df.dropna(subset=[comp_col, ref_col, rotor_col, turbine_col, time_col]).copy()
- d = d.sort_values([turbine_col, time_col])
- rs = pd.to_numeric(d[rotor_col], errors='coerce')
- d['_stop'] = rs.abs() < rs_stop
- if p_col and p_col in d.columns:
- d['_stop'] = d['_stop'] & (pd.to_numeric(d[p_col], errors='coerce').abs() < p_stop)
- # per-台 run-length: 连续停机段内第几行 (block=累计非停行数, 停行同block; cumcount=段内位置)
- d['_blk'] = d.groupby(turbine_col)['_stop'].transform(lambda s: (~s).cumsum())
- d['_runlen'] = d.groupby([turbine_col, '_blk']).cumcount()
- eq = d[d['_stop'] & (d['_runlen'] >= settle_rows)].copy() # 等平衡态(≥settle_rows连续停)
- if len(eq) < min_n:
- h = settle_rows * 10 / 60
- return {'per_turbine': [], 'fleet_median_recent': None, 'flagged': [], 'near_miss': [],
- 'split_quality': {'verdict': 'no_samples'},
- 'note': f'等平衡冷浸样本不足(<{min_n}); 无≥{h:.0f}h连续停机 → 冷浸测不可用(不代表无偏置)'}
- eq['_off'] = pd.to_numeric(eq[comp_col], errors='coerce') - pd.to_numeric(eq[ref_col], errors='coerce')
- eq = eq.dropna(subset=['_off'])
- eq['_t'] = pd.to_datetime(eq[time_col], errors='coerce')
- eq['_m'] = eq['_t'].dt.to_period('M')
- _fleet_m = eq.groupby('_m')['_off'].median() # fleet 逐月共模
- def _mshape(sub, _min_rows=30, _min_months=4):
- """月间sd/月内sd, **在扣 fleet 逐月共模后的序列上算**: <1 ⇒ 该台月序列的形状不可读
- (月内噪声盖过月间信号); None=可用月<4。
- ★必须扣共模: fleet 季节共模会抬高月间sd, 让形状显得比实际可读 —— 实测某台
- raw 1.05(过) vs 扣共模 0.67(不过), 而其"上升"经独立复核确为纯季节。
- 全场分布也随之改变: raw 中位 1.10 / 38% <1; 扣共模 中位 0.78 / 71% <1。"""
- try:
- s = sub.assign(_d=sub['_off'] - sub['_m'].map(_fleet_m))
- gm = s.groupby('_m')['_d']
- keep = gm.size() >= _min_rows
- med, sd = gm.median()[keep], gm.std()[keep]
- if len(med) < _min_months:
- return None
- ws = float(sd.median())
- if not ws or ws <= 0:
- return None
- return round(float(med.std()) / ws, 2)
- except Exception:
- return None
- tmid = eq['_t'].quantile(recent_frac) # 时间分位分早/晚
- rows = []
- for tid, g in eq.groupby(turbine_col):
- if len(g) < min_n:
- continue
- rec = g[g['_t'] >= tmid]['_off']
- ear = g[g['_t'] < tmid]['_off']
- co = float(g['_off'].median())
- cr = float(rec.median()) if len(rec) >= min_split else None
- ce = float(ear.median()) if len(ear) >= min_split else None
- # 双向 (2026-09-03): 发展性 = **幅度**增长, 读低型同样致盲 (SOP §4.6 停机冷浸判别三盲区 ②:
- # 读高型=报警阈被蒙蔽·假报警风险; 读低型=真超温时保护延迟 — 两向都该入测点修单)。
- # 原实现只判正向, 漏读低型 (邻线实测: 某台 早期 -30.70 → 近期 -10.80 全场最大变化, 零标记)。
- dev = bool(cr is not None and ce is not None
- and abs(cr) - abs(ce) > offset_thresh and abs(cr) > offset_thresh)
- rows.append({'tid': str(tid), 'cold_offset': round(co, 2),
- 'cold_recent': (None if cr is None else round(cr, 2)),
- 'cold_early': (None if ce is None else round(ce, 2)),
- # month_shape_ratio (2026-09-03 并行线提, 双线实证): 月中位本身也是一个概括量,
- # 读它的形状(趋势/阶跃/双态)之前必须先证它是**稳的概括**。
- # 月间sd/月内sd < 1 ⇒ 月序列主要反映月内抽样, 其形状不可读(分箱伪影)。
- # ⚠ 这是**可读性前置**不是异常判据 —— 健康台本就没有月尺度结构, 故全场高命中
- # 属预期(实测神木 55 台可算, 中位 0.78, 71% <1), **不适用 RULE-11 的命中率闸**。
- 'month_shape_ratio': _mshape(g),
- 'dev_offset': dev,
- # direction 见下方 fleet 参照段统一赋值 (读高/读低是相对 fleet 而非相对 0)
- 'direction': None,
- 'n': int(len(g))})
- if not rows:
- return {'per_turbine': [], 'fleet_median_recent': None, 'flagged': [], 'near_miss': [], 'note': 'per台等平衡样本不足'}
- recents = [r['cold_recent'] for r in rows if r['cold_recent'] is not None]
- fleet_rec = float(np.median(recents)) if recents else None
- rows.sort(key=lambda r: -(r['cold_recent'] if r['cold_recent'] is not None else r['cold_offset']))
- def _dev_fleet(r):
- if fleet_rec is None or r['cold_recent'] is None:
- return None
- return abs(r['cold_recent'] - fleet_rec)
- for r in rows:
- _d = _dev_fleet(r)
- r['dev_fleet'] = (None if _d is None else round(_d, 2))
- # 读高/读低必须相对 fleet 中位, 不能相对 0 —— fleet 冷态 Δ 常非零 (实测神木中间轴 DE−NDE
- # fleet 中位 +2.14℃ = 该配对本身不对称), 相对 0 定向会给出与判定矛盾的标签:
- # 某台 cr=-2.36 相对 0 只有 2.36 (不定向) 但相对 fleet 偏 4.50 (已进 near_miss) 且实为读低。
- r['direction'] = (None if _d is None or _d < near_frac * offset_thresh
- else ('high' if r['cold_recent'] > fleet_rec else 'low'))
- flagged = [r['tid'] for r in rows if r['dev_offset']
- or (_dev_fleet(r) is not None and _dev_fleet(r) > offset_thresh)]
- # near_miss (2026-09-03): 刀刃判据必带近失名单 —— 单一阈的必然性质是「差一点」的台整个消失,
- # 而它们可能有独立证据 (实测: 某台 |dev_fleet|=4.70 vs 阈 5.0 未标记, 但其最冷箱 -4.1 是独立读低证据)。
- # 与「报命中率必同时报距阈余量」同源, 只是余量在下方。交付名单必须 flagged + near_miss 一起给。
- near_miss = [{'tid': r['tid'], 'dev_fleet': r['dev_fleet'], 'direction': r['direction'],
- 'frac_of_thresh': round(_dev_fleet(r) / offset_thresh, 2)}
- for r in rows if r['tid'] not in flagged and _dev_fleet(r) is not None
- and _dev_fleet(r) >= near_frac * offset_thresh]
- # split_quality (2026-09-03): 冷浸取样在时间上天然有偏 —— 长停多在冬季/检修季 ⇒
- # early/recent 的对比很可能是**季节对比**而非时间漂移对比。双线实证: 一侧近期段 93.4% 落在冬季月、
- # 早期段基本是夏秋; 另一侧过质量门的月份只剩 4 个(早/近各 2 月)。⇒ 机器报, 不靠记得查。
- # ★必须用 eq(等平衡冷浸样本)与实际分界 tmid, 不能用全体行与时间中点 ——
- # 否则这个"诊断量"算的是另一个总体, 与它要诊断的对象不是同一批数据。
- _mm = eq['_t']
- _cal = _mm.dt.month
- _early_m = set(_cal[_mm < tmid].dropna().astype(int).unique().tolist())
- _recent_m = set(_cal[_mm >= tmid].dropna().astype(int).unique().tolist())
- _per = eq.assign(_m=_mm.dt.to_period('M'))
- _n_months = int(_per['_m'].nunique()) if len(_per) else 0
- # ★可用月 != 有样本的月: 一个月要能支撑对比, 需该月有足够多的台各自有足够多样本。
- # (2026-09-03 订正: 原 n_months 数的是"有≥1样本的月", 同一份数据库数 12 而对方按
- # 台级质量门数 4 —— 我据那个 4 标了 <6 的阈, 而它出自另一套过滤器 ⇒ 该阈未被任何场验证。
- # 修法不是改阈, 是让计数去数"可用月", 使阈与它声称衡量的东西一致。)
- _cnt = _per.groupby(['_m', turbine_col]).size()
- _usable = _cnt[_cnt >= min_month_rows].groupby(level=0).size()
- _n_usable = int((_usable >= min_month_turbines).sum())
- _share = _per['_m'].value_counts(normalize=True)
- _max_share = float(_share.iloc[0]) if len(_share) else 1.0
- _ov = (len(_early_m & _recent_m) / len(_early_m | _recent_m)) if (_early_m | _recent_m) else 0.0
- _trip = []
- if _n_usable < 6:
- _trip.append('too_few_months')
- if _ov < 0.34:
- _trip.append('season_confounded')
- if _max_share > 0.5:
- _trip.append('single_month_dominated')
- _sv = _trip[0] if _trip else 'ok'
- # near 带: 与 near_miss 同一纪律 —— 只报最先命中的那一档, 会看不见它同时逼近别档
- _near = []
- if 'too_few_months' not in _trip and _n_usable < 6 / 0.8:
- _near.append(f'too_few_months(可用月 {_n_usable})')
- if 'season_confounded' not in _trip and _ov < 0.34 / 0.8:
- _near.append(f'season_confounded(重叠 {_ov:.2f})')
- if 'single_month_dominated' not in _trip and _max_share > 0.5 * 0.8:
- _near.append(f'single_month_dominated(单月 {_max_share:.3f})')
- split_quality = {'n_months': _n_months, 'n_months_usable': _n_usable,
- 'max_month_share': round(_max_share, 3),
- 'early_calendar_months': sorted(_early_m), 'recent_calendar_months': sorted(_recent_m),
- 'month_overlap': round(_ov, 2), 'verdict': _sv, 'tripped': _trip, 'near': _near,
- # 每档的实测触发状态随输出一起走 —— 否则"这套判据是实证的"会把零触发的档一起盖进去。
- # (写进文档的会被跳过, 写进返回值的不会。2026-09-03 双线定)
- 'verdict_provenance': {
- 'season_confounded': '实测触发 n=2 场 (远景神木 重叠0.17 / 明阳昌平坳 重叠0.08)',
- 'too_few_months': '实测触发 n=1 场 (明阳昌平坳 可用月 4; 远景神木 9 未触发)',
- 'single_month_dominated': '**零实测触发**; 最接近 明阳昌平坳 单月 0.401 (逼近未命中) '
- '⇒ 该档阈 0.5 属推定, 未被任何场验证',
- },
- 'note': '非 ok 时 cold_early/cold_recent 与其差值只可作筛, 不得作趋势判据; '
- 'tripped 列全部命中档, near 列逼近但未命中档(0.8x)'}
- h = settle_rows * 10 / 60
- return {'per_turbine': rows, 'fleet_median_recent': (None if fleet_rec is None else round(fleet_rec, 2)),
- 'flagged': flagged, 'near_miss': near_miss, 'split_quality': split_quality,
- 'note': f'等平衡(连续停≥{h:.0f}h)静止态 {comp_col}−{ref_col}; 时间split早/晚; '
- f'flagged = |晚期偏fleet|>{offset_thresh}° ∨ dev_offset(|晚|>|早|=发展性传感器漂移); **双向**(读低型=保护延迟, SOP §4.6 三盲区②); 静止无摩擦热轴承不可能比油热'}
- def leading_edge_shape_screen(df, *, turbine_col, time_col, power_col, ref_ws_col,
- ref_dir_col=None, coords=None, rotor_d=None,
- setpoint_col=None, setpoint_min=None,
- p_gen_kw=20.0, n_fleet_min=12,
- bins=None, low_bins=(4.0, 5.0), mid_bins=(6.0, 7.0, 8.0),
- high_bins=(10.0, 11.0, 12.0),
- wake_max_d=8.0, hw_near=30.0, hw_far=20.0,
- n_mid_min=600, blade_age_years=None):
- """前缘腐蚀(LE erosion) 性能签名筛查 (SOP §4.7 气动轴; 源 fushan decay_m3_leading_edge v2-geomwake, n=1[暂行])
- ★**签名**: 相对亏空「**中风(Region2)最深 + 近额定收敛**」= LE。
- ≠ 低风最深(尾流/卡滞) · ≠ 高风更深(降容) · ≠ 全段均匀(uniform_low)。
- 物理: Region2 受 Cp 限, 气动亏空按比例现; 近/超额定后各台**都能到额定**(只是所需风速略高),
- 故亏空**自动收敛** -> 形状是**凹坑**不是单调斜线。
- ⚠**禁用线性斜率拟合该形状** —— 对凹坑恰好得 ≈0 (2026-07-26 实证: 我用斜率法在如东得"0台",
- 纯属统计量测不出真签名, 结论已撤回)。
- ★★**三道机器强制门** (本函数存在的主要理由 —— 原方法只写在场脚本注释里, 结果被重造成更差的版本):
- ① **ref_ws_col 必须是独立风(测风塔)**: 分 bin 与归一**禁用机舱风**(self_ref 会把仪表漂移
- 算成气动亏空)。本函数**机器核**: 同一 time 下 ref_ws_col 若**逐台不同** -> 判为非独立风,
- verdict 直接锁 INSUFFICIENT 并在 gate_failures 报出 (不靠调用方自觉)。
- ② **几何尾流扇区剔除**: 需 coords + ref_dir_col + rotor_d。dist<wake_max_d·D 的邻机,
- 半宽 hw_near(<5D)/hw_far(5~8D)。尾流亏空**也随风速变化**, 不剔会伪造/掩盖签名
- (fushan 实测部分台剔掉 61%)。缺 coords 或 ref_dir_col -> 只能出全样本版, verdict 降级。
- ③ ★**全向性判别 (判 LE 的必要条件)**: 「中风最深+高风收敛」是**任何**有效风速折损机制
- (尾流/湍流/地形绕流)**共有**的形状, **非前缘专属**。前缘是**叶片属性 -> 必须全向一致**。
- 故形状门过后**必须做扇区分解**: 若亏空集中在少数扇区 -> **改判来流/位置效应, 不是 LE**。
- 实证 (fushan A2): 中风亏空 −3.802pp 形状门刚过, 扇区分解 **~90% 来自北扇区**,
- 剔该扇区残余 −0.408pp≈0 -> 改判「北向来流劣化候选」。**缺 ref_dir_col 则 LE 一律 INSUFFICIENT**。
- ★**共性盲区 (必随结论输出, 不可省)**: fleet-relative 轴对**全场均匀**腐蚀**天然盲**
- (共模被 fleet 中位吸收)。同龄同环境同机型的场若**普遍**轻中度 LE, 本轴**既不能确认也不能排除**
- = INSUFFICIENT; 破盲区须**无人机前缘影像抽检**或**绝对基准**(投运初期功率曲线/保证曲线)。
- 量级参考: Sandia 重度 LE 的 AEP 损失可达 5%; IEA Wind Task 46(2025) 0.5~5%;
- fushan 台间检测下限 2σ≈3pp -> **轻度(<3pp)落噪声内**。
- ★叶龄先验: LE 高发段 ≈ 7 年往后 (blade_age_years 给出则入 note; 太新的场做了也是空)。
- 入: df 长表 (多台); ref_ws_col=测风塔风速(**每时刻全场同值**); ref_dir_col=测风塔风向(绝对, 对北);
- coords={台号: (lon, lat)}; rotor_d=转子直径 m; setpoint_col/setpoint_min 可选剥限电/降档。
- 出 dict: {per_turbine{台:{depth_low_pp, depth_mid_pp, conv_high_pp, le_score_pp, shape_class,
- strength, z_mid_fleet, n_mid, sector_dev_pp, worst_sector, omnidirectional}},
- curves, le_shape_gate_passed, le_candidates_final, wake_excluded_frac,
- gate_failures, near_miss, expected_fp, blindspot, verdict}
- verdict ≤ **候选** (前缘确诊须无人机/叶片巡检影像 + 粗糙度记录)。
- """
- import numpy as _np
- import pandas as _pd
- BINS = _np.arange(4, 14, 1.0) if bins is None else _np.asarray(bins, dtype=float)
- d = df.copy()
- for c in (power_col, ref_ws_col):
- d[c] = _pd.to_numeric(d[c], errors='coerce')
- gate_failures = []
- # ---- 门① ref_ws 必须独立 (机器核, 不靠调用方自觉) ----
- per_t_spread = d.groupby(time_col)[ref_ws_col].nunique(dropna=True)
- frac_multi = float((per_t_spread > 1).mean()) if len(per_t_spread) else 1.0
- ws_independent = frac_multi < 0.01
- if not ws_independent:
- gate_failures.append(
- 'ref_ws_col 非独立风: %.1f%% 的时刻该列逐台不同 -> 疑为机舱风(self_ref)。'
- '分bin/归一必须用测风塔独立风, 否则仪表漂移会被算成气动亏空。LE verdict 锁 INSUFFICIENT。'
- % (100 * frac_multi))
- # ---- 门② 几何尾流扇区 ----
- wake = {}
- can_wake = (coords is not None) and (ref_dir_col is not None) and (rotor_d is not None)
- if can_wake:
- # 坐标含非有限值的台: 其尾流对无法计算, 该台**及其邻机**都会少剔扇区 -> 静默降低
- # 剔除力度而不报警。响亮化 (原为静默): 缺失台点名进 gate_failures, 消费方可据以决定
- # 是否补坐标再跑。(rudong tid34 lon 缺失, 修 isfinite 后由"造假扇区"变"少扇区", 仍须知情)
- _bad_coord = sorted(str(k) for k, v in coords.items()
- if not (_np.isfinite(v[0]) and _np.isfinite(v[1])))
- if _bad_coord:
- gate_failures.append(
- '坐标非有限值的台 %s (共 %d/%d) -> 其尾流扇区无法构造, 该台及其邻机剔除力度偏低; '
- '补全坐标后重跑, 或对这些台的 LE 结论降级' % (_bad_coord[:10], len(_bad_coord), len(coords)))
- for i, (lo_i, la_i) in coords.items():
- secs = []
- for j, (lo_j, la_j) in coords.items():
- if i == j:
- continue
- dE = (lo_j - lo_i) * _np.cos(_np.deg2rad(la_i)) * 111320.0
- dN = (la_j - la_i) * 110540.0
- dist = float(_np.hypot(dE, dN)); dd = dist / float(rotor_d)
- # 坐标缺失 -> dd=NaN, 而 NaN>x 与 NaN<=0 均为 False 会漏过下面的排除判据,
- # 把该邻机当"在闸内"并按 hw_far 造出一个假扇区 (rudong tid34 lon 缺失实测:
- # 有效对数 66 虚胀成 136)。isfinite 必须先判。
- if not _np.isfinite(dd) or dd > wake_max_d or dd <= 0:
- continue
- brg = float(_np.rad2deg(_np.arctan2(dE, dN)) % 360.0)
- secs.append((brg, hw_near if dd < 5.0 else hw_far, str(j), round(dd, 2)))
- wake[str(i)] = secs
- else:
- gate_failures.append('缺 coords/ref_dir_col/rotor_d -> **无法剔尾流扇区**, 只能出全样本版; '
- '尾流亏空亦随风速变化, 会伪造/掩盖 LE 签名 -> verdict 降级')
- # ---- 发电/非限电闸 + fleet 同时刻配对 ----
- m = d[power_col] > p_gen_kw
- if setpoint_col is not None and setpoint_min is not None:
- d[setpoint_col] = _pd.to_numeric(d[setpoint_col], errors='coerce')
- m = m & (d[setpoint_col] >= setpoint_min)
- base = d[m].copy()
- med = base.groupby(time_col)[power_col].agg(['median', 'size'])
- med.columns = ['P_med', 'n_gen']
- base = base.merge(med, on=time_col)
- base = base[(base.n_gen >= n_fleet_min) & (base.P_med > 0)]
- if not len(base):
- return {'verdict': 'INSUFFICIENT', 'gate_failures': gate_failures + ['配对后样本为 0'],
- 'per_turbine': {}, 'le_candidates_final': []}
- base['ratio'] = base[power_col] / base.P_med
- base['bin'] = _pd.cut(base[ref_ws_col], BINS, right=False, labels=BINS[:-1]).astype(float)
- base = base.dropna(subset=['bin'])
- # free-flow 主工作集
- if can_wake:
- inw = _np.zeros(len(base), bool)
- dirs = _pd.to_numeric(base[ref_dir_col], errors='coerce').values
- tvals = base[turbine_col].astype(str).values
- for k, secs in wake.items():
- ix = (tvals == k)
- if not ix.any() or not secs:
- continue
- sub = _np.zeros(int(ix.sum()), bool)
- dsub = dirs[ix]
- for c, hw, _n, _dd in secs:
- sub |= (_np.abs((dsub - c + 180) % 360 - 180) <= hw)
- inw[ix] = sub
- base['in_wake'] = inw
- work = base[~base.in_wake].copy()
- wake_frac = base.groupby(base[turbine_col].astype(str)).in_wake.mean().round(4).to_dict()
- else:
- base['in_wake'] = False
- work = base
- wake_frac = {}
- LOW, MID, HIGH = list(low_bins), list(mid_bins), list(high_bins)
- def _scores(sub):
- g = sub.groupby('bin').ratio.agg(['median', 'std', 'size']).reindex(BINS[:-1])
- overall = float(sub.ratio.median())
- raw = 100 * (g['median'] - 1)
- shape = 100 * (g['median'] / overall - 1) if overall else raw * _np.nan
- dep = lambda bb: float(_np.nanmean([shape.get(b, _np.nan) for b in bb]))
- return g, raw, shape, dep
- 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)
- curves, sc = {}, {}
- for tb in tids:
- sub = work[work[turbine_col].astype(str) == tb]
- g, raw, shape, dep = _scores(sub)
- hi = sub[sub.bin.isin(HIGH)]
- achv_hi = float(100 * (hi.ratio.median() - 1)) if len(hi) >= 300 else _np.nan
- curves[tb] = {'bins': [float(b) for b in BINS[:-1]],
- 'n': [int(x) if x == x else 0 for x in g['size']],
- 'deficit_raw_pp': [None if x != x else round(float(x), 3) for x in raw],
- 'shape_dev_pp': [None if x != x else round(float(x), 3) for x in shape]}
- sc[tb] = {'depth_low_pp': round(dep(LOW), 3), 'depth_mid_pp': round(dep(MID), 3),
- 'achv_high_raw': None if achv_hi != achv_hi else round(achv_hi, 3),
- 'n_mid': int(_np.nansum([g['size'].get(b, 0) for b in MID]))}
- ah = _pd.Series({t: sc[t]['achv_high_raw'] for t in tids}, dtype=float)
- ah_med = float(ah.median()) if ah.notna().any() else 0.0
- dm = _pd.Series({t: sc[t]['depth_mid_pp'] for t in tids}, dtype=float)
- mad = float((dm - dm.median()).abs().median()) * 1.4826 # MADK
- for tb in tids:
- s = sc[tb]
- v = s['achv_high_raw']
- s['conv_high_pp'] = None if v is None else round(v - ah_med, 3)
- s['z_mid_fleet'] = round(float((s['depth_mid_pp'] - float(dm.median())) / mad), 4) if mad > 0 else None
- dl, dmid = s['depth_low_pp'], s['depth_mid_pp']
- ch = s['conv_high_pp'] or 0.0
- le = ch - dmid
- s['le_score_pp'] = round(le, 3)
- if s['n_mid'] < n_mid_min:
- cls = 'INSUFFICIENT_n'
- elif (dl - dmid <= -2.0) and dl <= -3.0:
- cls = 'wake_or_lowws_like'
- elif ch <= -3.0 and (dmid - ch >= -1.5):
- cls = 'derate_like'
- elif dmid <= -3.0 and le >= 2.5:
- cls = 'LE_like'
- elif dmid <= -3.0 and abs(dmid - ch) < 2.5 and abs(dl - dmid) < 2.0:
- cls = 'uniform_low'
- else:
- cls = 'normal'
- s['shape_class'] = cls
- s['strength'] = (('strong' if dmid <= -6.0 and abs(s['z_mid_fleet'] or 0) >= 2.0
- else 'moderate' if dmid <= -4.0 else 'weak')
- if cls == 'LE_like' else 'none')
- shape_passed = [t for t in tids if sc[t]['shape_class'] == 'LE_like']
- # ---- 门③ 全向性判别 (判 LE 的必要条件) ----
- le_final, sector_note = [], None
- if ref_dir_col is None:
- sector_note = ('缺 ref_dir_col -> **无法做扇区分解**; 而「中风最深+高风收敛」是任何有效风速折损'
- '机制共有形状, 前缘必须全向一致 -> **LE 一律 INSUFFICIENT**, 形状门过者只作待查')
- gate_failures.append(sector_note)
- else:
- SEC = _np.arange(0, 360, 45.0)
- for tb in shape_passed:
- sub = work[work[turbine_col].astype(str) == tb].copy()
- sub['sec'] = (_pd.to_numeric(sub[ref_dir_col], errors='coerce') // 45).astype('float')
- mid = sub[sub.bin.isin(MID)]
- if not len(mid):
- continue
- dev = {}
- omed = float(mid.ratio.median())
- for s_, gg in mid.groupby('sec'):
- if len(gg) >= 50 and omed:
- dev[int(s_)] = round(float(100 * (gg.ratio.median() / omed - 1)), 3)
- sc[tb]['sector_dev_pp'] = dev
- if dev:
- worst = min(dev, key=dev.get)
- sc[tb]['worst_sector'] = '%d-%d°' % (worst * 45, worst * 45 + 45)
- sc[tb]['worst_sector_share_of_mid'] = round(float((mid.sec == worst).mean()), 4)
- # ★剔最差扇区后**重算同一个 depth_mid 统计量** (不是拿 ratio 中位比自身总体 ——
- # 那样得到的是别的量; 2026-07-26 移植回归逮到此错: 残余 +5.183 vs 正解 −0.408)
- rest_all = sub[sub.sec != worst]
- if len(rest_all) >= n_mid_min:
- _g2, _raw2, _sh2, dep2 = _scores(rest_all)
- res_dep = dep2(MID)
- else:
- res_dep = _np.nan
- sc[tb]['depth_mid_excl_worst_sector_pp'] = None if res_dep != res_dep else round(res_dep, 3)
- # 全向 = 剔掉最差扇区后**亏空仍在**(保留至少一半深度); 否则=单扇区来流/位置效应
- omni = bool(abs(sc[tb]['depth_mid_pp']) > 0 and res_dep == res_dep
- and res_dep <= 0.5 * sc[tb]['depth_mid_pp'])
- sc[tb]['omnidirectional'] = omni
- if omni:
- le_final.append(tb)
- else:
- sc[tb]['reclassified'] = ('非全向 -> 改判**来流/位置效应候选**(尾流残余/地形绕流/'
- '背风湍流), 非前缘: 剔最差扇区后中风残余由 %.3f -> %.3f pp'
- % (sc[tb]['depth_mid_pp'], res_dep))
- nm = [('%s: depth_mid %.3f pp / le_score %.3f / class=%s — 门缘披露'
- % (t, sc[t]['depth_mid_pp'], sc[t]['le_score_pp'], sc[t]['shape_class']))
- for t in tids if -3.5 <= sc[t]['depth_mid_pp'] <= -2.0]
- verdict = ('INSUFFICIENT (门未过, 见 gate_failures)' if gate_failures and not ws_independent
- else ('候选: LE_like 且全向 = %s' % le_final) if le_final
- else ('形状门过 %s 但扇区分解后无全向存活 -> 前缘证据=无' % shape_passed) if shape_passed
- else '本场无前缘签名候选 (形状门零过)')
- return {'per_turbine': sc, 'curves': curves,
- 'le_shape_gate_passed': shape_passed, 'le_candidates_final': le_final,
- 'wake_excluded_frac': wake_frac, 'wake_geometry': wake,
- 'ws_independent': ws_independent, 'gate_failures': gate_failures,
- 'near_miss': nm,
- 'expected_fp': {'n_compare': len(tids), 'alpha': 0.046,
- 'expected_fp': round(len(tids) * 0.046, 2)},
- 'blade_age_years': blade_age_years,
- 'blindspot': ('★fleet-relative 对**全场均匀**腐蚀天然盲(共模被 fleet 中位吸收): 同龄同环境'
- '同机型的场若普遍轻中度 LE, 本轴**既不能确认也不能排除**=INSUFFICIENT; '
- '破盲须无人机前缘影像抽检或绝对基准(投运初期/保证曲线)。'
- '量级: Sandia 重度 LE AEP 损失可达 5%; IEA Wind Task 46(2025) 0.5~5%; '
- '台间检测下限 2σ≈3pp -> 轻度(<3pp)落噪声内'),
- 'verdict': verdict,
- 'note': ('签名=中风最深+近额定收敛(凹坑, **禁线性斜率拟合**); '
- '三道机器强制门=①独立风分bin ②几何尾流剔除 ③**全向性判别(判LE必要条件)**; '
- 'verdict ≤ 候选, 确诊须无人机/叶片巡检影像+粗糙度记录')}
- def matched_load_sink_dt(df, *, node_cols, sink_col, p_col, turbine_col, rated,
- time_col=None, gen_mask=None, p_lo_frac=0.30, p_hi_frac=0.95,
- bin_kw=100.0, min_cell=30, min_bins=3, dt_z_thresh=2.5,
- dt_abs_floor=2.0, small_mad_c=0.5, monthly=False,
- valid_range=(-40.0, 200.0),
- ld_lo_frac=0.50, ld_hi_frac=0.75, ld_min_seg_bins=3,
- ld_hi_abs=None, ld_min_gap=1.0,
- windows=(3, 6, 9, 12, 18)):
- """同载荷 × 消热汇 ΔT 判据 — matched_load_overheat 与 node_minus_sink_dt 的分层融合
- (SOP §4.6; 如东 U3v2 2026-08-09 实战反哺)。
- ⭐ 为什么要融合 (两法各自的盲区, 如东 n=1 实证):
- · matched_load_overheat 控住了载荷分布 (100kW 分箱同载荷比), 但**比的是部件绝对温**,
- 不消热汇 → **冷却器故障会伪装成"多部件齐热"**。如东 20# 冷却器旁通失效致油温本身
- +2.5℃(z 6.78), 该法读出 HSRot +4.7 / IMSRot +3.0 / 油温 +2.5 三处齐热, 形似三轴承同时坏;
- 减热汇后这两个轴承通道分别降到 **+2.0℃ 与 +0.3℃**(齐热消散), 而高速轴发电机侧为 **−4.6℃**
- (明显偏冷) —— 真正热的是油本身, 不是轴承。
- · node_minus_sink_dt 消了整机共模 (节点−即时热汇), 但**不控载荷分布** → 常年负荷偏高的台
- ΔT 系统性偏高 (摩擦生热随载荷升)。
- ⇒ 本函数 = 分箱控载荷 ∧ 减热汇消共模 ∧ 跨箱取中位, 两层混杂一次消掉。
- ★小分母闸 (small_mad_c, 本次实战新增): 台间 MAD < small_mad_c 的通道**禁用 z**, 只用绝对值门。
- 如东 IMSRot−油温 全场中位 −0.21℃、台间 MAD 仅 0.45℃ → 2.7℃ 的绝对差就给出 z>6,
- 与"%/月 小分母虚大"是同一现象; 该通道下 z 排名不可作判据。
- ★★dt_z_thresh 的实际把关力必须**仿真**, 不可查正态表 (2026-08-09 如东 n=38 实测):
- MAD 在小样本下系统性偏低 → z 被整体放大; 且 38 个 z 共用同一分子分母来源、彼此不独立
- ⇒ 解析式 "P(|z|>2.5)×n" 会**低估** 1.8 倍。蒙特卡洛 20 万次实测 (n=38, |z|≥2.5):
- 正态零分布 单通道期望误报 0.844 台 (解析天真值 0.472); 4 通道合计 3.37 台; 某通道≥2 个的概率 61%
- t5 重尾 单通道 1.899 台; 4 通道 7.59 台; ≥2 的概率 95%
- 族错误率 5% 的经验阈 = **z 3.79 (正态) / 6.52 (t5)** —— 远高于默认 2.5。
- ⇒ 纪律: (a) 默认 2.5 只是**筛查门**, 单独命中 = 候选, 升级必须靠跨源多链汇聚, 不可称"统计显著";
- (b) 报候选时须同时给"该通道 z 闸与绝对门谁在真正约束" —— 若 dt_abs_floor < dt_z_thresh×mad,
- 则约束在 z 那一半, 即落在上述误报率里; (c) **不要据此把阈值一味调高** —— 发电机线把 R² 门设过严
- 造成整族假阴性 (634→67 月, 真异常台从第 2 名压到第 29 名) 是同一枚硬币的反面。
- ⚠ 旁证 (反直觉但重要): small_mad_c 触发后**禁用 z 只走绝对门**的通道, 抗运气能力反而更好 ——
- 如东 IMSRot 台间 MAD 0.45℃, 2.0℃ 绝对门 = 4.44 个 MAD 单位 > 3.79 经验阈; 而 z 闸通道 (IMSGen,
- MAD 1.22℃) 的 z 2.5 ≈ 3.05℃ 反而更松。**物理门不随样本量漂移, 是抗多重比较的主依靠。**
- ★★"多链汇聚才升级"这条防线, 其前提 (各链独立) 必须**实测**, 不能假设 (2026-08-09 如东 38 台实测):
- 发电机线实证反例: DE|now 与 配对差|now 的 r=0.803 → "多轴汇聚"名存实亡 (同一份证据数两遍)。
- 如东齿轮箱线实测结果 (可作本判据的适用性佐证):
- · 四个节点 ΔT 彼此 |r| ≤ 0.265, 与振动/油液 |r| ≤ 0.311 → **共用同一热汇并未把它们绑在一起**,
- 每个节点的 ΔT 仍带独立信息 ⇒ 本判据用于多节点并列筛查是成立的。
- · 但**同节点的 ΔT 水平 与 该节点 β 模型的 α (截距) r 高达 0.78~0.93** (数学同源) ⇒
- "ΔT 高 ∧ α 破门" **不是两条独立证据**, 叙述与计票都只能算一票。
- α 的正当用法是与 β 作**背离比较** (α 高而 β 低 ⇒ 更像测温链偏置) —— 那是分辨用途、非印证用途。
- ★通道存活守卫 (§4.6c, 承 matched_load_overheat/node_minus_sink_dt): 热汇通道逐台验活, 死则整台剔出
- (ΔT 失参照会造伪离群); 被排名部件通道亦逐台验活, 冻结/坐死列剔出该台该通道 (防"传感器坏"伪装
- "部件热")。出 dead_sink_tids / dead_node_channels 供交付声明。
- ★同季差之后必看 fleet 同季分布 (2026-08-09 与发电机分析线交叉核对确立): 个体的年际同季差看着大
- (如 +6℃), 若 fleet 同季中位本就 +1~2℃ 且十余台更高, 那是**年际共模非个体异常** — 如东 24# 发电机
- 驱动端"同季 +6℃ 持续恶化"经此检验撤回 (该台排 11~12/38, 非离群)。判个体异常须给出该台在 fleet
- 同季差分布中的排名, 不可只报自身同季差。
- ★"改造/更换前后"比较必须控载荷 (承 §4.11 EL-4): 换件机组换后的平均负荷可能系统性变化 (如东三台
- 换发电机轴承台换后高载窗均负荷降 99~155kW), 不控载荷会高估/低估效果 — 如东三台换高速轴的温差
- 回落幅度按本函数分箱口径重算后由"3~7℃"修正为"2.4~5.6℃"。
- ★★"更换前后"还必须配**未更换对照组**, 只有净效应才算成效 (2026-08-09 与发电机分析线交叉确立):
- 控住载荷之后仍不够 —— 同期整个机群可能都在变好 (季节/油品批次/负荷结构)。
- 净效应 = Δ_换件台 − median(Δ_对照组), 对照组用**完全相同的前后月份**。
- · 发电机线实证 (反例): 2# "换轴承后温升回落 = 更换有效", 加对照组后 2# 前后变化 0.00K、
- 27 台对照同期中位**也是 0.00K** → 净效应零, 结论已撤回。
- · 齿轮箱线实证 (正例): 20#/22#/28# 换高速轴, 净效应 −5.62/−5.59/−2.32K,
- 三台均居对照分布**第 0 百分位**(35~36 台对照无一降幅相当), 为对照 MAD 的 4~10 倍 → 成效坐实。
- ⚠ 对照组的排除项只应含**处置本身**。例行换油/滤芯这类全场背景作业不可用来筛对照
- (换件台自己也经历), 否则把共模从对照里一并剔掉 → 高估净效应。
- 如东首版把 7 类干预全排, 后窗拉到 14 月时对照组塌到 2 台 —— **对照组塌缩 = 判据设错的信号**,
- 不是"本场没有可用对照"。
- ⚠ 前后窗长度须与头条 claim 同口径: 22# 用"后 3 月"得 3.96K、用"换后全窗"得 5.58K,
- 因该台换后**逐月继续走低到第 6 个月才稳**, 3 月窗切在爬坡段上。两口径并列输出防漂移。
- ★★热汇"改善"同样会在**未更换通道**上造伪信号 (2026-08-09 如东实逮, 与 20# 冷却器那个反向案例互补):
- 本判据减的是即时热汇, 所以热汇往任一方向移动都会污染 ΔT ——
- 不只"热汇故障变热"(20# 冷却器旁通失效致油温高 → 多个轴承伪装成齐热),
- **"热汇因检修而变凉"同样致命**: 如东三台换高速轴后, 高速轴**转子侧**(未更换)ΔT 反升 0.45~1.87K,
- 拆开绝对温才知道 20# 是油温降 4.12K 而转子侧轴承**自己也降了 2.57K**、只是没油降得多;
- 28# 同理 (油 −1.08K / 轴承 −0.46K)。**三台无一真的变热**, 若只看 ΔT 会开出一张不该开的工单。
- ⇒ 纪律: **判读换件/改造前后, 必须同时给出节点绝对温与热汇绝对温的各自变化**,
- ΔT 的变化要能被 Δ(节点) − Δ(热汇) 分解复现, 否则不得作部件结论。
- ★时序 (monthly=True): 全窗聚合看不见起点。如东 20# 曾被读作"换高速轴当月起冷却塌陷",
- 逐月复核才发现该台**换轴前即为全场最低**、可归因阶跃仅约 2℃ (其余为季节共模) —— 涉及
- 检修归责的结论必须带时序, 不可只给全窗一个数。
- ★★载荷依赖型判据 (2026-08-09 补; 原"已知盲区"的解, 见文末残留部分):
- 跨箱取中位对**只在高载才发作**的故障是稀释器 —— 一台低载正常、高载才热的机组,
- 27 个箱取中位会把它冲平 (若只有末 6 个箱抬 +4℃、其余 21 箱为 0, 中位仍 ≈0, 主判据必漏)。
- ⇒ 本函数现输出 per_tid.dev_by_bin (逐箱剖面) + **两个并列的载荷依赖性指标**:
- · dev_slope_c_per_1000kw = Theil-Sen(dev ~ 功率箱) × 1000 —— **形态描述量, 不作闸**。
- ★为什么不作闸 (与主判据同源的缺陷): Theil-Sen 取全体 pairwise 斜率的中位, 拐点型
- (低载平 + 高载抬) 的正斜率对只占少数 —— 27 箱中末 6 箱抬升时正号对仅 126/351 = 36%,
- 中位落在 0 附近甚至可被噪声翻号。**它和跨箱中位犯同一个错**(把局部发作摊薄),
- 所以它只能描述"是渐进爬升还是拐点", 不能当检出闸。这也是 analysis_kit.theil_sen
- docstring "对阶跃会摊成假渐进"那条铁律在本判据面上的同一现象。
- · dev_hi_minus_lo = 高载段 dev 中位 − 低载段 dev 中位 —— **这个才是闸**。
- 分段用**功率的绝对阈**(ld_hi_frac/ld_lo_frac × rated) 而非各台自身分位数:
- 否则一台只有低载箱的机组, 它的"高载段"其实是低载段, 跨台不可比。
- 判据 (load_dependent_flagged, 与 flagged **并列不混**):
- 跨箱中位未破主门 ∧ |dev_hi_seg| ≥ ld_hi_abs (None→继承 dt_abs_floor)
- ∧ |dev_hi_minus_lo| ≥ ld_min_gap ∧ 二者同号 ∧ (高载段台间 MAD 够大时) |z_hi| ≥ dt_z_thresh。
- 两段各须 ≥ ld_min_seg_bins 个箱, 否则该台不参与 (不出数 ≠ 判正常)。
- ⚠ 阈值的**可证伪判据** (不许当拍脑袋的数用):
- · ld_min_gap=1.0℃: 若本场健康台 (主判据未命中且无独立佐证) 的 |dev_hi_minus_lo| 分布
- 中位已 ≥ 0.5℃ 或 p90 ≥ 1.0℃, 则 1.0℃ 落在噪声里 → 该阈**在本场失效**, 须改用
- 该分布的 p95 或提高 min_cell。判法: 直接看本函数输出的 dev_hi_minus_lo 全场分布。
- · ld_hi_frac=0.75 / ld_lo_frac=0.50: 前提是**两段都有足够箱且 fleet 在两段都重叠**。
- 若某场功率带窄 (如限电场常年压在 0.5×rated 以下) 致高载段 < ld_min_seg_bins 箱,
- 本判据整体不出数 —— 这时 load_dependent_flagged 为空**只代表没测**, 不代表没有;
- 输出的 n_bins_hi_seg / n_bins_lo_seg 就是这个失效的机器可读信号。
- · |z_hi| ≥ dt_z_thresh 继承主判据的 2.5, 故**同样只是筛查门**: 上面 ★★dt_z_thresh 那段
- 的蒙特卡洛结论 (n=38 族错误率 5% 的经验阈实为 3.79) 对本闸一字不改地适用,
- 且高载段箱数只有全窗的 1/3 → dev_hi 更噪, 单独命中的证据力**弱于**主判据命中。
- ⚠ 已知残留 (未解): (a) 本判据只分"高/低"两段, **只在中载发作**的故障 (两端正常) 仍检不出 ——
- 须直接读 dev_by_bin; (b) 分箱是功率, 不是转速/扭矩, 变速段内同功率可对应不同扭矩,
- 对扭矩依赖型劣化只是代理; (c) 高载段样本天然少于中载段, 检出力随之低。
- ⚠ sink_diagnosis 的**定义域边界** (不是缺陷, 别当洞补; 2026-08-09 与发电机线交叉核画准):
- 它答的是"这台的热汇**水平**是不是整体偏了", 不答"这台的热汇在**两个窗之间**是不是漂了"
- —— 后者是**变化量**问题, 不在它的定义域内。**保持全窗口径是对的: 判别器答一个问题就好**,
- 时间维交给下面那条纪律, 比给判别器加时间维便宜得多、也更不容易忘。
- ★风险面**只有一个**: **同一台的跨时间窗比较** (换件前后/检修前后/季节前后)。
- **跨机同窗比较不受影响** —— 热汇漂移是共模, fleet 中位跟着一起移 → 不产生假离群。
- (这与"热汇被动会让所有温升整体平移、比个别台跳变更难察觉"是同一件事的两面:
- 难察觉, 但也正因为是共模, 它不制造跨机假阳性。)
- ⇒ 纪律: 凡做"同一台跨时间窗"的比较, **必须同时报节点绝对温与热汇绝对温的各自变化**
- (三行表: 节点绝对温 / 热汇绝对温 / 两者之差), **不论 ΔT 是变了还是没变**。
- **"没有变化"和"变化了"一样需要归因** —— ΔT 不动可能是真没变, 也可能是节点与热汇同向同幅
- 相互抵消。正向查(ΔT 升了→是不是热汇造的)容易想到; 反向查(ΔT 没动→是不是热汇掩盖的)
- 容易漏, 因为"没变化"看起来不需要解释。
- 实例 (发电机线 26#, 三个限定须连带引用): 某台清废脂前后, 热汇(机舱温)涨幅比 fleet 中位多 2.88K,
- 节点同幅跟涨、ΔT 恰好抵成 0.00K。① 该台**全窗**机舱温水平在机群里并不突出, 偏的是**变化量**;
- ② 当时**未造成错误结论**(该台主判据是绝对温升排名 z=+4.21 全场第一 + 振动 + 润滑 + 台账,
- 不依赖前后比较), 危险性是**潜在的** —— 若当时只有前后对比这一条证据就会被误导;
- ③ 是**反向撞上**的(本为验证"ΔT 没降"不是假象, 顺手才看到热汇涨幅异常)。
- ⇒ 该案例是"**为什么必须做分解**"的例子, 不是"判别式失效"的例子。
- ★实测 (不是推理; 复跑 outputs/rudong/units/scripts/u3v2_load_dependent_known_answer.py):
- · 摊薄倍数实证 (DP 合成台, 27 箱中末 9 箱抬 +4.5℃): 主判据跨箱中位读出 **dev=0.02℃**
- (完全漏), 高低段差读出 4.49℃ (逮到); Theil-Sen 斜率 0.72 vs 段差隐含 2.49 ℃/1000kW
- = **被摊薄 3.5 倍** —— "斜率不作闸"这条设计理由由此坐实, 非断言。
- · 阈值可证伪判据的实测值 (如东 38 台, 2025-01~2026-07, 全场 |dev_hi_minus_lo| 中位/p90):
- HSGen 0.61/1.26 · HSRot 0.24/0.53 · IMSGen 0.19/0.51 · IMSRot 0.20/0.38 ℃
- ⇒ **HSGen 通道 p90 已 ≥1.0 ⇒ ld_min_gap=1.0 落在该通道噪声内, 本场该通道判为失效**
- (要用须提到该通道 p95≈1.4 以上, 或加大 min_cell 压噪); 其余三通道 1.0℃ ≈ 2~5×p90, 有效。
- 这就是"阈值带可证伪判据"的具体形态: 同一个 1.0℃, 在同一场的不同通道上一个失效三个有效。
- · known-answer (如东): 四通道 flagged 与改动前落盘产物**逐字一致**, 载荷依赖名单**零命中**,
- 最近 near-miss 余量 −0.49 (非刀刃); 该场已知的 26#/29#/10#/7# 均为全段偏热型, 不该被本判据
- 改判, 实测未改判。零命中的正确读法见 load_dependent_note。
- node_cols: 部件温列名 list; sink_col: 即时热汇列 (齿箱=齿轮油温, 发电机=冷却水/环温)。
- monthly=True 时须给 time_col, 额外返回 per_month 的逐台 dev (同法逐月计算)。
- 返回 {per_node: {node: {fleet_dt_median, mad, z_usable, gate, flagged,
- per_tid: {tid: {dt, dev, z, n_bins, n_samples, # ★n_bins 必须与 dev 同时呈现
- dev_by_bin: {功率箱kW: dev}, # ★逐箱剖面 (跨箱中位的原料)
- dev_slope_c_per_1000kw, # 形态描述量, 非闸 (拐点型会被摊薄)
- dev_lo_seg, dev_hi_seg, dev_hi_minus_lo, z_hi_seg, # ★载荷依赖闸用这三个
- n_bins_lo_seg, n_bins_hi_seg}}, # 段箱数不足 = 没测, 非判正常
- n_bins_min_max, thin_evidence_tids, # 箱数 < 2×min_bins 的台 = 薄证据
- flagged, # 主判据 (跨箱中位)
- load_dependent_flagged, load_dependent_gate, # ★与 flagged 并列不混
- hi_seg_mad, hi_seg_z_usable}},
- n_power_bins, dead_sink_tids, dead_node_channels,
- sink_diagnosis: {sink_col, fleet_sink_median, mad, z_usable, gate, note,
- per_tid: {tid: {sink_dev, sink_z, n_nodes, n_nodes_opposite_sign, suspect}},
- suspect_tids}, # ★命中=异常在热汇侧不在部件侧
- caliber}。
- """
- d = df if gen_mask is None else df[gen_mask]
- band = _load_band(d, p_col, rated, p_lo_frac, p_hi_frac).copy()
- if band.empty:
- return {'per_node': {}, 'n_power_bins': 0, 'note': '匹配功率带内无样本'}
- band['_pb'] = np.round(pd.to_numeric(band[p_col], errors='coerce') / bin_kw) * bin_kw
- sink = pd.to_numeric(band[sink_col], errors='coerce')
- # §4.6c 通道存活守卫 (承 matched_load_overheat / node_minus_sink_dt 同款):
- # ① 热汇通道死 → 该台整体剔出 (ΔT 失去参照, 留下会造伪离群)
- # ② 被排名的部件通道逐台验活 → 冻结/坐死列剔出该台该通道 ("传感器坏"伪装"部件热")
- dead_sink = {str(t) for t, gc in band.groupby(turbine_col)
- if reference_channel_liveness(gc[sink_col], valid_range=valid_range,
- min_n=200)['live'] is False}
- if dead_sink:
- band = band[~band[turbine_col].astype(str).isin(dead_sink)]
- sink = sink.loc[band.index]
- dead_node = {}
- def _one(sub, pb_col):
- """箱内 per台 ΔT 中位 → 减箱内全场中位 → 跨箱取中位。
- 返回 (dev, absdt, nbins, nsamp, gbin)。★nbins/nsamp 必须外传 (2026-08-09 补):
- min_bins 只做过滤, 但"靠 3 个箱判出来的 dev"与"靠 27 个箱判出来的"置信度天差地别,
- 不出数就没人分得清 —— 交付时 dev 必须与其箱数同时呈现。
- ★gbin (逐 (箱,台) 明细, 含 _dev) 亦外传 (2026-08-09 补): 跨箱中位是它的**摘要**,
- 摘要会稀释局部发作 —— 不把原料交出来, 载荷依赖型故障就永远只能靠人重算一遍。
- """
- g = sub.groupby([pb_col, turbine_col])['_dt'].agg(med='median', nn='size').reset_index()
- g = g[g['nn'] >= min_cell]
- if g.empty:
- return None, None, None, None, None
- fl = g.groupby(pb_col)['med'].median().rename('_fleet')
- g = g.merge(fl, on=pb_col)
- g['_dev'] = g['med'] - g['_fleet']
- nb = g.groupby(turbine_col)[pb_col].nunique()
- ns = g.groupby(turbine_col)['nn'].sum()
- dev = g.groupby(turbine_col)['_dev'].median()
- dev = dev[nb >= min_bins]
- absdt = g.groupby(turbine_col)['med'].median()
- if dev.empty:
- return None, None, None, None, None
- return dev, absdt, nb, ns, g
- # ── 载荷依赖型剖面 (解"跨箱中位稀释高载才发作"的盲区) ──────────────────────────
- # 分段边界用**功率绝对值**(× rated), 不用各台自身分位数: 后者会把"只有低载箱的台"
- # 的低载段当成高载段, 跨台不可比。
- lo_edge = float(rated) * float(ld_lo_frac)
- hi_edge = float(rated) * float(ld_hi_frac)
- def _load_profile(gbin, keep):
- """逐箱 dev 剖面 + 两个并列的载荷依赖性指标 (斜率=形态描述, 高低段差=闸)。"""
- prof = {}
- for t, gt in gbin.groupby(turbine_col):
- if t not in keep:
- continue
- gt = gt.sort_values('_pb')
- pbs = gt['_pb'].to_numpy(dtype='float64')
- dvs = gt['_dev'].to_numpy(dtype='float64')
- lo_m, hi_m = pbs <= lo_edge, pbs >= hi_edge
- n_lo, n_hi = int(lo_m.sum()), int(hi_m.sum())
- d_lo = float(np.median(dvs[lo_m])) if n_lo >= ld_min_seg_bins else None
- d_hi = float(np.median(dvs[hi_m])) if n_hi >= ld_min_seg_bins else None
- prof[t] = {
- 'dev_by_bin': {int(round(b)): round(float(v), 2) for b, v in zip(pbs, dvs)},
- # ★斜率只作形态描述 (渐进 vs 拐点), **不入闸** —— 见 docstring: Theil-Sen 对
- # "低载平 + 高载抬"的拐点型同样会摊薄, 与跨箱中位犯同一个错。
- 'dev_slope_c_per_1000kw': round(float(theil_sen(dvs, x=pbs)) * 1000.0, 3),
- 'dev_lo_seg': None if d_lo is None else round(d_lo, 2),
- 'dev_hi_seg': None if d_hi is None else round(d_hi, 2),
- 'dev_hi_minus_lo': None if (d_lo is None or d_hi is None) else round(d_hi - d_lo, 2),
- 'n_bins_lo_seg': n_lo, 'n_bins_hi_seg': n_hi, 'z_hi_seg': None}
- return prof
- out, per_month = {}, {}
- for nc in [c for c in node_cols if c in band.columns]:
- band['_dt'] = pd.to_numeric(band[nc], errors='coerce') - sink
- _span = valid_range[1] - valid_range[0]
- band.loc[(band['_dt'] < -_span) | (band['_dt'] > _span), '_dt'] = np.nan
- sub = band.dropna(subset=['_dt', '_pb', turbine_col])
- _dn = {str(t) for t, gc in sub.groupby(turbine_col)
- if reference_channel_liveness(gc[nc], valid_range=valid_range, min_n=200)['live'] is False}
- if _dn:
- dead_node[nc] = sorted(_dn)
- sub = sub[~sub[turbine_col].astype(str).isin(_dn)]
- if sub.empty:
- continue
- dev, absdt, nbins, nsamp, gbin = _one(sub, '_pb')
- if dev is None:
- continue
- med = float(dev.median())
- mad = float((dev - med).abs().median() * 1.4826)
- z_usable = mad >= small_mad_c
- z = (dev - med) / max(mad, 1e-6)
- gate = (f'|z|≥{dt_z_thresh} ∧ |dev|≥{dt_abs_floor}℃' if z_usable
- else f'z 禁用(台间 MAD {mad:.2f}℃ < {small_mad_c}℃ 小分母) → 只用 |dev|≥{dt_abs_floor}℃')
- flagged = sorted([str(t) for t in dev.index
- if abs(dev[t]) >= dt_abs_floor and (not z_usable or abs(z[t]) >= dt_z_thresh)],
- key=lambda x: -abs(float(dev[x])) if x in dev.index else 0)
- # ── 载荷依赖型判据 (与 flagged 并列, **不混**) ─────────────────────────────
- prof = _load_profile(gbin, set(dev.index))
- hi_ser = pd.Series({t: p['dev_hi_seg'] for t, p in prof.items() if p['dev_hi_seg'] is not None},
- dtype='float64')
- ld_abs = dt_abs_floor if ld_hi_abs is None else float(ld_hi_abs)
- hi_mad, hi_z_usable, ld_hits = None, False, []
- if len(hi_ser) >= 3:
- hi_med = float(hi_ser.median())
- hi_mad = float((hi_ser - hi_med).abs().median() * 1.4826)
- hi_z_usable = hi_mad >= small_mad_c
- hi_z = (hi_ser - hi_med) / max(hi_mad, 1e-6)
- for t in hi_ser.index:
- prof[t]['z_hi_seg'] = round(float(hi_z[t]), 2) if hi_z_usable else None
- for t in dev.index:
- if str(t) in flagged or t not in hi_ser.index: # 主判据已逮到的不重复计
- continue
- p = prof[t]
- d_hi, gapv = p['dev_hi_seg'], p['dev_hi_minus_lo']
- if gapv is None or abs(d_hi) < ld_abs or abs(gapv) < ld_min_gap:
- continue
- if d_hi * gapv <= 0: # 高载段偏离方向须与"随载荷加剧"方向一致 (排非单调噪声)
- continue
- if hi_z_usable and abs(float(hi_z[t])) < dt_z_thresh:
- continue
- ld_hits.append((str(t), abs(float(d_hi))))
- ld_hits = [s for s, _v in sorted(ld_hits, key=lambda kv: -kv[1])]
- ld_gate = ((f'跨箱中位未破主门 ∧ 高载段(≥{hi_edge:.0f}kW) |dev|≥{ld_abs}℃'
- + (f' ∧ |z_hi|≥{dt_z_thresh}' if hi_z_usable
- else f' (z_hi 禁用: 高载段台间 MAD {hi_mad:.2f}℃ < {small_mad_c}℃ 小分母)')
- + f' ∧ |高载段−低载段(≤{lo_edge:.0f}kW)|≥{ld_min_gap}℃ 且与高载段同号'
- + f'; 两段各须≥{ld_min_seg_bins}箱')
- if len(hi_ser) >= 3 else
- f'未判(高载段(≥{hi_edge:.0f}kW)达 {ld_min_seg_bins} 箱的台仅 {len(hi_ser)} 台<3, 无 fleet 参照)')
- out[nc] = {
- 'fleet_dt_median': round(float(absdt.median()), 2),
- 'mad': round(mad, 2), 'z_usable': bool(z_usable), 'gate': gate,
- 'per_tid': {str(t): {'dt': round(float(absdt[t]), 2), 'dev': round(float(dev[t]), 2),
- 'z': (round(float(z[t]), 2) if z_usable else None),
- 'n_bins': int(nbins[t]), 'n_samples': int(nsamp[t]),
- **prof.get(t, {})} for t in dev.index},
- 'n_bins_min_max': [int(nbins[dev.index].min()), int(nbins[dev.index].max())],
- 'thin_evidence_tids': sorted(str(t) for t in dev.index if int(nbins[t]) < min_bins * 2),
- 'flagged': flagged,
- 'load_dependent_flagged': ld_hits, 'load_dependent_gate': ld_gate,
- 'hi_seg_mad': (None if hi_mad is None else round(hi_mad, 2)),
- 'hi_seg_z_usable': bool(hi_z_usable),
- 'load_dependent_note': ('本名单 = 跨箱中位漏掉、只在高载发作的候选; 与 flagged **并列不混**, '
- '证据力**弱于** flagged (高载段箱数仅全窗约 1/3, dev 更噪, 且 z 门 2.5 '
- '按本函数 n=38 蒙特卡洛实为筛查门非显著门)。空名单 ≠ 无此型故障: '
- '须先看 n_bins_hi_seg —— 段箱数不足的台根本没被测。')}
- if monthly and time_col is not None:
- sub2 = sub.copy()
- sub2['_ym'] = pd.to_datetime(sub2[time_col]).dt.strftime('%Y-%m')
- mm = {}
- for ym, gsub in sub2.groupby('_ym'):
- dv, _a, _nb, _ns, _g = _one(gsub, '_pb')
- if dv is not None:
- mm[ym] = {str(t): round(float(dv[t]), 2) for t in dv.index}
- per_month[nc] = mm
- # ── 热汇自诊 (2026-08-09 补; 原为纯人工纪律, 现由函数主动报) ──────────────────────
- # 本判据减的是即时热汇, 故**热汇本身异常时, 该台所有节点 ΔT 一起被推向反方向**:
- # 热汇偏热 → 各节点看着"偏冷"(如东 20# 冷却器旁通失效: 油温高, 三个轴承 ΔT 齐负)
- # 热汇偏冷 → 各节点看着"偏热"(换件后油温下降, 未换通道被动显示"变差")
- # 这两种都不是部件问题, 但只看 ΔT 一律看不出来 —— 20# 当初是靠人眼发现的, 不可依赖。
- # ★判别式 (第二版; 第一版"多数节点 dev 与热汇反号"**没通过 known-answer 检验**已废):
- # 20# 实况是 油温 dev +2.49℃, 而四节点 ΔT dev = -4.57/+1.98/+0.70/+0.30 —— **只 1 个反号**。
- # 真签名不是"节点反号", 而是 **节点绝对温偏高、减掉热汇后就不高了** (热汇解释了大部分抬升):
- # abs_dev(节点绝对温 vs 全场) ≈ ΔT_dev + sink_dev。20# 的 HSRot/IMSGen/IMSRot 绝对 +4.5/+3.2/+2.8,
- # 减热汇后只剩 +2.0/+0.7/+0.3 → 热汇解释了 56%/78%/89%。
- # ⇒ 判别 = 热汇自身 dev 破门 ∧ 该台"绝对温已破门的节点"中≥半数被热汇解释掉一半以上。
- sink_diag = {}
- band['_dt'] = sink
- _ssub = band.dropna(subset=['_dt', '_pb', turbine_col])
- sdev, sabs, snb, _sns, _sg = _one(_ssub, '_pb') if not _ssub.empty else (None,) * 5
- # 各节点的**绝对温** dev (与 ΔT dev 并置才能做上面的分解)
- abs_dev = {}
- for nc in [c for c in node_cols if c in band.columns]:
- band['_dt'] = pd.to_numeric(band[nc], errors='coerce')
- _asub = band.dropna(subset=['_dt', '_pb', turbine_col])
- if _asub.empty:
- continue
- adv, _aa, _ab, _as, _ag = _one(_asub, '_pb')
- if adv is not None:
- abs_dev[nc] = adv
- if sdev is not None:
- smed = float(sdev.median())
- smad = float((sdev - smed).abs().median() * 1.4826)
- s_z_usable = smad >= small_mad_c
- sz = (sdev - smed) / max(smad, 1e-6)
- per_tid_s, suspects = {}, []
- for t in sdev.index:
- sd = float(sdev[t])
- broke = abs(sd) >= dt_abs_floor and (not s_z_usable or abs(float(sz[t])) >= dt_z_thresh)
- # 只考察"绝对温已经破门"的节点 —— 没抬升的节点谈不上"被热汇解释"
- elevated, explained, detail = 0, 0, {}
- for nc, adv in abs_dev.items():
- if t not in adv.index:
- continue
- a = float(adv[t])
- if abs(a) < dt_abs_floor:
- continue
- elevated += 1
- ok = (a * sd > 0) and (abs(sd) >= 0.5 * abs(a)) # 同向 ∧ 热汇占抬升的一半以上
- explained += int(ok)
- detail[nc] = {'abs_dev': round(a, 2), 'sink_share': round(abs(sd) / abs(a), 2),
- 'sink_explained': bool(ok)}
- suspect = bool(broke and elevated and explained >= max(1, (elevated + 1) // 2))
- per_tid_s[str(t)] = {'sink_dev': round(sd, 2),
- 'sink_z': (round(float(sz[t]), 2) if s_z_usable else None),
- 'n_nodes_abs_elevated': elevated, 'n_nodes_sink_explained': explained,
- 'per_node': detail, 'suspect': suspect}
- if suspect:
- suspects.append(str(t))
- sink_diag = {
- 'sink_col': sink_col,
- 'fleet_sink_median': round(float(sabs.median()), 2), 'mad': round(smad, 2),
- 'z_usable': bool(s_z_usable),
- 'gate': (f'热汇自身 |dev|≥{dt_abs_floor}℃' + (f' ∧ |z|≥{dt_z_thresh}' if s_z_usable else ' (z 禁用: 小分母)')
- + f' ∧ 该台绝对温破门(|abs_dev|≥{dt_abs_floor}℃)的节点中≥半数满足'
- ' [与热汇同向 ∧ |sink_dev|≥0.5|abs_dev|]'),
- 'per_tid': per_tid_s,
- 'suspect_tids': sorted(suspects, key=lambda x: -abs(per_tid_s[x]['sink_dev'])),
- 'known_answer': ('如东 20# (已确诊冷却器旁通失效) 必须命中 —— 换判据后须复跑此检验; '
- '第一版判别式 (多数节点反号) 在该台只得 1/4 反号, 检不出, 已废。'),
- 'note': ('命中台 = **异常在热汇侧, 不在部件侧**; 其各节点 ΔT 已被热汇位移污染, '
- '不得据以判部件健康, 须回到节点**绝对温**并与热汇绝对温分解对拍 '
- '(ΔT 变化应能被 Δ节点−Δ热汇 复现)。零命中 ≠ 热汇必然干净: '
- '若热汇与节点同向同幅漂移 (整机测温链偏置), 本判别看不出来。')}
- # ── 窗长敏感性 (2026-08-09 补; 用户裁定"按 B 落地") ────────────────────────────
- # ★为什么必须出: 窗长是**未预注册的自由参数**, 却能实质改变结论 ——
- # 算法验证线实证: 某台发电机温度 5 月窗排 1/38(z=3.27), 12 月窗跌到 13/38(z=1.27)。
- # 只报一个窗 = 事实上在"挑窗"; 把窗长写死进预注册又不可行(急性故障与慢性劣化时间尺度本就不同)。
- # ⇒ 取中间路线: **不挑窗, 把窗的影响摊开** —— 每台报"在几个窗里过门", 让证据强度自己显形。
- # 这与"同一结论翻案>2次即停手判 INSUFFICIENT"是同一逻辑: 不在多个可能里挑一个报。
- # 判读: 全窗过门 = 稳健候选; 只在部分窗过门 = **窗长敏感, 证据强度低一档**, 须在交付中标明。
- win_sens = {}
- if windows and time_col is not None and time_col in band.columns:
- _ym = pd.to_datetime(band[time_col], errors='coerce').dt.strftime('%Y-%m')
- months = sorted(m for m in _ym.dropna().unique())
- for nc in [c for c in node_cols if c in out]:
- per_w, hit_cnt = {}, {}
- for w in windows:
- if w > len(months):
- continue
- keep = set(months[-int(w):])
- sub = band[_ym.isin(keep)].copy()
- sub['_dt'] = pd.to_numeric(sub[nc], errors='coerce') - pd.to_numeric(sub[sink_col], errors='coerce')
- sub = sub.dropna(subset=['_dt', '_pb', turbine_col])
- if sub.empty:
- continue
- dv, _ab, _nb, _ns, _g = _one(sub, '_pb')
- if dv is None:
- continue
- m2 = float(dv.median())
- d2 = float((dv - m2).abs().median() * 1.4826)
- zu = d2 >= small_mad_c
- z2 = (dv - m2) / max(d2, 1e-6)
- hits = sorted(str(t) for t in dv.index
- if abs(dv[t]) >= dt_abs_floor and (not zu or abs(z2[t]) >= dt_z_thresh))
- per_w[int(w)] = {'months': [months[-int(w)], months[-1]], 'z_usable': bool(zu),
- 'mad': round(d2, 2), 'flagged': hits}
- for t in hits:
- hit_cnt[t] = hit_cnt.get(t, 0) + 1
- n_w = len(per_w)
- win_sens[nc] = {
- 'windows_tested': sorted(per_w),
- 'per_window': per_w,
- 'per_tid_hit_count': dict(sorted(hit_cnt.items(), key=lambda kv: -kv[1])),
- 'stable': sorted(t for t, c in hit_cnt.items() if c == n_w),
- 'window_sensitive': sorted(t for t, c in hit_cnt.items() if 0 < c < n_w),
- 'note': (f'在全部 {n_w} 个窗长下都过门 = 稳健; 只在部分窗过门 = **窗长敏感, 证据强度低一档**, '
- f'交付时须标明其过门窗与不过门窗。'),
- }
- res = {'per_node': out, 'n_power_bins': int(band['_pb'].nunique()),
- 'dead_sink_tids': sorted(dead_sink), 'dead_node_channels': dead_node,
- 'sink_diagnosis': sink_diag, 'window_sensitivity': win_sens,
- 'caliber': (f'功率带 {p_lo_frac:.0%}~{p_hi_frac:.0%}×{rated}kW, {bin_kw:.0f}kW 分箱(箱内≥{min_cell} 样本, '
- f'跨箱≥{min_bins} 箱); 箱内 per台 ΔT=(节点−{sink_col}) 中位 − 箱内全场中位; 跨箱取中位; '
- f'小分母闸 MAD<{small_mad_c}℃ 禁用 z; 附热汇自诊(热汇自身破门∧多数节点反号); '
- f'★另出逐箱 dev 剖面 + 载荷依赖型并列名单(高载段≥{hi_edge:.0f}kW vs 低载段≤{lo_edge:.0f}kW, '
- f'段差≥{ld_min_gap}℃; 斜率仅作形态描述不入闸)')}
- if monthly:
- res['per_month'] = per_month
- return res
- def lubrication_pump_duty_split(df, *, timeon_col, counts_col, turbine_col, time_col,
- window_s=600.0, min_counts_month=20, full_run_frac=1.0,
- fleet_ref='median'):
- """集中润滑泵异常的**两型分离** — 拆开 "秒/次" 这个混合指标
- (SOP §4.6; 如东 U4 发电机 2026-08-09 实战反哺, known-answer 验证)。
- ⭐ 为什么必须拆 (踩坑实证):
- 月度 `sum(timeon)/sum(counts)` 是最自然的写法, 但它把**两种不同的物理现象混在一起** ——
- 当泵**持续运转跨越多个记录窗**时, 该窗 timeon 计入分子而**不产生新的 counts 入分母**,
- 比值被抬高。如东 26# 2025-08 该口径 = 853 秒, 曾被读作 "每次启动运转 853 秒";
- 实测该月**启动 62 次(正常频次)、单次运转中位仅 70 秒**, 真相是该月含 **10.5 小时泵连续满转**。
- 19# 更极端: 口径值 4086 秒 = **61.3 小时/月连续满转**。
- ⭐ 拆分后的两个量 (物理含义互斥, 处置方向不同):
- · **A 单次启动运转时长** = 仅 counts>0 的窗取 timeon/counts 的中位
- → **输送阻力** (供油管路/分配块/排油背压). 换泵未必有效, 先查排油与管路。
- · **B 泵连续满转时长** = timeon >= window_s*full_run_frac 的窗数 × window_s
- → **泵本体 / 系统压力建立失败** (泵启动后停不下来).
- ⭐ known-answer 验证 (这条是本判据可信的根据, 非推理):
- 如东 38 台中 B 型累计最高的 19#(91.7h) 与 14#(43.7h) **正是换泵后立即痊愈的两台**;
- 而 A 型高值台 (7# 190s / 25# 205s, 基准 55s) 泵满转仅 0.3h —— 用混合口径会把两者
- 混为一谈并派错工单 (给 A 型台开换泵单)。
- ⚠ 配套纪律:
- · counts/timeon 是**事件计数类通道**: liveness 必须用 kind='event'
- (低 nuniq + 大量零是正常形态, 用连续量口径会误杀)。
- · **季节共模**: 润滑脂粘度随温度变化, A 指标有强季节成分
- (如东 7# 两次峰值均在夏季、冬季回落至基线) → 趋势判读必须同月对同月。
- · 若该机型有柱塞/脉冲反馈通道 (出脂量直接计量), 三角交叉更强:
- B 高而柱塞脉冲不增 = 泵空转/压力建不起来; 柱塞正常而 A 高 = 管路阻力。
- 参数
- ----
- window_s : 记录窗长 (秒), 10min 面 = 600
- full_run_frac : 判 "整窗满运转" 的占比阈 (1.0 = timeon 达满窗)
- min_counts_month : 月启动次数门 (低于此月不出数, 防稀疏月比值病态)
- 返回
- ----
- {'fleet': {'A_median', 'A_mad'}, 'per_tid': {tid: {'A_now','A_peak','A_peak_ym',
- 'B_total_h','B_peak_h','B_peak_ym','type'}}, 'monthly': [...], '_caliber': str}
- type ∈ {'A(输送阻力)','B(泵打不停)','A+B(混合)','正常'}
- """
- import numpy as _np
- import pandas as _pd
- d = df[[turbine_col, time_col, timeon_col, counts_col]].copy()
- d['_ym'] = _pd.to_datetime(d[time_col]).dt.strftime('%Y-%m')
- full_thresh = window_s * float(full_run_frac)
- rows = []
- for (tid, ym), g in d.groupby([turbine_col, '_ym']):
- c_sum = float(_pd.to_numeric(g[counts_col], errors='coerce').fillna(0).sum())
- if c_sum < min_counts_month:
- continue
- t_on = _pd.to_numeric(g[timeon_col], errors='coerce').fillna(0)
- c_on = _pd.to_numeric(g[counts_col], errors='coerce').fillna(0)
- started = c_on > 0
- a_med = float((t_on[started] / c_on[started]).median()) if started.any() else _np.nan
- b_h = float((t_on >= full_thresh).sum() * window_s / 3600.0)
- rows.append({'tid': tid, 'ym': ym, 'A_s': a_med, 'B_h': b_h,
- 'mixed_ratio_s': float(t_on.sum() / c_sum), 'n_start': int(c_sum)})
- if not rows:
- return {'fleet': {}, 'per_tid': {}, 'monthly': [], '_caliber': 'no data'}
- m = _pd.DataFrame(rows)
- a_all = m.A_s.dropna()
- a_fleet = float(a_all.median() if fleet_ref == 'median' else a_all.mean())
- a_mad = float(_np.median(_np.abs(a_all - a_fleet))) if len(a_all) else _np.nan
- yms = sorted(m.ym.unique())
- now_yms = set(yms[-3:])
- n_tid = int(m.tid.nunique())
- # ★fleet 基线抗污染: 台数过少时中位会被异常台自身拉高 (DP 用例逮出) →
- # n<5 不做相对判型 (只报绝对值); n>=5 用下半部 (p25) 作正常水位参照。
- a_base = float(a_all.quantile(0.25)) if len(a_all) else _np.nan
- per = {}
- for tid, g in m.groupby('tid'):
- g_now = g[g.ym.isin(now_yms)]
- a_now = float(g_now.A_s.median()) if len(g_now) and g_now.A_s.notna().any() else _np.nan
- ia = g.A_s.idxmax() if g.A_s.notna().any() else None
- ib = g.B_h.idxmax() if len(g) else None
- b_tot = float(g.B_h.sum())
- 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))
- b_hi = b_tot >= 1.0
- if n_tid < 5:
- typ = 'INSUFFICIENT(机群样本不足, 不做相对判型)'
- else:
- typ = ('A+B(混合)' if a_hi and b_hi else
- 'A(输送阻力)' if a_hi else 'B(泵打不停)' if b_hi else '正常')
- per[tid] = {'A_now': None if _np.isnan(a_now) else round(a_now, 1),
- 'A_peak': None if ia is None else round(float(g.loc[ia, 'A_s']), 1),
- 'A_peak_ym': None if ia is None else str(g.loc[ia, 'ym']),
- 'B_total_h': round(b_tot, 1),
- 'B_peak_h': None if ib is None else round(float(g.loc[ib, 'B_h']), 1),
- 'B_peak_ym': None if ib is None else str(g.loc[ib, 'ym']),
- 'type': typ}
- return {'fleet': {'A_median': round(a_fleet, 1), 'A_base_p25': None if _np.isnan(a_base) else round(a_base, 1),
- 'A_mad': None if _np.isnan(a_mad) else round(a_mad, 2),
- 'n_tid': n_tid, 'n_month_cells': int(len(m))},
- 'per_tid': per, 'monthly': m.to_dict('records'),
- '_caliber': (f'A=counts>0窗的 timeon/counts 中位(输送阻力); B=timeon>={full_thresh:.0f}s 窗数×{window_s:.0f}s '
- f'(泵连续满转); 月门 counts>={min_counts_month}; 现窗=末3个合格月; '
- 'A_hi = A_now > max(1.5×基线, 基线+2MAD), 基线=A的p25(抗异常台自污染, 小样本中位会被拉高—DP用例逮出); 机群<5台不做相对判型; B_hi = B累计>=1h; '
- '★禁用 sum(timeon)/sum(counts) 单一口径下结论(混合两型, 如东 26# 853s 实为 B 型)')}
- # ---------------------------------------------------------------- §4.6 热阻β回归家族 (如东 U4 回灌 2026-08-08)
- def thermal_beta_family(df, *, node_col, sink_col, p_col, turbine_col, time_col,
- rated_kw, family_type='generic', probe_suspects=None,
- min_row_month=30, min_n_month=500, min_p_iqr_mw=0.8,
- min_months=5, recent_months=3, gate=2.5, nm_band=(2.0, 2.5),
- min_fleet=8, gain_top=6, min_gain_n=5000):
- """热阻 β 回归家族引擎: ΔT ~ α + β·P(MW) 逐台逐月 OLS → β/α×(现窗/趋势/同季差) 六轴 fleet-z (源 如东 U4 beta_thermal_regression, n=1 场×12族).
- 模型: ΔT(node−sink) = α + β·P。β=载荷相关热阻代理(K/MW), α=固定损耗×热阻(K)。
- ★铁律一 (RD-076 戒): **禁 ΔT/P 直除** — Q=a+b·P 下低功率 a/P 项发散, 月均 R=ΔT/P 的趋势符号可翻
- (如东实证 r +0.32→−0.737)。旧口径只作锚复现对照, 禁作判据。
- ★铁律二 (月质量门): 逐月 OLS 必过 n≥min_n_month ∧ P_IQR≥min_p_iqr_mw(MW) — 低风月 P 无跨度时
- β 病态 (设计矩阵近奇异), 不剔则趋势轴被垃圾月污染。
- ★铁律三 (族类型定主锚, axis_note 同): cabinet 族 ΔT 天然非负荷耦合 (Q≈const) ⇒ 主锚=α 轴
- (R 恶化抬 α 不动 β), β 显著反而=负荷耦合发热异常; gearbox/trafo β/α 双轴并读;
- paired 族(同部件双测点差)=α≈β≈0 预期, 破门=不对称信号; generator 空空冷却 sink 用独立环温。
- ★铁律四 (%/月 小分母虚大): trend_pct = 斜率/|fleet β 中位|, |fleet β|<0.5 的族百分比虚大,
- 判据以 z 与 K/MW/月 绝对斜率为准, %/月 仅供方向感。
- ★铁律四b (环温混杂 → β 不可作热阻物理量解读; 如东 U4 β-v2 证伪, 2026-08-08 回灌):
- **未把环温入回归时, β 会被环温季节性劫持** —— 如东实测 corr(β, 月环温)=0.939, 发电机前轴承族
- β 冬 −0.99 → 夏 +3.61 K/MW **跨零变号**; 负 β = "功率越高相对环温越冷", 物理上无法作热阻解。
- ⇒ ①**减去环温 sink 不等于控制了环温** (热阻与冷却效率本身随环温变, 残余依赖仍在), sink=环温 的
- 族尤其要防; ②要作物理量解读须把环温入回归 `ΔT ~ α + β·P + γ·T_amb`; ③本函数当前实现**未含 γ 项**,
- 故其 β **只可用于同月同口径的跨机相对比较**, **跨月/趋势类 β 结论一律降级** (ss_* 同季差轴正是
- 为此设计, 趋势轴 trend_* 受此限最重)。n=1 场 [暂行], 换场须自行复核符号是否同样变号。
- ★铁律四c (R² 类质量门必按族相对设, 禁跨族绝对门; 同源证伪): 各族 fleet R² 中位差异可达 8 倍
- (如东 绕组 0.83 / DE 0.73 / 配对 0.62 / **前轴承族仅 0.104**) —— 绝对门 (如 R²≥0.30) 会对低信噪族
- **毁灭性**: 如东实测砍掉前轴承族 89% 月份 (634→67), 把公认最异常台压到完全检不出 = **假阴性**。
- 须用 `系数 × 该族 fleet R² 中位` 形式的相对门 (系数本身无独立依据, 属经验值须标注)。
- 本函数当前用 月质量门(n + P_IQR) 而非 R² 门 — 若调用方自行加 R² 门, 必按本铁律设。n=1 [暂行]。
- ★铁律五 (β 简并拆分, 结论级必写): β/α 异常 = 散热变差(R↑) / 产热增加(Q↑) / 传感 gain 漂移 三者同形,
- 本引擎不可分辨。拆分靠外部交叉: 振动异常→产热(摩擦)路径; 振动净+润滑泵行为异常→润滑/排油路径;
- 两者均净→散热回路候选 (如东 6# 例); gain_annex 旗→"不排增益传感"(旗≠传感定论, 真退化台本就 gain>1)。
- ★铁律六 (多轴汇聚): 单轴破门不升候选 — 六轴×N台 多重比较自带, 调用方配 analysis_kit.expected_fp;
- near_miss 超集披露 (精确集断言戒)。
- 同季差轴 ss_*: 对齐 month-of-year 的相邻年差中位 (抗季节混杂, 解如东 RD-082/084 口径分歧)。
- 返回 {caliber, family_type, fleet_beta_median_K_MW, fleet_alpha_median_K, per_turbine:{tid:{...六轴+z}},
- gate_*/nearmiss_* ×6, gain_annex, axis_note, note}。样本/台数不足如实报 INSUFFICIENT。
- """
- from .analysis_kit import gen_mask, mad_z, near_miss, theil_sen, caliber_stamp
- d = df.dropna(subset=[node_col, sink_col, p_col, turbine_col, time_col]).copy()
- mask, cal = gen_mask(d, power_col=p_col, rated_kw=rated_kw)
- g = d[mask].copy()
- if len(g) < 2000:
- return {'verdict': 'INSUFFICIENT', 'evidence': '发电态样本不足(<2000)', 'per_turbine': {}}
- g['_mw'] = pd.to_numeric(g[p_col], errors='coerce') / 1000.0
- g['_dt'] = pd.to_numeric(g[node_col], errors='coerce') - pd.to_numeric(g[sink_col], errors='coerce')
- g['_ym'] = pd.to_datetime(g[time_col]).dt.to_period('M').astype(str)
- g = g.dropna(subset=['_mw', '_dt'])
- rows = []
- for (tid, ym), x in g.groupby([turbine_col, '_ym']):
- n = len(x)
- if n < min_row_month:
- continue
- p_iqr = float(x['_mw'].quantile(.75) - x['_mw'].quantile(.25))
- sol, *_ = np.linalg.lstsq(np.c_[x['_mw'].values, np.ones(n)], x['_dt'].values, rcond=None)
- if bool(n >= min_n_month and p_iqr >= min_p_iqr_mw): # 月质量门 (铁律二)
- rows.append((tid, ym, float(sol[0]), float(sol[1])))
- if not rows:
- return {'verdict': 'INSUFFICIENT', 'evidence': '无合格月 (月质量门全剔)', 'per_turbine': {}}
- dfm = pd.DataFrame(rows, columns=['tid', 'ym', 'beta', 'alpha'])
- fm = dfm.groupby('ym')[['beta', 'alpha']].median().rename(columns=lambda c: 'fleet_' + c)
- dfm = dfm.join(fm, on='ym')
- dfm['beta_dev'] = dfm.beta - dfm.fleet_beta
- dfm['alpha_dev'] = dfm.alpha - dfm.fleet_alpha
- fleet_b, fleet_a = float(dfm.fleet_beta.median()), float(dfm.fleet_alpha.median())
- res = {}
- for tid, x in dfm.groupby('tid'):
- x = x.sort_values('ym')
- if len(x) < min_months:
- continue
- ts = theil_sen(x.beta_dev.values)
- tsa = theil_sen(x.alpha_dev.values)
- per = pd.PeriodIndex(x.ym.values, freq='M')
- ss_a, ss_b = [], []
- for m in range(1, 13): # 同季差: month-of-year 对齐相邻年
- ys = sorted(per[per.month == m].year)
- for y in ys:
- if y + 1 in ys:
- k0, k1 = f'{y}-{m:02d}', f'{y + 1}-{m:02d}'
- xi = x.set_index('ym')
- ss_b.append(float(xi.loc[k1, 'beta_dev'] - xi.loc[k0, 'beta_dev']))
- ss_a.append(float(xi.loc[k1, 'alpha_dev'] - xi.loc[k0, 'alpha_dev']))
- res[tid] = {'n_months': int(len(x)),
- 'beta_now': round(float(x.beta_dev.iloc[-recent_months:].median()), 3),
- 'beta_level': round(float(x.beta.iloc[-recent_months:].median()), 3),
- 'trend_K_MW_mo': round(float(ts), 4),
- 'trend_pct_mo': round(float(ts) / abs(fleet_b) * 100, 2) if fleet_b else None,
- 'alpha_now': round(float(x.alpha_dev.iloc[-recent_months:].median()), 3),
- 'alpha_level': round(float(x.alpha.iloc[-recent_months:].median()), 3),
- 'alpha_trend_K_mo': round(float(tsa), 4),
- 'alpha_trend_pct_mo': round(float(tsa) / abs(fleet_a) * 100, 2) if fleet_a else None,
- 'ss_beta': round(float(np.median(ss_b)), 3) if ss_b else None,
- 'ss_alpha': round(float(np.median(ss_a)), 3) if ss_a else None,
- 'probe_suspect': (probe_suspects or {}).get(tid)}
- if not res:
- return {'verdict': 'INSUFFICIENT', 'evidence': f'合格台不足 (per台合格月<{min_months})', 'per_turbine': {}}
- axes = {'z_now': 'beta_now', 'z_trend': 'trend_pct_mo', 'z_alpha_now': 'alpha_now',
- 'z_alpha_trend': 'alpha_trend_pct_mo', 'z_ss_alpha': 'ss_alpha', 'z_ss_beta': 'ss_beta'}
- out = {'caliber': caliber_stamp(gen_mask=cal,
- model='ΔT ~ α + β·P(MW), 逐台逐月 OLS (np.linalg.lstsq)',
- month_gate={'min_n': min_n_month, 'min_p_iqr_mw': min_p_iqr_mw},
- trend='theil_sen(β_dev 月序), β_dev = β_tid − fleet 月中位 (消共模)',
- old_caliber='R=ΔT/P 直除已禁 (RD-076: 低功率发散, 趋势符号可翻)'),
- 'family_type': family_type,
- 'fleet_beta_median_K_MW': round(fleet_b, 3), 'fleet_alpha_median_K': round(fleet_a, 3),
- 'axis_note': ('cabinet 族主锚=α轴(Q≈const); gearbox/trafo β/α 双轴并读; '
- 'paired 族=同部件双测点差(α≈β≈0 预期, 破门=不对称信号); '
- 'ss_*=同季差轴(相邻年 month-of-year 对齐, 抗季节)'),
- 'per_turbine': res}
- tids = sorted(res)
- for zk, vk in axes.items():
- vals = pd.Series({t: res[t][vk] for t in tids}, dtype='float64').dropna()
- if len(vals) < min_fleet:
- out['gate_' + zk], out['nearmiss_' + zk] = [], []
- for t in tids:
- res[t][zk] = None
- continue
- z = mad_z(vals)
- nmr = near_miss(z.abs(), gate=gate, band=nm_band)
- out['gate_' + zk] = sorted(nmr['gate'])
- out['nearmiss_' + zk] = sorted(nmr['near_miss'])
- for t in tids:
- res[t][zk] = round(float(z[t]), 2) if t in z.index else None
- gain = {'method': '双变量 OLS 台温~fleet同通道中位(剔本台), 发电态; gain∉[0.8,1.2] 旗 — '
- '旗≠传感定论(真载荷退化台本就 gain>1), 但候选结论级须写"不排增益传感"'}
- if gain_top:
- zk6 = list(axes)
- ranked = sorted(res, key=lambda t: -max(abs(res[t][k] or 0) for k in zk6))[:gain_top]
- w = g.pivot_table(index=time_col, columns=turbine_col, values=node_col)
- ch = {}
- for t in ranked:
- if t not in w.columns:
- continue
- others = w.drop(columns=[t]).median(axis=1)
- xy = pd.concat([others, w[t]], axis=1, keys=['x', 'y']).dropna()
- if len(xy) < min_gain_n:
- continue
- sol, *_ = np.linalg.lstsq(np.c_[xy.x.values, np.ones(len(xy))], xy.y.values, rcond=None)
- ch[t] = {'gain': round(float(sol[0]), 3), 'offset': round(float(sol[1]), 2),
- 'n': int(len(xy)), 'flag': bool(sol[0] > 1.2 or sol[0] < 0.8)}
- gain['targets'] = list(ranked)
- gain['result'] = ch
- out['gain_annex'] = gain
- out['note'] = ('β/α 异常简并于 散热变差(R↑)/产热增加(Q↑)/传感gain漂移, 本引擎不可分辨; '
- '拆分靠交叉: 振动异常→产热; 振动净+润滑泵异常→润滑/排油; 均净→散热回路候选; '
- '单轴破门不升候选 (六轴×N台多重比较, 配 expected_fp), near_miss 超集披露; '
- '★本引擎未含 γ·T_amb 项 → β 受环温季节性劫持 (如东实测 corr(β,环温)=0.939, 可跨零变号), '
- '禁作热阻物理量解读; 仅可同月同口径跨机相对比较, 跨月/趋势类 β 结论须降级')
- return out
- # ---------------------------------------------------------------- §4.6 温控执行器 (防凝露加热器等, 如东 U4 回灌 2026-08-08)
- def heater_actuator_screen(df, *, heat_timeon_col, p_col, turbine_col, time_col,
- rated_kw, amb_col=None, comp_cols=None, humid_col=None,
- period_s=600.0, summer=(6, 7, 8), winter=(12, 1, 2),
- z_gate=2.5, winter_fail_pct=1.0, low_humid=50.0,
- strict_p_frac=0.125, on_thresh_s=300.0,
- amb_band=(5.0, 15.0), risk_tids=None, humid_bins=(40, 50, 60, 70, 80, 90)):
- """温控执行器(防凝露加热器)三判据 screen: 卡开候选 / 失效候选 / 湿度驱动验证 (源 如东 U4 ax8+heater_humidity, n=1 场×38台).
- ★铁律一 (错判 38 台戒, 2026-08-08): **恒温控制系统的 ON/OFF 窗温差对比 ≈0 是正常表现** —
- 控制目标即恒温 (控制成功 ⇒ 部件温度不随加热态摆动), **禁把"加热 ON 时不升温"当失效判据**
- (preflight v1 曾据此错判全场 38 台"加热无效")。idle_on_response 返回仅供参考, 不入 verdict。
- 执行器**功能判据 = 控制目标达成率** (如凝露风险窗内湿度/温度是否保持在目标带), 非温差响应。
- ★铁律二 (卡开判据双证): 卡开候选 = 夏季 duty 高 z (温控正常台夏季基本不加热)
- × 低湿窗 duty (湿度驱动控制下低湿仍高 duty=不响应输入) × 稳定运行整窗加热
- (P>rated×strict_p_frac ∧ timeon≥period_s 整窗, **排启停边界窗** — loose 口径全场无一台为 0,
- 系统性混入边界窗, 不可作单台证据; 如东 strict fleet 中位 0.18% vs loose 6.35%)。
- ★铁律三 (湿度驱动验证): 现场控制律可偏离 OEM 温度限值 (集成层自设, OEM"自担责"条款) —
- humid_col 给定时验 ON 率随湿度 bin 单调升 (如东 10.5%→99.5%) = 湿度驱动坐实;
- 判"低湿仍开"前先核湿度探头 (探头读低会造"低湿假象", 如东 20# 读低 20pp 分类失真)。
- ★铁律四 (运行中加热=违规轴): OEM 硬警告"仅在电机关闭时运行防凝露加热器" (运行中加热致内部温度↑
- 加速绝缘热老化, 且混入热阻 β 回归 α 项)。strict 口径高台单列, 非失效但须现场核温控接线。
- risk_tids: 外部冷凝风险台集 (如绝缘 D 轴), 冬季低 duty × risk 交叉才升失效候选 (单腿不升)。
- 返回 {caliber, fleet_summer_duty_pct, fleet_winter_duty_pct, per_turbine, stuckon_cand,
- winter_low, fail_cand, runstate_strict_pct, runstate_loose_pct, humid_on_curve,
- low_humid_duty, idle_on_response_K_ref(参考, 禁作判据), duty_traj_flagged, note}。
- """
- from .analysis_kit import mad_z, caliber_stamp
- d = df.dropna(subset=[heat_timeon_col, p_col, turbine_col, time_col]).copy()
- if len(d) < 2000:
- return {'verdict': 'INSUFFICIENT', 'evidence': '样本不足(<2000)', 'per_turbine': {}}
- d['_ht'] = pd.to_numeric(d[heat_timeon_col], errors='coerce').fillna(0.0)
- d['_p'] = pd.to_numeric(d[p_col], errors='coerce')
- d['_t'] = pd.to_datetime(d[time_col])
- d['_ym'] = d['_t'].dt.to_period('M').astype(str)
- d['_mo'] = d['_t'].dt.month
- hm = d.groupby([turbine_col, '_ym']).agg(hT=('_ht', 'sum'), n=('_t', 'size'), mo=('_mo', 'first')).reset_index()
- hm['duty'] = hm.hT / (hm.n * period_s)
- smr = hm[hm.mo.isin(summer)].groupby(turbine_col).duty.mean() * 100
- wtr = hm[hm.mo.isin(winter)].groupby(turbine_col).duty.mean() * 100
- if len(smr) < 5:
- return {'verdict': 'INSUFFICIENT', 'evidence': '夏季月覆盖台数不足(<5)', 'per_turbine': {}}
- rz = mad_z(smr)
- stuckon = sorted([str(t) for t in smr.index if rz[t] > z_gate])
- winter_low = sorted([str(t) for t in wtr.index if wtr[t] < winter_fail_pct])
- fail_cand = sorted(set(winter_low) & {str(t) for t in (risk_tids or [])})
- gen = d[d['_p'] > 50.0]
- loose = (gen[gen['_ht'] > on_thresh_s].groupby(turbine_col).size()
- / gen.groupby(turbine_col).size() * 100).fillna(0)
- sg = d[d['_p'] > rated_kw * strict_p_frac]
- strict = (sg[sg['_ht'] >= period_s].groupby(turbine_col).size()
- / sg.groupby(turbine_col).size() * 100).fillna(0)
- out = {'caliber': caliber_stamp(period_s=period_s, summer=list(summer), winter=list(winter),
- strict_runstate=f'P>{rated_kw * strict_p_frac:.0f}kW ∧ timeon≥{period_s:.0f}s 整窗 (排启停边界)',
- loose_runstate=f'P>50kW ∧ timeon>{on_thresh_s:.0f}s (仅场级参考, 系统性含边界窗)'),
- 'fleet_summer_duty_pct': round(float(smr.median()), 2),
- 'fleet_winter_duty_pct': round(float(wtr.median()), 2) if len(wtr) else None,
- 'per_turbine': {str(t): {'summer_duty_pct': round(float(smr.get(t, np.nan)), 2),
- 'winter_duty_pct': round(float(wtr.get(t, np.nan)), 2) if t in wtr.index else None,
- 'summer_z': round(float(rz.get(t, np.nan)), 2),
- 'runstate_strict_pct': round(float(strict.get(t, 0.0)), 2)}
- for t in smr.index},
- 'stuckon_cand': stuckon, 'winter_low': winter_low, 'fail_cand': fail_cand,
- 'runstate_strict_top': {str(t): round(float(v), 2)
- for t, v in strict.sort_values(ascending=False).head(5).items() if v > 0},
- 'duty_traj_flagged': {str(t): {ym: round(du * 100, 2)
- for ym, du in zip(hm.loc[hm[turbine_col] == t, '_ym'],
- hm.loc[hm[turbine_col] == t, 'duty'])}
- for t in smr.index if rz[t] > z_gate}}
- if humid_col and humid_col in d.columns:
- hv = pd.to_numeric(d[humid_col], errors='coerce')
- on = d['_ht'] > on_thresh_s
- edges = [-np.inf, *humid_bins, np.inf]
- curve = {}
- for i in range(len(edges) - 1):
- m = (hv >= edges[i]) & (hv < edges[i + 1])
- if m.sum() >= 200:
- lab = f'{edges[i]:.0f}-{edges[i + 1]:.0f}'.replace('-inf', '<' + str(humid_bins[0])).replace('inf', '>' + str(humid_bins[-1]))
- curve[lab] = round(float(on[m].mean() * 100), 1)
- vals = list(curve.values())
- out['humid_on_curve'] = curve
- out['humid_driven'] = bool(len(vals) >= 4 and all(b >= a for a, b in zip(vals, vals[1:])))
- lh = d[hv < low_humid]
- lhd = lh.groupby(turbine_col)['_ht'].sum() / (lh.groupby(turbine_col).size() * period_s) * 100
- out['low_humid_duty_pct'] = {str(t): round(float(v), 2)
- for t, v in lhd.sort_values(ascending=False).head(8).items()}
- if comp_cols and amb_col and amb_col in d.columns:
- idle = d[d['_p'] < 10.0].copy()
- av = pd.to_numeric(idle[amb_col], errors='coerce')
- band = idle[(av >= amb_band[0]) & (av <= amb_band[1])].copy() # 同环温带消 'ON选冷天' 混杂
- cc = [c for c in comp_cols if c in band.columns]
- if cc and len(band) > 1000:
- band['_w'] = band[cc].apply(pd.to_numeric, errors='coerce').median(axis=1) - pd.to_numeric(band[amb_col], errors='coerce')
- resp = {}
- for t, x in band.groupby(turbine_col):
- on_w = x[x['_ht'] > on_thresh_s]['_w']
- off_w = x[x['_ht'] == 0]['_w']
- if len(on_w) > 100 and len(off_w) > 100:
- resp[str(t)] = round(float(on_w.median() - off_w.median()), 2)
- out['idle_on_response_K_ref'] = resp
- out['note'] = ('★ON/OFF 温差≈0 = 恒温控制正常表现, 禁作失效判据 (错判38台戒); 功能判据=控制目标达成率; '
- '卡开=夏duty高z×低湿窗duty×strict整窗运行加热三证; 失效=冬低duty×冷凝风险交叉; '
- '低湿判读前核湿度探头读偏; strict 运行中加热高台=OEM 违规轴单列现场核')
- return out
- # ---------------------------------------------------------------- §4.6 开关量停机 响应比 (如东 U6 回灌 2026-08-08)
- def response_ratio(stop_intervals, signal_intervals, *, ratio_gate=10.0):
- """停机时长/故障信号实际在位时长 比值 — 分辨"真持续故障损失" vs "复位滞后/管理损失" (源 如东 U6 §3.2, n=2 known-answer).
- 对开关量触发的停机型事件 (油位/滤芯/温度开关…), 用**独立信号源** (din 开关实际断开的
- 在位区间) 除 SCADA 连续停机区间:
- ratio ≈ 1 = 信号持续在位 = 真持续性故障 (如东 20# 油位开关连续断开 77.5h ≈ 停机 1:1 = 真缺油/漏点);
- ratio ≫ 1 = 信号仅间歇触发 (阈值边缘抖动) + 人工复位滞后 ⇒ **损失是管理问题非设备问题**
- (如东 18B 开关实际断开 0.51h vs 停机 110.8h = 1:217, 57,922 kWh; 处方=持续性
- 判据/远程复位, 非修设备)。同名故障两台机制可差 152× — 必逐台算, 禁按故障名合并处置。
- ★铁律: signal_intervals 必须来自**与停机判定独立的信号源** (开关量 din 原始在位, 非停机状态字
- 派生), 否则比值恒 1 套套逻辑。ratio_gate 默认 10 只是分层参考 (A/B 判据由调用方按场定, RULE-3)。
- 入参: 区间列表 [(t0, t1), ...] (pd.Timestamp 或可比时间)。
- 返回 {stop_h, signal_h, ratio, classification, n_stop_intervals, n_signal_intervals, note}。
- """
- def _hours(iv):
- tot = 0.0
- for t0, t1 in iv:
- tot += max((pd.Timestamp(t1) - pd.Timestamp(t0)).total_seconds(), 0.0) / 3600.0
- return tot
- sh = _hours(stop_intervals)
- gh = _hours(signal_intervals)
- if sh <= 0:
- return {'stop_h': 0.0, 'signal_h': round(gh, 2), 'ratio': None,
- 'classification': 'no_stop', 'n_stop_intervals': len(stop_intervals),
- 'n_signal_intervals': len(signal_intervals)}
- if gh <= 0:
- return {'stop_h': round(sh, 2), 'signal_h': 0.0, 'ratio': None,
- 'classification': 'signal_absent',
- 'n_stop_intervals': len(stop_intervals), 'n_signal_intervals': len(signal_intervals),
- 'note': '停机窗内独立信号零在位 — 先核信号通道 liveness, 非直接判管理损失'}
- r = sh / gh
- return {'stop_h': round(sh, 2), 'signal_h': round(gh, 2), 'ratio': round(r, 1),
- 'classification': 'latency_dominated' if r > ratio_gate else 'fault_dominated',
- 'n_stop_intervals': len(stop_intervals), 'n_signal_intervals': len(signal_intervals),
- 'note': ('ratio≫1=阈值边缘间歇触发+复位滞后(管理杠杆: 持续性判据/远程复位)' if r > ratio_gate
- else 'ratio≈1=信号持续在位=真持续故障(设备处置线)')}
- # ---------------------------------------------------------------- §4.6 振动谱线四道前提闸 (skill vibration-spectral)
- def spectral_line_gates(spec, dx, hz, *, fleet_med=None, all_line_hz=(), tol_bins=2,
- bw_hz=None, min_struct=3.0, max_drift_bins=2.0,
- peak_hz_by_window=None, min_fleet_struct=3.0, total_energy=None,
- shaft_hz=(), int_order_tol=0.03, max_order=40, harmonic_check_hz=None,
- domain=None, max_peak_offset_bins=1.5, iso_ref_mms=None,
- x_fleet=None, min_x_fleet=3.0, selectivity_samewin=None, min_selectivity=1.5):
- """特征频率判据的四道前提闸 — 不过闸的 x_fleet/选择性/趋势一律不得进下游。
- 根因 (2026-08-14/19 如东 M5, 同一套判据一天内被推翻三次 + 拿到 OEM 表后逮出第四类):
- G1 分辨率: ±tol_bins 窗宽须 < 最近邻特征线间隔/3 (否则窗重叠取到同一峰)
- G2 有线: 峰/邻域本底 >= min_struct (如东 2220 项里 1969 项=89% 在此被拦, 那些位置根本没有线)
- G3 峰位稳: 跨窗峰位漂移 <= max_drift_bins bin (本底型乱跳: 239.2/244.7/241.7/246.4)
- G4 非固有: fleet 中位谱在该处 峰/本底 < min_fleet_struct, 否则该线全场共有(机型固有/结构共振),
- 相对判据在此失效 — 如东 HS转子侧179Hz fleet峰/本底8.5 基率26% 会造13台假阳,
- 而 GenDE内圈135Hz fleet峰/本底1.0 最大/中位80.5 = 真个体缺陷
- G5 非整阶: 实测峰位不得落在任何轴系转频的整数倍上(容差 int_order_tol 相对).
- **轴承特征频率由滚动体几何决定, 必为非整数倍; 齿轮/轴系频率必为整数倍** —
- 落在整数倍上说明归属是轴系/齿轮不是轴承。如东 02# 二级行星"外圈BPFO 20.9Hz"命中
- 6.5×fleet/结构3.3, 但亚bin插值实测峰位五窗 20.970~21.002 = 二级行星架转频(1.053Hz)
- 的 20.0 倍(误差0.005~0.14%), 距 BPFO 20.9 有 2.6bin — ±2bin 搜索窗把整阶线抓成了
- 轴承线。配套: 真轴承缺陷必有 2× 谐波, 02# 的 2×20.9Hz 峰/本底=1.0 完全无谐波
- Args:
- shaft_hz: ★**只传该轴承实际所在的那一根轴**的转频 (Hz) — 用 OEM 表的 shaft_Hz 字段.
- **禁止把全机所有轴都传进来**: G5 判"阶次落整数±tol", 传入轴数越多假阳率越高.
- 纯几何量化 (与任何场的数据无关, 20~300Hz 随机频率 n=200,000 蒙特卡洛):
- 只传本轴 1 根 → 误判整阶 **5.33%**
- 传 2 根 → **11.03%**
- 传 3 根 → **11.43%**
- 传全机 5 根 → **16.67%**
- 实证 (2026-08-21 如东): GenDE 内圈实测峰位 134.56Hz, 其所在轴是 25Hz →
- 134.56/25 = 5.3824 阶 (距整数 0.38, 非整阶, 正确放行); 但若把不相关的 IMS 轴 7.08 也传进来,
- 134.56/7.08 = **19.0057 阶** (距整数 0.0057) → **G5 误杀本场最强的那条结论**.
- 生产脚本 rudong_tcm_oem_scan.py 用 `bb.get('shaft_Hz')` 只取该轴承自己的轴, 是对的.
- int_order_tol: 整阶判定的**绝对阶数**容差 (默认 0.03 阶). ★禁用相对容差 —
- 相对容差在高阶数上等于极宽的绝对容差 (135Hz/1.053Hz=128.2 阶时 2% 相对 = ±2.5 阶,
- 几乎任何频率都会命中某个整数), 会把真轴承线误判成整阶. 自检用例 2 实逮
- max_order: 只在 k <= max_order 时判整阶 (默认 40). 高阶数上相邻整数在频率上只差一个
- shaft_hz, 谱分辨率与谐波衰减都使整阶判据失去意义; 那个量级应直接用 GMF 判
- harmonic_check_hz: 谐波检验用的 2× 频率 (None 则用 2*hz)
- spec: 该台该窗的谱 (1D, 物理单位如 m/s²)
- dx: 谱线间隔 Hz
- hz: 目标特征频率
- fleet_med: fleet 中位谱 (G4 用; None 则跳过 G4 并在 verdict 标注)
- all_line_hz: 该轴承/部件的全部特征频率, 用于 G1 算最近邻间隔
- min_struct: G2 峰/邻域本底门槛 (默认 3.0). ★**统计推导, 不是从任何场的数据调出来的**:
- 纯噪声谱上复高斯幅值服从 Rayleigh, 中位 = 1.1774σ, P(|X| > k·中位) = exp(-(1.1774k)²/2).
- ±tol_bins 窗内取最大 (5 bin) 的假阳率 = 1-(1-p)^5:
- 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%
- 蒙特卡洛 (纯高斯噪声 n=200,000): k=3.0 实测 **0.989%**, 与解析 0.973% 一致.
- ⇒ 取 3.0 = 窗内假阳率约 1%; 2.0 过松, 4.0 会漏早期弱线.
- ★**防过拟合铁律**: 该值不得因"某场检出率不好看"而调整 — 调整须重做上述统计推导,
- 且**禁止以"对标厂商/第三方检出率"为理由** (厂商口径本身可能错: 如东上气漏检 16# 六个月).
- bw_hz: 邻域半宽 (默认 min(15Hz, 最近邻间隔/2))
- peak_hz_by_window: {窗名: 峰位Hz} — 有则跑 G3
- total_energy: 该测点总振动能量 sum(v**2), 有则算能量占比
- x_fleet / min_x_fleet: ★**G8 fleet 离群门** — 该线相对 fleet 中位的倍数, 门槛默认 3.0.
- 前七闸判的都是"这条线真不真实、是不是个体的", **没有一道问"它比同类高多少"**.
- 根因 (2026-08-21): G7 改自适应后, 新过闸里冒出 x_fleet=1.06 的 (跟全场中位一样,
- 毫无异常含义), 过闸台数飙到 34/37 — 原来那些低 x_fleet 的是被固定 G7=1.5 **偶然**拦住的.
- ★门槛 3.0 **与 G2 同源, 不是调的**: G2 要求"峰比邻域本底高3倍才算有线",
- 同理"比 fleet 中位高3倍才算离群". x_fleet=None 则跳过该闸并在 verdict 标注.
- selectivity_samewin / min_selectivity: ★**G6 同窗选择性门** — 线÷自身宽带的 ×fleet 比, **必须同窗算**.
- ★本函数是**单窗**的, 算不出跨窗中位, 故 G6 需调用方传入 (由生产脚本按窗计算后传).
- 混合口径(线六窗max ÷ 宽带六窗中位)对突发事件台会虚高: 如东 38# 后主轴承内圈 3.10Hz
- 混合 sel=13.26 看着是强线, 同窗逐窗 1.33/0.95/0.94/0.84/0.47 **全 <=1.33**.
- None 则跳过该闸并在 verdict 标注. **[暂行]** 1.5 是经验值, 见退役判据.
- iso_ref_mms: ★G4 批次盲点旁路用的**不依赖 fleet 的绝对锚** (mm/s, 如 ISO 20816 的 7.1).
- G4 命中(fleet 中位谱同处有峰)时: 若该线的速度当量 >= 此锚 ⇒ 仍判 G4_FLEET_INHERENT
- (全场都超绝对线本身就是机型问题); 否则输出 **G4_INHERENT_OR_BATCH** —
- 显式声明"机型固有"与"批次缺陷"在本场数据下**不可区分**, 而不是默认判固有。
- 根因 (2026-08-21 Gemini 独立审): G4 必须先假设 fleet 健康才能用 fleet 判个体,
- 批次性缺陷会在 fleet 中位谱上显现从而被 G4 豁免 ⇒ 对批次缺陷集体失明。
- 该循环**无法用本场数据自证**, 只能显式标不可区分 + 走跨场对照。
- 本场实例: HS转子侧外圈179Hz 13台过前三闸、fleet峰/本底8.5、基率26%,
- 按新规则应判 G4_INHERENT_OR_BATCH 而非确定固有。
- max_peak_offset_bins: ★G7 峰位偏差闸 (默认 **1.5 bin**, 物理推导 + 敏感性验证).
- 敏感性扫描 (本场 146 行): 1.2/1.3→过闸39 · 1.4→42 · **1.5→48** · 1.6→85(+77% 断崖) · 2.0→99。
- ⇒ [1.2,1.5] 是平台, 1.6 起断崖 ⇒ 取**平台右端 1.5** (在物理区间 [1.04,2.0] 内且不进断崖).
- (DeepSeek 独立审判据"1.4→1.6 变化>5% 则物理推导不成立"实测 +102.4%, 质疑成立;
- 故论证由"物理区间取中值"改为"物理区间 ∩ 敏感性平台的右端点"). 窗内最强峰的**实测亚bin峰位**
- 距目标频率超过此值 ⇒ 判 G7_PEAK_OFFSET: 窗里抓到的是**邻线的裙边**, 不是目标线。
- 根因 (2026-08-21 如东 IMS 内圈): OEM 表给 BPFI=151.0Hz, 而该测点上真实存在的是一条
- **全场共有、六窗锁死在 151.609Hz** 的固定线 (34/37 台峰位逐位相同, 漂移 0.001~0.008Hz)。
- 151.0 的 ±2bin 窗 = [150.625, 151.250], 真峰在窗外 2.3 bin ⇒ 窗内测到的
- G2=5.54 全是那条线的裙边。**而 G4 恰好放行**: fleet 中位谱在 151.0 处峰/本底 = **2.99**
- (差 0.01 就到 3.0 门槛), 在真峰 151.609 处是 **23.22**。
- ⇒ **G4 看的是目标频率, G7 看的是实测峰位落在哪** — 两者互补, 缺 G7 时刀刃案例会漏。
- ★阈值 1.5 的推导 (不是拍的): 真线一侧的误差上界 = OEM 表取整 ±0.05Hz (±0.32bin)
- + 轴承线随载荷漂移 0.40~0.72bin (peak_drift_typology 的 LOAD_DRIFT 实测档)
- ≈ **1.04 bin**; 裙边一侧 = 搜索窗 ±tol_bins(默认2), 抓到邻线时峰必落窗边缘 ≈ **2.0 bin**。
- 取中间的 1.5 → 对真线留 44% 余量, 对裙边留 25% 判别距。**换 tol_bins 时须同步重推。**
- domain: 谱域 — 'raw'(原始谱) / 'env'(包络解调谱, 名字含 Env_ 的 shard).
- ★必须显式声明. **包络谱不做速度换算**: 包络谱上 f 处的分量表示
- "以 f 为重复率的冲击串的调制深度", **不是 f 处的正弦振动**, 除以 2πf 得到的
- "mm/s" 没有物理意义, 更不可与原始谱域的 mm/s 或 VDI 3834 烈度限值相比。
- 未声明时仍返回 vel_mms 但附 vel_mms_caveat 字段告警。
- 根因 (2026-08-20 如东, 综合报告线逮, 同一错第2次): 我把 16# GenDE 内圈 135Hz 的
- env850 域 2.602 m/s² 换算成 3.068 mm/s 当作"绝对量也强"的证据, 而**同一条频率
- 在原始谱域只有 0.145 mm/s = 1.2× 中位, VERDICT 是 G2_NO_LINE 根本没过闸**。
- 正确表述是"包络域显著 + 原始谱域不显著" — 这个组合本身就是早期轴承缺陷的
- 预期签名, 支持结论, 不需要靠跨域 mm/s 来撑
- Returns:
- dict: passed(bool) + verdict + 四闸各自结果 + 绝对物理量(abs_ms2/vel_mms/energy_pct)
- """
- import numpy as _np
- n = len(spec)
- i = int(round(hz / dx))
- nb_gaps = [abs(hz - o) for o in all_line_hz if abs(hz - o) > 1e-9]
- gap = min(nb_gaps) if nb_gaps else _np.inf
- win_hz = 2 * tol_bins * dx
- g1 = bool(win_hz < gap / 3)
- if bw_hz is None:
- bw_hz = max(15 * dx, min(15.0, gap / 2 if _np.isfinite(gap) else 15.0))
- def _struct(v):
- if i + tol_bins >= len(v) or i - tol_bins < 1:
- return None
- lo, hi = max(int((hz - bw_hz) / dx), 1), min(int((hz + bw_hz) / dx), len(v) - 1)
- if hi - lo < 10:
- return None
- seg = v[i - tol_bins:i + tol_bins + 1]
- a = float(_np.nanmax(seg))
- pk = (i - tol_bins + int(_np.nanargmax(seg))) * dx
- nbv = _np.concatenate([v[lo:i - tol_bins - 1], v[i + tol_bins + 2:hi]])
- base = float(_np.nanmedian(nbv)) if len(nbv) > 5 else _np.nan
- return {'abs': a, 'peak_hz': pk, 'base': base,
- 'struct': (a / base) if (base and base > 1e-12) else _np.nan}
- st = _struct(spec)
- if st is None:
- return {'passed': False, 'verdict': 'OUT_OF_RANGE', 'G1_resolution_ok': g1,
- 'win_hz': round(win_hz, 4), 'nearest_gap_hz': (round(float(gap), 3) if _np.isfinite(gap) else None)}
- g2 = bool(_np.isfinite(st['struct']) and st['struct'] >= min_struct)
- drift = _np.nan
- if peak_hz_by_window:
- pv = [v for v in peak_hz_by_window.values() if v is not None and _np.isfinite(v)]
- if len(pv) >= 3:
- drift = (max(pv) - min(pv)) / dx
- g3 = bool(not _np.isfinite(drift) or drift <= max_drift_bins)
- fst = _np.nan
- if fleet_med is not None:
- sf = _struct(_np.asarray(fleet_med))
- if sf and _np.isfinite(sf['struct']):
- fst = sf['struct']
- g4 = bool(not _np.isfinite(fst) or fst < min_fleet_struct)
- # G5 整阶排除: 用亚bin插值的实测峰位, 不用标称 hz
- def _subbin(v, k):
- if k < 1 or k + 1 >= len(v):
- return float(k)
- y0, y1, y2 = float(v[k - 1]), float(v[k]), float(v[k + 1])
- den = y0 - 2 * y1 + y2
- return float(k) + (0.0 if abs(den) < 1e-15 else 0.5 * (y0 - y2) / den)
- # 在 ±max(3*tol_bins, 邻线间隔/4) 宽窗内定位主峰 — 真峰可能落在 ±tol_bins 窗外,
- # 那时窗内 argmax 是噪声位置, 直接拿它判整阶会漏 (如东 02# 真峰 21.00 距标称 20.9 有 2.6bin)
- _wb = max(3 * tol_bins, int((gap / 4) / dx) if _np.isfinite(gap) else 3 * tol_bins)
- _lo, _hi = max(i - _wb, 1), min(i + _wb + 1, len(spec) - 1)
- _sv = _np.asarray(spec)
- j = _lo + int(_np.nanargmax(_sv[_lo:_hi])) if _hi > _lo else i
- peak_refined = _subbin(_sv, j) * dx
- int_order_hit = None
- for sh in (shaft_hz or ()):
- if sh and sh > 1e-9:
- k = peak_refined / sh
- if 2 <= k <= max_order and abs(k - round(k)) < int_order_tol:
- int_order_hit = {'shaft_hz': sh, 'order': int(round(k)),
- 'order_err': round(abs(k - round(k)), 4),
- 'implied_hz': round(sh * round(k), 4)}
- break
- g5 = int_order_hit is None
- # ★G7 峰位偏差闸: 实测亚bin峰位是否真的落在目标频率上 (防邻线裙边冒充目标线)
- # ★★2026-08-21 修 (自查 + Gemini③ 追问强信号来源时暴露): 容差必须**按 OEM 表的取整精度自适应**,
- # 不能用固定 1.5 bin。实测如东 OEM 表的取整精度**随频率变化**:
- # 0~10Hz 整数占比 0%(2位小数) · 10~50Hz 43% · 50~100Hz 53% · 100~200Hz **92%** · 200~400Hz **100%**
- # ⇒ 高频线被取整到 1Hz ⇒ 目标频率本身有 **±0.5Hz** 不确定 = **±3.2 bin** (dx=0.15625)
- # 而我原推导假设"取整到 0.1Hz ⇒ ±0.32bin" — **低估 10 倍**, 对高频线系统性误伤。
- # 活案例: HS转子侧外圈 OEM 给 179.00, 实测峰位跨台一致 **178.75**(差 1.6bin),
- # 22 台 G2 = 3~44 (**是真线**), 却被固定 1.5bin 的 G7 误杀。
- # (该线的正确归属是 G4 fleet 固有线 — G7 抢在 G4 前拦了它, 结果对但理由错。)
- _dec = 0
- _sh = f'{hz:.10g}'
- if '.' in _sh:
- _dec = len(_sh.split('.')[1])
- _oem_half_hz = 0.5 * (10.0 ** (-_dec)) # 整数→±0.5Hz · 1位→±0.05 · 2位→±0.005
- _oem_bins = _oem_half_hz / dx if dx > 0 else 0.0
- _eff_tol = max(max_peak_offset_bins, _oem_bins + 0.72) # 0.72bin = 轴承线载荷漂移实测上界
- peak_offset_bins = abs(peak_refined - hz) / dx if dx > 0 else _np.nan
- g7 = not (peak_offset_bins == peak_offset_bins and peak_offset_bins > _eff_tol)
- # 谐波佐证 (不作硬闸, 作字段): 真轴承缺陷应有 2x
- h2 = harmonic_check_hz if harmonic_check_hz else 2 * hz
- harm2 = None
- if h2 / dx + tol_bins + 1 < len(spec):
- k2 = int(round(h2 / dx))
- seg2 = _np.asarray(spec)[k2 - tol_bins:k2 + tol_bins + 1]
- lo3, hi3 = max(int((h2 - bw_hz) / dx), 1), min(int((h2 + bw_hz) / dx), len(spec) - 1)
- if hi3 - lo3 > 10:
- b3 = float(_np.nanmedian(_np.asarray(spec)[lo3:hi3]))
- if b3 > 1e-12:
- harm2 = round(float(_np.nanmax(seg2)) / b3, 2)
- # ★闸优先级 (2026-08-21 调整): 越**具体**的诊断越靠前 —
- # G5"这个峰是轴谐波"比 G7"峰位偏了"信息量大; 若两者同时成立, 报 G5 对现场更有用。
- # ★G4 批次盲点旁路 (2026-08-21 Gemini 独立审逮): G4 必须先假设 fleet 健康才能用 fleet 判个体;
- # 若缺陷是**批次性**的(如基率 26%), 该特征会在 fleet 中位谱上显现 → 反被 G4 判为"机型固有线"豁免
- # ⇒ 对批次缺陷集体失明。这个循环**无法用本场数据自证**, 只能显式声明不可区分。
- # 旁路: G4 命中时不直接判"固有", 先看有没有**不依赖 fleet** 的绝对锚 (ISO 速度当量) 能定性;
- # 无锚 ⇒ 输出 G4_INHERENT_OR_BATCH (机型固有 与 批次缺陷 在本场数据下不可区分)。
- # ★G6 同窗选择性 / G8 fleet 离群 (调用方未传则跳过, 并在 out 标注 skipped)
- g6 = True if selectivity_samewin is None else (float(selectivity_samewin) >= min_selectivity)
- g8 = True if x_fleet is None else (float(x_fleet) >= min_x_fleet)
- g4_label = 'G4_FLEET_INHERENT'
- if not g4:
- _vel = None
- if not (domain or '').lower().startswith('env') and hz > 0:
- _vel = st['abs'] / (2 * _np.pi * hz) * 1000
- if _vel is None or not (iso_ref_mms and _vel >= iso_ref_mms):
- g4_label = 'G4_INHERENT_OR_BATCH'
- # ★闸优先级 (越具体的诊断越靠前) — 2026-08-21 由嵌套三元改为 if/elif:
- # 同样的逻辑, 但不会再数错括号 (加 G8 时已因此报过两次语法错)
- if g1 and g2 and g3 and g4 and g5 and g6 and g7 and g8:
- verdict = 'PASS'
- elif not g1:
- verdict = 'G1_RESOLUTION'
- elif not g2:
- verdict = 'G2_NO_LINE'
- elif not g5:
- verdict = 'G5_INTEGER_ORDER'
- elif not g7:
- verdict = 'G7_PEAK_OFFSET'
- elif not g3:
- verdict = 'G3_PEAK_DRIFT'
- elif not g4:
- verdict = g4_label
- elif not g6:
- verdict = 'G6_SELECTIVITY'
- else:
- verdict = 'G8_NOT_FLEET_OUTLIER'
- out = {'passed': verdict == 'PASS', 'verdict': verdict,
- 'G5_non_integer_order': g5, 'G5_integer_order_hit': int_order_hit,
- 'G6_selectivity_ok': g6, 'G6_skipped': selectivity_samewin is None,
- 'G8_fleet_outlier': g8, 'G8_skipped': x_fleet is None,
- 'G7_peak_on_target': g7, 'G7_peak_offset_bins': (round(float(peak_offset_bins), 3)
- if peak_offset_bins == peak_offset_bins else None),
- 'G7_eff_tol_bins': round(float(_eff_tol), 3), 'G7_oem_decimals': _dec,
- 'peak_hz_refined': round(peak_refined, 4), 'harmonic_2x_struct': harm2,
- 'G1_resolution_ok': g1, 'win_hz': round(win_hz, 4),
- 'nearest_gap_hz': (round(float(gap), 3) if _np.isfinite(gap) else None),
- 'G2_struct': (round(float(st['struct']), 2) if _np.isfinite(st['struct']) else None),
- 'G3_drift_bins': (round(float(drift), 2) if _np.isfinite(drift) else None),
- 'G4_fleet_struct': (round(float(fst), 2) if _np.isfinite(fst) else None),
- 'G4_inherent_line': bool(_np.isfinite(fst) and fst >= min_fleet_struct),
- 'peak_hz': round(st['peak_hz'], 4), 'neighborhood_base': round(st['base'], 8),
- 'abs_ms2': round(st['abs'], 6), 'domain': domain}
- _dom = (domain or '').lower()
- if _dom.startswith('env'):
- out['vel_mms'] = None
- out['vel_mms_note'] = ('包络域不做速度换算 — 该分量是冲击重复率的调制深度, 非该频率的正弦振动; '
- '禁与原始谱域 mm/s 或 VDI 3834 烈度限值比较. 用 abs_ms2 + 同域 fleet 分位定级')
- else:
- out['vel_mms'] = round(st['abs'] / (2 * _np.pi * hz) * 1000, 5)
- if not _dom:
- out['vel_mms_caveat'] = '未声明 domain — 若该谱为包络谱则此 vel_mms 无物理意义, 请传 domain='+repr('env')
- if total_energy and total_energy > 0:
- lo2, hi2 = max(i - tol_bins, 0), min(i + tol_bins + 1, n)
- out['energy_pct'] = round(100.0 * float(_np.sum(_np.asarray(spec)[lo2:hi2] ** 2)) / total_energy, 5)
- out['_caliber'] = (
- f'G1 ±{tol_bins}bin窗宽{win_hz:.3f}Hz vs 最近邻间隔/3; G2 峰/邻域本底(±{bw_hz:.2f}Hz去线)>={min_struct}; '
- f'G3 跨窗峰位漂移<={max_drift_bins}bin; G4 fleet中位谱峰/本底<{min_fleet_struct}; '
- f'G6 同窗选择性>={min_selectivity}(需调用方传, 本函数单窗算不出跨窗中位); '
- f'G8 x_fleet>={min_x_fleet}(与G2同源: 峰比本底3倍 ⇒ 比fleet中位3倍); '
- f'G7 实测亚bin峰位距目标频率<={max_peak_offset_bins}bin (防邻线裙边冒充目标线); '
- f'G5 亚bin实测峰位不落轴系转频整数倍(绝对阶容差{int_order_tol}, 仅判k<={max_order}) — '
- '轴承频率必非整阶, 齿轮/轴系必整阶; ★禁用相对容差(高阶数上等于极宽绝对容差会误伤真轴承线). '
- '★不过闸的 x_fleet/选择性/趋势一律不进下游 (如东实测 G2 拦89%). '
- '★相对判据必配 abs_ms2+vel_mms+energy_pct 才可定级 (13台次"报警"实为万分之几能量占比已撤回). '
- '★★绝对锚必须同域: 包络域(Env_*)不产出 vel_mms — 包络谱 f 处是冲击重复率的调制深度非正弦振动, '
- '跨域比 mm/s 会得到方向相反的结论 (如东16# GenDE 内圈 env850 3.07mm/s 看着最强, '
- '同频率原始谱仅 0.145mm/s=1.2×中位且 G2_NO_LINE 没过闸). '
- '★选择性(线÷宽带)在"缺陷同时抬高线与宽带"(齿面磨损)时方向失效会漏台, 该场景改用绝对幅值+fleet分位')
- return out
- def peak_drift_typology(peak_hz_by_window, dx, *, shaft_hz=(), int_order_tol=0.03,
- lock_bins=0.05, wander_bins=2.0, max_order=40):
- """★峰位漂移三分型 — 用"漂移量"分辨 轴谐波 / 真轴承线 / 本底 (2026-08-20 如东实证 n=6)
- G3 原来只有一个阈值 (漂移>2bin 判本底), 是**二分**。实测发现漂移量本身分三档,
- 且中间那档 (小幅系统性漂移) 正是**真轴承线的正证**, 此前被当成"稳定"一笔带过:
- 漂移 <= lock_bins (默认0.05bin) → 'LOCKED' 峰位锁死
- 频率由轴系几何或电网锁定, 不随载荷变 ⇒ **轴谐波 / 电磁线**, 不是轴承缺陷
- lock_bins < 漂移 <= wander_bins → 'LOAD_DRIFT' 小幅系统性漂移
- 轴承特征频率(BSF/BPFO/BPFI)含接触角项, 随载荷/间隙/温度漂移 ⇒ **真轴承线的正证**
- 漂移 > wander_bins (默认2bin) → 'WANDER' 乱跳 = 本底无线 (= 原 G3 拦下的)
- ★如东实证 (IMS 滚动体 BSF 35.0Hz vs 5×IMS轴 35.40Hz, dx=0.15625, 六窗):
- 轴谐波 09#/19#/12#/31# 漂移 0.0008~0.0079 Hz = **0.005~0.05 bin** 阶次 4.9958
- 轴承线 07#/03# 漂移 0.063~0.113 Hz = **0.40~0.72 bin** 阶次 4.89~4.91
- 两档差**一个数量级**, 干净可分。12# 是全程健康对照台却与 09#/19# 同档 ⇒ 轴谐波的正证。
- ★用法铁律 — 'LOCKED' 单独**不足以**判轴谐波 (一条稳定的轴承线也可能漂移很小):
- 必须与**阶次检验双证**: 阶次落整数(G5) ∧ 峰位锁死 ⇒ 轴谐波。
- 反过来 'LOAD_DRIFT' ∧ 非整阶 ⇒ 支持"真轴承线"。本函数返回 verdict 已做此合取。
- ★与 G3 的关系: G3 是硬闸(拦 WANDER), 本函数是**分型器**(在过了 G3 之后再分 LOCKED/LOAD_DRIFT),
- 两者方向不同, 不要混用同一个阈值。
- [暂行 n=6] — 阈值 lock_bins=0.05 由单场 6 台标定, 跨场用前须复标; 退役判据: 若某场
- 出现"真轴承线漂移<0.05bin"或"轴谐波漂移>0.4bin"的反例, 本三分型失效, 回退到纯阶次判据。
- Args:
- peak_hz_by_window: {窗名: 亚bin峰位Hz} — **必须是亚bin插值峰位**;
- bin 分辨率峰位在低转频轴上太粗 (dx=0.15625Hz 在 7.08Hz 轴上仅 0.022 阶/bin,
- 对 0.03 阶容差不够), 会把轴谐波读成非整阶
- dx: 谱线间隔 Hz
- shaft_hz: 该测点相关的全部轴系转频 (Hz), 用于阶次合取
- lock_bins / wander_bins: 两档边界 (bin)
- Returns:
- dict: drift_hz / drift_bins / drift_class / order_by_shaft / integer_order_hit / verdict
- """
- import numpy as _np
- v = [float(x) for x in (peak_hz_by_window or {}).values() if x == x]
- if len(v) < 3 or dx <= 0:
- return {'drift_class': None, 'verdict': 'INSUFFICIENT_WINDOWS', 'n_win': len(v)}
- drift_hz = float(max(v) - min(v))
- drift_bins = drift_hz / dx
- cls = ('LOCKED' if drift_bins <= lock_bins else
- ('LOAD_DRIFT' if drift_bins <= wander_bins else 'WANDER'))
- pk = float(_np.median(v))
- hit, orders = None, {}
- for sh in (shaft_hz or ()):
- if sh and sh > 1e-9:
- k = pk / sh
- orders[round(float(sh), 4)] = round(float(k), 4)
- if hit is None and 2 <= k <= max_order and abs(k - round(k)) < int_order_tol:
- hit = {'shaft_hz': sh, 'order': int(round(k)),
- 'order_err': round(abs(k - round(k)), 4)}
- if cls == 'WANDER':
- verdict = 'BACKGROUND_NO_LINE'
- elif hit and cls == 'LOCKED':
- verdict = 'SHAFT_HARMONIC' # 双证: 整阶 ∧ 锁死
- elif hit:
- verdict = 'INTEGER_ORDER_BUT_DRIFTS' # 整阶但会漂 — 存疑, 不可自动归任一类
- elif cls == 'LOAD_DRIFT':
- verdict = 'BEARING_LINE_SUPPORTED' # 非整阶 ∧ 载荷漂移 = 真轴承线正证
- else:
- verdict = 'LOCKED_NON_INTEGER' # 锁死但非整阶 — 可能是结构共振/固定源
- return {'drift_hz': round(drift_hz, 5), 'drift_bins': round(drift_bins, 4),
- 'drift_class': cls, 'peak_hz_med': round(pk, 4),
- 'order_by_shaft': orders, 'integer_order_hit': hit,
- 'n_win': len(v), 'verdict': verdict,
- '_caliber': ('漂移<=%.3gbin=LOCKED(轴谐波/电磁, 需与整阶双证) · <=%.3gbin=LOAD_DRIFT'
- '(轴承线正证) · >%.3gbin=WANDER(本底). [暂行 n=6 如东标定]'
- % (lock_bins, wander_bins, wander_bins))}
- def adaptive_baseline_threshold(values, *, k_yellow=3.0, k_red=6.0, min_n=30,
- purge_iter=3, purge_k=5.0, abs_floor=None,
- iso_yellow=None, iso_red=None):
- """★L2 自适应基线阈值 — per-台 per-工况, 中位+k×MAD, 且**基线窗先净化**
- 根因 (2026-08-21 如东 CMS mask.tsv 8,511 条解出): CMS 有完整的自适应机制
- (`Method=StdDev` / `StdDevs=3` / `TargetCount=15`), 基线也**真的采满了**
- (Active 条目中 CurrentCount<TargetCount 的 = 0, RejectCount 全 0),
- **但最终阈值是手填的全场统一固定值** —— `rms_200` 在 6 个功率档 × 38 台上
- Red 全部 2.625 / Yellow 全部 1.875, 92% 的 mask 建于 2017–2018,
- 836 条建于 2017-12-28 08:28 同一分钟。**自适应能力完全浪费。**
- 本函数把那套机制真正跑起来, 并补三处 CMS 没有的:
- 1. **稳健统计**: 中位 + k×MAD 替代 均值 + k×σ。
- 基线窗内若含事件, 均值与 σ 双双被污染, 而中位/MAD 抗污染。
- 2. **★基线窗净化 (最关键的一条)**: 迭代剔除窗内离群点再估基线。
- 实证 —— 如东 20# 初算基线 41.20 是错的, 因为基线窗 (7/10 前) 本身就含
- 7/2~7/8 的发作; 净化后真实基线约 3, 发作时 41~129 = **13~43 倍**,
- 而污染基线只给出 1.0~3.1 倍 ⇒ **倍数被严重低估, 差一个数量级**。
- 3. **绝对下限与 ISO 上限双向夹逼**: 自适应阈值可能在"这台一直很安静"时
- 算得过低 (假阳) 或在"这台一直很吵"时算得过高 (假阴)。
- `abs_floor` 防前者; `iso_yellow/iso_red` (如 ISO 20816 的 7.1/11.0 mm/s)
- 防后者 —— **任何自适应阈值都不得高于绝对标准限值**。
- Args:
- values: 基线窗内的逐次测量值 (1D array-like, 同一 台×测点×判据×工况档)
- k_yellow / k_red: MAD 倍数 (默认 3 / 6; CMS 原用 3σ, MAD≈0.6745σ 故 3MAD 更严)
- min_n: 最小样本数 (默认 30; CMS 用 15 —— 15 个样本估离散度相对误差约 18%)
- purge_iter / purge_k: 净化迭代次数与剔除倍数
- abs_floor: 阈值绝对下限 (物理噪声本底), None 则不设
- iso_yellow / iso_red: 国际标准限值, 自适应阈值不得超过它
- Returns:
- dict: yellow / red / baseline_med / mad / n_used / n_purged /
- purged_frac / capped_by / verdict
- """
- import numpy as _np
- v = _np.asarray([x for x in _np.ravel(values) if x == x], dtype=float)
- if v.size < min_n:
- return {'verdict': 'INSUFFICIENT_BASELINE', 'n_used': int(v.size), 'min_n': min_n,
- 'yellow': None, 'red': None,
- '_caliber': f'基线样本 {v.size} < min_n {min_n} — 不出阈值, 不可回退到全场固定值'}
- kept = v.copy()
- n_purged = 0
- for _ in range(max(0, purge_iter)):
- med = float(_np.median(kept))
- mad = float(_np.median(_np.abs(kept - med))) * 1.4826
- if mad <= 1e-12:
- break
- keep = _np.abs(kept - med) <= purge_k * mad
- if keep.all():
- break
- n_purged += int((~keep).sum())
- kept = kept[keep]
- if kept.size < min_n:
- return {'verdict': 'BASELINE_TOO_CONTAMINATED', 'n_used': int(kept.size),
- 'n_purged': n_purged, 'yellow': None, 'red': None,
- '_caliber': ('净化后样本不足 — 该窗事件占比过高, 说明**这个窗本身就不是常态窗**, '
- '须换窗重建基线, 不可用污染窗的统计量')}
- med = float(_np.median(kept))
- mad = float(_np.median(_np.abs(kept - med))) * 1.4826
- y = med + k_yellow * mad
- r = med + k_red * mad
- capped = []
- if abs_floor is not None:
- if y < abs_floor: y = float(abs_floor); capped.append('yellow_floor')
- if r < abs_floor: r = float(abs_floor); capped.append('red_floor')
- if iso_yellow is not None and y > iso_yellow:
- y = float(iso_yellow); capped.append('yellow_iso')
- if iso_red is not None and r > iso_red:
- r = float(iso_red); capped.append('red_iso')
- frac = n_purged / float(v.size)
- verdict = ('OK' if frac < 0.10 else
- ('OK_BASELINE_HAD_EVENTS' if frac < 0.30 else 'BASELINE_WINDOW_SUSPECT'))
- return {'yellow': round(y, 6), 'red': round(r, 6),
- 'baseline_med': round(med, 6), 'mad': round(mad, 6),
- 'n_used': int(kept.size), 'n_purged': n_purged, 'purged_frac': round(frac, 4),
- 'capped_by': capped, 'verdict': verdict,
- '_caliber': (f'中位{med:.4g}+{k_yellow}/{k_red}×MAD({mad:.4g}); '
- f'净化剔除 {n_purged}/{v.size} ({frac:.1%}, >{purge_k}MAD); '
- f'夹逼 {capped or "无"}. ★基线窗净化实证: 如东20#未净化基线41.20 vs '
- f'净化后约3 ⇒ 发作倍数从1.0~3.1 变 13~43, **差一个数量级**')}
- def vib_verdict_and_writeback(*, gate_result=None, abs_value=None, abs_unit=None,
- energy_pct=None, trend_class=None, iso_yellow=None, iso_red=None,
- data_quality='OK', component=None, mechanism_confirmed=False,
- positive_anchor=False, x_fleet=None, order_of_magnitude=10.0,
- component_class=None, anchors=None, n_evidence_types=1,
- own_turbine_physical=False):
- """★L6 裁决与写回 — 六枚举定级 + "机制未定拒绝命名部件" + 写回优先级
- 把前五层的结果合成一个可交付的裁决。三条硬规则:
- 1. **L0 未通过则短路**: data_quality != 'OK' ⇒ 一律 INSUFFICIENT, 不论谱侧多显著。
- 实证 (如东 27#): Generator_DE 的 iso_rms_vel 16.2× 中位、逐日 0.4↔69.7mm/s 双态,
- 而同测点加速度域 Rms_HP 分位 0.0%(全场最低)、其余七测点全正常 ⇒ 测量链故障。
- 在修好之前该测点**任何**结论(包括"清洁")都不可采信。
- 2. **机制未定则拒绝命名部件**: mechanism_confirmed=False ⇒ 只出"区域宽带/冲击异常",
- component 被强制置空。这不是把话说软, 它**限制错误的传播半径** —
- 命名了滚道, 错的是"换哪个轴承"; 只判区域, 错的最多是"要不要开箱看"。
- 实证: G5 缺失曾让 7 台轴谐波被判"IMS 滚动体缺陷", 而**交付报告零污染**,
- 正因为齿轮箱分册守着这条口径(判级台账只收 5 条频率, 35.0Hz 从未进入)。
- 3. **无正样本锚则封顶在候选级**: positive_anchor=False (全场没有一台经内窥/拆检证实)
- ⇒ 最高只能到"候选", 不得出"定论"。如东现状正是如此。
- 写回优先级 (对应 CMS 的 C4 缺失: YellowAction 全空, 只有 8/1091 有 RedAction):
- RED → 必须写回控制器 (超 ISO 绝对限值 或 定论级)
- YELLOW → **也必须写回** (低优先级码) — 否则慢性劣化永远不上报;
- 如东实证: 2025-01 起 1,239 条黄告警一条没传到 SCADA
- INFO → 只进报表
- Returns: dict(verdict, level, component_named, writeback, reasons)
- """
- reasons = []
- if data_quality != 'OK':
- return {'verdict': 'INSUFFICIENT', 'level': 'A类-数据质量', 'component_named': None,
- 'writeback': 'RED' if data_quality == 'MEASUREMENT_CHAIN_FAULT' else 'YELLOW',
- 'reasons': [f'L0 未通过 ({data_quality}) — 该测点任何结论不可采信, 先修测量链']}
- passed = bool(gate_result.get('passed')) if isinstance(gate_result, dict) else bool(gate_result)
- if not passed:
- v = gate_result.get('verdict') if isinstance(gate_result, dict) else 'GATE_FAIL'
- return {'verdict': '撤回', 'level': None, 'component_named': None, 'writeback': 'INFO',
- 'reasons': [f'未过谱前提闸 ({v}) — 不过闸的 x_fleet/选择性/趋势一律不进下游']}
- over_iso = (abs_value is not None and iso_red is not None and abs_value >= iso_red)
- over_yel = (abs_value is not None and iso_yellow is not None and abs_value >= iso_yellow)
- if abs_value is None:
- reasons.append('★无绝对量 — 相对判据不能单独定级, 降为 参考')
- lvl = '参考'
- elif over_iso:
- lvl = '准定论·预警'; reasons.append(f'超 ISO 红线 ({abs_value}{abs_unit or ""} >= {iso_red})')
- elif over_yel:
- lvl = '候选'; reasons.append(f'超 ISO 黄线 ({abs_value}{abs_unit or ""} >= {iso_yellow})')
- else:
- # ★2026-08-21 补 (端到端跑暴露): 原来只认"持续恶化"判候选, 其余一律"参考" ⇒
- # **包络域没有 ISO 可比时定级完全依赖趋势**, 16# 那条 ×fleet **84.87**、结构度 20.61、
- # 趋势"稳定高位"也只判参考 — 而它有外部佐证(上气月报 2026-07 报"早期损伤")。
- # 补两条, 并把趋势三型都用上:
- # 持续恶化 ⇒ 候选 (在发展)
- # 稳定高位 + 数量级离群 ⇒ 候选·记基线 (长期高但不在发展 — 下窗升破即真信号)
- # 间歇尖峰 ⇒ 候选·查偶发源 (与持续恶化处置完全不同)
- # ★门槛 x_fleet >= 10 的依据: **一个数量级**, 是自然分界不是本场调参
- # (G8 已保证过闸线 >= 3; 从"离群"到"极端离群"取整数量级)。
- _extreme = (x_fleet is not None and x_fleet == x_fleet and x_fleet >= order_of_magnitude)
- if trend_class == '持续恶化':
- lvl = '候选'; reasons.append(f'趋势=持续恶化 (未超/无 ISO 绝对线)')
- elif trend_class == '稳定高位' and _extreme:
- lvl = '候选·记基线'
- reasons.append(f'稳定高位 且 ×fleet={x_fleet:.1f} >= {order_of_magnitude}(一个数量级) — '
- f'长期高但不在发展; **下窗升破即真信号**')
- elif trend_class == '间歇尖峰':
- lvl = '候选·查偶发源'
- reasons.append('间歇尖峰 — 与持续恶化处置不同: 查偶发冲击源/测量瞬态, 不是部件在演进')
- elif _extreme:
- lvl = '候选'
- reasons.append(f'趋势={trend_class} 但 ×fleet={x_fleet:.1f} >= {order_of_magnitude} — 数量级离群仍须跟踪')
- else:
- lvl = '参考'; reasons.append(f'未超/无 ISO 绝对线; 趋势={trend_class}; ×fleet={x_fleet}')
- if energy_pct is not None and energy_pct < 0.01:
- lvl = '参考'; reasons.append(f'★能量占比仅 {energy_pct}% — 相对偏离真实但工程意义不成立')
- # ★2026-08-23 按部件类解封 (替代全局 positive_anchor):
- # anchors 给了 ⇒ anchor_cap 决定最高可达级; 且**解封不只是"不压", 还能升**:
- # cap 允许准定论 ∧ 独立证据类型 ≥3 ∧ (趋势在发展 ∨ 数量级离群) ⇒ 准定论·预警
- # (此前 准定论 只能由 ISO 红线触达 ⇒ 包络域谱线结论永远卡在候选 — 16# 84.87× 即是)
- if anchors is not None or component_class is not None:
- ci = anchor_cap(component_class, anchors, n_evidence_types=n_evidence_types,
- own_turbine_physical=own_turbine_physical, mechanism_confirmed=mechanism_confirmed)
- _dev = trend_class in ('持续恶化', '在升·个体', '持续恶化·高位') or (x_fleet is not None and x_fleet == x_fleet and x_fleet >= order_of_magnitude)
- if str(lvl).startswith('候选') and _AL.get(ci['cap'], 0) >= _AL['准定论·预警'] \
- and n_evidence_types >= 3 and _dev:
- reasons.append(f"★解封升级: {lvl} → 准定论·预警 (锚 tier={ci['tier']}, 独立证据类型 {n_evidence_types}, "
- f"趋势={trend_class}, ×fleet={x_fleet})")
- lvl = '准定论·预警'
- lvl = apply_anchor_cap(lvl, ci, reasons)
- elif not positive_anchor and lvl == '定论':
- lvl = '候选'; reasons.append('★无正样本锚(无内窥/拆检证实台) — 封顶在候选级')
- named = component if mechanism_confirmed else None
- if not mechanism_confirmed:
- reasons.append('★机制未定 — 只判"区域宽带/冲击异常", 不命名保持架/滚动体/外圈/内圈')
- wb = ('RED' if lvl == '准定论·预警' else
- ('YELLOW' if str(lvl).startswith('候选') else 'INFO'))
- return {'verdict': lvl, 'level': lvl, 'component_named': named, 'writeback': wb, 'reasons': reasons}
- # ---------------------------------------------------------------- §4.6 标量振动指标劣化分型 (skill vibration-spectral)
- def vib_scalar_typology(daily, *, baseline_end, late_start, fleet_daily_median=None,
- spike_ratio=8.0, sustain_ratio=2.5, high_ratio=2.5, min_days=10):
- """逐日标量振动指标(Peak/Kurtosis/RMS)的劣化分型 — 分开"持续恶化"与"间歇尖峰"。
- 根因 (2026-08-19 如东 M5): 按末段 max 排名会把间歇尖峰台排在持续恶化台前面。
- 实测 25# 主轴承前 max 496 m/s² 比 38# 的 113 高 4.4 倍, 但 25# 在 43 天里只有 2 天高、
- **逐日中位纹丝不动**(4.65 对基线 4.69); 38# 是 8/7 阶跃后连续 5 天且**中位跟着涨到基线的3.8倍**。
- 两者处置完全不同: 前者查偶发冲击源/测量瞬态, 后者是部件在演进。
- **单一 max 统计量对这个区别完全失明** — 必须同时看 max 与中位的抬升倍数。
- Args:
- daily: DataFrame/dict, index=日期, 需含 'max' 与 'median' 两列 (同一指标的逐日聚合)
- baseline_end: 基线段结束日期 (该日之前为基线)
- late_start: 末段开始日期
- fleet_daily_median: 全场同测点同指标的逐日中位的中位 (标量), 有则判"稳定高位"型
- spike_ratio: 末段max/基线 >= 此值才算尖峰 (默认8)
- sustain_ratio: 末段中位/基线 >= 此值判持续恶化 (默认2.5)
- high_ratio: 全期中位/fleet中位 >= 此值判稳定高位 (默认2.5)
- Returns:
- dict: type ∈ {持续恶化, 间歇尖峰, 稳定高位, 正常}, 各倍数, 高值日数, 以及 _caliber
- """
- import numpy as _np
- import pandas as _pd
- d = _pd.DataFrame(daily) if not isinstance(daily, _pd.DataFrame) else daily.copy()
- if 'max' not in d or 'median' not in d or len(d) < min_days:
- return {'type': 'INSUFFICIENT', 'n_days': len(d),
- 'reason': f'需 max/median 两列且 >= {min_days} 天'}
- idx = _pd.to_datetime(_pd.Series(d.index)).dt.date.values
- b = d[idx < _pd.Timestamp(baseline_end).date()]
- l = d[idx >= _pd.Timestamp(late_start).date()]
- if not len(b) or not len(l):
- return {'type': 'INSUFFICIENT', 'n_days': len(d), 'reason': '基线段或末段为空'}
- base = float(_np.nanmedian(b['max']))
- if not _np.isfinite(base) or base <= 1e-9:
- return {'type': 'INSUFFICIENT', 'n_days': len(d), 'reason': '基线为零或非有限'}
- late_max = float(_np.nanmax(l['max']))
- late_med = float(_np.nanmedian(l['median']))
- all_med = float(_np.nanmedian(d['median']))
- r_max, r_med = late_max / base, late_med / base
- thr = max(4 * base, 15.0)
- n_hi = int((d['max'] >= thr).sum())
- r_fleet = (all_med / fleet_daily_median) if (fleet_daily_median and fleet_daily_median > 1e-9) else _np.nan
- if r_med >= sustain_ratio:
- typ = '持续恶化'
- elif r_max >= spike_ratio:
- typ = '间歇尖峰'
- elif _np.isfinite(r_fleet) and r_fleet >= high_ratio:
- typ = '稳定高位'
- else:
- typ = '正常'
- return {'type': typ, 'n_days': len(d), 'baseline_max': round(base, 3),
- 'late_max': round(late_max, 3), 'late_median': round(late_med, 3),
- 'ratio_max': round(r_max, 2), 'ratio_median': round(r_med, 2),
- 'n_high_days': n_hi, 'all_median': round(all_med, 3),
- 'ratio_vs_fleet': (round(float(r_fleet), 2) if _np.isfinite(r_fleet) else None),
- '_caliber': (
- f'基线=<{baseline_end} 逐日max的中位; 末段=>={late_start}. '
- f'持续恶化: 末段中位/基线>={sustain_ratio} (病情在演进); '
- f'间歇尖峰: 末段max/基线>={spike_ratio} 但中位未抬 (偶发冲击/测量瞬态, 查同时刻工况); '
- f'稳定高位: 全期中位/fleet中位>={high_ratio} (变化型判据看不见, 需绝对水平闸). '
- '★禁止只按 max 排名定优先级 — 如东实测 25#(max 496, 中位抬升1.0) 会排在 '
- '38#(max 113, 中位抬升3.8) 前面, 而只有 38# 是真在恶化. '
- '★三型可并存: 同台可既稳定高位又持续恶化(如东 23# 前轴承 fleet 5.2倍且逐窗中位 25.0->43.4)')}
- # ---------------------------------------------------------------- §4.6 冲击的结构响应判据 (skill vibration-spectral)
- def impact_ringing_shift(waveforms, dx, *, n_impacts=5, ring_ms=1.5,
- f_lo=500.0, f_hi=20000.0, shift_tol=0.25, min_sep_pts=200,
- exclude_top=10, min_bg=30):
- """冲击激发的振铃频率是否迁移 — 判"结构响应变了" vs "只是激励变强"。
- 物理: 冲击近似宽频激励, 响应频率由**结构固有模态**决定, 与冲击强度无关。
- ⇒ 幅值涨而振铃频率不动 = 同一传递路径, 激励变强 (不足以证明部件损伤);
- 振铃频率迁移 = 参与振动的质量/刚度改变 = 结构响应本身变了 (强证据)。
- ★该判据**不需要任何特征频率**, 因此不受轴承几何/OEM表/谱分辨率等前提的影响。
- ★★2026-08-20 重大修正 (原实现有采样偏差, 曾导致我给出错误结论):
- "取最大 N 个冲击求振铃" 会被**少数极端事件主导**。冲击能量足够大时会激发大质量低频模态,
- 这是**激励非线性**不是结构改变。如东 38# 实证:
- 最大5个冲击 振铃 2.69 kHz → 我据此判"结构响应改变" **错**
- 全部185个冲击分档: 小/中/大 档均 10.07~11.42 kHz
- **排除最大10个后的背景冲击(n=172) 振铃 10.75 kHz, 而事件前是 11.42 kHz — 几乎没变**
- ⇒ 38# 结构并未改变, 只是叠加了少数能量极大的冲击(峰值15.70, 成簇, 簇内间隔12.5~20.3ms)
- **反证**: 20# 发作时最大冲击峰值 19.39 **比 38# 的 15.70 还大**, 振铃却仍是 18.80 kHz
- ⇒ 幅值不是唯一因素, 但也正因如此, 单看极端冲击无法分离"结构"与"激励"
- **⇒ 结构改变的判据必须用 `bg_ring_hz`(排除极端后的背景振铃), 不是 `ring_hz`**
- 实证 (如东 M5 2026-08-20):
- 38# 后主轴承 事件前 11.4 kHz → 事件中 **2.7 kHz** (降4.2倍) = 低频模态被激发,
- 原本只"听"到局部安装座(小质量→高频), 事件时整个轴承座/机架参与(大质量→低频) ⇒ 结构变了
- 20# 后主轴承 发作 18.8~19.5 kHz 而**平静日同为 19.5 kHz** ⇒ 只是激励变强, 结构未变
- (该台幅值涨6.7倍、峭度3229全场最高, 但振铃零迁移 ⇒ 机制判 INSUFFICIENT)
- Args:
- waveforms: {窗标签: 1D波形}. 至少两个窗 (基线窗 + 待判窗)
- dx: 采样间隔(秒)
- n_impacts: 每窗取最大的 n 个冲击求振铃主频中位
- ring_ms: 从冲击峰起取多长一段做振铃频谱
- f_lo/f_hi: 振铃搜索频带 (Hz)
- shift_tol: 相对迁移阈值 — |f/f_ref - 1| >= shift_tol 判迁移
- min_sep_pts: 冲击之间的最小间隔(采样点), 防重复取同一冲击
- Returns:
- dict: per_window {窗: {ring_hz, peak_abs, n_used}}, shifted(bool),
- ref_window, max_shift_ratio, verdict, _caliber
- """
- import numpy as _np
- out = {}
- for lab, v in waveforms.items():
- v = _np.asarray(v, dtype=float)
- if v.size < 64:
- continue
- a = _np.abs(v - _np.median(v))
- order = _np.argsort(a)[::-1]
- used, rings = [], []
- for i in order:
- if any(abs(int(i) - u) < min_sep_pts for u in used):
- continue
- used.append(int(i))
- w = v[int(i):min(len(v), int(i) + int(ring_ms / 1000.0 / dx))]
- if w.size < 32:
- continue
- W = _np.abs(_np.fft.rfft(w * _np.hanning(w.size)))
- ff = _np.fft.rfftfreq(w.size, dx)
- m = (ff > f_lo) & (ff < f_hi)
- if not m.any():
- continue
- rings.append(float(ff[m][int(_np.argmax(W[m]))]))
- if len(rings) >= n_impacts:
- break
- # 背景振铃: 排除最大 exclude_top 个冲击后的众多小冲击 — 这才反映结构常态
- bg_rings = []
- excl = used[:exclude_top]
- thr_bg = _np.median(a) + 6 * 1.4826 * _np.median(_np.abs(a - _np.median(a)))
- i2 = 1
- while i2 < len(a) - 1 and len(bg_rings) < 400:
- if a[i2] > thr_bg and a[i2] >= a[i2 - 1] and a[i2] > a[i2 + 1]:
- if not any(abs(i2 - e) < 2 * min_sep_pts for e in excl):
- w = v[i2:min(len(v), i2 + int(ring_ms / 1000.0 / dx))]
- if w.size >= 32:
- W = _np.abs(_np.fft.rfft(w * _np.hanning(w.size)))
- ff = _np.fft.rfftfreq(w.size, dx)
- mm = (ff > f_lo) & (ff < f_hi)
- if mm.any():
- bg_rings.append(float(ff[mm][int(_np.argmax(W[mm]))]))
- i2 += min_sep_pts // 2
- else:
- i2 += 1
- if rings:
- out[lab] = {'ring_hz': float(_np.median(rings)),
- 'bg_ring_hz': (float(_np.median(bg_rings)) if len(bg_rings) >= min_bg else None),
- 'n_bg': len(bg_rings),
- 'ring_all_hz': [round(r, 1) for r in rings],
- 'peak_abs': float(_np.max(a)), 'n_used': len(rings)}
- if len(out) < 2:
- return {'per_window': out, 'shifted': None, 'verdict': 'INSUFFICIENT',
- 'reason': '需 >=2 个窗才能判迁移'}
- labs = list(out)
- ref = min(labs, key=lambda k: out[k]['peak_abs']) # 幅值最小者作基线窗
- # ★优先用背景振铃判结构改变; 背景样本不足才退回 ring_hz 并降级 verdict
- use_bg = all(out[k].get('bg_ring_hz') for k in labs)
- fld = 'bg_ring_hz' if use_bg else 'ring_hz'
- fr = out[ref][fld]
- ratios = {k: out[k][fld] / fr for k in labs if k != ref}
- mx = max(ratios.values(), key=lambda r: abs(r - 1.0)) if ratios else 1.0
- shifted = abs(mx - 1.0) >= shift_tol
- amp_ratio = max(out[k]['peak_abs'] for k in labs) / max(out[ref]['peak_abs'], 1e-12)
- return {'per_window': out, 'ref_window': ref, 'basis': fld,
- 'ring_ratio_by_window': {k: round(v, 3) for k, v in ratios.items()},
- 'max_shift_ratio': round(float(mx), 3), 'shifted': bool(shifted),
- 'amp_ratio_max': round(float(amp_ratio), 2),
- 'verdict': (('结构响应改变' if shifted else '仅激励变强') if use_bg
- else ('结构改变嫌疑(背景样本不足, 仅极端冲击口径)' if shifted else '仅激励变强(背景样本不足)')),
- '_caliber': (
- f'每窗取最大{n_impacts}个冲击, 自峰起{ring_ms}ms 段的 {f_lo:.0f}~{f_hi:.0f}Hz 主频中位; '
- f'基线窗=峰值最小者; 迁移阈 |比值-1|>={shift_tol}. '
- '★"仅激励变强"**不足以支撑部件损伤定性** — 如东20#幅值涨6.7倍/峭度3229全场最高但振铃零迁移, 机制判INSUFFICIENT. '
- '★振铃频率下降通常=参与振动的质量变大或刚度下降(局部安装座→整个轴承座/机架). '
- '★采样率须足够: 振铃周期至少4个采样点, 否则时域看似毛刺(如东19kHz@40960Hz仅约2点/周期, 必须靠频谱判)')}
- def bearing_end_symmetry(daily_front, daily_rear, *, thresh=None, base_end=None, min_days=10):
- """前后端(或成对测点)的发作对称性 — 判外部共模 vs 单部件局部。
- 根因 (如东 M5 2026-08-19, 台风"巴威"共模事件): 外部激励(台风/涌浪/并网瞬态)会**同时**
- 抬高同一部件的前后两端; 单部件缺陷只抬高一端。该判据不需要任何特征频率或波形。
- 实证: 台风共模6台(08#/15#/19#/33#/34#/36#)前后峭度**都高**(33~405);
- 20# 后端峭度3229 而前端仅106(30倍差)且四窗纹丝不动(39.3/40.7/40.7/39.5) ⇒ 单端 ⇒ 局部;
- 16# 前后**同时**阶跃(1.91/1.61)但全场其余台抬升中位0.99 ⇒ 虽双端但**全场唯一** ⇒ 仍是该台整机变化非共模。
- ★双端 != 必然共模 — 必须再看**同期全场有几台同样变**, 只有一台就不是共模。
- Args:
- daily_front / daily_rear: Series 或 {日期: 值}, 同一指标的逐日聚合(建议用 max)
- thresh: 发作判定阈; None 则用 max(15, 4×基线中位)
- base_end: 基线段结束日期 (该日之前算基线); None 则用前 1/3 天数
- Returns:
- dict: n_front / n_rear / both_days / type / _caliber
- """
- import numpy as _np
- import pandas as _pd
- f = _pd.Series(daily_front).dropna(); r = _pd.Series(daily_rear).dropna()
- if len(f) < min_days or len(r) < min_days:
- return {'type': 'INSUFFICIENT', 'reason': f'需每端 >= {min_days} 天'}
- def _base(s):
- if base_end is not None:
- b = s[_pd.to_datetime(_pd.Series(s.index)).dt.date.values < _pd.Timestamp(base_end).date()]
- else:
- b = s.iloc[:max(len(s) // 3, 3)]
- return float(_np.median(b)) if len(b) else float(_np.median(s))
- bf, br = _base(f), _base(r)
- tf = thresh if thresh is not None else max(15.0, 4 * bf)
- tr = thresh if thresh is not None else max(15.0, 4 * br)
- hf = set(f[f >= tf].index); hr = set(r[r >= tr].index)
- both = hf & hr
- nf, nr, nb = len(hf), len(hr), len(both)
- if nf == 0 and nr == 0:
- typ = '无发作'
- elif nb >= 0.6 * max(nf, nr) and min(nf, nr) >= 2:
- typ = '双端同步(需查全场是否同日→共模)'
- elif nf >= 2 and nr <= 1:
- typ = '★单端(前)=局部'
- elif nr >= 2 and nf <= 1:
- typ = '★单端(后)=局部'
- else:
- typ = '零星'
- # ★配套: 前后端相关性 (独立于"发作"的判据) — 正常时两端测同一刚性结构应高度相关
- corr = None
- try:
- j = f.index.intersection(r.index)
- if len(j) >= min_days:
- corr = float(_np.corrcoef(f.loc[j].values, r.loc[j].values)[0, 1])
- except Exception:
- corr = None
- return {'n_front': nf, 'n_rear': nr, 'both_days': nb, 'end_corr': (round(corr, 3) if corr is not None else None),
- 'base_front': round(bf, 3), 'base_rear': round(br, 3),
- 'thresh_front': round(tf, 3), 'thresh_rear': round(tr, 3), 'type': typ,
- '_caliber': (
- '发作阈 = max(15, 4×基线中位) per 端; 双端同步 = 共同发作日 >= 0.6×较多一端. '
- '★双端同步只是**共模嫌疑**, 必须再查"同日全场有几台同样发作" — '
- '如东07-12 有15个测点跨8台同日 = 台风共模; 而16#前后同时阶跃但全场其余台抬升中位0.99 = 非共模. '
- '★基线窗内若本身含发作, 基线会被自身异常污染 — 如东20#初算基线41.20(窗内含7/2~7/8发作), '
- '真实基线约3, 倍数被低估13倍. **设基线窗前必须先看窗内有无事件**. '
- '★统一阈值对高基线台失效: 如东23#常态逐日中位28.42, "Peak>=15共39天"只是"它一直这么高", '
- '与20#的13天不可横比 — 高基线台须改用相对自身基线的阈. '
- '★配套字段 end_corr = 前后端逐次配对相关: 如东全场中位0.935(p10 0.649), '
- '而四台问题机组全部落在低端(38#=0.311 20#=0.506 23#=0.710 16#=0.865) — '
- '**某端出现局部异常会冒出另一端没有的成分, 相关随之下降**, 这是独立于"发作次数"的判据; '
- '38# 同台内 事件前波形相关0.9924 -> 事件中0.6417 = 时变证据')}
- # ---------------------------------------------------------------- §4.6 轴系谐波比 (台内线间比例型判据; skill vibration-spectral)
- def shaft_harmonic_ratios(spec, dx, shaft_hz, *, orders=(1, 2, 3), tol_bins=2,
- bw_hz=8.0, misalign_r2=0.5, looseness_r3=0.5, min_base_ratio=3.0):
- """轴系转频谐波 1×/2×/3× 的**台内比值** — 不对中/松动类判据。
- ★为何单独立一类 (2026-08-20 如东实证, 用户追问"为什么之前的分析没暴露"后补):
- 我此前整套谱线判据都是"线 vs fleet"或"线 vs 自身宽带"型, **缺"线 vs 同台另一条线"型**。
- 不对中的判据恰恰是后者 — 判的是 2×/1× 的**比例**, 不是某条线是否突出。
- 三层原因导致它被漏掉:
- (a) 源头: OEM 特征频率表是**轴承频率表**(如东72个频率全是BPFI/BPFO/BSF/轴频),
- 不含转频谐波 ⇒ 扫描列表从源头就没有这一类
- (b) 闸: 转频谐波**必然全场共有**(如东97%的台2×线峰/本底>=3), 送进 `spectral_line_gates`
- 必被 G4(机型固有线) 拦下 — 实测 34#/26#/02# 全部 G4_FLEET_INHERENT (fleet结构度34.86)
- (c) 判据类型: 我的体系找"异常突出的线", 而这里要看"正常线之间的比例"
- ⇒ **对转频谐波禁用 spectral_line_gates**, 用本函数。
- 实证 (如东高速轴发电机侧, FFT_250_Tr, w0707):
- 全场 1× 峰/本底中位101.7 · 2× 中位32.0 · 3× 中位6.8; **2×/1× 全场中位 0.20** = 正常
- ★34# 2×/1×=0.98 (2×几乎等于1×) = 平行不对中典型
- ★26# 2×/1×=0.89 且 3×比21.6(全场中位6.8) = 角度+平行复合嫌疑
- 对照 02#=0.13 · 30#=0.10 · 04#=0.15 · 20#=0.22 均正常
- ★与包络域的2×调制不是一回事: 原始谱2×线=该频率的**正弦振动**(本判据);
- 包络2×调制=**高频冲击以该频率重复**。如东实测两者 r=+0.137 几乎无关, 混用会得出错误机制。
- **本函数必须喂原始谱(FFT_*), 不能喂包络谱(Env_*)。**
- Args:
- spec: 原始谱(非包络), 1D
- dx: 谱线间隔 Hz
- shaft_hz: 该轴的转频 (order-tracked 谱上通常是标称值, 如 HSS=25.0)
- orders: 要算的谐波次数
- bw_hz: 邻域本底的半宽
- misalign_r2: 2×/1× 超此值提示不对中 (经典阈 0.5)
- looseness_r3: 3×/1× 超此值提示松动/复合
- Returns:
- dict: amp_by_order / ratio_to_base(峰/邻域本底) / r2_over_1 / r3_over_1 / flags / _caliber
- """
- import numpy as _np
- v = _np.asarray(spec, dtype=float)
- amp, rb = {}, {}
- for k in orders:
- hz = shaft_hz * k
- i = int(round(hz / dx))
- if i + tol_bins + 1 >= len(v) or i - tol_bins < 1:
- continue
- lo, hi = max(int((hz - bw_hz) / dx), 1), min(int((hz + bw_hz) / dx), len(v) - 1)
- if hi - lo < 8:
- continue
- a = float(_np.nanmax(v[i - tol_bins:i + tol_bins + 1]))
- nb = _np.concatenate([v[lo:i - tol_bins - 1], v[i + tol_bins + 2:hi]])
- base = float(_np.nanmedian(nb)) if len(nb) > 4 else _np.nan
- amp[k] = a
- rb[k] = (a / base) if (base and base > 1e-12) else _np.nan
- if 1 not in amp or amp[1] <= 1e-12:
- return {'verdict': 'INSUFFICIENT', 'reason': '1×线缺失或为零, 比值无意义',
- 'amp_by_order': amp, 'ratio_to_base': rb}
- # ★前提门: 1× 线本身必须存在 — 宽带抬升台上所有线都被自身本底淹没, 比值是噪声比噪声
- if not (_np.isfinite(rb.get(1, _np.nan)) and rb[1] >= min_base_ratio):
- return {'verdict': 'INSUFFICIENT',
- 'reason': f'1×线 峰/邻域本底 {rb.get(1)} < {min_base_ratio} — 该台1×线不成立(常见于宽带抬升台), 比值无意义',
- 'amp_by_order': {k: round(x, 5) for k, x in amp.items()},
- 'ratio_to_base': {k: (round(x, 2) if _np.isfinite(x) else None) for k, x in rb.items()},
- 'r2_over_1': None, 'r3_over_1': None, 'flags': []}
- r2 = amp.get(2, _np.nan) / amp[1]
- r3 = amp.get(3, _np.nan) / amp[1]
- flags = []
- if _np.isfinite(r2) and r2 >= misalign_r2:
- flags.append('不对中嫌疑(2×/1×高)')
- if _np.isfinite(r3) and r3 >= looseness_r3:
- flags.append('松动/复合嫌疑(3×/1×高)')
- return {'amp_by_order': {k: round(x, 5) for k, x in amp.items()},
- 'ratio_to_base': {k: (round(x, 2) if _np.isfinite(x) else None) for k, x in rb.items()},
- 'r2_over_1': (round(float(r2), 3) if _np.isfinite(r2) else None),
- 'r3_over_1': (round(float(r3), 3) if _np.isfinite(r3) else None),
- 'flags': flags, 'verdict': (' '.join(flags) if flags else '轴系谐波比正常'),
- '_caliber': (
- f'幅值取 ±{tol_bins}bin 峰; 邻域本底取 ±{bw_hz}Hz 去线后中位. '
- f'2×/1× >= {misalign_r2} 提示不对中; 3×/1× >= {looseness_r3} 提示松动/复合. '
- '★必须喂**原始谱**不能喂包络谱 — 原始谱2×线=正弦振动, 包络2×调制=冲击重复率, '
- '如东实测两者 r=+0.137 几乎无关. '
- '★禁止对转频谐波用 spectral_line_gates — 它们必然全场共有, 会被 G4(机型固有线)拦掉; '
- '本判据看的是**台内线间比例**不是线是否突出. '
- '★阈值无绝对锚时结论只到候选, 且应与温度/对中记录等独立轴交叉 — '
- '如东 34#/26# 振动侧提示不对中但发电机DE温升(37.22/38.46K)低于全场中位39.37K, 温度侧不支持. '
- f'★前提门: 1×线 峰/邻域本底须 >= {min_base_ratio} 否则判 INSUFFICIENT — '
- '宽带抬升台上所有线都被自身本底淹没, 比值是噪声比噪声 '
- '(如东17# 1×比仅1.3、2×比1.0, 却算出2×/1×=0.781 假阳; 该台是宽带抬升型宽带75×fleet)')}
- # ══════════════════════════════════════════════════════════════════════════════
- # ★ 六窗趋势五规则 + 分型 (2026-08-22 如东时域/密标量趋势实证 → 落库)
- # 此前只活在 scripts/rudong_{timedomain,scalar}_trend.py / rudong_band_select.py 里 ⇒ "如东跑通了" ≠ "模型建成了"
- # 每条规则对应一个实逮的错 (见各 docstring), 自检 tests/test_trend_rules.py 逐例复现
- # ══════════════════════════════════════════════════════════════════════════════
- def require_same_band(bands, *, name='band'):
- """★混功率档机器闸 — 跨窗比值函数的输入必须单一工况档, 否则抛异常 (不是警告).
- Why: 如东同一坑踩三次 — 33# 135Hz 载荷依赖 / 35#28# 低转速伪影 / 26# 主轴承混档 3.12× 假"在升"
- (分档后 HI 1.01× / MID 0.73×, 全部来自功率档构成变化)。纪律写了三次没用, 要机器闸。
- 用法: 在任何 first/last、端段、变化比函数入口调 require_same_band(df[band_col])。
- """
- import numpy as _np
- vals = _np.asarray(list(bands), dtype=object)
- vals = vals[[v == v and v is not None for v in vals]]
- u = set(map(str, vals))
- if len(u) > 1:
- raise ValueError(f'混档: {name} 有 {len(u)} 个取值 {sorted(u)} — 跨窗比值必须同档 (如东 26# 混档 3.12× 假在升)')
- return True
- def rated_speed_guard(rpm, *, rpm_min=1500.0):
- """★转速守卫 — 同功率档内仍按额定转速区过滤, 返回布尔掩码.
- Why: 二级分层实测 (如东 w0127, 扣台效应后): 0-1600kW 档内 GeneratorRPM 对 Rms_HP 残差 ρ=0.89,
- ≥1601 档 0.2~0.5 ⇒ 厂家的功率档太宽, 档内振动仍随转速走; 六窗跨冬夏转速构成会变, 不守则把
- 转速构成读成趋势。rpm_min=1500 = 额定 1600 的 94%, 只比满速区。
- """
- import numpy as _np
- r = _np.asarray(rpm, dtype=float)
- return _np.isfinite(r) & (r >= rpm_min)
- def ringing_sentinel(ring_khz, *, floor_khz=1.0, tol=0.05):
- """★振铃哨兵 — 背景振铃搜索下限附近的值 = 该快照没捕到振铃 (冲击缺席), 转 NaN 而非当作频率.
- Why: 如东 21# 后主轴承 2026-07-23 10:20 一条快照 ring=1.02kHz (搜索下限 1.0k), 同窗 1h 前 11.84k,
- 六窗谱形 9~13k 比值 1.03~1.10 稳 ⇒ 单快照冲击缺席; 不转 NaN 会造出 11.4→1.0 的假"在降" (排 11/11)。
- """
- import numpy as _np
- r = _np.asarray(ring_khz, dtype=float).copy()
- r[(r <= floor_khz * (1 + tol))] = _np.nan
- return r
- def end_segment_ratio(values_by_window, *, k=2):
- """★端段对比 — 前 k 窗中位 / 后 k 窗中位 的比, 不用首末两点, 不用斜率.
- Why (memory slope-shares-median-dilution-blindspot): 沿 X 取中位会稀释只在一段发作的变化, Theil-Sen 同样;
- 首末两点则把单快照当趋势 (如东时域 81% 格单快照: 19# 主轴承 kurt 38→800 排1 实为 w0724 一窗台风尖峰)。
- 端段取 2 窗中位各压一半快照噪声。k 自动降到 1 当窗数 <4。
- 返回 (first, last, ratio, n_used);任一端无有效值返回 NaN。
- """
- import numpy as _np
- v = _np.asarray([x for x in values_by_window if x == x and x is not None], dtype=float)
- if len(v) < 2:
- return _np.nan, _np.nan, _np.nan, len(v)
- kk = k if len(v) >= 2 * k else 1
- a, b = float(_np.median(v[:kk])), float(_np.median(v[-kk:]))
- return a, b, (b / a if a > 0 else _np.nan), len(v)
- def paired_change_rank(first_by_unit, last_by_unit):
- """★变化的中位数 (不是中位数的变化) — 逐台配对比值 → fleet 变化的中位数 + 每台排名 k/N.
- Why (memory median-of-changes-not-change-of-median): 如东 08# 用"中位数的变化"得 +12.3% 而"变化的中位数"
- +52.7%, 差 4 倍, 把 fleet 共模读成个体恶化 → 撤回。判"这台在恶化"必用逐台配对变化 + 排名。
- 输入: 两个 {unit: value} 映射 (或 Series)。返回 dict(unit → dict(ratio, rank, N)) 与 fleet_med_change。
- """
- import numpy as _np
- f = dict(first_by_unit); l = dict(last_by_unit)
- 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}
- ratio = {u: r for u, r in ratio.items() if _np.isfinite(r)}
- if not ratio:
- return {}, _np.nan
- # ★并列名次取"竞争名次·最差" (method='max'): 5 台并列最高 ⇒ 各得 5 不是 1。
- # 理由: 并列上升的一串台是共模不是个体; 对 "rank<=3" 的在升门保守。
- # (接线回归实逮: n_imp6 一串 ratio=1.0 的台被按出现顺序给了 1,2,3… = 假"排 1")
- N = len(ratio); fmc = float(_np.median(list(ratio.values())))
- vals = _np.array(list(ratio.values()))
- out = {u: dict(ratio=float(ratio[u]), rank=int((vals >= ratio[u]).sum()), N=N) for u in ratio}
- return out, fmc
- def band_stability(struct_by_band_by_window, *, win_frac=0.5, min_win=3):
- """★带选择稳定性 — 多候选解调带取最佳时, 用"跨窗胜出稳定性"压多重比较假阳, 不抬阈值.
- Why: 扫 ~15 个候选带取 max, 15 个噪声抽样的最大值天然高于单次 ⇒ G2≥3 被"多试几次"绕过。
- 真信号在最佳带里每窗都赢; 噪声的最佳带逐窗乱跳。胜出带须在 ≥win_frac 的窗里排第一。
- (如东全扫 942 条→过 G2 仅 35→再过排名稳定 Kendall τ≥0.6 只剩 6 条可给推荐)
- 输入: {band: {window: struct}}。返回 dict(best, win_frac, n_win, stable)。
- """
- import numpy as _np
- wins = {}
- for band, d in struct_by_band_by_window.items():
- for w, v in (d or {}).items():
- try:
- v = float(v)
- except (TypeError, ValueError):
- continue
- if _np.isfinite(v):
- wins.setdefault(w, {})[band] = v
- if len(wins) < min_win:
- return dict(best=None, win_frac=_np.nan, n_win=len(wins), stable=False)
- cnt = {}
- for w, d in wins.items():
- b = max(d, key=lambda kk: d[kk]); cnt[b] = cnt.get(b, 0) + 1
- best = max(cnt, key=lambda kk: cnt[kk]); frac = cnt[best] / len(wins)
- return dict(best=best, win_frac=round(frac, 3), n_win=len(wins), stable=bool(frac >= win_frac))
- def trend_typology(*, ratio, rank, N, fleet_med_change, level_x_fleet,
- rise_ratio=1.5, rise_rank=3, rise_vs_fleet=1.4, fall_ratio=0.67, high_level=2.5,
- n_windows_above=None, moderate_level=1.5, persist_n=3):
- """★六窗趋势分型 — 在升·个体 / 持续恶化·高位 / 稳定高位 / 在降 / 平.
- 在升须三条同时: 端段比 ≥1.5 ∧ 排名 ≤3 ∧ 比 fleet 变化的中位数高 ≥1.4× (第三条防共模: 全场一起涨不算个体)。
- 高位 = 六窗中位 / fleet 中位 ≥2.5 (skill 稳定高位门)。
- Why 排名门: 如东 04#/05# 齿轮箱端段比 2.0~2.5× 但水平 0.6~1.0× = 从低处回到 fleet, 不是恶化 — 靠 level 与 rank 合判。
- """
- import numpy as _np
- if not all(_np.isfinite([ratio, rank, N, fleet_med_change, level_x_fleet])):
- return 'INSUFFICIENT'
- rising = ratio >= rise_ratio and rank <= rise_rank and (ratio / fleet_med_change) >= rise_vs_fleet
- falling = ratio <= fall_ratio and rank >= N - 2
- high = level_x_fleet >= high_level
- if rising and high:
- return '持续恶化·高位'
- if rising:
- return '在升·个体'
- if high:
- return '稳定高位'
- # ★中等持续·观察 (2026-08-26 历史锚回测回灌, 回测器路径2): level 落 [moderate_level, high_level) 即
- # 1.5~2.5 之间 ∧ 该指标跨 >=persist_n 窗都 >=moderate_level。Why: 09# (内窥确诊 HS 轴承**轻微**损伤,
- # tier=physical_in_window 运行中未换) Peak@MID 跨四窗 1.61~1.74×fleet 位次 6-9 — **rank<=3 与 level>=2.5
- # 两个门都够不着**, 旧版直接判"平"完全忽略。回测该路径单独 precision 0.417 > 锚台基率 0.316; 命中率 0.56→0.64。
- # ⇒ 损伤程度决定信号强度: 轻微损伤给中等持续信号而非爆发式高倍数, 排名类判据天然筛不出。
- # n_windows_above=None (不传) 时行为与旧版完全一致 = 向后兼容, 调用方自愿接入。
- # ⚠ 本档是**观察级非报警级**: 回测假阳中 14#/30#/31#/10# 落在 skill cms-tcm-swt4000 §2 子群指纹
- # {14,17,24,30,31}/{09,10,14,30,31} (疑齿轮箱变体/批次, 相对判据对子群系统性报高) — 用前先查 BOM/序列号。
- if falling:
- return '在降'
- # ★次序: 本档必须在 falling **之后** — 一台可能水平仍在 1.5~2.5 但正在快速下降(ratio<=0.67 ∧ 排名末位),
- # 那是"正在恢复"应判在降; 放在 falling 之前会截胡, 把恢复中的台误报成观察对象 (2026-08-26 接线时自逮)
- if (n_windows_above is not None and _np.isfinite(n_windows_above)
- and moderate_level <= level_x_fleet < high_level and n_windows_above >= persist_n):
- return '中等持续·观察'
- return '平'
- # ══════════════════════════════════════════════════════════════════════════════
- # ★ 倒谱域齿轮判据 — 厂商 (Brüel & Kjær Vibro TCM) `Indicator_*` 的精确等价物 + 扩展 (2026-08-22 如东实证)
- # 复现: Indicator_X = 倒谱在 [q0 - Δ/2, q0 + Δ/2] 窗内的 **L2 范数** √Σc² (Δ = 记录 X-axisDelta, 全宽非半宽),
- # 谱种按齿轮级: 行星级 Cep_FFT_5_110 / 二级·中速 Cep_FFT_75_830 / 高速级 Cep_FFT_600_1000
- # 如东 w0811 9 齿轮 × 4 测点逐记录 Spearman ρ = 0.974~1.000, 厂商值/窗RMS 的比 = √窗bin数 (HSP 4.2/HSW 7.8/2R 11.5/1P 9.7)
- # ⇒ 同序且同尺度 = 精确等价。峰值/峰-本底/峰÷本底 口径都只到 ρ 0.5~0.9
- # 物理: 齿轮局部损伤 ⇒ 啮合谐波两侧出现以该齿轮轴频为间距的边带簇 ⇒ 倒谱在 quefrency = 1/轴频 处聚成一根拉姆谐波
- # 厂商只做 per 台 per 档自适应绝对阈; 这里加: 结构度 (窗 RMS / 邻域 RMS) · 2q0 拉姆谐波 · fleet 相对 · 端段趋势
- # ══════════════════════════════════════════════════════════════════════════════
- VENDOR_CEPSTRAL_GEARS = {
- # name: (q0_s, width_s, shard, shaft_hz, 含义) — q0/width 取自如东 TCM 记录 X-axisOffset/X-axisDelta
- 'HSP': (0.0400, 0.006, 'Cep_FFT_600_1000_Tr', 25.00, '高速级小齿轮 (HSS 轴)'),
- 'PW': (0.0415, 0.007, 'Cep_FFT_600_1000_Tr', 24.10, '高速级 (24.1Hz, 含义待厂商确认)'),
- 'HSW': (0.1415, 0.023, 'Cep_FFT_600_1000_Tr', 7.067, '高速级大齿轮 (中速轴)'),
- '2S': (0.0555, 0.009, 'Cep_FFT_75_830_Tr', 18.02, '二级行星 太阳轮'),
- '2R': (0.3165, 0.051, 'Cep_FFT_75_830_Tr', 3.160, '二级行星 内齿圈'),
- '2P': (0.3920, 0.062, 'Cep_FFT_75_830_Tr', 2.551, '二级行星 行星轮'),
- '1S': (0.2960, 0.048, 'Cep_FFT_5_110_Tr', 3.378, '一级行星 太阳轮'),
- '1R': (1.1975, 0.191, 'Cep_FFT_5_110_Tr', 0.835, '一级行星 内齿圈'),
- '1P': (1.8030, 0.288, 'Cep_FFT_5_110_Tr', 0.555, '一级行星 行星轮'),
- }
- def cepstral_gear_indicator(cep, dq, q0, width, *, bg_mult=3.0, rahmonic=True, max_window_frac=0.05):
- """倒谱齿轮指标 — 厂商精确等价 (窗 RMS) + 结构度 + 2q0 拉姆谐波.
- cep : 倒谱 (quefrency 轴从 0 起, 步长 dq, 无量纲)
- q0 : 目标 quefrency (s) = 1/齿轮轴频; width: 全窗宽 (s), 即厂商 X-axisDelta
- 返回 dict(indicator, bg_rms, struct, rahm2, i_lo, i_hi); 窗出界返回 NaN 字段。
- struct = indicator / 邻域 RMS (邻域 = 窗两侧各 bg_mult 倍窗宽, 去掉窗本身) — 厂商没有的"有没有真拉姆谐波"闸,
- 同 G2 (峰/本底) 的精神; rahm2 = 2q0 处同法窗 RMS / 其邻域 (真齿轮调制有二次拉姆谐波)。
- """
- import numpy as _np
- c = _np.asarray(cep, dtype=float); n = len(c)
- lo = int(_np.floor((q0 - width / 2) / dq)); hi = int(_np.ceil((q0 + width / 2) / dq)) + 1
- nan = dict(indicator=_np.nan, bg_rms=_np.nan, struct=_np.nan, rahm2=_np.nan, i_lo=lo, i_hi=hi)
- if lo < 2 or hi > n or hi - lo < 2:
- return nan
- w = c[lo:hi]; ind = float(_np.sqrt(_np.nansum(w ** 2))) # ★L2 范数 = 厂商精确尺度 (= RMS×√n)
- rms = float(_np.sqrt(_np.nanmean(w ** 2)))
- # ★结构度 (窗RMS/邻域RMS, G2 精神) 只在**几何有效**时算 (2026-08-23 PENDING 核实逮):
- # 厂商给低速齿轮的窗本身占 quefrency 轴 10~15% (1P: 0.288s/2.5s), 3 倍窗宽邻域占 69% 且撞轴端;
- # 去趋势 (7 倍窗宽滑动中位) 对这种窗等于整条轴, 修不了 — 1P/2P struct 仍 0.55/0.61, 2R 反而 1.48。
- # "峰/本底" 对窗宽 >5% 轴长的量本身没意义 ⇒ 判 NaN + struct_note, 不硬算。
- # 去趋势保留 (对窄窗去掉倒谱随 quefrency 的衰减), 改用 scipy C 实现 (python 循环 43 万条 >1h)。
- frac = (hi - lo) / n
- span = int(round(bg_mult * (hi - lo)))
- valid = frac <= max_window_frac and (lo - span) >= 2 and (hi + span) <= n
- out = dict(indicator=ind, window_rms=rms, struct=_np.nan, rahm2=_np.nan, i_lo=lo, i_hi=hi,
- window_frac=round(frac, 4),
- struct_note=None if valid else f'窗占轴 {100*frac:.1f}% 或邻域出界 ⇒ 峰/本底无意义, 不算')
- if not valid:
- return out
- try:
- from scipy.ndimage import median_filter as _mf
- k = max(5, int(round(7 * (hi - lo)))) | 1
- d = c - _mf(c, size=min(k, n | 1), mode='nearest')
- except Exception:
- d = c
- def _struct(a, b):
- if a - span < 2 or b + span > n or b - a < 2:
- return _np.nan
- wd = d[a:b]; nb = _np.r_[d[a - span:a], d[b:b + span]]
- s_w = float(_np.sqrt(_np.nanmean(wd ** 2))); s_b = float(_np.sqrt(_np.nanmean(nb ** 2)))
- return s_w / s_b if s_b > 0 else _np.nan
- out['struct'] = _struct(lo, hi)
- if rahmonic:
- lo2 = int(_np.floor((2 * q0 - width / 2) / dq)); hi2 = int(_np.ceil((2 * q0 + width / 2) / dq)) + 1
- out['rahm2'] = _struct(lo2, hi2)
- return out
- # ══════════════════════════════════════════════════════════════════════════════
- # ★ 正样本锚 按部件类解封 (2026-08-23) — 替代全局布尔 positive_anchor
- # 背景: 我此前写"38 台零内窥零拆检"并把封顶做成全局开关 — 错。台账有 6 台内窥镜 HS 轴承点蚀、2 台金相发电机内圈剥落、
- # 4 台 FAG 主轴点蚀、9# 2026-01 内窥镜 (窗内) ⇒ 锚是**按部件类**存在的, 封顶也必须按部件类。
- # 登记表 = reference/<场>/positive_anchors.json (数据不是代码; 每条带 tier/日期/证据出处)。
- # ══════════════════════════════════════════════════════════════════════════════
- ANCHOR_LEVELS = ('参考', '候选·记基线', '候选', '准定论·预警', '定论')
- def line_energy_anchor(share_med, share_over_p90, *, min_share=0.01, min_over_p90=10.0, weak_over_p90=3.0):
- """★同域线能量占比 = 谱线定级的绝对锚 (2026-08-23, 如东 48 条过闸线实证).
- ×fleet 的分母是 fleet 中位谱在该频率的本底 — fleet 无此线时倍数天然巨大, 不是损伤量;
- 原始谱域 135Hz 区全场 p90 占比 8.2% = 机型固有成分, 只看 ×fleet 会把 20#/34#/26# 当缺陷。
- share_med : 该线 (±2 bin 功率 / 全谱功率) 六窗逐台中位
- share_over_p90 : share_med / 同 shard fleet p90 (同域同 shard 才可比; 宽带 shard 会稀释占比)
- 返回 dict: status ∈ {'ANCHOR_OK', 'WEAK', 'MID'} + cap (定级上限 或 None) + reason
- ANCHOR_OK: share ≥ min_share ∧ over_p90 ≥ min_over_p90 → 允许解封至 准定论
- WEAK : over_p90 < weak_over_p90 → 封顶 候选·记基线 (相对偏离真实但绝对量在 fleet 分布内)
- MID : 其余 → 封顶 候选 (不得准定论)
- 实证: 16#/33# GenDE 内圈 env 域 占比 2.6~6.2% / 10.9~175×p90 = ANCHOR_OK; 03# GenDE 1.5%/6.2× = MID;
- 14#/31# HS 发电机侧 BSF 0.01~0.02% / 1.1~2.4× = WEAK (×fleet 16~21 全是分母效应);
- 换件后对照 02#/24#/27# 六窗 0.04~0.07% = fleet 水平。
- """
- import math as _m
- 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):
- return dict(status='UNKNOWN', cap=None, reason='无能量占比 (shard 无谱或频率出界)')
- if share_over_p90 < weak_over_p90:
- return dict(status='WEAK', cap='候选·记基线',
- reason=f'★绝对量微弱: 线能量占比 {100*share_med:.3f}% 仅 {share_over_p90:.1f}× 同shard fleet p90 — ×fleet 高因分母≈本底, 封顶 候选·记基线')
- if share_med >= min_share and share_over_p90 >= min_over_p90:
- return dict(status='ANCHOR_OK', cap=None, reason=f'绝对锚达标: 占比 {100*share_med:.2f}% = {share_over_p90:.1f}× fleet p90')
- return dict(status='MID', cap='候选', reason=f'★绝对锚未达标 (占比 {100*share_med:.2f}%, {share_over_p90:.1f}×p90; 需 ≥{100*min_share:.0f}% ∧ ≥{min_over_p90:.0f}×) — 不得准定论')
- def apply_line_energy_anchor(level, anchor_info, reasons, levels=ANCHOR_LEVELS):
- """把 line_energy_anchor 的 cap 施加到定级上 (只降不升); 返回 (level, reasons)."""
- cap = anchor_info.get('cap')
- if cap is None: return level, reasons
- base = str(level).split('·')[0] if str(level) != '候选·记基线' else '候选·记基线'
- def rank(x):
- x = str(x)
- if x in levels: return levels.index(x)
- return levels.index(x.split('·')[0]) if x.split('·')[0] in levels else -1
- if rank(level) > rank(cap):
- return cap, list(reasons) + [anchor_info['reason']]
- return level, reasons
- _AL = {v: i for i, v in enumerate(ANCHOR_LEVELS)}
- def component_class_of(sensor):
- """测点名 → 部件类 (锚按此匹配)."""
- s = str(sensor)
- if s.startswith('Gear_HS'): return 'gearbox_hs_bearing'
- if s.startswith('Gear_IMS'): return 'gearbox_ims_bearing'
- if s.startswith('Gear_planet'): return 'gearbox_planetary'
- if s.startswith('Generator'): return 'generator_bearing'
- if s.startswith('Main_bearing'): return 'main_bearing'
- return 'unknown'
- def anchor_cap(component_class, anchors=None, *, n_evidence_types=1, own_turbine_physical=False,
- mechanism_confirmed=False, min_types_for_historical=3):
- """该部件类在本场的**最高可达定级** + 理由.
- 规则 (可证伪, 写进模型文档 §13.2 B9):
- own_turbine_physical ∧ mechanism_confirmed → 定论 (本台拆检且机制对上)
- own_turbine_physical → 准定论·预警 (本台有实物但圈侧/机制未对上, 拒绝命名)
- tier physical_in_window 存在 → 准定论·预警
- tier physical_historical 存在 ∧ 独立证据类型 ≥3 → 准定论·预警 (失效模式本场有实物先例, 但无 CMS 谱↔实物配对 ⇒ 以证据多样性补)
- tier physical_historical 存在 ∧ 类型 <3 → 候选 (+ "本场有实物先例" 标注)
- 只有 closed_loop / 无锚 → 候选
- 确诊 (定论) 永远要求本台拆检 — 任何锚都不能替代。
- 返回 dict(cap, tier, reason, anchors_n)
- """
- al = [a for a in (anchors or []) if a.get('component_class') == component_class]
- tiers = {a.get('tier') for a in al}
- n = len(al)
- if own_turbine_physical and mechanism_confirmed:
- return dict(cap='定论', tier='own_turbine', reason='本台实物证实且机制对上', anchors_n=n)
- if own_turbine_physical:
- return dict(cap='准定论·预警', tier='own_turbine', reason='本台实物证实, 但圈侧/机制未对上 ⇒ 不命名, 不到定论', anchors_n=n)
- if 'physical_in_window' in tiers:
- return dict(cap='准定论·预警', tier='physical_in_window',
- reason=f'同部件类在数据窗内有实物锚 ({n} 条登记)', anchors_n=n)
- if 'physical_historical' in tiers:
- if n_evidence_types >= min_types_for_historical:
- return dict(cap='准定论·预警', tier='physical_historical',
- reason=f'同部件类有历史实物锚 ({n} 条) 且独立证据类型 {n_evidence_types} ≥ {min_types_for_historical}', anchors_n=n)
- return dict(cap='候选', tier='physical_historical',
- reason=f'同部件类有历史实物锚但独立证据类型仅 {n_evidence_types} < {min_types_for_historical} ⇒ 候选 (本场有实物先例)', anchors_n=n)
- if 'closed_loop' in tiers:
- return dict(cap='候选', tier='closed_loop', reason=f'只有诊断→换件闭环 ({n} 条), 无拆检文字 ⇒ 不解封', anchors_n=n)
- return dict(cap='候选', tier='none', reason='本场该部件类无实物锚', anchors_n=0)
- def apply_anchor_cap(level, cap_info, reasons):
- """把 anchor_cap 应用到 L6 的 level: 高于 cap 的压到 cap; 候选级且有实物先例的加标注."""
- lv = level
- if lv in _AL and _AL[lv] > _AL[cap_info['cap']]:
- reasons.append(f"★锚封顶: {lv} → {cap_info['cap']} ({cap_info['reason']})")
- lv = cap_info['cap']
- elif cap_info['tier'] in ('physical_historical', 'physical_in_window') and str(lv).startswith('候选'):
- reasons.append(f"(本场该部件类有实物先例: tier={cap_info['tier']}, {cap_info['anchors_n']} 条)")
- return lv
- # ══════════════════════════════════════════════════════════════════════════════
- # ★ L0 A1 三源合判 — "无记录" 三子型 (2026-08-23 如东 31#/18# 实证 → 落代码)
- # 只看 CMS 最后时间戳分不开: (a) 停机无数据 (CMS 只在档内闭合才测 = 正确) / (b) 可达但不采集 (31# 8h, 风机发电中)
- # / (c) 主机离线 (18#: No Communication + 表 crashed)。厂商看门狗对 (b) 延迟 35h。
- # ══════════════════════════════════════════════════════════════════════════════
- def a1_blind_subtype(*, last_cms_ts, now, running_spans=(), syslog_events=(), cycle_min=30.0,
- gap_cycles=2, offline_types=('No Communication', 'Crashed Site Server Tables',
- 'M-System Data retrival Warning')):
- """CMS 采集盲区子型裁决.
- last_cms_ts : 该台该测点最后一条 CMS 实测时间 (datetime)
- now : 数据末端 / 判定时刻
- running_spans : [(t0, t1), ...] 风机运行 (发电) 时段, 来自 SCADA/fastlog 运行态见证; 空 = 无见证
- syslog_events : [(t0, t1, type), ...] CMS syslog 事件
- 返回 dict(subtype, gap_h, running_unobserved_h, offline_h, reason)
- subtype ∈ {'正常', '停机无数据', '可达但不采集', '主机离线', '无记录·无运行态见证'}
- """
- import datetime as _dt
- gap_h = (now - last_cms_ts).total_seconds() / 3600 if last_cms_ts is not None else float('inf')
- if gap_h <= gap_cycles * cycle_min / 60:
- return dict(subtype='正常', gap_h=gap_h, running_unobserved_h=0.0, offline_h=0.0, reason=f'最后记录距末端 {gap_h:.1f}h ≤ {gap_cycles} 周期')
- t_gap0 = last_cms_ts if last_cms_ts is not None else None
- # 离线时长: syslog 离线类事件与盲区的交集
- off = 0.0
- for (a, b, ty) in syslog_events:
- if ty in offline_types and t_gap0 is not None:
- lo, hi = max(a, t_gap0), min(b or now, now)
- if hi > lo: off += (hi - lo).total_seconds() / 3600
- # 运行中未观测时长: 运行时段与盲区的交集
- run = 0.0
- for (a, b) in running_spans:
- if t_gap0 is None: continue
- lo, hi = max(a, t_gap0), min(b, now)
- if hi > lo: run += (hi - lo).total_seconds() / 3600
- if off >= gap_cycles * cycle_min / 60:
- return dict(subtype='主机离线', gap_h=gap_h, running_unobserved_h=run, offline_h=off,
- reason=f'syslog 离线类事件覆盖盲区 {off:.1f}h')
- if run >= gap_cycles * cycle_min / 60:
- return dict(subtype='可达但不采集', gap_h=gap_h, running_unobserved_h=run, offline_h=off,
- reason=f'盲区内风机运行 {run:.1f}h (≥{gap_cycles} 周期) 而无 CMS 记录且无离线事件 ⇒ 机侧采集停摆')
- if running_spans:
- return dict(subtype='停机无数据', gap_h=gap_h, running_unobserved_h=run, offline_h=off,
- reason='盲区内风机基本未运行 ⇒ CMS 只记停机档属正常行为')
- return dict(subtype='无记录·无运行态见证', gap_h=gap_h, running_unobserved_h=0.0, offline_h=off,
- reason='无 SCADA/fastlog 运行态见证, 三子型不可分 ⇒ 只能报 INSUFFICIENT, 不得写成"停机"或"故障"')
- 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):
- """共享成分污染门 (Codex 回传 §5-1 落库, 2026-08-23 如东 TCM 争议裁决 D1).
- 判两只同步通道在某频带内是否为"同一路信号" (测量链耦合/电气拾取), 该带不得用于机械源定位:
- SHARED = 相干中位 >= coh_shared ∧ |群延迟| <= gd_max_us ∧ 相位合成向量 R >= r_min
- (如东 WTG12/38 主轴承前后 8-13 kHz: 相干 0.984-0.999, 群延迟 -1.16..-1.33 µs, R>=0.9998;
- 两测点相距 >=1 m, 结构传播 >=200 µs ⇒ 零群延迟与机械传播不相容)
- INDEPENDENT = 相干中位 < coh_independent
- PARTIAL = 其余 (如东 WTG23 同带: 相干 0.59-0.69, 滞后 +4 µs, 相位恒 -22°)
- 只看相干幅值不能区分固定结构传递与串扰 (Codex §6-1); 必须同时有相位/群延迟。
- 返回 dict(status, usable_for_localization, reason)。
- """
- import math
- vals = [coh_med, group_delay_us, phase_resultant_R]
- if any(v is None or (isinstance(v, float) and math.isnan(v)) for v in vals):
- return {'status': 'UNKNOWN', 'usable_for_localization': False, 'reason': '输入缺失 (相干/群延迟/相位R 任一为空)'}
- if coh_med >= coh_shared and abs(group_delay_us) <= gd_max_us and phase_resultant_R >= r_min:
- return {'status': 'SHARED', 'usable_for_localization': False,
- 'reason': f'相干 {coh_med:.3f}>={coh_shared} ∧ |群延迟| {abs(group_delay_us):.2f}µs<={gd_max_us} ∧ R {phase_resultant_R:.4f}>={r_min}: 同一路信号, 该带不用于定位'}
- if coh_med < coh_independent:
- return {'status': 'INDEPENDENT', 'usable_for_localization': True, 'reason': f'相干 {coh_med:.3f}<{coh_independent}: 两路独立'}
- return {'status': 'PARTIAL', 'usable_for_localization': True,
- 'reason': f'相干 {coh_med:.3f} 介于 {coh_independent}-{coh_shared} 或 群延迟/相位未满足共享判据: 部分共享, 定位须剔除共享带并报占比'}
- def bimodal_shape(x, *, fleet_iqr_median=None, span_iqr_max=2.0, iqr_ratio_min=2.5,
- min_n=200, corroboration=None):
- """双态(间歇) vs 单峰(整体偏移) 判别 —— **不需要基线切点**, 补 vib_scalar_typology 的盲区。
- 为什么另起一个: `vib_scalar_typology` 是**变化型**判据 (末段 vs 基线), 对"一直就这样"的
- 稳态双态完全失明 (memory change-detector-blind-to-steady-bias)。而"间歇发作"与"渐进退化"
- 的现场措辞完全不同 —— 按"查渐进退化"下单, 现场大概率在正常态测一遍回报"未见异常"。
- 判据用**分布形状**, 两个量都 scale-free (对振动残差、温度残差同样适用):
- · IQR / fleet_IQR中位 —— 离散度是否异常大
- · span/IQR = (p95-p05)/IQR —— **尾巴长度**。参考值 (可解析推导, 自检里断言):
- 正态 (2*1.6449σ)/(1.3490σ) = 2.4386
- 均匀 0.9/0.5 = 1.8
- 两点质量 (完美双态) -> 1.0
- **低值 = 质量在两端、中间不长尾** ⇒ 双态候选; 高值 = 单峰带长尾。
- ★低 span/IQR 是**必要非充分**: 双态给低值, 但重中心的单峰、被 clip/量化压过的序列
- 同样给低值。所以 verdict 只在**另有独立正证**时才升到"双态", 否则停在候选 ——
- `corroboration` 传入那条独立证据的说明 (如"热态占空比逐月 8%->60% 而幅度恒定":
- 占空比变而幅度不变, 本身就是比 span/IQR 更硬的双态正证)。
- Args:
- x: 一维样本 (残差/指标值), NaN 自动剔除
- fleet_iqr_median: 全场同指标 IQR 的中位; 给了才出 iqr_ratio 与"离散异常"判
- span_iqr_max: span/IQR 低于此值算"尾巴短" (默认 2.0, 介于均匀 1.8 与正态 2.44 之间)
- iqr_ratio_min: IQR/fleet中位 高于此值算"离散异常大"
- corroboration: 独立正证的一句话说明; None = 无 ⇒ verdict 封顶在 候选
- Returns:
- dict: verdict ∈ {双态, 双态候选, 单峰, INSUFFICIENT}, span_iqr/iqr/iqr_ratio, _caliber
- [暂行 n=1场 · 零阳性对照] 门槛由古城+昌平坳 49 台一轮标定 (2026-09-03 温度侧, 跨会话回灌),
- **无拆检证实的双态台**。⇒ 只作筛查, 不用于给机组定级 (skill vibration-spectral "无正样本时")。
- 退役/复标判据: 出现"实证双态台 span/IQR > 2.0"或"实证单峰台 < 1.8"即失效, 须重标。
- """
- import numpy as _np
- v = _np.asarray(x, dtype=float).ravel()
- v = v[_np.isfinite(v)]
- if v.size < min_n:
- return {'verdict': 'INSUFFICIENT', 'n': int(v.size),
- 'reason': f'样本 {v.size} < {min_n}'}
- q05, q25, q75, q95 = (float(_np.percentile(v, q)) for q in (5, 25, 75, 95))
- iqr = q75 - q25
- if iqr <= 1e-12:
- return {'verdict': 'INSUFFICIENT', 'n': int(v.size), 'iqr': iqr,
- 'reason': 'IQR≈0 (常数或量化死通道) — 形状判据不适用'}
- span_iqr = (q95 - q05) / iqr
- iqr_ratio = (iqr / fleet_iqr_median) if fleet_iqr_median else None
- short_tail = span_iqr < span_iqr_max
- wide = (iqr_ratio is not None and iqr_ratio >= iqr_ratio_min)
- if short_tail and wide:
- verdict = '双态' if corroboration else '双态候选'
- elif short_tail or wide:
- verdict = '双态候选'
- else:
- verdict = '单峰'
- return {'verdict': verdict, 'n': int(v.size), 'iqr': iqr, 'span_iqr': span_iqr,
- 'iqr_ratio': iqr_ratio, 'p05': q05, 'p95': q95,
- 'short_tail': short_tail, 'wide_spread': wide,
- 'corroboration': corroboration,
- '_caliber': (f'span/IQR={span_iqr:.2f} (正态2.44/均匀1.80/双点1.00), '
- + (f'IQR={iqr:.3g} 为 fleet 中位的 {iqr_ratio:.2f}×' if iqr_ratio
- else 'fleet_iqr_median 未给, 仅报形状')
- + ('; 有独立正证' if corroboration else
- '; **无独立正证 ⇒ 封顶在候选** (低 span/IQR 必要非充分)')),
- '_retire': '[暂行 n=1场 零阳性对照] 只作筛查, 不给机组定级'}
- _MADZ_CACHE = {}
- def madz_null_rate(n, z, *, n_mc=200_000, seed=0, two_sided=True):
- """fleet MAD-z 判据在**有限机群规模 n** 上的真实尾概率 (实验 J, 2026-09-07)。
- ★为什么需要这个函数: `z = (x − median) / (1.4826·MAD)` 常被当作近似标准正态读, 于是
- "z≥2 ⇒ 4.55% 假阳" / "z≥3 ⇒ 0.27%" 被用来估基率。**这在小机群上是错的, 且方向是反的
- (低估假阳, 不是高估)** —— MAD 本身是从同一批 n 个样本估出来的噪声量, 其抽样波动把比值的
- 尾巴抬厚 (同 t 分布相对正态)。**纯 iid 正态数据**下实测 (n_mc=2e5):
- n P(|z|≥2)/正态 P(|z|≥3)/正态
- 12 1.78× 8.62×
- 20 1.51× 5.22×
- 38 1.29× 2.98×
- 80 1.14× 1.85×
- 200 1.06× 1.32×
- 1000 1.01× 1.06×
- ⇒ **判据阈值的名义假阳率只在大机群上成立**; 12 台的场用 z≥3 出榜, 真实假阳是名义的 8.6 倍。
- ⇒ 与 `fleet_two_component_split` 的 n_ref 前提同源: 小参考池不只是"污染"问题, **z 的刻度本身就偏**。
- ⚠ 本函数只给"估计量自身"的那一份偏差 (数据 iid 正态)。真实机群的偏差分布还更厚尾 (台间异质 +
- 真异常台污染) —— 如东实测置换零模型 z≥2 为 1.67×、z≥3 为 7.1×, 其中 1.29×/2.98× 由本函数解释,
- 余下 1.29×/2.4× 来自数据本身。**要拿场级真基率必须跑置换零模型**, 本函数是它的可移植下界。
- 返回 P(|z| ≥ z) (two_sided=False 则为单侧 P(z ≥ z))。
- """
- key = (int(n), float(z), int(n_mc), int(seed), bool(two_sided))
- if key in _MADZ_CACHE:
- return _MADZ_CACHE[key]
- if n < 3:
- raise ValueError(f'madz_null_rate: n={n} < 3, MAD 无意义 (必需输入不静默兜底)')
- rng = np.random.default_rng(seed)
- hit = 0
- done = 0
- block = max(1, min(n_mc, int(2e7 // max(n, 1))))
- while done < n_mc:
- m = min(block, n_mc - done)
- X = rng.standard_normal((m, n))
- med = np.median(X, axis=1, keepdims=True)
- mad = 1.4826 * np.median(np.abs(X - med), axis=1, keepdims=True)
- zz = (X - med) / np.where(mad > 0, mad, np.nan)
- hit += int(np.nansum(np.abs(zz) >= z) if two_sided else np.nansum(zz >= z))
- done += m
- r = hit / (n_mc * n)
- _MADZ_CACHE[key] = r
- return r
- def fleet_madz_base_rate(n_turbine, n_comparison, z=2.0, *, two_sided=True, **kw):
- """榜单基率: n_comparison 次 fleet MAD-z 比较下, 纯噪声期望命中数 (**用 madz_null_rate 而非正态**)。
- 返回 {expected_madz, expected_normal_WRONG, inflation, note}。`expected_normal_WRONG` 只为对照 ——
- 它是常见的错算法 (拿正态尾概率乘比较数), 实测系统性**低估**基率, 名字里带 WRONG 防被误引。
- """
- from math import erf, sqrt
- p_norm = (1 - erf(z / sqrt(2))) if two_sided else 0.5 * (1 - erf(z / sqrt(2)))
- p_mad = madz_null_rate(n_turbine, z, two_sided=two_sided, **kw)
- return {'expected_madz': n_comparison * p_mad,
- 'expected_normal_WRONG': n_comparison * p_norm,
- 'inflation': (p_mad / p_norm) if p_norm else float('nan'),
- 'p_madz': p_mad, 'n_turbine': int(n_turbine), 'z': float(z),
- 'note': ('仅含估计量自身偏差 (iid 正态假设); 台间异质与真异常污染会再抬高 → '
- '场级真基率须跑置换零模型, 本值是其可移植下界')}
- def _grand_chart(gser, h0_window, test_start, *, mu1_k=1.0, run_alpha=0.05,
- min_h0_n=30, min_test_n=30, note=None):
- """共模序列 g_t 的时序判据 (fleet_two_component_split 与 nbm_residual 共用, 防两处判据漂移)。
- H0 冻结自 h0_window (**应与测试窗同历月**, 否则季节错配会把共模图点亮)。
- 返回 (grand_dict, h0_dict|None); grand['verdict'] 三态见 fleet_two_component_split docstring。
- """
- from wind_analytics.health_assessment.mset_sprt import fit_h0, sprt
- note = note if note is not None else []
- h0_lo, h0_hi, t_lo = pd.Timestamp(h0_window[0]), pd.Timestamp(h0_window[1]), pd.Timestamp(test_start)
- base = gser[(gser.index >= h0_lo) & (gser.index < h0_hi)].dropna().values
- test = gser[gser.index >= t_lo].dropna().values
- if len(base) < min_h0_n or len(test) < min_test_n:
- return {'decision': 'insufficient', 'verdict': 'INSUFFICIENT', 'n_alarm': 0, 'candidate': False,
- 'reason': f'H0 {len(base)} / 测试 {len(test)} 样本不足'}, None
- h0 = fit_h0(base)
- if 'note' in h0:
- return {'decision': 'insufficient', 'verdict': 'INSUFFICIENT', 'n_alarm': 0,
- 'candidate': False, 'reason': h0['note']}, None
- sr = sprt(test, mu_0=h0['mu_0'], sigma_0=h0['sigma_0'],
- mu_1=h0['mu_0'] + mu1_k * h0['sigma_0'], phi=h0['phi'],
- method='whiten', persist_k=2, run_alpha=run_alpha)
- grand = {'decision': sr.decision, 'n_alarm': len(sr.alarm_indices),
- 'candidate': bool(sr.candidate), 'n_eff': int(sr.n_eff),
- 'decorr_ok': bool(sr.decorr_ok), 'sigma_0_K': float(h0['sigma_0']),
- 'phi': float(h0['phi']), 'mu1_K': float(mu1_k * h0['sigma_0'])}
- # ★失效必须落在显式状态, 不许降级成"另一个合法状态" (memory parse-failure-read-as-positive-result 第三类):
- # 共模量近随机游走 (φ→1) 时白化把电平阶跃压成 (1−φ)Δ → 真有批次漂也返回 'no_change',
- # 与"干净阴性"外观完全一样, 调用方读不出差别。故不可信的阴性单独判 INSUFFICIENT。
- if sr.candidate:
- grand['verdict'] = 'batch_shift_candidate'
- elif not sr.decorr_ok:
- grand['verdict'] = 'INSUFFICIENT'
- grand['reason'] = (f'共模序列 φ={h0["phi"]:.3f} 白化后残余自相关超容忍 → 电平阶跃被压成 '
- f'{(1 - h0["phi"]) * 100:.1f}%, 阴性不可信 (非"未见异常")')
- note.append('⚠ decorr_ok=False: 阴性判 INSUFFICIENT 而非 no_change; run_alpha 的 ARL 上界亦不成立')
- else:
- grand['verdict'] = 'no_change'
- return grand, h0
- # ------------------------------------------------- 两成分分解: 共模图 (fleet-relative 缺失的那半张)
- def fleet_two_component_split(df, *, comp_col, p_col, amb_col, turbine_col, time_col,
- fit_window, h0_window, test_start,
- curtail_col=None, gen_min=None, freq='D',
- z_flag=2.0, mu1_k=1.0, run_alpha=0.05,
- min_fit_n=60, min_h0_n=30, min_test_n=30):
- """MSP 两成分分解 (Runger/Alt/Montgomery 1996; Epprecht/Barbosa/Simões 2011 doi:10.1590/S0103-65132011005000022)。
- 把 per 台 NBM 残差 r_it 拆成 **共模 g_t = median_i r_it** 与 **单台偏差 d_it = r_it − g_t**, 两条各配一张图:
- · deviation 图 (d): 横截面 MAD-z, = 现行 fleet-relative 生产语义 (windscada/subsys/temp_nbm.py::registry,
- discriminators.nbm_residual)。**本函数不替代它们**, 只把它算在同一次分解里以保证两图同源可比。
- · grand-mean 图 (g): 时序 SPRT (health_assessment.mset_sprt.fit_h0 + sprt, H0 冻结自独立标定窗)。
- **这是原先缺的那半张** —— 共模量此前只作为回归量 (nbm_residual 的 Tmed) 被吸收后丢弃, 从不被监控。
- ★为什么必须两张: deviation 图对"全场同步平移"**逐台逐值精确不变**(d = r − median(r), 全体加 Δ 则 d 不变),
- 故批次退化在它上面**必然全绿** —— 这是设计属性不是 bug (memory relative-criterion-batch-blindness 的正式版:
- SPC 称之为"滤掉共模", 风电语境称之为"批次盲")。补救只能靠另加共模图, 不能靠调 deviation 图的阈值。
- 2026-09-07 实验 K 如东 38 台 × 4 温度通道实测: 全场注入 +1σ 阶跃, deviation z **max|Δ|=0.00e+00**(4 通道全部),
- 而共模图 4/4 报警; 单台注入 +1σ 则反过来 —— 该台 z 从 +0.48~+1.77 抬到 +3.73~+10.03, 其余 37 台 max|Δz|≤0.118,
- 共模图 3/4 不动。两图正交, 互不替代。
- ⚠ 前置① 限电必须先剥 (SOP 模块0.1): curtail_col 给定则剔除该列 >0 的行。如东实测 **41.8% 发电行处于降功率态**,
- 不剥 → 小时粒度下 3/4 通道**零注入即误报**(GeOil/GenBeG/HSGen 共模图 null 报警), 剥后 3/4 转 no_change。
- ⚠ 前置② 粒度: 小时粒度共模序列 φ≈0.76~0.91, 白化后残余 AC 多数超容忍 (decorr_ok=False) → **默认日粒度**
- (freq='D', 实测 φ 降到 0.29~0.49)。decorr_ok=False 时 run_alpha 的 ARL 上界不成立, 报警数不可当已标定读。
- ⚠ 前置③ **共模图不能单独定因**: 全场同步上移既可能是批次设备退化, 也可能是场级参考量变化 (环温传感器族偏、
- 场址/微气象变、控制版本全场升级、限电制度切换)。**本图给不出区分**, 单靠它最高只能到 准定论·预警,
- 升定论须外部绝对锚 (memory anchor-validation-shear-tautology-trap)。
- ★common_frac = grand_sd0 / resid_sd = 共模图灵敏度的天花板诊断。若残差散布主要是各台独立噪声, 38 台取中位
- 应把它压到 ≈1/√38≈0.16; 如东实测 **0.60~0.92** ⇒ 散布的 60~92% 本身就是共模 (模型没解释掉的天气/工况),
- **中位数几乎没降噪** ⇒ **加台数买不到共模灵敏度**, 只能靠把物理模型做准。common_frac 越接近 1,
- 共模图越钝, 越该先改模型而不是调阈值。
- ★grand['verdict'] 三态 (不许把不可信阴性混进 no_change): 'batch_shift_candidate' / 'no_change' /
- 'INSUFFICIENT'(样本不足, 或共模量近随机游走 φ→1 致白化把电平阶跃压没 —— 此时阴性只是"测不出", 非"没有")。
- 返回 {grand:{...sprt..., verdict}, dev:{z, flagged, n_flagged, n_total}, common_frac, resid_sd_K,
- grand_sd0_K, n_turbine, curtail_stripped_frac, freq, note:[...]}。
- """
- need = [comp_col, p_col, amb_col, turbine_col, time_col]
- miss = [c for c in need if c not in df.columns]
- if miss:
- raise KeyError(f'fleet_two_component_split 缺必需列: {miss}') # 必需输入禁静默 .get
- d = df[need + ([curtail_col] if curtail_col else [])].copy()
- d[time_col] = pd.to_datetime(d[time_col])
- d = d.dropna(subset=need)
- note = []
- if gen_min is not None:
- d = d[d[p_col] > gen_min]
- strip_frac = None
- if curtail_col:
- if curtail_col not in df.columns:
- raise KeyError(f'curtail_col={curtail_col} 不在 df (限电剥离是硬前置, 不静默跳过)')
- n0 = len(d)
- d = d[d[curtail_col].fillna(0) <= 0]
- strip_frac = float(1 - len(d) / n0) if n0 else None
- note.append(f'限电剥离 {strip_frac:.1%} 行 (SOP 模块0.1)')
- else:
- note.append('⚠ 未剥限电 (curtail_col=None): 共模图零注入误报风险, 结论最高 INSUFFICIENT')
- d['_t'] = d[time_col].dt.floor(freq)
- g = d.groupby([turbine_col, '_t'])[[p_col, amb_col, comp_col]].median().reset_index()
- fit_lo, fit_hi = pd.Timestamp(fit_window[0]), pd.Timestamp(fit_window[1])
- res = []
- for tid, gs in g.groupby(turbine_col):
- f = gs[(gs['_t'] >= fit_lo) & (gs['_t'] < fit_hi)]
- if len(f) < min_fit_n:
- note.append(f'{tid}: 拟合窗样本 {len(f)}<{min_fit_n}, 剔出')
- continue
- X = np.c_[np.ones(len(f)), f[p_col].values, f[amb_col].values]
- beta, *_ = np.linalg.lstsq(X, f[comp_col].values, rcond=None)
- Xa = np.c_[np.ones(len(gs)), gs[p_col].values, gs[amb_col].values]
- res.append(pd.DataFrame({turbine_col: tid, '_t': gs['_t'].values,
- 'r': gs[comp_col].values - Xa @ beta}))
- if len(res) < 3:
- return {'grand': {'decision': 'insufficient', 'verdict': 'INSUFFICIENT'}, 'dev': None, 'common_frac': None,
- 'note': note + [f'可建模台数 {len(res)}<3, 共模量无意义']}
- r = pd.concat(res)
- gser = r.groupby('_t')['r'].median().rename('g')
- r = r.join(gser, on='_t')
- r['d'] = r['r'] - r['g']
- # --- 共模图 (grand-mean): 走共用 helper (nbm_residual 同调, 判据不许两处各写一份)
- t_lo = pd.Timestamp(test_start) # deviation 图下方仍需
- grand, h0 = _grand_chart(gser, h0_window, test_start, mu1_k=mu1_k, run_alpha=run_alpha,
- min_h0_n=min_h0_n, min_test_n=min_test_n, note=note)
- # --- deviation 图 (生产语义横截面 MAD-z; 对全场平移逐值不变)
- t = r[r['_t'] >= t_lo]
- dev = t.groupby(turbine_col)['d'].mean()
- mad = float(1.4826 * np.median(np.abs(dev - dev.median())))
- z = (dev - dev.median()) / max(mad, 0.3)
- resid_sd = float(r['r'].std())
- grand_sd0 = float(h0['sigma_0']) if h0 and 'note' not in h0 else None
- cf = (grand_sd0 / resid_sd) if (grand_sd0 and resid_sd) else None
- if cf is not None and cf > 0.5:
- note.append(f'common_frac={cf:.2f}>0.5: 散布主要是共模, 中位数几乎未降噪 ⇒ 加台数买不到灵敏度, 先改物理模型')
- return {'grand': grand,
- 'dev': {'z': {str(k): round(float(v), 3) for k, v in z.items()},
- 'flagged': sorted(str(k) for k in z[z.abs() >= z_flag].index),
- 'n_flagged': int((z.abs() >= z_flag).sum()), 'n_total': int(len(z))},
- 'common_frac': cf, 'resid_sd_K': round(resid_sd, 3),
- 'grand_sd0_K': round(grand_sd0, 3) if grand_sd0 else None,
- 'n_turbine': int(r[turbine_col].nunique()),
- 'curtail_stripped_frac': strip_frac, 'freq': freq, 'note': note}
|