"""Causal daily economic family comparison using frozen plans and later physical data. This is a labelled *simulation*, not savings on an invoice. A cohort is captured near local midnight, uses only prices/forecasts then available, and all families share initial storage, physical outcomes, tariff and constraints. No device calls. Supported v1: one grid-charge-enabled battery; other topologies remain explicit. """ from dataclasses import asdict, replace from datetime import datetime, timedelta from hashlib import sha256 from math import sqrt import json from .domain import ZURICH, utc, month_key, quarter_start from .selection import ReplayScore from . import measurement_pipeline as m def schema(con): con.executescript(''' CREATE TABLE IF NOT EXISTS planner_economic_cohorts( plant TEXT NOT NULL, local_day TEXT NOT NULL, issued_at INTEGER NOT NULL, ends_at INTEGER NOT NULL, policy_id TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(plant,local_day)); CREATE TABLE IF NOT EXISTS planner_economic_results( plant TEXT NOT NULL, local_day TEXT NOT NULL, evaluated_at INTEGER NOT NULL, policy_id TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(plant,local_day)); CREATE TABLE IF NOT EXISTS planner_economic_attempts( plant TEXT NOT NULL, local_day TEXT NOT NULL, checked_at INTEGER NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(plant,local_day)); ''') def _policy(data, cfg): batteries=[] for b in data['batteries']: v=asdict(b) for k in ('soc_percent','measured_at','discharge_blocked'):v.pop(k) batteries.append(v) return sha256(m.canonical({'battery':batteries,'limits':asdict(data['limits']), 'mapping':cfg['mappingSha256'],'formula':cfg['formula'],'dataset':cfg['datasetId'], 'method':'frozen_day_ahead_grid_tracking_v1','external':'sdl_request_estimate','peakTariffs':data['peak_prices']}).encode()).hexdigest() def capture(store, plant, now, assemble, optimizer): settings=store.settings(plant) if settings.get('forecastSource')!='corrected_profile': return local=utc(now).astimezone(ZURICH); day=local.date().isoformat() # Freeze only at the beginning of a local day. Never reconstruct an old forecast from hindsight. if local.hour!=0 or local.minute>=15:return if store.con.execute('SELECT 1 FROM planner_economic_cohorts WHERE plant=? AND local_day=?',(plant,day)).fetchone():return attempted=store.con.execute('SELECT checked_at FROM planner_economic_attempts WHERE plant=? AND local_day=?',(plant,day)).fetchone() if attempted and now.timestamp()-attempted[0]<300:return reason='awaiting_comparable_snapshot' try: cfg=m.configuration(store.con,plant,settings['measurementDataset']);plans={}; common=None;policy=None end=datetime.combine(local.date()+timedelta(days=1),datetime.min.time(),tzinfo=ZURICH).astimezone(utc(now).tzinfo) for family in store.registry.entries(): data,_,quality=assemble(store,plant,family.key,now) data['batteries']=[replace(b,roundtrip_efficiency=settings['roundtripEfficiency']) for b in data['batteries']] if len(data['batteries'])!=1 or not data['batteries'][0].grid_charging: raise ValueError('unsupported_replay_topology') if data['steps'][-1].end=rearm-1e-9:blocked=False discharge=0. if blocked else min(b['max_discharge_w'],max(0.,(energy-low)*eta/dt*1000)) target=p['gridTargetW'];cap=limits['import_w'];monthly=limits['manager_month_limits_w'].get(str(start.astimezone(ZURICH).month),limits['manager_month_limits_w'].get(start.astimezone(ZURICH).month)) if monthly is not None:cap=monthly if cap is None else min(cap,monthly) if cap is not None:target=min(target,cap) if limits['export_w'] is not None:target=max(target,-limits['export_w']) battery=min(charge,max(-discharge,target-residual));grid=residual+battery energy+=battery/1000*dt*(eta if battery>=0 else 1/eta) if energyhigh+1e-6:raise ValueError('Replay SOC invariant') if energy<=low+1e-9:blocked=True if cap is not None and grid>cap+1.:breaches+=1 if limits['export_w'] is not None and grid < -limits['export_w']-1.:breaches+=1 money+=(max(grid,0)*p['importPriceChfKwh']-max(-grid,0)*p['exportPriceChfKwh'])/1000*dt wear+=abs(battery)/1000*dt*b['throughput_chf_kwh'] q=quarter_start(start).isoformat();v=quarters.setdefault(q,[0.,0]);v[0]+=max(grid,0)/1000*dt;v[1]+=seconds if end==quarter_start(start)+timedelta(minutes=15): if v[1]!=900:raise ValueError('Incomplete simulated billing quarter') month=month_key(start);peaks[month]=max(peaks[month],v[0]/.25);quarter_max[month]=max(quarter_max.get(month,0.),v[0]/.25) additional=sum(max(0,v-ctx['peaks'][month])*ctx['peakTariffs'][month] for month,v in peaks.items()) # Identical terminal valuation for all families, known at cohort creation; separate from cash. last=plan['points'][-1];buy=max(0.,last['importPriceChfKwh']);sell=max(0.,min(buy,last['exportPriceChfKwh'])) terminal=max(initial-energy,0)/eta*buy-max(energy-initial,0)*eta*sell return {'cashCostChf':money+additional,'energyCostChf':money,'peakCostChf':additional,'throughputCostChf':wear, 'terminalAdjustmentChf':terminal,'costChf':money+additional+wear+terminal, 'initialEnergyKwh':initial,'finalEnergyKwh':energy,'coverage':min(coverage), 'quarterMaximaKw':quarter_max,'initialPeaksKw':ctx['peaks'],'peakTariffs':ctx['peakTariffs'], 'constraintBreaches':breaches,'terminalNormalized':True, 'terminalMethod':'common_known_end_price_inventory_valuation_not_physical_restoration'} def advance(store,plant,now): timestamp=int(now.timestamp()) rows=store.con.execute('SELECT c.* FROM planner_economic_cohorts c LEFT JOIN planner_economic_results r USING(plant,local_day) WHERE c.plant=? AND c.ends_at<=? AND c.ends_at>=? AND r.local_day IS NULL AND NOT EXISTS(SELECT 1 FROM planner_economic_attempts a WHERE a.plant=c.plant AND a.local_day=c.local_day AND a.checked_at>?) ORDER BY c.issued_at LIMIT 1',(plant,timestamp-120,timestamp-90*86400,timestamp-300)).fetchall() for row in rows: with store.con:store.con.execute('INSERT INTO planner_economic_attempts VALUES(?,?,?,?) ON CONFLICT(plant,local_day) DO UPDATE SET checked_at=excluded.checked_at,reason=excluded.reason',(plant,row['local_day'],timestamp,'evaluating_actuals')) try: cohort=json.loads(row['value']);cfg=m.configuration(store.con,plant,cohort['datasetId']);source=cfg.get('sourceDatasetId',cfg['datasetId']);start=m.epoch(cohort['start']);end=m.epoch(cohort['end']) observed=store.con.execute('SELECT value FROM planner_observations WHERE plant=? AND dataset=? AND captured_at>=? AND captured_at<=? AND received_at<=? ORDER BY captured_at',(plant,source,start-600,end+600,timestamp)) windows=actuals([json.loads(r[0]) for r in observed],cfg);outcomes={w['start']:w for w in windows} results={f:simulate(cohort,f,outcomes) for f in cohort['plans']} value={'status':'evaluated','results':results,'start':cohort['start'],'end':cohort['end'], 'evaluationBasis':'physical_balance_with_sdl_request_estimate','method':cohort['method'], 'actualCashSavings':False,'controlEnabled':False} with store.con:store.con.execute('INSERT INTO planner_economic_results VALUES(?,?,?,?,?)',(plant,row['local_day'],timestamp,row['policy_id'],m.canonical(value))) except (ValueError,KeyError,TypeError): # Retain unscored cohort. Lack of actual data must never become a zero cost. pass def scores(store,plant,now): settings=store.settings(plant) if settings.get('forecastSource')!='corrected_profile':return [] since=int((utc(now)-timedelta(days=settings['autoLookbackDays'])).timestamp()) recent=store.con.execute('SELECT policy_id,value FROM planner_economic_cohorts WHERE plant=? ORDER BY issued_at DESC LIMIT 1',(plant,)).fetchone() if not recent or json.loads(recent[1])['datasetId']!=settings.get('measurementDataset'):return [] rows=store.con.execute('SELECT c.issued_at,c.ends_at,r.evaluated_at,r.value FROM planner_economic_results r JOIN planner_economic_cohorts c USING(plant,local_day) WHERE r.plant=? AND r.policy_id=? AND c.issued_at>=? AND r.evaluated_at<=? ORDER BY c.issued_at',(plant,recent[0],since,int(now.timestamp()))).fetchall() if not rows:return [] keys=[f.key for f in store.registry.entries()];valid=[] for r in rows: v=json.loads(r['value']);out=v['results'] if set(out)!=set(keys) or any(out[k]['constraintBreaches'] for k in keys):continue valid.append((r,v)) if not valid:return [] result=[] for k in keys: # Monthly demand cost is paid ONCE for the maximum, not once per replay day. total=sum(v['results'][k]['energyCostChf']+v['results'][k]['throughputCostChf']+v['results'][k]['terminalAdjustmentChf'] for _,v in valid) bases={}; maxima={}; rates={} for _,v in valid: d=v['results'][k] for month,peak in d['quarterMaximaKw'].items(): bases.setdefault(month,d['initialPeaksKw'][month]) maxima[month]=max(maxima.get(month,0.),peak);rates[month]=d['peakTariffs'][month] total+=sum(max(0.,maxima[month]-bases[month])*rates[month] for month in maxima) cover=min(v['results'][k]['coverage'] for _,v in valid) first=utc(valid[0][1]['start']);end=utc(valid[-1][1]['end']);available=datetime.fromtimestamp(max(r['evaluated_at'] for r,_ in valid),utc(now).tzinfo) result.append(ReplayScore(k,recent[0],first,end,first,available,total,cover,len(valid))) return result def status(store,plant): rows=store.con.execute('SELECT local_day,value FROM planner_economic_results WHERE plant=? ORDER BY local_day DESC LIMIT 14',(plant,)) evaluated=[{'day':r[0],**json.loads(r[1])} for r in rows] count=store.con.execute('SELECT COUNT(*) FROM planner_economic_cohorts WHERE plant=?',(plant,)).fetchone()[0] return {'connected':True,'method':'frozen_day_ahead_grid_tracking_v1','cohorts':count,'completedComparisons':evaluated, 'evaluationBasis':'physical_balance_with_sdl_request_estimate','isBillingEvidence':False, 'limitations':['one_grid_charging_battery','frozen_daily_plan_not_receding_horizon_field_replay']}