174 lines
9.1 KiB
Python
174 lines
9.1 KiB
Python
"""Economic peak policy. Planning assumptions NEVER become metering facts.
|
|
|
|
Monthly marginal cost is convex. A rest-month scenario is a peak expected after
|
|
this horizon under a comparable control policy, not a configured limit, free
|
|
allowance, or a promise of savings. With insufficient evidence use full tariff.
|
|
"""
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from math import isclose
|
|
from .domain import number, utc, month_key, ZURICH
|
|
|
|
VERIFIED_SOURCES = frozenset({'meter_month_register','verified_month_history','verified_new_month'})
|
|
ESTIMATE_SOURCES = frozenset({'power_history_estimate','sampled_power_estimate','counter_interval_estimate','operator_estimate','new_month'})
|
|
|
|
|
|
def valid_month(value):
|
|
if not isinstance(value,str) or len(value)!=7:
|
|
raise ValueError('Calendar month YYYY-MM required')
|
|
if datetime.strptime(value,'%Y-%m').strftime('%Y-%m') != value:
|
|
raise ValueError('Invalid calendar month')
|
|
return value
|
|
|
|
|
|
def basis_record(value, month, at, allow_estimates=False):
|
|
"""Strict provenance; missing stays missing. Caller decides explicit opt-in."""
|
|
valid_month(month)
|
|
if not isinstance(value,dict) or set(value)-{'kw','quality','source','observedAt','notes','coverage','meterId'}:
|
|
raise ValueError('Invalid peak-basis schema')
|
|
kw=number(value.get('kw'),'peak basis kW',0,1e6)
|
|
quality=value.get('quality')
|
|
source=value.get('source')
|
|
if quality=='verified':
|
|
if source not in VERIFIED_SOURCES:raise ValueError('Not a verified peak source')
|
|
elif quality=='estimated':
|
|
if not allow_estimates or source not in ESTIMATE_SOURCES:
|
|
raise ValueError('Estimated planning basis not permitted or source invalid')
|
|
elif quality=='new_month':
|
|
if source!='new_month' or kw!=0 or month<=month_key(at):
|
|
raise ValueError('Zero future-month state is not a past measured peak')
|
|
else:raise ValueError('Peak quality must be explicit')
|
|
stamp=utc(value.get('observedAt'))
|
|
if stamp>utc(at):raise ValueError('Peak evidence from the future')
|
|
if quality!='new_month' and month>month_key(at):
|
|
raise ValueError('Future peak is an outlook, not historical evidence')
|
|
notes=value.get('notes','')
|
|
if not isinstance(notes,str) or len(notes)>500:raise ValueError('Invalid peak notes')
|
|
coverage=value.get('coverage')
|
|
if coverage is not None:number(coverage,'peak coverage',0,1)
|
|
mid=value.get('meterId')
|
|
if mid is not None and (not isinstance(mid,str) or not 1<=len(mid)<=160):raise ValueError('Invalid meter identity')
|
|
return {**value,'kw':kw,'quality':quality,'source':source,'observedAt':stamp.isoformat()}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PeakScenario:
|
|
future_peak_kw: float
|
|
probability: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RestMonthOutlook:
|
|
month: str
|
|
scenarios: tuple[PeakScenario, ...]
|
|
issued_at: datetime
|
|
future_from: datetime
|
|
history_until: datetime
|
|
control_policy_id: str
|
|
method: str
|
|
evidence_id: str
|
|
reliance: float = 0.5
|
|
|
|
def validate(self, at, horizon_end):
|
|
valid_month(self.month)
|
|
at=utc(at);end=utc(horizon_end)
|
|
if utc(self.issued_at)>at or utc(self.history_until)>utc(self.issued_at):
|
|
raise ValueError('Rest-month outlook contains future information')
|
|
if at-utc(self.issued_at)>timedelta(days=2):raise ValueError('Stale rest-month outlook')
|
|
if utc(self.future_from)<end:
|
|
raise ValueError('Rest-month outlook overlaps optimized horizon')
|
|
if month_key(self.future_from)!=self.month:
|
|
raise ValueError('Rest-month outlook outside target calendar month')
|
|
if not self.control_policy_id or not self.evidence_id or not self.method:
|
|
raise ValueError('Comparable control policy and historical evidence required')
|
|
if not 1<=len(self.scenarios)<=100:raise ValueError('Need 1..100 scenarios')
|
|
for item in self.scenarios:
|
|
number(item.future_peak_kw,'future scenario peak',0,1e6)
|
|
number(item.probability,'scenario probability',0,1)
|
|
if not isclose(sum(s.probability for s in self.scenarios),1.,abs_tol=1e-8):
|
|
raise ValueError('Scenario probabilities must sum to one')
|
|
number(self.reliance,'outlook reliance',0,1)
|
|
|
|
def incremental_cost(self, basis_kw, planned_kw, tariff):
|
|
"""Separate planning value, never an already-paid or guaranteed saving."""
|
|
full=max(0.,planned_kw-basis_kw)*tariff
|
|
expected=tariff*sum(s.probability*(max(planned_kw,basis_kw,s.future_peak_kw)-max(basis_kw,s.future_peak_kw)) for s in self.scenarios)
|
|
return (1-self.reliance)*full+self.reliance*expected
|
|
|
|
def add_to_model(self, model, peak_variable, basis_kw, tariff):
|
|
model.cost[peak_variable]+=(1-self.reliance)*tariff
|
|
for s in self.scenarios:
|
|
if s.probability<=0:continue
|
|
end_peak=model.variable(max(basis_kw,s.future_peak_kw),cost=self.reliance*tariff*s.probability)
|
|
model.constraint({end_peak:1.,peak_variable:-1.},lower=0.)
|
|
|
|
def as_dict(self):
|
|
return {'month':self.month,'issuedAt':utc(self.issued_at).isoformat(),'futureFrom':utc(self.future_from).isoformat(),
|
|
'historyUntil':utc(self.history_until).isoformat(),'controlPolicyId':self.control_policy_id,'method':self.method,
|
|
'evidenceId':self.evidence_id,'reliance':self.reliance,
|
|
'scenarios':[{'peakKw':s.future_peak_kw,'probability':s.probability} for s in self.scenarios]}
|
|
|
|
@classmethod
|
|
def from_dict(cls, value):
|
|
if set(value)!={'month','issuedAt','futureFrom','historyUntil','controlPolicyId','method','evidenceId','reliance','scenarios'}:
|
|
raise ValueError('Unexpected rest-month outlook fields')
|
|
if any(set(s)!={'peakKw','probability'} for s in value['scenarios']):raise ValueError('Unexpected scenario fields')
|
|
return cls(value['month'],tuple(PeakScenario(s['peakKw'],s['probability']) for s in value['scenarios']),
|
|
utc(value['issuedAt']),utc(value['futureFrom']),utc(value['historyUntil']),
|
|
value['controlPolicyId'],value['method'],value['evidenceId'],value['reliance'])
|
|
|
|
|
|
def next_month_start(at):
|
|
local=utc(at).astimezone(ZURICH)
|
|
if local.month==12:return utc(local.replace(year=local.year+1,month=1,day=1,hour=0,minute=0,second=0,microsecond=0))
|
|
return utc(local.replace(month=local.month+1,day=1,hour=0,minute=0,second=0,microsecond=0))
|
|
|
|
|
|
def empirical_rest_month(daily_records, *, month, at, horizon_end, control_policy_id, minimum_days=14, reliance=.5):
|
|
"""Deterministic circular block bootstrap over comparable completed daily peaks.
|
|
|
|
Returns None without sufficient historical evidence; never fills with a cap.
|
|
Input requires valid whole-day coverage and recorded policy identity. Historical
|
|
measured daily peaks are only a planning proxy for future comparable operation.
|
|
No fabricated future energy prices are needed for this peak-only outlook.
|
|
"""
|
|
valid_month(month)
|
|
start=max(utc(horizon_end),utc(datetime.strptime(month,'%Y-%m').replace(tzinfo=ZURICH)))
|
|
end=next_month_start(start)
|
|
if month_key(start)!=month or start>=end:return None
|
|
good={}
|
|
for r in daily_records:
|
|
if r.get('controlPolicyId')!=control_policy_id or r.get('complete') is not True:continue
|
|
if r.get('quality') not in ('verified','estimated'):continue
|
|
day=datetime.strptime(r['day'],'%Y-%m-%d').replace(tzinfo=ZURICH)
|
|
finished=utc(day+timedelta(days=1))
|
|
observed=utc(r['observedAt'])
|
|
if finished>utc(at) or observed>utc(at) or observed<finished:continue
|
|
if utc(at)-finished>timedelta(days=90):continue
|
|
kw=number(r['peakKw'],'historical daily peak',0,1e6)
|
|
if r['day'] in good and good[r['day']]!=kw:raise ValueError('Conflicting daily peak evidence')
|
|
good[r['day']]=kw
|
|
if len(good)<minimum_days:return None
|
|
days=sorted(good)
|
|
# Use a continuous segment. Missing days cannot be disguised as complete coverage.
|
|
longest=[];segment=[]
|
|
for day in days:
|
|
if segment and datetime.strptime(day,'%Y-%m-%d')-datetime.strptime(segment[-1],'%Y-%m-%d')!=timedelta(days=1):
|
|
if len(segment)>len(longest):longest=segment
|
|
segment=[]
|
|
segment.append(day)
|
|
if len(segment)>len(longest):longest=segment
|
|
if len(longest)<minimum_days:return None
|
|
future_days=max(1,(end.astimezone(ZURICH).date()-start.astimezone(ZURICH).date()).days)
|
|
values=[good[d] for d in longest]
|
|
maxima=[max(values[(offset+n)%len(values)] for n in range(future_days)) for offset in range(len(values))]
|
|
weights={}
|
|
for value in maxima:weights[value]=weights.get(value,0)+1
|
|
weights={value:count/len(maxima) for value,count in weights.items()}
|
|
import hashlib,json
|
|
evidence=hashlib.sha256(json.dumps({'history':[(d,good[d]) for d in longest],'policy':control_policy_id},sort_keys=True).encode()).hexdigest()
|
|
return RestMonthOutlook(month,tuple(PeakScenario(k,v) for k,v in sorted(weights.items())),utc(at),start,
|
|
utc(datetime.strptime(longest[-1],'%Y-%m-%d').replace(tzinfo=ZURICH)+timedelta(days=1)),
|
|
control_policy_id,'comparable_daily_peak_block_bootstrap',evidence,reliance)
|