sop_findings_from_ledger.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""事实契约的**重算台账生成端**(用户令 2026-09-19:「要让观澜从重算台账生成 claim」)。
  4. ## 背景(为什么换掉人裁底稿)
  5. 门户的「本场结论·契约生成」段、`/detail` 的问答/报告面(`/api/facts`)都读事实契约
  6. (`outputs/<场>/guanlan/facts_contract_v0.json` + `derived/{detail_cards,portal_claims,qa_refs}.json`)。
  7. 契约的输入此前是**人裁底稿**:`sop/findings.json`(119 条结论)+ `paradigm_r1` 的 E3/E5/E8 三份底稿
  8. —— 清过产物、只从 `data/raw` 重算的机器上它们必然不在位 ⇒ 契约面永远 503、门户结论段停在旧数。
  9. 用户令选定:**由观澜从重算台账生成 claim**,这三处随重算刷新。
  10. ## 本器做什么
  11. 按 `scripts/guanlan_facts_contract.py` 读的四份输入的**字段结构**产出同结构件(契约构建器一行不改):
  12. outputs/<场>/sop/findings.json 结论本体(claim 列表)
  13. outputs/<场>/paradigm_r1/experiments/E5_coverage_table/底稿.json finding_rows: 模块映射
  14. outputs/<场>/paradigm_r1/experiments/E3_candidate_closure/底稿.json rows: 归宿提议
  15. outputs/<场>/paradigm_r1/experiments/E8_rudong_rebuild/底稿_s0.json findings_rows: 系统/台号
  16. claim 全部由**重算产物**算出(每条都记 `source_refs` 的件与 sha16):
  17. · 报警集中台 windscada/alarms.parquet(逐台计数 + 同族码集中度)
  18. · 重复检修件 windscada/workorders.parquet(同台同部件重复更换)
  19. · 停机损失集中 windscada/loss_monthly.parquet(停机态损失占比)
  20. · 温度离群台 windscada/temp_bins.parquet(同工况同通道 dev 离群)
  21. · 功率曲线偏离台 windscada/powercurve_dev.parquet(同风速残差)
  22. · 振动面报警台 m5_cms_tcm/fusion_38.csv(融合级 定论/准定论·预警)
  23. ## 判据纪律(不造数)
  24. · 每条 claim 的 `verdict` 只取契约六枚举,且**只能用台账能支撑的那一档**:全部相对判据 + 无实物锚 ⇒ 最高
  25. 「候选」(振动面来自观澜自算融合, 同样封顶候选);口径性/样本性问题用「参考」或「INSUFFICIENT」。
  26. · 逐条写 `mandatory_limitation`(缺什么证据)与 `falsifiability`(怎么证伪),而不是只给结论。
  27. · `coverage` 写**实算**的窗天数与覆盖台数; 点名台号写进 `sample_level.named`。
  28. · 与振动线/厂家正本无关: 本件是**观澜自算结论**, 不冒充人工裁决(generate 侧写在文件头与 meta)。
  29. 用法:
  30. python scripts/sop_findings_from_ledger.py # 生成四份输入件并自登记
  31. python scripts/sop_findings_from_ledger.py --dry-run # 只报会生成几条 claim
  32. python scripts/sop_findings_from_ledger.py --status
  33. """
  34. from __future__ import annotations
  35. import argparse
  36. import hashlib
  37. import json
  38. import pathlib
  39. import sys
  40. import time
  41. import pandas as pd
  42. ROOT = pathlib.Path(__file__).resolve().parents[1]
  43. sys.path.insert(0, str(ROOT))
  44. sys.path.insert(0, str(ROOT / 'scripts'))
  45. from src import paths as P # noqa: E402
  46. SIX = ('定论', '准定论·预警', '候选', '参考', 'INSUFFICIENT', '撤回')
  47. OUT_FILES = ('sop/findings.json',
  48. 'paradigm_r1/experiments/E5_coverage_table/底稿.json',
  49. 'paradigm_r1/experiments/E3_candidate_closure/底稿.json',
  50. 'paradigm_r1/experiments/E8_rudong_rebuild/底稿_s0.json')
  51. def _sha16(p: pathlib.Path) -> str:
  52. return hashlib.sha256(p.read_bytes()).hexdigest()[:16]
  53. def _win(t, days: int) -> tuple:
  54. """(起, 止) 与窗天数 —— 从数据实算, 不写死。"""
  55. end = pd.to_datetime(t.max())
  56. start = end - pd.Timedelta(days=days)
  57. return start, end, int((end - start).days)
  58. def claims(store: pathlib.Path, m5: pathlib.Path) -> list[dict]:
  59. out: list[dict] = []
  60. def add(cid, title, verdict, refs, *, named=(), systems=(), module='', window_days=None,
  61. n_machines=None, limitation='', falsify='', nature='', claim_class='相对判据',
  62. coverage_note=''):
  63. out.append(dict(id=cid, title=title, verdict=verdict, source_refs=refs, named=list(named),
  64. systems=list(systems), module=module, window_days=window_days,
  65. n_machines=n_machines, mandatory_limitation=limitation, falsifiability=falsify,
  66. problem_nature=nature, claim_class=claim_class, coverage_note=coverage_note))
  67. # ── ① 报警集中台 ────────────────────────────────────────────────────────────
  68. ap = store / 'alarms.parquet'
  69. if ap.is_file():
  70. al = pd.read_parquet(ap)
  71. al['t_on'] = pd.to_datetime(al['t_on'], errors='coerce')
  72. s, e, days = _win(al['t_on'], 365)
  73. w = al[(al.t_on >= s) & (al.t_on <= e)]
  74. cnt = w.groupby('turbine').size().sort_values(ascending=False)
  75. if len(cnt) > 2:
  76. med = float(cnt.median())
  77. top = cnt.head(3)
  78. named = [f'{t}({int(n)}条)' for t, n in top.items()]
  79. x = float(top.iloc[0]) / med if med else float('nan')
  80. refs = [dict(file=P.rel(ap), sha16=_sha16(ap))]
  81. code_top = (w[w.turbine == top.index[0]].groupby('code').size().sort_values(ascending=False)
  82. .head(3).to_dict()) if 'code' in w else {}
  83. add('RD-LEDGER-ALM-01',
  84. f'报警集中: {top.index[0]} {int(top.iloc[0])} 条(机群中位 {med:.0f} 条的 {x:.1f} 倍)',
  85. '候选', refs, named=named, systems=['报警面'], module='台账·报警',
  86. window_days=days, n_machines=int(w.turbine.nunique()),
  87. limitation='报警条数不等于缺陷严重度: 单码抖动(同一码反复报)会抬高条数; 未做同码基线归一。',
  88. falsify=f'若该台的高发码是全场共因(同一码在多台同量出现)或与限电/调度同向, 则该"集中"是工况不是缺陷 —— 判据: 逐码机群基率>{1 / max(len(cnt), 1):.0%}。',
  89. nature='口径性(计数面)', coverage_note=f'窗 {s:%Y-%m-%d}~{e:%Y-%m-%d}, 全员 {int(w.turbine.nunique())} 台',
  90. claim_class=f'计数面相对判据; 头部码 {code_top}')
  91. # ── ② 重复检修件 ────────────────────────────────────────────────────────────
  92. wo = store / 'workorders.parquet'
  93. if wo.is_file():
  94. d = pd.read_parquet(wo)
  95. comp_col = next((c for c in ('故障位置二级', '故障位置一级', '故障名称') if c in d.columns), None)
  96. tm = next((c for c in ('故障报出时间', '复位运行时间') if c in d.columns), None)
  97. unit = next((c for c in ('机组编号',) if c in d.columns), None)
  98. if comp_col and unit:
  99. d['_c'] = d[comp_col].astype(str).str.strip()
  100. g = d[d['_c'] != ''].groupby([unit, '_c']).size().sort_values(ascending=False)
  101. if len(g):
  102. (t0, c0), n0 = g.index[0], int(g.iloc[0])
  103. tm_ = pd.to_datetime(d[tm], errors='coerce') if tm else None
  104. s, e, days = _win(tm_, 365) if tm_ is not None else ('', '', None)
  105. _norm = lambda x: (str(x) if str(x).upper().startswith('WTG') else f'WTG{int(str(x)):02d}' if str(x).isdigit() else str(x))
  106. named = [f'{_norm(t)}·{c}({int(n)}次)' for (t, c), n in g.head(3).items()]
  107. add('RD-LEDGER-WO-01',
  108. f'重复检修: {_norm(t0)} 的「{c0}」累计 {n0} 次(窗内)',
  109. '候选', [dict(file=P.rel(wo), sha16=_sha16(wo))], named=named, systems=['传动链'],
  110. module='台账·工单', window_days=days, n_machines=int(d[unit].nunique()),
  111. limitation='检修台账记录的是"做过什么", 不等于"坏过几次": 同一部件的例行检查也会计次; '
  112. '未做计划性/故障性停机区分(缺 work_type 字段)。',
  113. falsify='若这些次记录多为计划性检查/预防性更换, 则该"重复"无非计划性含义 —— 判据: 记录里标注为故障停机的比例。',
  114. nature='趋势性(重复发生)', coverage_note=f'窗 {s}~{e}' if s else '窗未标(无时间列)',
  115. claim_class='计数面相对判据; 部件名取台账原文')
  116. # ── ③ 停机损失集中 ─────────────────────────────────────────────────────────
  117. lm = store / 'loss_monthly.parquet'
  118. if lm.is_file():
  119. d = pd.read_parquet(lm)
  120. st_col = 'state' if 'state' in d.columns else None
  121. if st_col and 'loss' in d.columns and 'turbine' in d.columns:
  122. stop = d[d[st_col].astype(str).str.contains('停机', na=False)]
  123. # ★单位: loss 列是 **kWh**(实测全场合计 4,428 万、停机态 2,273 万)—— 直接当 MWh 报会
  124. # 把量级说大 1000 倍。这里统一换算成 MWh 再报, 分子分母同口径。
  125. tot_stop = float(stop['loss'].sum()) / 1000.0
  126. per = (stop.groupby('turbine')['loss'].sum() / 1000.0).sort_values(ascending=False)
  127. if len(per) and tot_stop > 0:
  128. sh = float(per.iloc[0]) / tot_stop
  129. named = [f'{t}({v:,.0f}MWh)' for t, v in per.head(3).items()]
  130. add('RD-LEDGER-LOSS-01',
  131. f'停机损失集中: {per.index[0]} 占全场停机损失 {sh:.0%}({per.iloc[0]:,.0f} MWh, 全场停机 {tot_stop:,.0f} MWh)',
  132. '参考', [dict(file=P.rel(lm), sha16=_sha16(lm))], named=named, systems=['发电量'],
  133. module='台账·损失', window_days=int(d['month'].nunique()) * 30 if 'month' in d else None,
  134. n_machines=int(d['turbine'].nunique()),
  135. limitation='损失是"损失了多少电", 不是"为什么损失"; 含调度令停机(非缺陷)与限电, 需与故障类分开看。',
  136. falsify='若该台损失主要来自调度令/限电(状态列已区分)而非故障停机, 则与设备无关。',
  137. nature='口径性(电量面)', coverage_note='按月汇总, 月份数取自产物')
  138. # ── ④ 温度离群台 ────────────────────────────────────────────────────────────
  139. tb = store / 'temp_bins.parquet'
  140. if tb.is_file() and (m5 / 'fusion_38.csv').is_file():
  141. d = pd.read_parquet(tb)
  142. if {'turbine', 'channel', 'pbin', 'med', 'n'}.issubset(d.columns):
  143. d = d[d['n'] >= 20] # 样本量门 (n 太少的中位不稳)
  144. if len(d):
  145. piv = d.pivot_table(index=['channel', 'pbin', 'win'], columns='turbine', values='med')
  146. dev = piv.sub(piv.median(axis=1), axis=0).abs()
  147. worst = dev.max(axis=1).sort_values(ascending=False)
  148. if len(worst):
  149. (ch, pb, win), val = worst.index[0], float(worst.iloc[0])
  150. t0 = str(dev.loc[worst.index[0]].idxmax())
  151. add('RD-LEDGER-TEMP-01',
  152. f'温度相对离群: {t0} 的 {ch} 在 {pb} 功率档偏离机群中位 {val:.1f}K(工况窗 {win})',
  153. '候选', [dict(file=P.rel(tb), sha16=_sha16(tb))],
  154. named=[f'{t0}·{ch} +{val:.1f}K'], systems=['温度面'], module='台账·温度 NBM',
  155. window_days=None, n_machines=int(d['turbine'].nunique()),
  156. limitation='同工况中位比对的是"机群", 若全机群共因升温和(环境/冷却水温)则判据失效; '
  157. '未接绝对限值(无 ISO/厂商温度锚)。',
  158. falsify='若该通道全场中位同期同向上升(共模), 则该台"离群"不成立 —— 判据: 逐月机群中位趋势。',
  159. nature='趋势性(个体偏离)', coverage_note=f'{win} 工况段, 样本门 n≥20',
  160. claim_class='同工况机群残差; 功率档分箱')
  161. # ── ⑤ 功率曲线偏离台 ───────────────────────────────────────────────────────
  162. pc = store / 'powercurve_dev.parquet'
  163. if pc.is_file():
  164. d = pd.read_parquet(pc)
  165. if 'dev_w' in d.columns:
  166. d2 = d.assign(_a=pd.to_numeric(d['dev_w'], errors='coerce').abs()).sort_values('_a', ascending=False)
  167. r0 = d2.iloc[0]
  168. # ★单位: dev_w 是 **MW**(实测 -0.147 → -147 kW)—— 直接标 kW 会把量级说小 1000 倍。
  169. _dv = float(pd.to_numeric(pd.Series([r0.get('dev_w')]), errors='coerce').iloc[0]) * 1000.0
  170. _cnt = (d['判别'].astype(str).value_counts().to_dict() if '判别' in d.columns else {})
  171. _bad = {k: v for k, v in _cnt.items() if k not in ('—', 'nan', '')}
  172. add('RD-LEDGER-PC-01',
  173. f'功率曲线偏离: {r0.get("turbine")} 同风速下功率偏差 {_dv:+,.0f} kW(判别: {r0.get("判别", "—")})'
  174. + (f'; 全场判别分布 {_bad}' if _bad else ''),
  175. '参考', [dict(file=P.rel(pc), sha16=_sha16(pc))],
  176. named=[f'{r0.get("turbine")} {float(r0.get("dev_w")):,.0f}kW'],
  177. systems=['发电量', '功率曲线'], module='台账·功率曲线', window_days=None,
  178. n_machines=int(len(d)),
  179. limitation='同风速残差受机位风资源(尾流/扇区)影响; 未做机位修正, 故只作"待核"不作缺陷结论。',
  180. falsify='若该台所处扇区长期受尾流(邻机偏置)影响, 残差可由机位解释 ⇒ 非机组问题。',
  181. nature='口径性(机位/风资源)', coverage_note='逐台一行(残差+判别列)')
  182. # ── ⑥ 振动面过闸线(观澜自算六层链) ───────────────────────────────────────
  183. f38 = m5 / 'fusion_38.csv'
  184. l6p = m5 / 'model_run_l6.parquet'
  185. cand = pd.DataFrame()
  186. if l6p.is_file():
  187. l6 = pd.read_parquet(l6p)
  188. if '定级' in l6.columns and len(l6):
  189. cand = l6[l6['定级'].astype(str).str.contains('候选|准定论|定论', na=False)]
  190. if len(cand):
  191. named = [f"{r['台']}·{str(r['线'])[:22]} {r['定级']}" for _, r in cand.head(5).iterrows()]
  192. add('RD-LEDGER-VIB-01',
  193. f"振动面过闸线: {len(cand)} 条特征线达「候选」及以上({', '.join(n.split(' ')[0] for n in named[:3])})",
  194. '候选', [dict(file=P.rel(l6p), sha16=_sha16(l6p))], named=named, systems=['振动面'],
  195. module='振动·六层链 L6 过闸线', window_days=None, n_machines=int(cand['台'].nunique()),
  196. limitation='机制未定 ⇒ **不命名部件**(滚道/圈侧须实物或换件闭环); 峰值拾取为本器口径'
  197. '(目标频率 ±2 bin 取最大), 无正样本锚 ⇒ 封顶「候选」, 不出「定论」。',
  198. falsify='若复测同一判据回落(或拆检未见对应损伤), 则该候选撤回 —— 判据: 下一窗同线定级 + 实物证据。',
  199. nature='趋势性(证据面)', coverage_note='L6 过闸线表; 闸分布见 m5_cms_tcm/model_run_summary.json',
  200. claim_class='观澜自算(非振动线正裁)')
  201. if not len(cand) and f38.is_file():
  202. d = pd.read_csv(f38, dtype=str)
  203. if '融合' in d.columns:
  204. bad = d[d['融合'].astype(str).str.contains('候选|定论|准定论', na=False)]
  205. if len(bad):
  206. named = [f"{r['台']}·{r['融合']}" for _, r in bad.head(5).iterrows()]
  207. add('RD-LEDGER-VIB-01',
  208. f"振动面融合级: {len(bad)} 台达「候选」及以上({', '.join(named[:3])})",
  209. '候选', [dict(file=P.rel(f38), sha16=_sha16(f38))], named=named, systems=['振动面'],
  210. module='振动·六层链融合', window_days=None, n_machines=int(len(d)),
  211. limitation='机制未定 ⇒ 不命名部件; 无正样本锚 ⇒ 封顶「候选」。',
  212. falsify='复测同一判据回落或拆检未见损伤即撤回。',
  213. nature='趋势性(证据面)', coverage_note='融合表逐台一行',
  214. claim_class='观澜自算融合(非振动线正裁)')
  215. return out
  216. def build(farm: str | None = None, dry=False) -> int:
  217. root = P.out_root(farm)
  218. store, m5 = P.store(farm), P.m5(farm)
  219. cl = claims(store, m5)
  220. if not cl:
  221. print('[X] 一条 claim 也没算出来 —— 台账产物不在位? 先跑重算链(②三门台账 / ③SCADA 侧)')
  222. return 2
  223. if dry:
  224. for c in cl:
  225. print(f' [{c["verdict"]:6}] {c["id"]:20} {c["title"][:70]}')
  226. print(f' (dry-run, 共 {len(cl)} 条;未写文件)')
  227. return 0
  228. t0 = time.strftime('%Y-%m-%d %H:%M:%S')
  229. findings = dict(
  230. schema='guanlan-sop-findings/v1',
  231. generated_by='scripts/sop_findings_from_ledger.py (用户令 2026-09-19: 从重算台账生成 claim)',
  232. built=t0, n=len(cl),
  233. note='本件由**观澜自算**(重算台账 → claim), 不含人工裁决; 逐条 source_refs 记件与 sha16, '
  234. 'mandatory_limitation/falsifiability 如实写明缺证据与证伪判据。',
  235. findings=[dict(
  236. id=c['id'], title=c['title'], verdict=c['verdict'],
  237. coverage=dict(window_days=c['window_days'], window_days_note=c['coverage_note'],
  238. per_day_evidence=None, n_machines=c['n_machines']),
  239. claim_class=c['claim_class'], module=c['module'],
  240. mandatory_limitation=c['mandatory_limitation'], falsifiability=c['falsifiability'],
  241. problem_nature=c['problem_nature'], source_chapter='重算台账',
  242. evidence=[c['title']], named=c['named'], systems=c['systems'],
  243. source_refs=c['source_refs']) for c in cl])
  244. p1 = root / 'sop' / 'findings.json'
  245. p1.parent.mkdir(parents=True, exist_ok=True)
  246. p1.write_text(json.dumps(findings, ensure_ascii=False, indent=1), encoding='utf-8')
  247. base = root / 'paradigm_r1' / 'experiments'
  248. # E5 底稿: 模块映射(契约的 scope.module / module_map_how)
  249. (base / 'E5_coverage_table').mkdir(parents=True, exist_ok=True)
  250. (base / 'E5_coverage_table' / '底稿.json').write_text(json.dumps(dict(
  251. schema='guanlan-paradigm-e5/v1', generated_by='scripts/sop_findings_from_ledger.py (自算)',
  252. note='模块映射: 每条 claim 归到哪个分析模块(观澜自算口径)',
  253. finding_rows=[dict(i=i, module=c['module'], map_how='ledger:' + c['module']) for i, c in enumerate(cl)]),
  254. ensure_ascii=False, indent=1), encoding='utf-8')
  255. # E3 底稿: 归宿提议(契约的 closure.machine_proposed / human=空)
  256. (base / 'E3_candidate_closure').mkdir(parents=True, exist_ok=True)
  257. (base / 'E3_candidate_closure' / '底稿.json').write_text(json.dumps(dict(
  258. schema='guanlan-paradigm-e3/v1', generated_by='scripts/sop_findings_from_ledger.py (自算)',
  259. note='归宿提议: 机器只提"下一步该做什么", 人裁字段留空(不在本器职责内)',
  260. rows=[dict(i=i, human=None,
  261. proposed=('复测同一判据并核实物证据' if c['verdict'] == '候选' else '例行跟踪, 不单独排程'))
  262. for i, c in enumerate(cl)]), ensure_ascii=False, indent=1), encoding='utf-8')
  263. # E8 底稿_s0: 系统/台号(契约的 scope.systems/units 与 sample_level.named)
  264. (base / 'E8_rudong_rebuild').mkdir(parents=True, exist_ok=True)
  265. (base / 'E8_rudong_rebuild' / '底稿_s0.json').write_text(json.dumps(dict(
  266. schema='guanlan-paradigm-e8-s0/v1', generated_by='scripts/sop_findings_from_ledger.py (自算)',
  267. note='系统/台号: 由 claim 的点名台与系统列直出',
  268. findings_rows=[dict(i=i, n_tid=len(c['named']), tids=[n.split('(')[0].split('·')[0] for n in c['named']],
  269. systems=c['systems'], units=c['named']) for i, c in enumerate(cl)]),
  270. ensure_ascii=False, indent=1), encoding='utf-8')
  271. print(f'已写 {P.rel(p1)}({len(cl)} 条 claim)+ 3 份底稿(E5/E3/E8_s0)')
  272. for c in cl:
  273. print(f' [{c["verdict"]:6}] {c["id"]:20} {c["title"][:64]}')
  274. try:
  275. from src import derived_manifest as DM
  276. rels = {f: 'scripts/sop_findings_from_ledger.py (重算台账 → claim; 观澜自算)'
  277. for f in OUT_FILES}
  278. DM.record(root, rels, by='sop_findings_from_ledger')
  279. print(' 已自登记 → _derived_manifest.json')
  280. except Exception as e:
  281. print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
  282. return 0
  283. def status(farm: str | None = None) -> int:
  284. root = P.out_root(farm)
  285. p = root / 'sop' / 'findings.json'
  286. if not p.is_file():
  287. print(json.dumps(dict(path=P.rel(p), exists=False,
  288. note='不在位 —— 门户结论段/问答/报告面会 503 或停在旧数; 跑本器可由重算台账生成'),
  289. ensure_ascii=False))
  290. return 0
  291. try:
  292. d = json.loads(p.read_text(encoding='utf-8'))
  293. except Exception as e:
  294. print(json.dumps(dict(path=P.rel(p), exists=True, err=str(e)[:120])))
  295. return 1
  296. print(json.dumps(dict(path=P.rel(p), exists=True, n=d.get('n'), built=d.get('built'),
  297. generated_by=d.get('generated_by'),
  298. kind=('观澜自算' if 'sop_findings_from_ledger' in str(d.get('generated_by')) else '人裁底稿')),
  299. ensure_ascii=False))
  300. return 0
  301. def main() -> int:
  302. ap = argparse.ArgumentParser(description='事实契约的 claim 生成端(重算台账 → findings/底稿)')
  303. ap.add_argument('--farm', default=None)
  304. ap.add_argument('--dry-run', action='store_true')
  305. ap.add_argument('--status', action='store_true')
  306. a = ap.parse_args()
  307. if a.status:
  308. return status(a.farm)
  309. return build(a.farm, a.dry_run)
  310. if __name__ == '__main__':
  311. for _s in (sys.stdout, sys.stderr):
  312. try:
  313. _s.reconfigure(errors='replace')
  314. except Exception:
  315. pass
  316. sys.exit(main())