ledger_dates.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. # -*- coding: utf-8 -*-
  2. """大部件维修台账 日期区间解析器 + 三条硬校验 (2026-08-26).
  3. **为什么要有**: `rudong.ops.maint_major_parts` 的 `维修时间_dt` 列 **20/20 全部 NaT** —— 日期解析
  4. 完全失败却无人报错, 下游任何用该列的分析拿到的都是空值 (**静默失效**)。而回测的 ground truth 精度
  5. 直接取决于锚日期: 解析失败 → 自动流程全废; 更坏的是**宽松解析吃进脏值** → 造出错误的提前期。
  6. 台账实况 (20 行, 人工录入, 格式极不统一):
  7. '2024年1月5日-2023年1月7日' ← 结束年份笔误, 早于开始
  8. '2024年5月13日至2024年5月19日' ← "至" 而非 "-"
  9. '2024/9/19至2024-10-13' ← 一行内混用 / 与 -
  10. '2025/8/23 -2025-8-28' ← 空格 + 混用分隔符
  11. '2025年12月4日-2025年12月70' ← 日越界 (70 号)
  12. nan ← 缺失
  13. ## 三条硬校验 (违反即 raise, 不静默降级)
  14. 1. **解析成功率** — 非空行必须 100% 解析出 (start, end); 失败即 raise 并列出原文
  15. 2. **区间单调** — end >= start; 违反即 raise (捕 "2024年1月5日-2023年1月7日" 类年份笔误)
  16. 3. **日期合法** — 年 2000~2100 ∧ 月 1~12 ∧ 日在该月实际天数内; 违反即 raise (捕 "12月70")
  17. **为什么 raise 而不是标 NaT**: 静默降级正是本模块要消灭的病 —— 原 `维修时间_dt` 就是"解析不了就给
  18. NaT", 结果 20/20 全空却无人察觉。宁可跑不动, 不可假装跑通 (memory criterion-required-input-no-silent-get)。
  19. 用法:
  20. from src.sop.ledger_dates import parse_range, parse_ledger_column
  21. parse_range('2025/8/23 -2025-8-28') # → (Timestamp('2025-08-23'), Timestamp('2025-08-28'))
  22. parse_ledger_column(df['维修时间']) # → DataFrame[start, end, raw]; 任一校验失败 raise
  23. """
  24. from __future__ import annotations
  25. import calendar
  26. import re
  27. import pandas as pd
  28. # 分隔符: 中文"至" / 连字符 / 波浪号 / "到"; 两侧允许空白
  29. _SEP = re.compile(r'\s*(?:至|到|~|—|–|-)\s*')
  30. # 单个日期: 2024年1月5日 | 2024/9/19 | 2024-10-13 | 2025年12月4日(无"日")
  31. _ONE = re.compile(r'(\d{4})\s*[年/\-]\s*(\d{1,2})\s*[月/\-]\s*(\d{1,2})\s*日?')
  32. YEAR_MIN, YEAR_MAX = 2000, 2100
  33. class LedgerDateError(ValueError):
  34. """台账日期解析/校验失败 — 响亮抛出, 不静默降级。"""
  35. def _check_ymd(y, m, d, raw, which):
  36. if not (YEAR_MIN <= y <= YEAR_MAX):
  37. raise LedgerDateError(f'[校验3 日期合法] {which} 年份 {y} 越界 ({YEAR_MIN}~{YEAR_MAX}) — 原文 {raw!r}')
  38. if not (1 <= m <= 12):
  39. raise LedgerDateError(f'[校验3 日期合法] {which} 月份 {m} 越界 (1~12) — 原文 {raw!r}')
  40. dmax = calendar.monthrange(y, m)[1]
  41. if not (1 <= d <= dmax):
  42. raise LedgerDateError(f'[校验3 日期合法] {which} 日 {d} 越界 ({y}年{m}月 共 {dmax} 天) — 原文 {raw!r}')
  43. def parse_range(raw, *, allow_single=True):
  44. """解析一个维修时间单元格 → (start, end)。三条硬校验全在此处执行, 违反即 raise。
  45. allow_single=True 时, 只有一个日期的单元格返回 (d, d) (维修当天完成)。
  46. """
  47. if raw is None or (isinstance(raw, float) and pd.isna(raw)):
  48. return None
  49. s = str(raw).strip().strip("'\"")
  50. if not s or s.lower() == 'nan':
  51. return None
  52. hits = _ONE.findall(s)
  53. if not hits:
  54. raise LedgerDateError(f'[校验1 解析成功率] 无法解析出任何日期 — 原文 {raw!r}')
  55. if len(hits) == 1:
  56. if not allow_single:
  57. raise LedgerDateError(f'[校验1 解析成功率] 只解析出 1 个日期但要求区间 — 原文 {raw!r}')
  58. y, m, d = (int(x) for x in hits[0])
  59. _check_ymd(y, m, d, raw, '单日')
  60. t = pd.Timestamp(year=y, month=m, day=d)
  61. return t, t
  62. if len(hits) > 2:
  63. raise LedgerDateError(f'[校验1 解析成功率] 解析出 {len(hits)} 个日期 (期望 2) — 原文 {raw!r}')
  64. (y1, m1, d1), (y2, m2, d2) = ((int(x) for x in h) for h in hits)
  65. _check_ymd(y1, m1, d1, raw, '开始')
  66. _check_ymd(y2, m2, d2, raw, '结束')
  67. a = pd.Timestamp(year=y1, month=m1, day=d1)
  68. b = pd.Timestamp(year=y2, month=m2, day=d2)
  69. if b < a:
  70. raise LedgerDateError(
  71. f'[校验2 区间单调] 结束 {b:%Y-%m-%d} 早于开始 {a:%Y-%m-%d} — 原文 {raw!r}; '
  72. f'台账实见此类为**年份笔误** (如 2024年1月5日-2023年1月7日, 结束年应为 2024)')
  73. return a, b
  74. def parse_ledger_column(col, *, strict=True):
  75. """整列解析 → DataFrame[raw, start, end, n_days]。
  76. strict=True (默认): 任一非空行解析或校验失败 → raise, 并在消息里汇总全部问题行 (一次看全, 不用逐条试)。
  77. strict=False: 问题行的 start/end 置 NaT 并在返回的 `.attrs['errors']` 里列出 —— **仅供人工排查用**,
  78. 禁止用于判据输入 (那就退回静默降级了)。
  79. """
  80. rows, errs = [], []
  81. for i, raw in enumerate(col):
  82. try:
  83. r = parse_range(raw)
  84. except LedgerDateError as e:
  85. errs.append(f' 行{i}: {e}')
  86. rows.append(dict(raw=raw, start=pd.NaT, end=pd.NaT, n_days=None))
  87. continue
  88. if r is None:
  89. rows.append(dict(raw=raw, start=pd.NaT, end=pd.NaT, n_days=None))
  90. else:
  91. rows.append(dict(raw=raw, start=r[0], end=r[1], n_days=int((r[1] - r[0]).days) + 1))
  92. if errs and strict:
  93. raise LedgerDateError(f'台账日期校验失败 {len(errs)} 行 (共 {len(col)} 行):\n' + '\n'.join(errs))
  94. out = pd.DataFrame(rows)
  95. out.attrs['errors'] = errs
  96. n_nonnull = sum(1 for x in col if not (x is None or (isinstance(x, float) and pd.isna(x))
  97. or str(x).strip().lower() in ('', 'nan')))
  98. out.attrs['parse_rate'] = (out.start.notna().sum() / n_nonnull) if n_nonnull else 1.0
  99. return out