Files
Enelix-EMS/services/netplan-v4/netplan_v4/peak_policy.py
T

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)