import copy import json import tempfile import unittest from datetime import datetime, timezone from pathlib import Path from fastapi.testclient import TestClient from netplan_v4 import measurement_pipeline as m from netplan_v4.store import PlannerStore from netplan_v4.service import create_app, ingest, run_once, status from test_v4 import inputs, AID, TOKEN NOW=int(datetime(2026,10,2,12,tzinfo=timezone.utc).timestamp()) def config(**changes): c={'datasetId':'physical-v1','mappingSha256':'a'*64,'inventorySha256':'b'*64, 'formula':'physical_sum_v1','solarReference':None,'minimumCoverage':.95, 'maximumGapSeconds':10,'minimumTrainingHours':1,'historyDays':14,'sources':[]} for i,(k,role) in enumerate((('grid','grid'),('pv','pv'),('battery','physical_storage'),('sdl','sdl_request'))): c['sources'].append({'key':k,'role':role,'variableId':101+i,'factorToW':1,'maxAgeSeconds':60}) c.update(changes);return c def record(t, c=None, **values): c=c or config(); v={'grid':3000.,'pv':5000.,'battery':2000.,'sdl':0.,**values} return {'schemaVersion':1,'kind':'raw_accounting_capture','installationId':AID, 'capturedAt':m.iso(t),'captureStartedAt':m.iso(t),'mappingSha256':c['mappingSha256'], 'reportedInventorySha256':c['inventorySha256'],'raw':{s['key']:{'variableId':s['variableId'],'value':v.get(s['key'],0), 'sourceUpdatedAt':t,'issues':[]} for s in c['sources']}} class MeasurementPipelineTest(unittest.TestCase): def setUp(self): self.tmp=tempfile.TemporaryDirectory();self.path=str(Path(self.tmp.name)/'pipeline.sqlite') self.s=PlannerStore(self.path);self.c=config();m.register_dataset(self.s.con,AID,self.c,NOW) def tearDown(self):self.s.close();self.tmp.cleanup() def batch(self,rows,now=NOW,plant=AID):return m.ingest_batch(self.s.con,plant,{'version':1,'datasetId':'physical-v1','records':rows},now) def history(self,hours=2): rows=[record(t) for t in range(NOW-hours*3600,NOW+1,30)] for i in range(0,len(rows),120):self.batch(rows[i:i+120]) def test_append_ack_idempotence(self): r=record(NOW);self.assertEqual(self.batch([r])['stored'],1) a=self.batch([r]);self.assertEqual(a['duplicates'],1);self.assertEqual(a['acceptedThrough'],m.iso(NOW)) def test_conflict_rolls_back_whole_batch(self): self.batch([record(NOW)]) with self.assertRaises(ValueError):self.batch([record(NOW-30),record(NOW,grid=9000)]) self.assertEqual(self.s.con.execute('SELECT COUNT(*) FROM planner_observations').fetchone()[0],1) def test_registration_immutable(self): self.assertEqual(m.register_dataset(self.s.con,AID,self.c,NOW)['status'],'configured') with self.assertRaises(ValueError):m.register_dataset(self.s.con,AID,{**self.c,'minimumCoverage':1.},NOW) def test_plants_isolated(self): with self.assertRaises(ValueError):self.batch([record(NOW)],plant='30509683-4569-49e4-848f-4905e4cc813a') def test_mapping_is_not_device_supplied(self): r=record(NOW);r['mappingSha256']='c'*64 with self.assertRaises(ValueError):self.batch([r]) def test_no_unknown_text_persisted(self): r=record(NOW);r['unknown']='do-not-store';r['raw']['grid']['value']='do-not-store';r['assessment']={'status':'verified'} self.batch([r]);data=self.s.con.execute('SELECT value FROM planner_observations').fetchone()[0] self.assertNotIn('do-not-store',data);self.assertIn('null',data);self.assertNotIn('verified',data) def test_missing_zero_not_invented(self): r=record(NOW);r['raw'].pop('grid');p=m.project(r,self.c,AID,NOW) self.assertIsNone(p['raw']['grid']['value']);self.assertFalse(p['raw']['grid']['valid']) def test_boolean_not_numeric(self): p=m.project(record(NOW,grid=True),self.c,AID,NOW);self.assertFalse(p['raw']['grid']['valid']) def test_batch_order_required(self): with self.assertRaises(ValueError):self.batch([record(NOW),record(NOW-30)]) def test_batch_size_limit(self): with self.assertRaises(ValueError):self.batch([record(NOW)]*121) def test_no_naive_time(self): r=record(NOW);r['capturedAt']='2026-10-02T12:00:00' with self.assertRaises(ValueError):self.batch([r]) def test_measurement_time_remains_whole_second(self): r=record(NOW);r['capturedAt']='2026-10-02T12:00:00.000001+00:00' with self.assertRaises(ValueError):self.batch([r]) def test_future_source_not_rejuvenated(self): r=record(NOW);r['raw']['grid']['sourceUpdatedAt']=NOW+1 self.assertFalse(m.project(r,self.c,AID,NOW)['raw']['grid']['valid']) def test_profile_uses_physical_not_virtual(self): r=record(NOW);r['raw']['virtual_ev']={'value':-90000} self.assertEqual(m.physical_value({'grid':3000,'pv':5000,'battery':2000},self.c),6000) def test_zero_numeric_valid(self): p=m.project(record(NOW,grid=0,pv=0,battery=0),self.c,AID,NOW);self.assertTrue(p['raw']['grid']['valid']) def projected(self,rows):return [m.project(r,self.c,AID,NOW) for r in rows] def test_rollup_time_weighted(self): rows=[record(t,grid=3000 if t