taxonomy.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. # -*- coding: utf-8 -*-
  2. """B翼 系统划分 (2026-08-25 用户令: 按交付分册的系统学重组, 温度NBM是方法不是系统).
  3. 九系统 = 交付汇编 01_分系统 蓝本: 变桨/偏航/主轴承/齿轮箱/发电机/变流器/主控与传感网 (+叶片/塔架=SCADA无轴, 边界注记).
  4. 每系统状态 = worst(该系统全部轴), 依据原文保留; 对账基线 = reference/rudong/<系统>_delivered_grades.json."""
  5. from app_common.app_common_guanlan.api import paths as P
  6. import json, pathlib
  7. import numpy as np, pandas as pd
  8. from src.windscada.config import farm
  9. from app_ETL.app_ETL_guanlan.api import load_10min
  10. SYSTEMS = ['变桨', '偏航', '主轴承', '齿轮箱', '发电机', '变流器', '主控与传感网', '叶片与叶根', '塔架与基础']
  11. # 9 系统 = 交付 9 分册一一对应 (2026-08-26 补齐: 先前只做 7 个, 缺叶片与叶根/塔架与基础)
  12. NO_SCADA = {'叶片与叶根': '无SCADA轴(分册蓝本=CMS叶频/巡检)', '塔架与基础': '无SCADA轴(分册蓝本=CMS塔频/巡检)'}
  13. # 温度通道 → 系统 (通道身份按契约命名指纹; GFilB*=定值80℃加热器带 [恒值哨兵], Brk=高速轴刹车归齿轮箱侧)
  14. TEMP2SYS = {
  15. 'MBearGTm': '主轴承', 'MBearHTm': '主轴承',
  16. 'GeOilTmp': '齿轮箱', 'IMSGenTm': '齿轮箱', 'IMSRotTm': '齿轮箱', 'HSGenTmp': '齿轮箱', 'HSRotTmp': '齿轮箱',
  17. 'GFilB1Tm': '齿轮箱', 'GFilB2Tm': '齿轮箱', 'GFilB3Tm': '齿轮箱', 'BrkTmpGn': '齿轮箱', 'BrkTmpGr': '齿轮箱',
  18. 'GenBeGTm': '发电机', 'GenBeRTm': '发电机', 'Gen1U1Tm': '发电机', 'Gen1V1Tm': '发电机', 'Gen1W1Tm': '发电机',
  19. 'ConvWTmp': '变流器', 'TrafoTMa': '变流器', 'TraOilTF': '变流器', 'TraRooTF': '变流器',
  20. 'A1ExtTmp': '主控与传感网', 'A21IntTm': '主控与传感网', 'A3LefTmp': '主控与传感网', 'A3RigTmp': '主控与传感网',
  21. 'NacelTmp': '主控与传感网', 'HubTemp': '主控与传感网',
  22. 'HydOilTm': '变桨',
  23. 'AmbieTmp': None, # 环温=热汇参考, 不归系统
  24. }
  25. LUB2SYS = {'din_wtc_MBLubPis_timeon': '主轴承', 'din_wtc_GBLubPis_timeon': '齿轮箱', 'dot_wtc_GenLubPu_timeon': '发电机'}
  26. def build_aux_store(cfg=None, since='2026-01-01'):
  27. """轻量补仓: 三系统润滑柱塞 duty (s/日) + ScComErr 通讯计数增量."""
  28. cfg = cfg or farm()
  29. rows = []
  30. for t in cfg['turbines']:
  31. rec = dict(turbine=t)
  32. try:
  33. d = load_10min(t, cfg, groups=['A.状态', 'B.润滑液压'])
  34. cur = d[d.ts >= since]
  35. byday = cur.groupby(cur.ts.dt.date)
  36. for col, sysname in LUB2SYS.items():
  37. rec[f'lub_{sysname}'] = float(byday[col].sum().mean())
  38. v = cur['cnt_wtc_ScComErr_accum'].dropna()
  39. rec['scomerr'] = float(v.iloc[-1] - v.iloc[0]) if len(v) > 2 else np.nan
  40. except Exception as e:
  41. rec['err'] = str(e)[:60]
  42. rows.append(rec); print(t, flush=True)
  43. df = pd.DataFrame(rows)
  44. df.to_parquet(pathlib.Path(cfg['store']) / 'system_aux.parquet')
  45. print('→ system_aux.parquet', df.shape)
  46. ORDER = {'报警': 0, '良好': 1, '不可判': 2, '优秀': 3, '—': 3}
  47. def _worst(states):
  48. js = [s for s in states if s in ORDER]
  49. return sorted(js, key=lambda x: ORDER[x])[0] if js else '不可判'
  50. def _load_tc(cfg):
  51. import pandas as _pd, pathlib as _pl
  52. f = _pl.Path(cfg['store']) / 'thermal_chain.parquet'
  53. return _pd.read_parquet(f).set_index('turbine') if f.exists() else None
  54. def system_matrix(cfg=None, span=None):
  55. """38台 × 7系统 状态 + 依据. 轴: 分系统模块(变桨/偏航/蓄能) + 温度通道归系统 + 润滑柱塞×fleet + 测风/通讯.
  56. `span=(起, 止)` 给了就**按所选时间窗重算判级**(用户令 2026-09-21,含两端):
  57. 四个判级面各自把它当判级窗(变桨=日粒度件切片、偏航/蓄能/温度=走 slim10min 窄仓重算;
  58. 温度的 ref/ref_sm 对照窗仍固定)。不给 = 各面原口径(近 60/90 日)。
  59. """
  60. cfg = cfg or farm()
  61. TC = _load_tc(cfg)
  62. # ★ 2026-09-17 缺件容错: 交付包不随产物 ⇒ 这批"无生成端"的件(pitch_daily/yaw_daily/system_aux/
  63. # thermal_chain…)在目标机上必然缺。这里逐个兜住: 缺件 = 该面无数据 (记 GAPS, 页面显示缺件),
  64. # 绝不让 KeyError/FileNotFoundError 把本体层整步打断 (实测清产物后重算就死在这里)。
  65. GAPS: list[str] = []
  66. def _missing(what: str, why: str = ''):
  67. tag = f'{what} 缺件 ({why})' if why else f'{what} 缺件'
  68. if tag not in GAPS:
  69. GAPS.append(tag)
  70. def _load_reg(fn, what, idx_col):
  71. try:
  72. reg = fn()
  73. reg = reg[0] if isinstance(reg, tuple) else reg
  74. return reg.set_index(idx_col)
  75. except FileNotFoundError as e:
  76. _missing(what, str(e).split("'")[-2] if "'" in str(e) else str(e)[:60])
  77. except Exception as e:
  78. _missing(what, f'{type(e).__name__}')
  79. return pd.DataFrame()
  80. def _load_parquet(name):
  81. f = pathlib.Path(cfg['store']) / name
  82. if not f.exists():
  83. _missing(name)
  84. return pd.DataFrame()
  85. try:
  86. return pd.read_parquet(f).set_index('turbine')
  87. except Exception as e:
  88. _missing(name, f'{type(e).__name__}')
  89. return pd.DataFrame()
  90. def _cell(i, t_, col, default='不可判'):
  91. """按台取一格 —— 表空/行缺/列缺都返回 default (缺件时该轴不可判, 不是崩溃)。"""
  92. try:
  93. v = i.loc[t_, col]
  94. except Exception:
  95. return default
  96. try:
  97. if v != v: # NaN
  98. return default
  99. except Exception:
  100. pass
  101. return v
  102. try:
  103. from .subsys import structure as stxmod
  104. _stx, STXD = stxmod.registry(cfg)
  105. STX = _stx.set_index('机组')
  106. except Exception:
  107. STX, STXD = None, {}
  108. from .subsys import pitch as pitchmod, yaw as yawmod, hydraulic, temp_nbm
  109. # ★2026-09-21 用户令「判级也按所选时间窗重算」: span 给了就逐面按窗重算(各面自己决定怎么按窗取数)。
  110. # 四个面里只有"变桨"的输入是日粒度序列;偏航/蓄能/温度走 slim10min 窄仓重算(同一份 load_10min 的列子集)。
  111. p_i = _load_reg(lambda: pitchmod.registry(span=span), '变桨面 pitch_daily.parquet', '机组')
  112. y_i = _load_reg(lambda: yawmod.registry(span=span), '偏航面 yaw_daily.parquet', '机组')
  113. h_i = _load_reg(lambda: hydraulic.registry(span=span), '蓄能面 (液压深挖件)', 'turbine')
  114. treg = temp_nbm.registry(win='cur', span=span)
  115. tdf = treg[0] if isinstance(treg, tuple) else treg
  116. if tdf is None or not len(tdf):
  117. tdf = pd.DataFrame(columns=['turbine', 'channel', 'dev_K', '判'])
  118. aux = _load_parquet('system_aux.parquet')
  119. ydf = _load_parquet('yaw_daily.parquet')
  120. pairs = temp_nbm.pairs_table()
  121. try:
  122. pcd = pd.read_parquet(pathlib.Path(cfg['store']) / 'powercurve_dev.parquet')
  123. pc_a = {r.turbine: f"dev{r.dev_w:+.1%} ws偏置{r.ws_bias:+.2f}" for _, r in pcd.iterrows() if '风速计' in str(r['判别'])}
  124. except Exception:
  125. pc_a = {}
  126. lub_med = {s: aux[f'lub_{s}'].median() for s in ('主轴承', '齿轮箱', '发电机')}
  127. sc_med = aux['scomerr'].median()
  128. tmap = {'温度候选(定向查)': '报警', '传感器不可信(A类)': '报警', '记基线观察': '良好', '不可判(返服嫌疑)': '不可判'}
  129. result = {}
  130. for t in cfg['turbines']:
  131. sysd = {}
  132. # 每系统: (states, 依据 list)
  133. acc = {s: ([], []) for s in SYSTEMS}
  134. # 变桨 = 分系统四维 + 蓄能深挖
  135. acc['变桨'][0].append(_cell(p_i, t, '系统级'))
  136. _pd_ = [_cell(p_i, t, d, '') for d in pitchmod.DIMS]
  137. if any(_pd_):
  138. acc['变桨'][1].append('四维:' + '/'.join(f'{d}{v}' for d, v in zip(pitchmod.DIMS, _pd_) if v))
  139. else:
  140. acc['变桨'][1].append('四维: 缺件, 不可判')
  141. hv = _cell(h_i, t, '判', '—')
  142. acc['变桨'][0].append('报警' if '候选' in hv else ('良好' if '记基线' in hv else '优秀'))
  143. if hv != '—': acc['变桨'][1].append(f"蓄能: {h_i.loc[t, '依据']}")
  144. # 偏航
  145. acc['偏航'][0].append(_cell(y_i, t, '系统级'))
  146. _yd_ = [_cell(y_i, t, d, '') for d in yawmod.DIMS]
  147. if any(_yd_):
  148. acc['偏航'][1].append('四维:' + '/'.join(f'{d[:2]}{v}' for d, v in zip(yawmod.DIMS, _yd_) if v))
  149. else:
  150. acc['偏航'][1].append('四维: 缺件, 不可判')
  151. # 温度通道归系统
  152. for _, r in tdf[tdf['turbine'] == t].iterrows():
  153. ch = r['channel'].replace('tmp_wtc_', '').replace('_mean', '').replace('_max', '')
  154. sysname = TEMP2SYS.get(ch)
  155. if not sysname: continue
  156. st = tmap.get(str(r['判']), None)
  157. if st:
  158. acc[sysname][0].append(st)
  159. acc[sysname][1].append(f"{ch} {r['dev_K']:+.1f}K({r['判'][:4]})")
  160. # 配对差 (主轴承双温差 / 发电机DE-NDE)
  161. for _, pr in pairs[pairs['turbine'] == t].iterrows():
  162. sysname = '主轴承' if '主轴承' in str(pr['pair']) or 'MBear' in str(pr['pair']) else '发电机'
  163. if abs(pr['pair_dev']) >= 4:
  164. acc[sysname][0].append('良好')
  165. acc[sysname][1].append(f"配对差{pr['pair']} {pr['pair_diff']:+.1f}K(偏{pr['pair_dev']:+.1f})")
  166. # 润滑柱塞 ×fleet (筛查级; 单窗相对门弱, 只报不定级除非极端)
  167. for s in ('主轴承', '齿轮箱', '发电机'):
  168. v = _cell(aux, t, f'lub_{s}', float('nan'))
  169. x = v / max(lub_med[s], 1e-9) if v == v else np.nan
  170. if x == x and (x >= 2.5 or x <= 0.25):
  171. acc[s][0].append('良好')
  172. acc[s][1].append(f"润滑duty {v:.0f}s/日 ({x:.2f}×fleet)")
  173. # 热链轴 (M13): 节点−热汇ΔT / 冷却执行器 duty / 三相极差 → 归所属系统
  174. if TC is not None and t in TC.index:
  175. tr = TC.loc[t]
  176. for col, sysn, thr, lab in (('齿轮箱高速轴−油', '齿轮箱', 4.0, '高速轴−油ΔT'),
  177. ('齿轮箱中速轴−油', '齿轮箱', 4.0, '中速轴−油ΔT'),
  178. ('齿箱油−冷却水', '齿轮箱', 4.0, '油−冷却水ΔT'),
  179. ('发电机绕组−冷却水', '发电机', 6.0, '绕组−冷却水ΔT')):
  180. v = tr.get(col)
  181. if v == v and (v - TC[col].median()) >= thr:
  182. acc[sysn][0].append('良好')
  183. acc[sysn][1].append(f'{lab} {v:.1f}K (超全场中位{v - TC[col].median():+.1f}K)')
  184. ph = tr.get('三相极差')
  185. if ph == ph and ph >= max(3.0, 2.5 * TC['三相极差'].median()):
  186. acc['发电机'][0].append('良好')
  187. acc['发电机'][1].append(f'绕组三相极差 {ph:.1f}K ({ph / TC["三相极差"].median():.1f}×fleet, 仪表/接线候选)')
  188. cd = tr.get('冷却器高速_高油温档'); cm = TC['冷却器高速_高油温档'].median()
  189. if cd == cd and cm > 0.02 and cd >= max(1.5 * cm, cm + 0.1):
  190. acc['齿轮箱'][0].append('良好')
  191. acc['齿轮箱'][1].append(f'冷却器高速档 duty {cd:.2f} ({cd / cm:.2f}×fleet, 同油温档) 换热能力低候选')
  192. # 叶片与叶根 / 塔架与基础 (M16 结构件)
  193. if STX is not None and t in STX.index:
  194. sr = STX.loc[t]
  195. for dim in ('叶片A', '叶片B', '叶片C', '叶根螺栓'):
  196. if sr.get(dim) in ('报警', '良好'):
  197. acc['叶片与叶根'][0].append(sr[dim]); acc['叶片与叶根'][1].append(f"{dim}: {STXD[t]['叶片'][dim][:60]}")
  198. for dim in ('塔筒频率', '塔筒振动', '塔筒湿度'):
  199. v = sr.get(dim)
  200. if v in ('报警', '良好'):
  201. acc['塔架与基础'][0].append(v); acc['塔架与基础'][1].append(f"{dim}: {STXD[t]['塔架'][dim][:70]}")
  202. elif v == '监测不可用':
  203. acc['塔架与基础'][0].append('不可判'); acc['塔架与基础'][1].append(f"{dim}: {STXD[t]['塔架'][dim][:70]}")
  204. # 主控与传感网: 测风(含曲线A类风速计路由) + 通讯
  205. if pc_a.get(t):
  206. acc['主控与传感网'][0].append('报警')
  207. acc['主控与传感网'][1].append(f"曲线判风速计偏置A类 ({pc_a[t]})")
  208. wf = float((_cell(ydf, t, 'windfau_s', 0) or 0) + (_cell(ydf, t, 'windfau_t', 0) or 0))
  209. yfa = int(_cell(ydf, t, 'D4风传感', 0) or 0)
  210. if wf >= 100 or yfa >= 30: acc['主控与传感网'][0].append('报警'); acc['主控与传感网'][1].append(f'测风故障计数{wf:.0f}+报警{yfa}')
  211. elif wf >= 30 or yfa >= 5: acc['主控与传感网'][0].append('良好'); acc['主控与传感网'][1].append(f'测风计数{wf:.0f}/报警{yfa}')
  212. sc = _cell(aux, t, 'scomerr', float('nan'))
  213. scx = sc / max(sc_med, 1e-9) if sc == sc else np.nan
  214. if scx == scx and scx >= 3 and sc >= 50:
  215. acc['主控与传感网'][0].append('良好'); acc['主控与传感网'][1].append(f'通讯错误增量{sc:.0f} ({scx:.1f}×fleet)')
  216. for s in SYSTEMS:
  217. states, why = acc[s]
  218. base = _worst(states) if states else '优秀'
  219. sysd[s] = dict(状态=base, 依据='; '.join(why) if why else '各轴无非常态')
  220. result[t] = sysd
  221. if GAPS:
  222. # 整表挂一条缺件说明 (页面/维护页据此显示"哪些面因缺件不可判"), 不静默造数
  223. result['_缺件说明'] = dict(GAPS=list(GAPS),
  224. 注='这些是"包内没有生成端"的产物 (docs §7); 交付包按用户令不随产物, '
  225. '运行期**不由交付包补齐** (用户令 2026-09-17): 放原始件后重算, '
  226. '或由研发补生成端 (逐族可逆性见 docs §13.6)。')
  227. # 振动映射进分系统状态 (2026-08-28 用户令): handoff 判级按 component 归系统, 取严合并+依据可溯.
  228. # 定论/报警/预警级 → 该系统升'报警'; 候选/监视/观察级 → 状态不动只追加依据 (筛查级展示态,
  229. # 振动判级本身已过双模审, 非本层产判级 — 转录取严)
  230. try:
  231. from .subsys import fusion as _fus
  232. _ftab, _ = _fus.fusion_table(cfg)
  233. _SYSMAP = [('发电机', '发电机'), ('齿轮箱', '齿轮箱'), ('行星', '齿轮箱'), ('中速', '齿轮箱'),
  234. ('高速', '齿轮箱'), ('主轴承', '主轴承'), ('主轴', '主轴承'), ('润滑泵', '主轴承')]
  235. import re as _re
  236. for _, r in _ftab.iterrows():
  237. t2, comp, lv = r.turbine, str(r.部件), str(r.振动结论)
  238. tgt = next((sysn for kw, sysn in _SYSMAP if kw in comp), None)
  239. if not tgt or t2 not in result:
  240. continue
  241. _cls, _mlv = _fus.verdict_class(lv)
  242. alarm = _mlv in ('bad', 'warn') and _cls != 'meta' # 测量链问题不入部件状态
  243. _dates = _re.findall(r'20\d{2}-\d{2}(?:-\d{2})?', str(r.claim_window))
  244. _cw = f"窗至{max(_dates)}" if _dates else '窗未标'
  245. note = f"振动: {comp} {lv} ({_cw}; 振动线转录)"
  246. d2 = result[t2][tgt]
  247. if alarm:
  248. d2['状态'] = '报警'
  249. if d2['依据'] == '各轴无非常态':
  250. d2['依据'] = note
  251. elif '振动:' not in d2['依据']:
  252. d2['依据'] += '; ' + note
  253. # ── windcms 评估报告转录 (2026-08-28 用户令: 与 windcms 核对设备状态评级) ──
  254. # 三方对拍逮出两类真问题, 都是"我方界面比 CMS 乐观":
  255. # A 漏报 6 处: CMS 判报警而本层显示优秀/良好 (09#齿轮箱 / 16#·20#·23#·38#主轴承 / 17#发电机)
  256. # C 10 处: CMS 判不可判而本层显示"优秀" — 把"监测面没覆盖"显示成"设备好", 是纪律问题
  257. # 合并规则 (取严, 不覆盖本层更强的证据):
  258. # CMS 报警/危险 → 抬到该级 (本层已≥该级则不动)
  259. # CMS 不可判 且 本层无自有异常证据 → 标不可判 (没测到 ≠ 正常)
  260. # CMS 良好/优秀 → 不动 (本层可能有 SCADA 侧证据, 那是另一条轴)
  261. try:
  262. _wg = _fus.windcms_grades()
  263. _WCOL = {'主轴承': ('主轴承前', '主轴承后'), '齿轮箱': ('齿轮箱',), '发电机': ('发电机',)}
  264. _ORD = ['危险', '报警', '良好', '优秀', '不可判']
  265. import pathlib as _pl, re as _re3
  266. from src.windcms.config import cms_out as _cms_out # 读写同一口 (WINDCMS_OUT 不能被读侧忽略)
  267. _wmd = sorted(_cms_out().glob('报告_CMS振动状态评估报告_*.md'))
  268. _wdate = _wmd[-1].stem.rsplit('_', 1)[-1] if _wmd else '?'
  269. _wpart = {}
  270. if _wmd:
  271. for _l in _wmd[-1].read_text(encoding='utf-8').split('## 附录 A')[1].splitlines():
  272. if _l.startswith('| WTG'):
  273. _c = [x.strip() for x in _l.strip('|').split('|')]
  274. _wpart[_c[0]] = dict(主轴承前=_c[1], 主轴承后=_c[2], 齿轮箱=_c[3], 发电机=_c[4])
  275. for _t3, _row3 in _wpart.items():
  276. if _t3 not in result:
  277. continue
  278. for _sysn3, _keys in _WCOL.items():
  279. _lvs = [_row3[k] for k in _keys if _row3.get(k) in _ORD]
  280. if not _lvs:
  281. continue
  282. _w = min(_lvs, key=_ORD.index)
  283. _d3 = result[_t3][_sysn3]
  284. _cur = _d3['状态']
  285. _note3 = f"CMS评估报告{_wdate}判{_w}"
  286. if _w in ('危险', '报警') and _cur not in ('危险', '报警'):
  287. _d3['状态'] = _w
  288. _d3['依据'] = (_note3 + ('; ' + _d3['依据'] if _d3['依据'] != '各轴无非常态' else '')).strip()
  289. elif _w == '不可判' and _cur in ('优秀', '良好'):
  290. _d3['状态'] = '不可判'
  291. _d3['依据'] = f"{_note3} (监测面未覆盖 — 没测到≠正常)" + (
  292. '; 本层: ' + _d3['依据'] if _d3['依据'] != '各轴无非常态' else '')
  293. elif _note3 not in _d3['依据'] and _w != _cur:
  294. _d3['依据'] = (_d3['依据'] + f"; {_note3}").lstrip('; ') if _d3['依据'] != '各轴无非常态' else _note3
  295. except Exception as _e3:
  296. for _t3 in result:
  297. for _s3 in ('主轴承', '齿轮箱', '发电机'):
  298. result[_t3][_s3]['依据'] += f'; ⚠CMS报告转录失败({_e3})'
  299. except Exception as e:
  300. # 振动映射失败必须可见 (依据里响亮标注, 不静默降级)
  301. for t2 in result:
  302. for sysn in ('主轴承', '齿轮箱', '发电机'):
  303. result[t2][sysn]['依据'] += f'; ⚠振动映射失败({str(e)[:40]})'
  304. return result
  305. def reconcile(cfg=None):
  306. """逐系统对账交付分册 (仅比 SCADA 可覆盖维; 分册振动维=CMS证据, SCADA 不可见, 不比)."""
  307. cfg = cfg or farm()
  308. mx = system_matrix(cfg)
  309. ref = pathlib.Path(cfg['contract']).parent
  310. out = {}
  311. books = {'主轴承': '主轴承_delivered_grades.json', '齿轮箱': '齿轮箱_delivered_grades.json',
  312. '发电机': '发电机_delivered_grades.json', '变流器': '变流器_delivered_grades.json',
  313. '主控与传感网': '主控传感网_delivered_grades.json'}
  314. # SCADA 可覆盖维 (白名单; 其余维=CMS/巡检证据不比)
  315. cover = {'主轴承': ['温度'], '齿轮箱': ['中速轴', '高速轴'],
  316. '发电机': ['温升'], '变流器': ['水冷系统', '变压器油温'],
  317. '主控与传感网': ['测风', '通讯与控制']} # 只列我真有轴的维; 润滑排油/散热/防凝露/电源电池等未建轴不入对账
  318. for s, fn in books.items():
  319. g = json.loads((ref / fn).read_text(encoding='utf-8'))
  320. book_alarm = {t for t, d in g.items() if any('报警' in str(d.get(c, '')) for c in cover[s])}
  321. mine_alarm = {t for t, d in mx.items() if d[s]['状态'] == '报警'}
  322. mine_near = {t for t, d in mx.items() if d[s]['状态'] in ('报警', '良好')}
  323. out[s] = dict(分册可比维报警=sorted(book_alarm), 我方报警=sorted(mine_alarm),
  324. 交集=sorted(book_alarm & mine_alarm), 分册独有=sorted(book_alarm - mine_alarm),
  325. 我独有=sorted(mine_alarm - book_alarm),
  326. 近失覆盖=f"{len(book_alarm & mine_near)}/{len(book_alarm)} (分册报警台落我报警∪良好)",
  327. 近失漏=sorted(book_alarm - mine_near))
  328. return out