Files
Enelix-EMS/services/netplan-v4/tests/test_peak_release.py
T

156 lines
11 KiB
Python

import copy
import json
from dataclasses import replace
from datetime import datetime,timedelta,timezone
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from netplan_v4.domain import Battery,Limits,Price,Step,ZURICH
from netplan_v4.peak_policy import basis_record,PeakScenario,RestMonthOutlook,empirical_rest_month
from netplan_v4.meter_runtime import integrate_power,assumptions,observe,daily_peaks
from netplan_v4.store import PlannerStore
from netplan_v4.service import ingest,run_once,status,create_app
from netplan_v4.optimizer import optimize
from test_v4 import inputs,seq,batt,AT,AID,TOKEN
from fastapi.testclient import TestClient
def estimate(kw=5.,at=AT,source='power_history_estimate'):
return {'kw':kw,'quality':'estimated','source':source,'observedAt':at.isoformat(),'coverage':.8,'notes':'Incomplete historical archive'}
def outlook(reliance=1.,scenarios=((5.,1.),),at=AT):
return RestMonthOutlook('2026-10',tuple(PeakScenario(*s) for s in scenarios),at,at+timedelta(hours=1),at-timedelta(days=1),'same-policy','synthetic-test','evidence-test',reliance)
class PeakEconomicsTest(unittest.TestCase):
def solve(self,**kw):
kw.setdefault('steps',seq([1000.]*6,buy=[.01]*3+[1.]*3))
kw.setdefault('batteries',[batt(soc_percent=10.)]);kw.setdefault('at',AT)
kw.setdefault('observed_peaks',{'2026-10':1.});kw.setdefault('peak_prices',{'2026-10':5.})
return optimize(**kw)
def test_full_tariff_still_default(self):
p=self.solve();self.assertTrue(p['executable']);self.assertLessEqual(p['plannedPeaksKw']['2026-10'],1.0001)
self.assertEqual(p['peakOutlook'],'full_incremental_tariff')
def test_increase_allowed_when_future_month_peak_expected(self):
p=self.solve(peak_outlooks={'2026-10':outlook()})
self.assertTrue(p['executable']);self.assertGreater(p['plannedPeaksKw']['2026-10'],1.1)
self.assertGreater(p['additionalPeakCostChf'],0.)
self.assertAlmostEqual(p['planningPeakCostChf'],0.)
self.assertEqual(p['measuredPeaksKw']['2026-10'],1.)
def test_zero_reliance_equals_full_tariff(self):
p=self.solve(peak_outlooks={'2026-10':outlook(reliance=0.)})
self.assertAlmostEqual(p['planningPeakCostChf'],p['additionalPeakCostChf'])
def test_marginal_price_non_decreasing_and_full_above_all_scenarios(self):
o=outlook(.8,((2.,.5),(4.,.5)))
costs=[o.incremental_cost(1.,x,5.) for x in range(1,8)]
slopes=[b-a for a,b in zip(costs,costs[1:])]
self.assertTrue(all(b>=a-1e-10 for a,b in zip(slopes,slopes[1:])));self.assertAlmostEqual(slopes[-1],5.)
self.assertAlmostEqual(o.incremental_cost(1.,.8,5.),0.)
def test_hard_cap_beats_outlook(self):
p=self.solve(limits=Limits(import_w=1000.),peak_outlooks={'2026-10':outlook()})
self.assertLessEqual(max(x['gridTargetW'] for x in p['points']),1000.001)
def test_no_fixed_cap_is_supported(self):
p=self.solve(limits=Limits(),peak_outlooks={'2026-10':outlook()})
self.assertTrue(p['executable']);self.assertTrue(all(x['importLimitW'] is None for x in p['points']))
def test_monthly_cap_beats_outlook(self):
p=self.solve(limits=Limits(manager_month_limits_w={10:1000.}),peak_outlooks={'2026-10':outlook()})
self.assertLessEqual(p['plannedPeaksKw']['2026-10'],1.0001)
def test_estimated_basis_not_in_measured_map(self):
p=self.solve(peak_context={'2026-10':estimate(1.)})
self.assertEqual(p['measuredPeaksKw'],{});self.assertEqual(p['peakBasisKw']['2026-10'],1.)
self.assertTrue(p['peakCostIsEstimate'])
def test_context_cannot_lie_about_numerical_basis(self):
self.assertFalse(self.solve(peak_context={'2026-10':estimate(3.)})['executable'])
def test_no_free_cap_source(self):
with self.assertRaises(ValueError):basis_record(estimate(source='manager_cap'),'2026-10',AT,True)
def test_overlap_rejected(self):
self.assertFalse(self.solve(peak_outlooks={'2026-10':replace(outlook(),future_from=AT)})['executable'])
def test_future_observation_rejected(self):
self.assertFalse(self.solve(peak_outlooks={'2026-10':replace(outlook(),issued_at=AT+timedelta(seconds=1))})['executable'])
def test_wrong_month_rejected(self):
self.assertFalse(self.solve(peak_outlooks={'2026-11':outlook()})['executable'])
def test_invalid_probabilities_rejected(self):
self.assertFalse(self.solve(peak_outlooks={'2026-10':outlook(scenarios=((5.,.8),))})['executable'])
def test_no_history_no_outlook_not_fake_allowance(self):
self.assertIsNone(empirical_rest_month([],month='2026-10',at=AT,horizon_end=AT+timedelta(days=1),control_policy_id='p'))
def test_empirical_requires_comparable_complete_history(self):
records=[]
for n in range(20):
d=AT.astimezone(ZURICH).replace(hour=0,minute=0)-timedelta(days=n+1)
records.append({'day':d.strftime('%Y-%m-%d'),'peakKw':3.+n%4,'observedAt':(d+timedelta(days=1)).isoformat(),'quality':'estimated','complete':True,'controlPolicyId':'p'})
args=dict(month='2026-10',at=AT,horizon_end=AT+timedelta(days=1),control_policy_id='p')
o=empirical_rest_month(records,**args);self.assertIsNotNone(o);o.validate(AT,args['horizon_end'])
for r in records:r['controlPolicyId']='different'
self.assertIsNone(empirical_rest_month(records,**args))
class EstimatedBasisServiceTest(unittest.TestCase):
def setUp(self):self.tmp=TemporaryDirectory();self.path=str(Path(self.tmp.name)/'planner.sqlite');self.s=PlannerStore(self.path)
def tearDown(self):self.s.close();self.tmp.cleanup()
def load(self,when=AT,quarter=False):
op,fc,tar=inputs(when);op['measuredPeaks']={};op['planningPeaks']={'2026-10':estimate(at=when)}
if quarter:
op['quarterEstimate']={'start':AT.isoformat(),'importKwh':.2,'measuredSeconds':int((when-AT).total_seconds()),'quality':'estimated','source':'power_history_estimate','coverage':1.}
for k,v in zip(('operation','forecast','tariffs'),(op,fc,tar)):ingest(self.s,AID,k,v,when)
return op
def allow(self):self.s.save_settings(AID,{'measurementPolicy':'allow_estimates'},self.s.settings(AID)['revision'],AT)
def test_opt_in_required(self):
self.load();self.assertEqual(run_once(self.s,AT)['status'],'awaiting_inputs')
self.allow();p=run_once(self.s,AT);self.assertTrue(p['executable']);self.assertTrue(p['peakCostIsEstimate'])
self.assertEqual(self.s.peaks(AID),{})
def test_no_peak_not_invented_even_when_opted_in(self):
op,fc,tar=inputs();op['measuredPeaks']={};self.allow()
for k,v in zip(('operation','forecast','tariffs'),(op,fc,tar)):ingest(self.s,AID,k,v,AT)
self.assertEqual(run_once(self.s,AT)['status'],'awaiting_inputs')
def test_historical_seed_independent_from_live_operation(self):
self.allow();op,fc,tar=inputs();op['measuredPeaks']={}
for k,v in zip(('operation','forecast','tariffs'),(op,fc,tar)):ingest(self.s,AID,k,v,AT)
ingest(self.s,AID,'planning_basis',{'version':1,'eventId':'seed','observedAt':AT.isoformat(),'peaks':{'2026-10':estimate()}},AT)
p=run_once(self.s,AT);self.assertTrue(p['executable']);self.assertEqual(p['measuredPeaksKw'],{})
def test_estimates_persist_but_plans_do_not_increase_them(self):
self.load();self.allow();p=run_once(self.s,AT)
self.assertEqual(assumptions(self.s.con,AID)['2026-10']['kw'],5.)
self.s.close();self.s=PlannerStore(self.path)
self.assertEqual(assumptions(self.s.con,AID)['2026-10']['quality'],'estimated')
def test_verified_source_has_precedence_even_if_lower_than_estimate(self):
self.load();self.allow();self.s.initialize_peak(AID,'2026-10',4.,'verified_month_history',AT)
p=run_once(self.s,AT);self.assertEqual(p['measuredPeaksKw'],{'2026-10':4.});self.assertFalse(p['peakCostIsEstimate'])
def test_quarter_estimate_never_disguised(self):
self.load(AT+timedelta(minutes=5),True);self.allow();p=run_once(self.s,AT+timedelta(minutes=5))
self.assertTrue(p['executable']);self.assertEqual(p['inputQuality']['quarter'],'estimated')
def test_incomplete_quarter_refused(self):
op,_,_=inputs();op['quarterEstimate']={'start':AT.isoformat(),'importKwh':.1,'measuredSeconds':0,'quality':'estimated','source':'power_history_estimate','coverage':.8}
with self.assertRaises(ValueError):ingest(self.s,AID,'operation',op,AT)
def test_new_settings_load_old_rows(self):
old=self.s.settings(AID);old.pop('revision');old.pop('measurementPolicy');old.pop('peakOutlookPolicy');old.pop('peakOutlookReliance')
with self.s.con:self.s.con.execute('INSERT INTO planner_settings VALUES(?,?,?)',(AID,1,json.dumps(old)))
self.assertEqual(self.s.settings(AID)['measurementPolicy'],'verified_only')
self.s.save_settings(AID,{'measurementPolicy':'allow_estimates'},1,AT)
def test_http_explicit_estimate_optin_and_no_live(self):
app=create_app(str(Path(self.tmp.name)/'api.sqlite'),TOKEN,[AID],start_worker=False)
with TestClient(app) as c:
url=f'/internal/v2/prognosis/{AID}/planner';h={'X-Enelix-Service-Token':TOKEN}
self.assertEqual(c.put(url+'/settings',headers=h,json={'expectedRevision':0,'changes':{'measurementPolicy':'allow_estimates'}}).status_code,200)
self.assertEqual(c.put(url+'/settings',headers=h,json={'expectedRevision':1,'changes':{'runMode':'live'}}).status_code,400)
class RuntimeMeterTest(unittest.TestCase):
def test_exact_samples_still_estimates(self):
r=integrate_power([(0,1000.,0.,'p'),(60,1000.,.02,'p'),(120,1000.,.04,'p')],0,120)
self.assertAlmostEqual(r['importKwh'],1/30);self.assertFalse(r['billingEvidence'])
def test_gap_not_bridged(self):self.assertIsNone(integrate_power([(0,1000.,0.,'p'),(180,1000.,.1,'p')],0,180))
def test_reset_not_bridged(self):self.assertIsNone(integrate_power([(0,1000.,1.,'p'),(60,1000.,0.,'p')],0,60))
def test_control_policy_change_not_comparable(self):self.assertIsNone(integrate_power([(0,1000.,0.,'a'),(60,1000.,1.,'b')],0,60))
def test_only_completed_quarters_update_peak(self):
with TemporaryDirectory() as d:
s=PlannerStore(str(Path(d)/'p.sqlite'));mid='symcon-active-import:'+'a'*64
for i in range(16):
at=AT+timedelta(minutes=i)
obs={'meterId':mid,'sampleAt':at.isoformat(),'powerW':2000.,'totalImportKwh':10+i/30,'controlPolicyId':'p'}
with s.con:observe(s.con,AID,obs,at)
if i<15:self.assertEqual(assumptions(s.con,AID),{})
self.assertAlmostEqual(assumptions(s.con,AID)['2026-10']['kw'],2.)
self.assertEqual(s.peaks(AID),{});s.close()
if __name__=='__main__':unittest.main()