schedule.py 5.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. # -*- coding: utf-8 -*-
  2. """schedule.py — 柱2「智能排程」核心: 检修停机窗口机会成本最小化 (farm-agnostic).
  3. B1 检修排程 (docs/B1检修排程_模块设计_v0.1.md) 的可复用内核: 把 scripts/maint_schedule_gjd.py
  4. 的郭家店写死数学抽成纯函数 + 接柱1 cases 当任务池。
  5. 一句话: 同样的必要停机, 移到低(风×价)窗口 — 不修任何东西的纯排程收益。
  6. ★物理内核: 检修窗机会成本 = Σ_窗内(预期功率 × 预期电价)。现货省份两轴合一 = 低(风×价)积窗。
  7. ★纪律: 输出 best+mean+worst + saving (禁单点最优, 风/价预期都有误差); ¥ 用假定价 → verdict【参考·参数假定】。
  8. ★柱1×柱2 桥: tasks_from_cases 把柱1 CONFIRMED/预警 case 转成停机任务池 (发现→排程闭环)。
  9. 纯函数 (optimize_schedule): 不碰文件/时钟; per-farm 数据加载在 runner。
  10. """
  11. def optimize_schedule(day_costs, task_turbine_days, per_day_capacity=2):
  12. """核心: 给每个可选作业日的停机成本 → 排 task_turbine_days 台日 → best/mean/worst 机会成本。
  13. day_costs: {day_key: 元/台日} — 每个候选作业日停一台的机会成本 (低风×低价日=便宜)。
  14. task_turbine_days: 总停机台日 (如 偏航治理 59 台日 / D29 大修 3 台日)。
  15. per_day_capacity: 每日最多同时停几台 (班组约束)。
  16. 返回 {best_cny, mean_cny, worst_cny, saving_vs_mean, saving_pct, need_days, best_days, worst_days}。
  17. 禁单点最优: 同时给最差/均匀基线 = 决策者看区间不看点。
  18. """
  19. if not day_costs or task_turbine_days <= 0 or per_day_capacity <= 0:
  20. return {'verdict': 'INSUFFICIENT', 'reason': '空任务池/无候选日/容量非正'}
  21. import math
  22. days_sorted = sorted(day_costs.items(), key=lambda kv: kv[1]) # 便宜→贵
  23. costs = [c for _, c in days_sorted]
  24. need_days = math.ceil(task_turbine_days / per_day_capacity)
  25. if need_days > len(costs):
  26. return {'verdict': 'INSUFFICIENT',
  27. 'reason': f'需 {need_days} 作业日 > 可选 {len(costs)} 日 (候选窗不足)'}
  28. def _sum_schedule(day_cost_list):
  29. """按台日铺进日: 前 need_days-1 日各满容量, 末日铺剩余。"""
  30. rem, total = task_turbine_days, 0.0
  31. for c in day_cost_list[:need_days]:
  32. put = min(per_day_capacity, rem)
  33. total += c * put
  34. rem -= put
  35. return total
  36. best = _sum_schedule(costs) # 最便宜 need_days 日
  37. worst = _sum_schedule(costs[::-1]) # 最贵 need_days 日
  38. mean_day = sum(costs) / len(costs)
  39. mean = mean_day * task_turbine_days # 均匀随机基线
  40. saving = mean - best
  41. return {
  42. 'verdict': '参考',
  43. 'best_cny': round(best), 'mean_cny': round(mean), 'worst_cny': round(worst),
  44. 'saving_vs_mean': round(saving),
  45. 'saving_pct': round(saving / mean * 100, 1) if mean else None,
  46. 'need_days': need_days, 'n_candidate_days': len(costs),
  47. 'best_days': [k for k, _ in days_sorted[:need_days]],
  48. 'worst_days': [k for k, _ in days_sorted[::-1][:need_days]],
  49. '_note': 'best/mean/worst 三值 (禁单点最优); ¥ 若用假定价 → 交付标【参考·参数假定·待业主结算价】',
  50. }
  51. def day_costs_from_power_price(power_by_day_hour, price_by_hour, work_hours=range(8, 17)):
  52. """构造 day_costs: 每完整覆盖作业窗的日 → Σ_作业时(场中位功率 kW/1000 × 逐时价 元/MWh)。
  53. power_by_day_hour: {(day, hour): 场中位功率kW}; price_by_hour: {hour: 元/MWh}。
  54. 只取作业窗被完整覆盖的日 (缺时段的日不可比)。单台停机成本 = 该日作业窗少发的电 × 价。
  55. """
  56. wh = list(work_hours)
  57. by_day = {}
  58. for (day, hour), pkw in power_by_day_hour.items():
  59. # NaN-safe: pkw != pkw 逮 NaN (缺测小时不计入, 该日作业窗覆盖会因此不足而剔)
  60. if hour in wh and price_by_hour.get(hour) is not None and pkw is not None and pkw == pkw:
  61. by_day.setdefault(day, {})[hour] = max(pkw, 0.0) / 1000.0 * price_by_hour[hour]
  62. # 只保留作业窗每小时都在的日
  63. return {day: round(sum(hc.values()), 2) for day, hc in by_day.items() if len(hc) == len(wh)}
  64. def tasks_from_cases(cases, states=('CONFIRMED', 'TICKETED', 'RELAPSED'),
  65. verdicts=('准定论·预警', '定论'), td_per_turbine=1.0):
  66. """柱1×柱2 桥: 柱1 cases → 停机任务池。默认取 CONFIRMED/工单态 (真需停机的); 可放宽到预警候选。
  67. states/verdicts: 纳入排程的门槛 (默认只排已确诊/已开工单; 放宽 verdicts 含预警可做"预排程")。
  68. 返回 {'turbines': [...], 'turbine_days': N, 'basis': ...}。
  69. """
  70. picked = []
  71. for c in (cases or []):
  72. if not isinstance(c, dict):
  73. continue
  74. st_ok = c.get('state') in states
  75. vd_ok = (c.get('verdict') or '').strip('【】') in verdicts
  76. if st_ok or vd_ok:
  77. picked.append({'turbine': c.get('turbine'), 'system': c.get('system'),
  78. 'case_id': c.get('case_id'), 'state': c.get('state'), 'verdict': c.get('verdict')})
  79. tds = round(len(picked) * td_per_turbine, 1)
  80. return {'turbines': picked, 'turbine_days': tds,
  81. 'basis': f'{len(picked)} 台 (state∈{states} ∨ verdict∈{verdicts}) × {td_per_turbine}台日/台'}