analysis_kit.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. # -*- coding: utf-8 -*-
  2. """analysis_kit — 分析层原子动作库 (2026-07-26)
  3. **定位**: discriminators.py = 高层判别器 (nbm_residual/fleet_sector_devz…, 复用良好 25-36处);
  4. 本模块 = 其下的**原子动作层** —— 每个分析脚本都要写、每次都可能写歪的那些小函数。
  5. **为什么建**: 如东一场 14 个 per 场脚本只 1 个 import 库; 4 个并行 agent 各自重写 MAD-z
  6. (一个脚本内实现 19 次)、各自定义发电态 → 口径微差 → 独立审逮到"方向对、绝对值飘"。
  7. **每个函数都是踩坑后定下的纪律**, 收进库 = 把纪律从文档里的话变成代码里的默认行为。
  8. **已有工具索引 (别重复造! 这些在 discriminators.py 里)**:
  9. near_equal_columns — 重复列/别名检测 (逐值近等+线性别名双路; ★禁只用 corr, 如东油压列误弃戒)
  10. reference_channel_liveness — 参考通道 per台存活校验 (淄川舱外温冻结戒)
  11. fleet_sector_devz — 控扇区机群相对功率 devz
  12. nbm_residual — 温度 NBM 残差
  13. availability_calibers — A_WIL/A_PBA 多口径可用率 (★横比须传独立风列, 阜山 A11 戒)
  14. curtail_vs_fault_sync — 限电 vs 故障同步性三证
  15. mast_usability / ntf_correction / cp_operating_peak / matched_load_overheat …
  16. 用法: `from src.sop.analysis_kit import mad_z, gen_mask, drop_partial_periods, ...`
  17. """
  18. from __future__ import annotations
  19. import numpy as np
  20. import pandas as pd
  21. MADK = 1.4826
  22. __all__ = ['mad_z', 'near_miss', 'expected_fp', 'gen_mask', 'drop_partial_periods',
  23. 'fleet_relative_dev', 'liveness', 'caliber_stamp', 'sensitivity_sweep',
  24. 'theil_sen', 'episode_segments', 'changepoint_events', 'trajectory_shape',
  25. 'repair_split_eval']
  26. # ---------------------------------------------------------------- 统计原子
  27. def mad_z(s, *, center=None, scale_floor=None, resolution=None):
  28. """稳健 z = (x − median) / (1.4826·MAD)。
  29. ★铁律 (阜山 A12 戒): **必须对全精度值算**, 禁 mad_z(round(x, 2)) ——
  30. 门边台 z 偏移可达 0.07, 直接翻转 gate 成员 (A12 TSR −2.52 → −2.45 伪影)。
  31. round 只用于显示, 不用于判定。MAD=0 时返回全 0 (不炸)。
  32. ★★铁律二 (如东 34# 戒, 2026-08-07): **台间高度一致时 z 会虚高** ——
  33. 分母 MADK·MAD 趋零, 微小绝对差被放大成巨大 z。实证: 高速轴窗内 stddev
  34. 台间 MAD 仅 0.03, 34# 与 fleet 绝对差 **0.066℃** 却得 z=**−8.2**(本轮最强信号),
  35. 复核后是假阳 —— 该测点**量化步长 1℃**, 0.066℃ 无物理意义。
  36. `resolution=`: 给通道的量化步长/噪声底, **绝对差 < resolution 的一律置 0**
  37. (比设常数地板更物理: 闸绑定测量分辨率而非拍脑袋)
  38. `scale_floor=`: 直接给 MAD 下限 (SOP §7 缺口⑤ 原设想; 温控紧列 MAD~0.04℃ 型)
  39. ⚠适用边界 (如东润滑响应戒): 本闸**只约束单点/单窗比较**, 不约束大样本聚合量 ——
  40. 180 次事件的中位差 0.59K 虽 < 1℃ 步长, 但大数定律下有效, 那种场景勿套本闸。
  41. """
  42. s = pd.Series(s, dtype='float64')
  43. med = float(s.median()) if center is None else float(center)
  44. mad = float((s - med).abs().median())
  45. scale = MADK * mad
  46. if scale_floor is not None:
  47. scale = max(scale, float(scale_floor))
  48. if scale <= 0:
  49. return s * 0.0
  50. z = (s - med) / scale
  51. if resolution is not None:
  52. z = z.where((s - med).abs() >= float(resolution), 0.0)
  53. return z
  54. def near_miss(z, *, gate=2.5, band=(2.0, 2.5)):
  55. """门边披露: 返回 {'gate': 破门集, 'near_miss': 刀刃带集}。
  56. ★铁律 (精确集断言錯型, 已复发 3 次): 任何 `==[]` / `全台<阈` / `恰 N 台` 的
  57. 空集/全称/精确计数断言, 若集合来自硬 cutoff, **交付前必带 near_miss**, 且
  58. 绝对断言须配 sensitivity_sweep 多口径核 —— 换口径即翻的断言不可印给业主。
  59. """
  60. z = pd.Series(z, dtype='float64')
  61. a = z.abs()
  62. return {'gate': sorted(z.index[a > gate].tolist()),
  63. 'near_miss': sorted(z.index[(a > band[0]) & (a <= band[1])].tolist()),
  64. 'gate_thresh': gate, 'near_miss_band': list(band)}
  65. def expected_fp(n_compare, alpha=0.046):
  66. """RULE-4: 筛查必附期望假阳数 = N_比较 × α。
  67. 比较族按 **metric × 台 总数**计 (逐台筛查的多重空间是台数, 非 metric 数)。
  68. α 默认 0.046 ≈ |z|>2 双尾。返回 {'n_compare', 'alpha', 'expected_fp'}。
  69. """
  70. return {'n_compare': int(n_compare), 'alpha': alpha,
  71. 'expected_fp': round(float(n_compare) * alpha, 2)}
  72. def sensitivity_sweep(fn, values, *, label='caliber'):
  73. """口径敏感性核: 对一组口径值跑同一判定, 返回 {值: 结果} —— 供门边绝对断言使用。
  74. fn(v) 应返回可比对象 (如 flag 集合 / z 值)。用法:
  75. sweep = sensitivity_sweep(lambda c: set(redflag(cutin=c)), [2.5, 3.0, 3.5, 4.0])
  76. 结果集随口径变 → 断言必须写成"口径敏感 (值域 …)", 禁写单点绝对结论。
  77. """
  78. out = {}
  79. for v in values:
  80. try:
  81. r = fn(v)
  82. out[str(v)] = sorted(r) if isinstance(r, (set, list, tuple)) else r
  83. except Exception as e: # 口径不适用如实报, 不静默吞
  84. out[str(v)] = f'ERROR: {type(e).__name__}: {e}'
  85. stable = len({str(x) for x in out.values()}) == 1
  86. return {'sweep_by_' + label: out, 'stable_across_calibers': stable,
  87. 'note': '结果随口径变 → 断言须带口径限定, 禁单点绝对结论' if not stable else '口径稳健'}
  88. # ---------------------------------------------------------------- 数据面原子
  89. def gen_mask(df, *, power_col, rated_kw, min_frac=0.0125, setpoint_col=None,
  90. setpoint_min=None, extra=None):
  91. """发电态掩码 (统一口径, 防各脚本各写一版)。
  92. P > min_frac·rated (默认 1.25% ≈ 阜山 50/4000 量级) [∧ setpoint ≥ setpoint_min]。
  93. setpoint_col 给出时同时剥限电/降档样本 (阜山 pset≥2000 型)。
  94. 返回 (mask, caliber_dict) —— caliber 直接塞进产物 json (见 caliber_stamp)。
  95. ⚠ **`setpoint ≥ 常数` 不等于"剥限电" —— 用前必先验该 setpoint 列是不是调度封顶**
  96. (2026-07-26 独立审逮, 原 docstring 的"如东 PowerRef≥3990 型"举例**是错的, 已删**):
  97. 如东 `wtc_PowerRef_endvalue` **跟随可用功率**而非调度封顶 —— 实测 corr(PowerRef, 风速)=0.484,
  98. 且逐风速 bin 的 PowerRef 中位紧贴该 bin 的 ActPower 中位 (5-6m/s: 535 vs 320;
  99. 9-10m/s: 2234 vs 1943; >12m/s: 4000 vs 3986)。故 `PowerRef<3990` ≈ **"运行在额定以下"**,
  100. 发电态占比 66.8% 只是海上场的低于额定占空比, **不是限电率**; 真限电只在**额定以上**可辨
  101. (>12m/s 段该占比降到 33.1%)。
  102. **正确判据**: setpoint 显著低于**该风速下的可达功率**, 或 `setpoint<额定 ∧ ws>额定风速`。
  103. 误用后果 (本轮实证): 气动闸只剩 33.2% 样本, 风速中位 9.12→10.86 m/s,
  104. Region-2 (桨距<2°) 样本 −68% —— 恰好闸掉了 Cp 工作峰所在段。
  105. """
  106. m = df[power_col] > min_frac * rated_kw
  107. cal = {'power_col': power_col, 'rated_kw': rated_kw, 'min_frac': min_frac,
  108. 'gen_thresh_kw': round(min_frac * rated_kw, 2)}
  109. if setpoint_col is not None and setpoint_min is not None:
  110. m = m & (df[setpoint_col] >= setpoint_min)
  111. cal.update({'setpoint_col': setpoint_col, 'setpoint_min': setpoint_min,
  112. 'note': '同时剥限电/降档样本'})
  113. if extra is not None:
  114. m = m & extra
  115. cal['extra_filter'] = str(extra.name) if hasattr(extra, 'name') else 'custom'
  116. cal['n_rows_kept'] = int(m.sum())
  117. return m, cal
  118. def drop_partial_periods(df, *, time_col, freq='M', min_frac=0.8, expected_rows=None,
  119. per_tid=None):
  120. """剔不完整周期 (★阜山残月戒: 2024-01 仅 13h 进月度回归 → E_mast σ 被搅 5 倍,
  121. A2 假下行 −14.8→+0.25, A15 假近门 −7.02→−0.59)。
  122. 按 freq 分组, 保留行数 ≥ min_frac × 该周期期望行数的周期。
  123. expected_rows=None 时用各周期行数中位数作期望 (自适应, 免手填采样率)。
  124. per_tid=<台号列>: 多台长表**必传** —— 否则 fleet 合计行数会掩盖单台残月
  125. (且台数变化的周期会被误判完整)。返回 (df_filtered, caliber_dict)。
  126. """
  127. if per_tid is not None: # 多台长表: 按 (周期) 计每台平均行数
  128. n_tid = df[per_tid].nunique()
  129. p = df[time_col].dt.to_period(freq)
  130. cnt = (p.value_counts() / max(n_tid, 1)).round(0)
  131. else:
  132. p = df[time_col].dt.to_period(freq)
  133. cnt = p.value_counts()
  134. exp = float(cnt.median()) if expected_rows is None else float(expected_rows)
  135. keep = cnt[cnt >= min_frac * exp].index
  136. cal = {'freq': freq, 'min_frac': min_frac, 'expected_rows_per_period': round(exp, 1),
  137. 'periods_kept': len(keep), 'periods_dropped': sorted(str(x) for x in cnt.index if x not in set(keep))}
  138. return df[p.isin(keep)], cal
  139. def fleet_relative_dev(df, *, tid_col, time_col, value_col, freq='M', ref='median',
  140. min_tids_per_period=3):
  141. """机群相对偏差 (扣同期共模) —— 趋势/横比的标准前处理。
  142. per (周期, 台) 聚合 → 减该周期 fleet 参考 (median/mean) → 相对偏差 %。
  143. ★为什么必须扣共模: 阜山单塔山地 ratio 季节摆 0.130 会冒充漂移 (13/18 台假 flag)。
  144. 返回 (wide_df[台×周期 的相对偏差%], caliber_dict)。
  145. """
  146. p = df[time_col].dt.to_period(freq)
  147. piv = df.assign(_p=p).groupby([tid_col, '_p'])[value_col].median().unstack()
  148. # ★稀台周期守卫 (2026-07-26 轴1+5 重构逮): 某周期只剩 1-2 台时, 扣共模会把该台
  149. # 自身钉成 0 (单台) 或造 ±对称伪点 (两台) → 趋势斜率被伪造点污染 (如东 38B PV
  150. # 斜率 0.72 → 剔稀台月后 4.81)。周期有效台数 < min_tids_per_period 的列整列置 NaN。
  151. n_per_period = piv.notna().sum(axis=0)
  152. thin = n_per_period[n_per_period < min_tids_per_period].index.tolist()
  153. fleet = piv.median(axis=0) if ref == 'median' else piv.mean(axis=0)
  154. rel = piv.sub(fleet, axis=1).div(fleet, axis=1) * 100.0
  155. if thin:
  156. rel[thin] = np.nan
  157. return rel, {'freq': freq, 'ref': ref, 'value_col': value_col,
  158. 'min_tids_per_period': min_tids_per_period,
  159. 'thin_periods_nulled': [str(x) for x in thin],
  160. 'note': '扣同期 fleet 共模后的相对偏差(%); 趋势斜率须在此基础上算; 稀台周期已置NaN防伪造点'}
  161. def liveness(df, *, tid_col, cols=None, kind='continuous', work_thresh=None,
  162. stuck_min_run=None, time_col=None):
  163. """per台×per列 存活扫描 (★阜山 A12 戒: 全场聚合 nonzero% 掩盖单台死列)。
  164. ★★ kind 必须按列型选 (如东两次误杀戒, SOP §2.2 CT-2 断言③):
  165. - 'continuous' 连续量 (温度/风速/功率): nonzero% + nuniq + std
  166. - 'event' 事件/计数/状态位列: **低 nuniq + 大量零值是正常形态非死列** →
  167. 只报取值枚举与非零行数, 不下死列判定 (须调用方与功率/状态行为交叉)
  168. work_thresh: 工作区间阈 (如转速 >10rpm) → 判活阈与工作阈**双报** (只报前者会低估死度)。
  169. stuck_min_run + time_col: 卡滞检测 (最长恒值 run; ★如东 03E 卡值达数月,
  170. nuniq 判据抓不到, 只有 run-length 逮得到)。
  171. ★★ 卡滞结果必须回喂给下游趋势/漂移计算 (2026-07-26 kit 重构对拍二次逮):
  172. ① 卡值未必是 0 —— 如东实测 0.010 (03E 连 6 月/10F 连 5 月中位恒 0.010),
  173. `x > 0` 型过滤完全无效, 须按实测卡值设阈 (查 stuck_value/月度中位);
  174. ② **只滤卡值行不够** —— 卡滞占前 5-7 个月时, 剩余月份的"从卡滞恢复"本身
  175. 造成假斜率 (03E 滤行后仍 28.7%/yr 居首, 压过真候选 06F 的 4.05%);
  176. → **卡滞台须整体排除出漂移/趋势评估** (其漂移不可评估), 并在产物里
  177. 显式列出被排除台及其假斜率, 禁静默丢弃。
  178. """
  179. cols = cols or [c for c in df.columns
  180. if c not in (tid_col, time_col) and pd.api.types.is_numeric_dtype(df[c])]
  181. rows = []
  182. for tid, g in df.groupby(tid_col):
  183. for c in cols:
  184. s = g[c]
  185. r = {'tid': tid, 'col': c, 'kind': kind, 'n': len(s),
  186. 'notna_pct': round(100 * s.notna().mean(), 2), 'nuniq': int(s.nunique())}
  187. if kind == 'event':
  188. vc = s.dropna().value_counts().head(8)
  189. r.update({'values': {float(k): int(v) for k, v in vc.items()},
  190. 'nonzero_rows': int((s.fillna(0) != 0).sum()),
  191. 'verdict': 'EVENT_COL_需行为交叉判活 (禁用连续量口径判死)'})
  192. else:
  193. nz = float((s.fillna(0) != 0).mean())
  194. r.update({'nonzero_pct': round(100 * nz, 2), 'std': float(s.std() or 0)})
  195. if work_thresh is not None:
  196. r['work_thresh'] = work_thresh
  197. r['above_work_pct'] = round(100 * float((s > work_thresh).mean()), 2)
  198. r['verdict'] = ('DEAD' if (r['nuniq'] <= 1 or r['std'] == 0) else
  199. 'SPARSE' if nz < 0.01 else 'ALIVE')
  200. if stuck_min_run and time_col is not None and len(s) > 1:
  201. # ★NaN-run 与恒值-run 必须分扫 (2026-07-26 三路重构独立报同一 bug):
  202. # 混计会把断档误标 stuck (如东 24C 8.2天缺失段假阳), 且掩盖真恒值段。
  203. v = s.to_numpy()
  204. isna = pd.isna(v)
  205. mx_val, mx_val_v, mx_nan, i = 0, None, 0, 0
  206. while i < len(v):
  207. j = i
  208. if isna[i]:
  209. while j + 1 < len(v) and isna[j + 1]:
  210. j += 1
  211. if j - i + 1 > mx_nan:
  212. mx_nan = j - i + 1
  213. else:
  214. while j + 1 < len(v) and (not isna[j + 1]) and v[j + 1] == v[i]:
  215. j += 1
  216. if j - i + 1 > mx_val:
  217. mx_val, mx_val_v = j - i + 1, v[i]
  218. i = j + 1
  219. r['max_const_run'] = int(mx_val) # 真恒值 run (不含 NaN)
  220. r['max_nan_run'] = int(mx_nan) # 断档 run (单列报, 不当 stuck)
  221. if mx_val >= stuck_min_run:
  222. r['stuck_flag'] = True
  223. r['stuck_value'] = float(mx_val_v) if mx_val_v is not None else None
  224. if mx_nan >= stuck_min_run:
  225. r['gap_flag'] = True # 断档≠卡滞: 处置不同(补数 vs 换传感器)
  226. rows.append(r)
  227. return pd.DataFrame(rows)
  228. # ---------------------------------------------------------------- 交付原子
  229. def caliber_stamp(**calibers):
  230. """口径戳: 把各步骤 caliber_dict 汇总成产物 json 的 `_caliber` 节。
  231. ★铁律 (独立审 R7 实测): 多 agent 数字"方向全一致但绝对值屡有口径差" ——
  232. 无口径定义则跨场复用时"方向对、数字飘"。每个判据的口径 (列/阈/窗/台集/参考系)
  233. 必随产物落盘。
  234. """
  235. return {'_caliber': calibers,
  236. '_caliber_note': '口径定义随产物落盘 (SOP 元层纪律); 复现须按此口径'}
  237. # ------------------------------------------------- 轨迹/趋势原子 (2026-08-07 如东回灌)
  238. def theil_sen(y, x=None):
  239. """Theil-Sen 稳健斜率 (公开原子; 解 SOP §7 缺口②)。
  240. 原 `discriminators._theil_sen(y)` 私有且签名只收 y (隐含等间距索引), 外部脚本
  241. 无法传自定义 x → 本轮如东扫老化趋势时直接踩到 TypeError。此处公开并支持 x。
  242. ⚠**铁律 (SOP §7 缺口④原文): Theil 对阶跃会摊成假渐进** —— 断崖型失效若用
  243. Theil 拟合, 会报出一个"缓慢下降"的斜率, 掩盖真实的突变时刻。**用前必先过
  244. `trajectory_shape()` 判形态**: 只有 shape='drift' 才可用斜率描述。
  245. 实证 (如东 10#): 月度 V_cyc 4.02→3.06 用 Theil 读作"缓降", 实为 2026-06-29
  246. **单周** 4.73→0.76 的断崖。
  247. """
  248. y = np.asarray(pd.Series(y, dtype='float64').dropna())
  249. n = len(y)
  250. if n < 3:
  251. return 0.0
  252. x = np.arange(n, dtype='float64') if x is None else np.asarray(x, dtype='float64')[-n:]
  253. i, j = np.triu_indices(n, k=1)
  254. dx = x[j] - x[i]
  255. ok = dx != 0
  256. if not ok.any():
  257. return 0.0
  258. return float(np.median((y[j][ok] - y[i][ok]) / dx[ok]))
  259. def episode_segments(flag, *, min_len=1):
  260. """连续段分割 (解 SOP §7 缺口⑥): 布尔序列 → [(start_idx, end_idx, length), ...]。
  261. ★为什么需要 (如东 27# 戒, 2026-08-07): 只看"超限周占比"会把
  262. **一次长发作**与**多次短发作**混为一谈 —— 二者机制完全不同
  263. (前者=持续劣化, 后者=间歇性)。27# 41 周中 15 周超限(37%), 分割后是
  264. **5 个发作段**(长度 1/5/7/1/1) → 判为反复发作型, 而非单次事件。
  265. """
  266. f = pd.Series(flag).fillna(False).astype(bool).reset_index(drop=True)
  267. out, start = [], None
  268. for i, v in enumerate(f):
  269. if v and start is None:
  270. start = i
  271. elif not v and start is not None:
  272. if i - start >= min_len:
  273. out.append((start, i - 1, i - start))
  274. start = None
  275. if start is not None and len(f) - start >= min_len:
  276. out.append((start, len(f) - 1, len(f) - start))
  277. return out
  278. def changepoint_events(s, *, k_mad=6.0, min_abs=None, rel_to=None):
  279. """轨迹突变点 (解 SOP §7 缺口③): 返回 [(idx, before, after, delta), ...]。
  280. 单步差分超过 k_mad 倍**自身差分稳健尺度**即记一次突变。
  281. ★★铁律一 (如东 10# 戒): **粒度决定能否看见突变** —— 同一台 V_cyc,
  282. 月粒度读作"缓慢下滑"(4.02→3.06), 周粒度才看出是 **2026-06-29 单周
  283. 4.73→0.76(−84%)** 的断崖。**断崖型失效必须用周粒度复核, 月度是钝的**。
  284. ★★铁律二 (如东 27# 戒): 返回的 idx 是**差分序列的位置**, 对应
  285. "变化发生后的那一点"。本轮曾把它当成峰值周去做交叉核对, 查到的却是
  286. 相邻的正常周, 得出"孤立通道"的**假阴性**。**定位事件请用 idxmax(|dev|),
  287. 不要用差分索引**。
  288. `min_abs=`: 绝对幅度门 (配合 mad_z 的 resolution 思路, 防高一致序列虚报)
  289. `rel_to=`: 给基准值时, min_abs 按其比例解释
  290. """
  291. s = pd.Series(s, dtype='float64').dropna()
  292. if len(s) < 4:
  293. return []
  294. d = s.diff().dropna()
  295. mad = float((d - d.median()).abs().median()) * MADK
  296. if mad <= 0:
  297. # ★阶跃序列的 diff 大部分为 0 ⇒ MAD(diff)=0, 直接返回会漏掉唯一的那次突变
  298. # (本测试逼出的真 bug, 2026-08-07)。退回非零 diff 的稳健尺度。
  299. # MAD(diff)=0 ⇒ "绝大多数时刻无变化" ⇒ 任何非零变化都是突变。
  300. # (曾误用"非零 diff 的中位"做尺度 → 唯一那次突变自己就是中位, 永远检不出)
  301. out0 = []
  302. for pos0, (idx0, dv0) in enumerate(d.items()):
  303. if dv0 == 0:
  304. continue
  305. if min_abs is not None and abs(dv0) < (min_abs * (abs(rel_to) if rel_to else abs(float(s.median()))) if rel_to else min_abs):
  306. continue
  307. out0.append((idx0, float(s.iloc[pos0]), float(s.iloc[pos0 + 1]), float(dv0)))
  308. return out0
  309. base = float(abs(rel_to)) if rel_to else float(abs(s.median()))
  310. out = []
  311. for pos, (idx, dv) in enumerate(d.items()):
  312. if abs(dv - d.median()) <= k_mad * mad:
  313. continue
  314. if min_abs is not None and abs(dv) < (min_abs * base if rel_to else min_abs):
  315. continue
  316. out.append((idx, float(s.iloc[pos]), float(s.iloc[pos + 1]), float(dv)))
  317. return out
  318. def trajectory_shape(s, *, gate=None, tail=6, head=6):
  319. """轨迹形状分型 (解 SOP §7 缺口④): 阶跃/反复发作/漂移/平台/正常。
  320. ★为什么形态比数值重要 (如东 2026-08-07 全轮教训): **"现在多差"不是紧迫性依据,
  321. "还在不在变"才是**。同为蓄能器失效: 已跌到底且斜率归零的台**损失已发生、
  322. 再快也追不回**(可排期); 仍在下降的台**每早一周处置就少损失一周**(该抢)。
  323. 本轮按"现值高低"排的派工序列因此是错的, 改按形态重排。
  324. 返回 dict: shape ∈ {'cliff_then_plateau','recurrent','drifting','flat_offset','normal'}
  325. + n_episodes / last_mean / delta / slope。
  326. shape='drifting' 时斜率才有意义 (见 theil_sen 的 Theil-对阶跃警告)。
  327. ★派工排序 override (如东 U6 外审双席 2026-08-08): 形态排序 (还在变>反复>已到底) 之上
  328. 再加一层 — **已触保护阈随时跳机的台排最前** (绝对水平接近/超过保护整定值时,
  329. 形态再"平"也压不过"随时会停机"; 排序=override(近保护阈) → 形态 → 现值)。
  330. """
  331. s = pd.Series(s, dtype='float64').dropna()
  332. if len(s) < 8:
  333. return {'shape': 'insufficient', 'n': len(s)}
  334. cps = changepoint_events(s, k_mad=6.0)
  335. last, first = float(s.iloc[-tail:].mean()), float(s.iloc[:head].mean())
  336. slope = theil_sen(s.values)
  337. g = gate if gate is not None else float(s.median() + 3 * MADK * (s - s.median()).abs().median())
  338. eps = episode_segments(s > g)
  339. tail_slope = theil_sen(s.iloc[-tail:].values)
  340. # ★优先级按 派工紧迫性 排 (如东 2026-08-07 对拍逼出的修正):
  341. # 还在变的最急 → 反复发作次之 → 已到底可排期 → 稳态偏置 → 正常。
  342. # 曾把 recurrent 放最前, 致尾斜率 −1.94 (仍在急降) 的 7# 被判 recurrent, 掩盖紧迫性。
  343. scale = MADK * float((s - s.median()).abs().median())
  344. if scale <= 0:
  345. scale = abs(float(s.median())) * 0.02 or 1e-9
  346. if abs(tail_slope) > 0.15 * scale:
  347. shape = 'drifting'
  348. elif len(eps) >= 2:
  349. shape = 'recurrent'
  350. elif cps:
  351. shape = 'cliff_then_plateau'
  352. elif abs(last - first) > scale:
  353. shape = 'flat_offset'
  354. else:
  355. shape = 'normal'
  356. return {'shape': shape, 'n_episodes': len(eps), 'n_changepoints': len(cps),
  357. 'last_mean': last, 'first_mean': first, 'delta': last - first,
  358. 'slope': slope, 'tail_slope': tail_slope}
  359. def repair_split_eval(s, *, event, exclude=None, min_side=3):
  360. """换件/检修前后分段对比原子 (维修后评估 known-answer 回测; 如东 U4 五台换件回灌 2026-08-08)。
  361. s: 周期聚合 Series (月度中位等), index 为 'YYYY-MM' 字符串或可与 event 比较的时间标签。
  362. event: 换件时点 (同 index 类型)。event 当期**恒剔** (换件月混两态); exclude 再剔污染期
  363. (如充脂月污染油样/泵秒数)。
  364. ★铁律一 (更换是最好的实验): 换件在窗内 = 天然 known-answer 机制检验 —
  365. 换后回归基准 = 根因在被换件上; 换后不回归/有残余 = 根因另有所在
  366. (如东 14# 换泵即愈=泵侧根因 vs 26# 清废脂不愈=排油侧根因; 19# 换泵后残余+20%=排油背压另存)。
  367. ★铁律二 (memory degradation-timewindow): 换件在窗前 (数据窗全在 event 后) → verdict='all_post',
  368. **全窗都是"换后状态", 禁把全窗统计当"换前基线"**; 判"现在坏不坏"必现窗。
  369. 对偶: 数据窗全在 event 前 → 'all_pre' (换后效果本窗不可评)。
  370. ★铁律三 (不设 magic 阈): improved/worsened 的判定阈由调用方按通道物理定 (RULE-3),
  371. 本原子只给 pre/post 中位 + delta + ratio; 方向语义 (低=好 or 高=好) 调用方持有。
  372. 返回 {pre_median, post_median, delta, ratio, n_pre, n_post, verdict, excluded}
  373. verdict ∈ {'pre_post_available', 'all_post', 'all_pre', 'insufficient'}。
  374. """
  375. s = pd.Series(s).dropna()
  376. excl = {str(event)} | {str(x) for x in (exclude or [])}
  377. s = s[~s.index.astype(str).isin(excl)]
  378. pre = s[s.index < event]
  379. post = s[s.index > event]
  380. if len(s) == 0 or (len(pre) < min_side and len(post) < min_side):
  381. return {'pre_median': None, 'post_median': None, 'delta': None, 'ratio': None,
  382. 'n_pre': int(len(pre)), 'n_post': int(len(post)),
  383. 'verdict': 'insufficient', 'excluded': sorted(excl)}
  384. if len(pre) < min_side:
  385. return {'pre_median': None, 'post_median': round(float(post.median()), 3), 'delta': None,
  386. 'ratio': None, 'n_pre': int(len(pre)), 'n_post': int(len(post)),
  387. 'verdict': 'all_post', 'excluded': sorted(excl),
  388. 'note': '换件在窗前: 全窗=换后状态, 禁当"换前基线"'}
  389. if len(post) < min_side:
  390. return {'pre_median': round(float(pre.median()), 3), 'post_median': None, 'delta': None,
  391. 'ratio': None, 'n_pre': int(len(pre)), 'n_post': int(len(post)),
  392. 'verdict': 'all_pre', 'excluded': sorted(excl),
  393. 'note': '换件在窗末/窗外: 换后效果本窗不可评'}
  394. pm, qm = float(pre.median()), float(post.median())
  395. return {'pre_median': round(pm, 3), 'post_median': round(qm, 3),
  396. 'delta': round(qm - pm, 3), 'ratio': round(qm / pm, 3) if pm else None,
  397. 'n_pre': int(len(pre)), 'n_post': int(len(post)),
  398. 'verdict': 'pre_post_available', 'excluded': sorted(excl)}