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()