price.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. # -*- coding: utf-8 -*-
  2. """price.py — 柱2「智能排程」电价层: 参数化假定价形状 + 接真电价 (provenance→verdict 联动).
  3. B1 排程机会成本 = Σ(功率×电价)。此前价形状写死在 runner(maint_schedule_gjd 280×五段式);
  4. 本模块把价形状抽成 config 驱动(value_assumptions.price) + 留真电价接口:
  5. - 真价源在 (spot.source 文件 / spot.hourly_real dict) → 用真价, provenance=real → 【结算】
  6. - 否则 benchmark × tou_factor × season 展开假定形状 → provenance=assumed → 【参考·参数假定·待业主结算价】
  7. verdict 自动联动: ¥ 用假定价则交付降【参考·假定】; 真结算价到货 → 切【结算】 (不改一行排程逻辑)。
  8. 纯函数 (build_price_by_hour): 除 spot.source 显式文件路径外不碰 IO/时钟; 展开逻辑无副作用。
  9. 配套: templates/value_layer_assumptions.example.yaml (价形状模板) / schedule.py (消费 price_by_hour)。
  10. """
  11. import json
  12. from pathlib import Path
  13. ASSUMED_TAG = '【参考·参数假定·待业主结算价】'
  14. SETTLEMENT_TAG = '【结算】'
  15. def _parse_hours(spec):
  16. """'00-08' | '08-17,22-24' | '23-2'(跨零点) → set(hours) ⊂ [0,24)。半开 [a,b)。"""
  17. out = set()
  18. for part in str(spec or '').split(','):
  19. part = part.strip()
  20. if not part:
  21. continue
  22. if '-' in part:
  23. a, b = (int(x) for x in part.split('-', 1))
  24. rng = range(a, b) if b > a else list(range(a, 24)) + list(range(0, b)) # 跨零点
  25. out.update(h % 24 for h in rng)
  26. else:
  27. out.add(int(part) % 24)
  28. return out
  29. def _parse_months(spec):
  30. """'7-8' | '12-1'(跨年) | '3' → set(months) ⊂ [1,12]。闭区间。"""
  31. out = set()
  32. for part in str(spec or '').split(','):
  33. part = part.strip()
  34. if not part:
  35. continue
  36. if '-' in part:
  37. a, b = (int(x) for x in part.split('-', 1))
  38. out.update(range(a, b + 1) if b >= a else list(range(a, 13)) + list(range(1, b + 1)))
  39. else:
  40. out.add(int(part))
  41. return out
  42. def _season_factor(season_cfg, month):
  43. """month → (factor, name)。命中具名季则用其 factor, 否则 normal(缺省1.0)。"""
  44. if not isinstance(season_cfg, dict) or month is None:
  45. return 1.0, 'normal'
  46. for name, band in season_cfg.items():
  47. if name == 'normal' or not isinstance(band, dict):
  48. continue
  49. if band.get('months') and month in _parse_months(band['months']):
  50. return float(band.get('factor', 1.0)), name
  51. return float((season_cfg.get('normal') or {}).get('factor', 1.0)), 'normal'
  52. def _load_source(source):
  53. """真电价源 → {hour: 元/MWh}。支持: dict 直传 / .json({hour:price}) / .csv(hour,price 两列)。
  54. 不存在/null/不支持 → None (回落假定形状)。"""
  55. if not source:
  56. return None
  57. if isinstance(source, dict):
  58. return {int(k): float(v) for k, v in source.items()}
  59. p = Path(str(source))
  60. if not p.is_file():
  61. return None
  62. try:
  63. if p.suffix == '.json':
  64. d = json.loads(p.read_text(encoding='utf-8'))
  65. return {int(k): float(v) for k, v in d.items()}
  66. if p.suffix == '.csv':
  67. out = {}
  68. for ln in p.read_text(encoding='utf-8').splitlines()[1:]: # 跳表头
  69. cells = ln.split(',')
  70. if len(cells) >= 2:
  71. out[int(float(cells[0]))] = float(cells[1])
  72. return out or None
  73. except (ValueError, OSError, json.JSONDecodeError):
  74. return None
  75. return None
  76. def build_price_by_hour(price_cfg, month=None):
  77. """价 config (value_assumptions.price) → ({hour: 元/MWh}, provenance)。
  78. provenance: {mode, tariff_anchor, assumed(bool), verdict_tag, season, note}。
  79. ★接真电价: spot.hourly_real(dict) 或 spot.source(文件) 在 → 用真价, assumed=False, 【结算】。
  80. ★假定形状: benchmark × tou_factor(时段) × season_factor(月) 展开 24 时; 任一 assumed 标 → 整体假定。
  81. """
  82. if not isinstance(price_cfg, dict):
  83. return {}, {'error': 'price_cfg 非 dict', 'verdict_tag': 'INSUFFICIENT'}
  84. spot = price_cfg.get('spot') or {}
  85. real = spot.get('hourly_real') or _load_source(spot.get('source'))
  86. if real:
  87. price = {int(h) % 24: float(p) for h, p in real.items()}
  88. return price, {'mode': 'real', 'tariff_anchor': None, 'assumed': False,
  89. 'verdict_tag': SETTLEMENT_TAG, 'season': None,
  90. 'note': f'真电价 (源={spot.get("source") or "hourly_real"}, {len(price)}时段)'}
  91. bench = price_cfg.get('benchmark_cny_per_mwh')
  92. if bench is None:
  93. return {}, {'error': '缺 benchmark_cny_per_mwh 且无真价源', 'verdict_tag': 'INSUFFICIENT'}
  94. tou = spot.get('tou_factor') or {}
  95. hourly_factor = {h: 1.0 for h in range(24)}
  96. for band in tou.values():
  97. if isinstance(band, dict):
  98. for h in _parse_hours(band.get('hours', '')):
  99. hourly_factor[h] = float(band.get('factor', 1.0))
  100. sf, sname = _season_factor(spot.get('season_factor'), month)
  101. price = {h: round(float(bench) * hourly_factor[h] * sf, 2) for h in range(24)}
  102. assumed = bool(price_cfg.get('benchmark_assumed', True) or spot.get('tou_assumed', True)
  103. or spot.get('season_assumed', True))
  104. return price, {
  105. 'mode': 'assumed_shape', 'tariff_anchor': float(bench), 'assumed': assumed,
  106. 'season': sname, 'season_factor': sf,
  107. 'verdict_tag': ASSUMED_TAG if assumed else SETTLEMENT_TAG,
  108. 'note': f'benchmark {bench}元/MWh × tou × season({sname}×{sf}) 展开; '
  109. + ('参数假定→待业主结算价' if assumed else '标定为真'),
  110. }
  111. def stamp_schedule_verdict(sched_result, provenance):
  112. """schedule.optimize_schedule 结果 + 价 provenance → 打交付 verdict (¥ 用假定价则降级)。
  113. 排程逻辑不变(数字照算), 只在交付层据价来源标 verdict — 真价到货切【结算】不改一行。"""
  114. if not isinstance(sched_result, dict):
  115. return sched_result
  116. out = dict(sched_result)
  117. out['price_provenance'] = provenance
  118. out['delivery_verdict'] = provenance.get('verdict_tag', ASSUMED_TAG)
  119. base = out.get('_note', '')
  120. out['_note'] = f'{base} | 价格: {provenance.get("note", "")} → {out["delivery_verdict"]}'
  121. return out