contract_gate.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. # -*- coding: utf-8 -*-
  2. """contract_gate.py — CL-1 清洗调用门: apply_contract_gate(df, contract, section) (docs/SOP.md §2.3/§3.1).
  3. 清洗入口强制经由此函数 (campaign 94 个脚本零读契约 = DP-10 主证的修复本体)。
  4. 执行即落盘 outputs/<farm>/sop/clean_gate.json — artifact 证调用 (sop_check CL-1 断言)。
  5. 语义:
  6. - sentinel → NaN (契约 sentinel 列表, 计数)
  7. - valid_range 界外 → NaN (非 clamp: 越物理界 = 无效测量, clamp 会扭曲统计;
  8. 历史教训: 唐兴振动 raw max 308973 入 mean 冒充头条 [#36]; 振动统计另须 median, 见 SOP.md §3.1)
  9. - 死列检测: 全局常值/全NaN; 给 machine_col 时按跨机判 (单机常值≠死列, memory dec_3p2mw)
  10. - ffill: 契约字段标 ffill: true (事件驱动稀疏状态码 [#14]) 的列前向填充, 留痕
  11. """
  12. try:
  13. from app_common.app_common_guanlan.api import install_root as _install_root
  14. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  15. from pathlib import Path as _P
  16. def _install_root(_f): return _P(_f).resolve().parents[4]
  17. from src import paths as P
  18. import json
  19. import hashlib
  20. from datetime import datetime
  21. from pathlib import Path
  22. ROOT = _install_root(__file__)
  23. def _sha256(p, buf=1 << 20):
  24. h = hashlib.sha256()
  25. with open(p, 'rb') as f:
  26. for blk in iter(lambda: f.read(buf), b''):
  27. h.update(blk)
  28. return h.hexdigest()
  29. def _load_contract(contract):
  30. if isinstance(contract, dict):
  31. return contract, '<dict>'
  32. import yaml
  33. p = Path(contract)
  34. with open(p, encoding='utf-8') as f:
  35. return yaml.safe_load(f), str(p)
  36. def apply_contract_gate(df, contract, section, farm=None, out_dir=None, machine_col=None,
  37. persist=True, time_col=None):
  38. """按契约清洗 df 并落盘 clean_gate.json。返回 (df_clean, report)。
  39. df: pandas.DataFrame; contract: yaml 路径或 dict; section: 契约 section 名 (如 turbine_sec);
  40. farm: outputs/<farm>/sop/ 落盘 (与 out_dir 二选一); machine_col: 跨机死列判定用机号列。
  41. persist: True(默认) 同时落清洗快照 outputs/<farm>/cleaned/<section>.parquet(zstd) +
  42. <section>_fingerprint.json(sha256/行数/窗/契约版本) 供审计 (审计=可复现∧指纹匹配, §3/§9.1.1);
  43. 秒级 GB 层探查跑可传 persist=False 省盘。time_col: 有则指纹记数据窗 min/max。
  44. """
  45. import pandas as pd
  46. doc, cpath = _load_contract(contract)
  47. sec = doc.get(section) or {}
  48. cols = sec.get('columns', sec.get('fields', {})) or {}
  49. n_in = len(df)
  50. d = df.copy()
  51. clip_counts, sentinel_counts, ffill_cols, dead = {}, {}, [], []
  52. for col, spec in cols.items():
  53. if not isinstance(spec, dict) or col not in d.columns:
  54. continue
  55. s = pd.to_numeric(d[col], errors='coerce') if d[col].dtype == object else d[col]
  56. # 1) sentinel → NaN
  57. for sent in (spec.get('sentinel') or []):
  58. cnt = int((s == sent).sum())
  59. if cnt:
  60. s = s.where(s != sent)
  61. sentinel_counts[col] = sentinel_counts.get(col, 0) + cnt
  62. # 2) valid_range 界外 → NaN
  63. vr = spec.get('valid_range') or spec.get('valid_range_canonical')
  64. if isinstance(vr, (list, tuple)) and len(vr) == 2:
  65. bad = ((s < vr[0]) | (s > vr[1]))
  66. cnt = int(bad.sum())
  67. clip_counts[col] = cnt
  68. if cnt:
  69. s = s.where(~bad)
  70. # 3) ffill 声明 (事件驱动稀疏)
  71. if spec.get('ffill'):
  72. s = s.ffill()
  73. ffill_cols.append(col)
  74. d[col] = s
  75. # 4) 死列检测 (跨机判)
  76. for col in d.columns:
  77. if col == machine_col:
  78. continue
  79. s = d[col]
  80. nn = s.notna().sum()
  81. if nn == 0:
  82. dead.append({'col': col, 'reason': 'all-NaN'})
  83. continue
  84. try:
  85. nu = s.nunique(dropna=True)
  86. except TypeError:
  87. continue
  88. if nu <= 1:
  89. if machine_col and machine_col in d.columns:
  90. per_m = d.groupby(machine_col)[col].nunique(dropna=True)
  91. if (per_m <= 1).all():
  92. dead.append({'col': col, 'reason': f'跨机全常值 ({int(per_m.shape[0])} 机)'})
  93. # 单机常值不判死 (memory dec_3p2mw)
  94. else:
  95. dead.append({'col': col, 'reason': '全局常值 (无machine_col, 未跨机验, 降置信)'})
  96. report = {
  97. 'gate': 'CL-1', 'contract': cpath, 'section': section,
  98. 'ts': datetime.now().isoformat(timespec='seconds'),
  99. 'clip_counts': clip_counts or {'_note': '契约无 valid_range 列或零界外'},
  100. 'sentinel_to_nan': sentinel_counts or {'_note': '零哨兵残留'},
  101. 'dead_columns': dead,
  102. 'ffill_applied': ffill_cols,
  103. 'rows_in_out': {'in': int(n_in), 'out': int(len(d))},
  104. 'contract_range_cols': [c for c, sp in cols.items()
  105. if isinstance(sp, dict) and (sp.get('valid_range') or sp.get('valid_range_canonical'))],
  106. }
  107. target = None
  108. if out_dir:
  109. target = Path(out_dir)
  110. elif farm:
  111. target = P.sop(farm)
  112. if target:
  113. target.mkdir(parents=True, exist_ok=True)
  114. (target / 'clean_gate.json').write_text(
  115. json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
  116. # 清洗快照 + 指纹 (用户 2026-06-17 审计留存): 审计=可复现 ∧ 指纹匹配, 对齐 §9.1.1 输入指纹
  117. fp = None
  118. if target and persist:
  119. try:
  120. cleaned_dir = target.parent / 'cleaned'
  121. cleaned_dir.mkdir(parents=True, exist_ok=True)
  122. pq = cleaned_dir / f'{section}.parquet'
  123. d.to_parquet(pq, compression='zstd', index=False)
  124. win = None
  125. if time_col and time_col in d.columns:
  126. tc = pd.to_datetime(d[time_col], errors='coerce')
  127. win = {'min': str(tc.min()), 'max': str(tc.max())}
  128. try:
  129. rel = pq.relative_to(ROOT).as_posix()
  130. except ValueError:
  131. rel = str(pq)
  132. fp = {
  133. 'artifact': 'cleaned_fingerprint', 'section': section, 'parquet': rel,
  134. 'sha256': _sha256(pq), 'rows': int(len(d)), 'cols': int(d.shape[1]),
  135. 'window': win, 'contract': cpath,
  136. 'n_machines': int(d[machine_col].nunique(dropna=True))
  137. if machine_col and machine_col in d.columns else None,
  138. 'ts': report['ts'],
  139. }
  140. (cleaned_dir / f'{section}_fingerprint.json').write_text(
  141. json.dumps(fp, ensure_ascii=False, indent=2), encoding='utf-8')
  142. except Exception as e:
  143. report['persist_error'] = str(e)[:200]
  144. report['cleaned_fingerprint'] = fp
  145. return d, report