feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user