feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once dirname(__DIR__).'/integrations/NetzfahrplanV4.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4;
|
||||
$count=0;
|
||||
function check(bool $ok,string $message):void {global $count;$count++;if(!$ok)throw new RuntimeException($message);}
|
||||
$point=['time'=>'2026-10-01T12:00:00Z','validUntil'=>'2026-10-01T12:05:00Z','gridTargetW'=>0.,'intent'=>'pvCharge'];
|
||||
$plan=['schemaVersion'=>2,'planId'=>'test','configRevision'=>7,'status'=>'optimal','executable'=>true,
|
||||
'runMode'=>'shadow','liveEnabled'=>false,'generatedAt'=>$point['time'],'validFrom'=>$point['time'],'validUntil'=>'2026-10-01T12:10:00Z',
|
||||
'points'=>[$point,['time'=>'2026-10-01T12:05:00Z','validUntil'=>'2026-10-01T12:10:00Z','gridTargetW'=>9000.,'intent'=>'gridCharge']]];
|
||||
$now=strtotime('2026-10-01T12:03:00Z');
|
||||
check(NetzfahrplanV4::currentPoint($plan,$now,7)===null,'Shadow never live');
|
||||
check(NetzfahrplanV4::currentPoint($plan,$now,7,true)['gridTargetW']===0.,'Current interval, not nearest future point');
|
||||
check(NetzfahrplanV4::currentPoint($plan,$now,8,true)===null,'Revision mismatch');
|
||||
check(NetzfahrplanV4::currentPoint($plan,$now+3600,7,true)===null,'Expired plan');
|
||||
$bad=$plan;$bad['points'][1]['time']=$point['time'];check(NetzfahrplanV4::currentPoint($bad,$now,7,true)===null,'Overlapping intervals');
|
||||
$bad=$plan;$bad['points'][0]['gridTargetW']=NAN;check(NetzfahrplanV4::currentPoint($bad,$now,7,true)===null,'NaN rejected');
|
||||
$bad=$plan;$bad['generatedAt']='2026-10-01T12:00:00';check(NetzfahrplanV4::currentPoint($bad,$now,7,true)===null,'Timezone required');
|
||||
$r=NetzfahrplanV4::correction($point,-5332.,0.,39000.,0.,15000.);check($r['batteryTargetW']===5332.,'Surplus charges');check($r['gridTargetW']===0.,'Peak cap never replaces zero target');check($r['expectedGridW']===0.,'Expected grid balanced');
|
||||
$r=NetzfahrplanV4::correction($point,-5332.,0.,3000.,0.);check($r['batteryTargetW']===3000.&&$r['limited'],'Charging availability enforced');
|
||||
$r=NetzfahrplanV4::correction(['gridTargetW'=>5000.,'intent'=>'hold'],1000.,0.,39000.,0.);check($r['batteryTargetW']===0.,'No inadvertent grid charge from load forecast');
|
||||
$r=NetzfahrplanV4::correction(['gridTargetW'=>5000.,'intent'=>'hold'],7000.,0.,39000.,1000.,5000.);check($r['batteryTargetW']===-1000.&&$r['limited'],'Safety override still respects available discharge');
|
||||
$r=NetzfahrplanV4::correction(['gridTargetW'=>-40000.,'intent'=>'pvCharge'],-10000.,0.,39000.,10000.);check($r['batteryTargetW']===0.,'No unintended discharge in PV charge intent');check($r['trackingErrorW']===30000.,'Intent saturation reports unmet target');
|
||||
echo "Netzfahrplan V4: {$count} assertions passed. No IPS or device calls.\n";
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createPlannerV4Bridge} from '../integrations/netplan-v4-bridge.mjs';
|
||||
const PLANT='00000000-0000-4000-8000-000000000001',INSTALL='e3a08f9e-af12-4695-99bd-8b51c0520021';
|
||||
function fixture(options={}){
|
||||
const sent=[],auth=[],upstream=[];
|
||||
const bridge=createPlannerV4Bridge({
|
||||
configuredPrognosisPlant:(req,res,id,csrf)=>{auth.push({id,csrf});return options.denied?null:{plant:{installation_id:INSTALL},license:{quantities:{grid_schedule:options.unlicensed?0:1}},session:{}};},
|
||||
roleAllowed:()=>!options.viewer,bodyJson:async()=>({expectedRevision:0,changes:{family:'23'}}),
|
||||
json:(res,status,payload)=>sent.push({status,payload}),deviceActivation:()=>options.deviceDenied?null:{plant_id:PLANT},
|
||||
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:1}}),serviceToken:'synthetic-test-only',
|
||||
fetchImpl:async(url,args)=>{upstream.push({url:String(url),args});if(options.down)throw new Error('secret-host-detail');return {ok:!options.conflict,status:options.conflict?409:200,json:async()=>options.conflict?{detail:'internal-field'}:{liveEnabled:false,plan:{planId:'p1'}}};}
|
||||
});return {bridge,sent,auth,upstream};
|
||||
}
|
||||
const url=suffix=>new URL(`https://portal.test/api/plants/${PLANT}/prognosis/planner-v4${suffix}`);
|
||||
test('read scoped by customer plant, upstream uses installation id',async()=>{const f=fixture();assert.equal(await f.bridge({method:'GET'},{},url('')),true);assert.equal(f.auth[0].csrf,false);assert.match(f.upstream[0].url,new RegExp(INSTALL));assert.equal(f.sent[0].status,200);});
|
||||
test('save requires existing CSRF checks',async()=>{const f=fixture();await f.bridge({method:'PUT'},{},url('/settings'));assert.equal(f.auth[0].csrf,true);assert.equal(f.upstream[0].args.method,'PUT');});
|
||||
test('viewer cannot mutate',async()=>{const f=fixture({viewer:true});await f.bridge({method:'PUT'},{},url('/settings'));assert.equal(f.sent[0].status,403);assert.equal(f.upstream.length,0);});
|
||||
test('unowned plant blocked',async()=>{const f=fixture({denied:true});await f.bridge({method:'GET'},{},url(''));assert.equal(f.upstream.length,0);});
|
||||
test('license required',async()=>{const f=fixture({unlicensed:true});await f.bridge({method:'GET'},{},url(''));assert.equal(f.sent[0].status,403);});
|
||||
test('revision conflict preserved without internal error disclosure',async()=>{const f=fixture({conflict:true});await f.bridge({method:'PUT'},{},url('/settings'));assert.equal(f.sent[0].status,409);assert.doesNotMatch(JSON.stringify(f.sent),/internal-field/);});
|
||||
test('service failure neither exposes details nor changes live plan',async()=>{const f=fixture({down:true});await f.bridge({method:'GET'},{},url(''));assert.equal(f.sent[0].status,503);assert.doesNotMatch(JSON.stringify(f.sent),/secret-host-detail|synthetic-test-only/);});
|
||||
test('V1 live schedule is NOT intercepted',async()=>{const f=fixture();assert.equal(await f.bridge({method:'GET'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/schedule`)),false);assert.equal(f.upstream.length,0);});
|
||||
test('device telemetry mapped only to operation ingress',async()=>{const f=fixture();await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/operation`));assert.match(f.upstream[0].url,/\/inputs\/operation$/);});
|
||||
test('unauthenticated device blocked',async()=>{const f=fixture({deviceDenied:true});await f.bridge({method:'GET'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4`));assert.equal(f.upstream.length,0);});
|
||||
test('device rate limit reused',async()=>{const f=fixture({ratelimited:true});await f.bridge({method:'GET'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4`));assert.equal(f.sent[0].status,429);assert.equal(f.upstream.length,0);});
|
||||
test('unexpected method rejected',async()=>{const f=fixture();await f.bridge({method:'DELETE'},{},url(''));assert.equal(f.sent[0].status,405);assert.equal(f.upstream.length,0);});
|
||||
|
||||
test('measurement batches use authenticated dedicated data ingress',async()=>{const f=fixture();await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.match(f.upstream[0].url,/\/planner\/measurements$/);assert.equal(f.upstream[0].args.method,'POST');});
|
||||
test('unauthenticated measurement upload cannot reach storage',async()=>{const f=fixture({deviceDenied:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.upstream.length,0);});
|
||||
test('device cannot change dataset mapping through public proxy',async()=>{const f=fixture();assert.equal(await f.bridge({method:'PUT'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/datasets/physical-v1`)),false);assert.equal(f.upstream.length,0);});
|
||||
test('measurement upload keeps device rate protection',async()=>{const f=fixture({ratelimited:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.sent[0].status,429);assert.equal(f.upstream.length,0);});
|
||||
@@ -0,0 +1,39 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sqlite3,tempfile,unittest
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('app_deploy',ROOT/'commissioning/deploy_application.py');d=importlib.util.module_from_spec(spec);spec.loader.exec_module(d)
|
||||
class ApplicationDeploymentTest(unittest.TestCase):
|
||||
def test_backup_consistent_and_original_not_changed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src=Path(tmp)/'source.sqlite';out=Path(tmp)/'backup.sqlite'
|
||||
with sqlite3.connect(src) as c:c.execute('CREATE TABLE sample(value)');c.execute('INSERT INTO sample VALUES(3)')
|
||||
before=src.read_bytes();d.backup_database(src,out)
|
||||
with sqlite3.connect(out) as c:self.assertEqual(c.execute('SELECT value FROM sample').fetchone()[0],3)
|
||||
self.assertEqual(before,src.read_bytes())
|
||||
def test_backup_cannot_invent_missing_database(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(ValueError):d.backup_database(Path(tmp)/'missing.sqlite',Path(tmp)/'new.sqlite')
|
||||
def test_atomic_refuses_symlink(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
a=Path(tmp)/'a';b=Path(tmp)/'b';a.write_text('original');b.symlink_to(a)
|
||||
with self.assertRaises(ValueError):d.atomic(b,b'changed')
|
||||
self.assertEqual(a.read_text(),'original')
|
||||
def test_only_two_reviewed_portal_files(self):
|
||||
self.assertEqual(set(d.PORTAL_FILES.values()),{'netplan-v4-bridge.mjs','public/netplan-v4.js'})
|
||||
def test_build_tests_backup_before_runtime_replacement(self):
|
||||
s=(ROOT/'commissioning/deploy_application.py').read_text()
|
||||
self.assertLess(s.index("'target_python_and_portal_tests_passed'"),s.index('v4_replaced=True'))
|
||||
self.assertLess(s.index("'consistent_database_backup'"),s.index('v4_replaced=True'))
|
||||
def test_does_not_change_optimizer_or_device_permissions(self):
|
||||
self.assertNotIn('/settings',d.BOOTSTRAP)
|
||||
self.assertNotIn('trial/arm',d.BOOTSTRAP)
|
||||
self.assertNotIn('IPS_',d.BOOTSTRAP)
|
||||
self.assertIn("state['liveEnabled'] is False",d.BOOTSTRAP)
|
||||
def test_image_contains_application_installer_imported_by_tests(self):
|
||||
self.assertIn('COPY commissioning/deploy_application.py ./commissioning/deploy_application.py',(ROOT/'Dockerfile').read_text())
|
||||
def test_file_bind_mount_recreated_not_only_restarted(self):
|
||||
s=(ROOT/'commissioning/deploy_application.py').read_text()
|
||||
self.assertIn("'--force-recreate','--wait','license-portal'",s)
|
||||
self.assertIn('additive_data_retained',s)
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
from netplan_v4.optimizer import optimize
|
||||
from netplan_v4.domain import Limits
|
||||
from test_v4 import AT,seq,batt
|
||||
|
||||
class BatteryRecoveryTest(unittest.TestCase):
|
||||
def plan(self,b,steps=None):
|
||||
steps=steps or seq([0.]*3+[1000.]*3,pv=[1000.]*3+[0.]*3,sell=[0.]*6)
|
||||
return optimize(steps,[b],at=AT,observed_peaks={'2026-10':1.},peak_prices={'2026-10':0.})
|
||||
def test_reserve_recovery_preserves_real_initial_energy(self):
|
||||
b=batt(capacity_kwh=1.,soc_percent=5.,min_soc_percent=15.,physical_min_soc_percent=3.,recovery_allowed=True,rearm_soc_percent=17.,discharge_blocked=True,grid_charging=False,max_charge_w=1000.,max_discharge_w=1000.)
|
||||
p=self.plan(b);self.assertTrue(p['executable'],p)
|
||||
energy=.05
|
||||
for x in p['points']:
|
||||
target=x['batteryTargetW'];energy+=target/1000/12*(.9**.5 if target>=0 else 1/(.9**.5))
|
||||
self.assertAlmostEqual(100*energy,x['socEndPercent']['b'],places=5)
|
||||
self.assertGreaterEqual(energy,.05-1e-6)
|
||||
self.assertGreaterEqual(p['points'][-1]['socEndPercent']['b'],15.-1e-5)
|
||||
def test_below_hardware_min_not_fabricated(self):
|
||||
b=batt(soc_percent=1.,min_soc_percent=15.,physical_min_soc_percent=3.,recovery_allowed=True)
|
||||
self.assertFalse(self.plan(b)['executable'])
|
||||
def test_disabled_can_rearm_after_charging(self):
|
||||
b=batt(capacity_kwh=10.,soc_percent=16.,min_soc_percent=15.,rearm_soc_percent=17.,discharge_blocked=True,grid_charging=False,max_charge_w=1000.,max_discharge_w=1000.)
|
||||
p=self.plan(b);self.assertTrue(p['executable'],p)
|
||||
self.assertAlmostEqual(min(0.,p['points'][0]['batteryTargetW']),0.)
|
||||
self.assertTrue(any(x['batteryTargetW']<-1. for x in p['points'][3:]))
|
||||
first=next(i for i,x in enumerate(p['points']) if x['batteryTargetW']<-1.)
|
||||
self.assertGreaterEqual(p['points'][first-1]['socEndPercent']['b'],17.-1e-5)
|
||||
def test_cannot_discharge_below_rearm_while_blocked(self):
|
||||
b=batt(capacity_kwh=10.,soc_percent=16.,min_soc_percent=15.,rearm_soc_percent=17.,discharge_blocked=True,grid_charging=False)
|
||||
p=self.plan(b,seq([1000.]*6));self.assertTrue(p['executable'])
|
||||
self.assertTrue(all(x['batteryTargetW']>=-1e-5 for x in p['points']))
|
||||
def test_unavailable_power_does_not_get_invented(self):
|
||||
b=batt(capacity_kwh=1.,soc_percent=5.,min_soc_percent=15.,physical_min_soc_percent=3.,recovery_allowed=True,max_charge_w=0.,max_discharge_w=0.)
|
||||
p=self.plan(b);self.assertFalse(p['executable']);self.assertEqual(p['status'],'no_feasible_plan')
|
||||
def test_invalid_hysteresis_range_rejected(self):
|
||||
b=batt(rearm_soc_percent=95.)
|
||||
self.assertFalse(self.plan(b)['executable'])
|
||||
def test_no_recovery_permission_keeps_strict_behavior(self):
|
||||
self.assertFalse(self.plan(batt(soc_percent=5.))['executable'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import os
|
||||
import unittest
|
||||
from runtime_preflight import SOURCE_DIRS, SOURCE_FILES, verify_access
|
||||
|
||||
|
||||
class ContainerAccessTest(unittest.TestCase):
|
||||
def fixture(self, tmp):
|
||||
root = Path(tmp) / "app"
|
||||
root.mkdir()
|
||||
for name in SOURCE_DIRS:
|
||||
(root / name).mkdir()
|
||||
(root / name / "sample.txt").write_text("readable")
|
||||
for name in SOURCE_FILES:
|
||||
(root / name).write_text("readable")
|
||||
return root, Path(tmp) / "reports"
|
||||
|
||||
def test_code_readable_and_reports_outside_readonly_tree(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root, reports = self.fixture(tmp)
|
||||
self.assertEqual(verify_access(root, reports), 8)
|
||||
self.assertEqual(list(reports.iterdir()), [])
|
||||
|
||||
def test_missing_runner_is_reported(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root, reports = self.fixture(tmp)
|
||||
(root / "run_tests.py").unlink()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
verify_access(root, reports)
|
||||
|
||||
@unittest.skipIf(os.geteuid() == 0, "Permission semantics require an unprivileged user")
|
||||
def test_unreadable_source_fails(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root, reports = self.fixture(tmp)
|
||||
path = root / "netplan_v4" / "sample.txt"
|
||||
path.chmod(0)
|
||||
try:
|
||||
with self.assertRaises(PermissionError):
|
||||
verify_access(root, reports)
|
||||
finally:
|
||||
path.chmod(0o600)
|
||||
|
||||
@unittest.skipIf(os.geteuid() == 0, "Permission semantics require an unprivileged user")
|
||||
def test_untraversable_directory_is_not_silently_skipped(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root, reports = self.fixture(tmp)
|
||||
path = root / "tests"
|
||||
path.chmod(0)
|
||||
try:
|
||||
with self.assertRaises(PermissionError):
|
||||
verify_access(root, reports)
|
||||
finally:
|
||||
path.chmod(0o700)
|
||||
|
||||
def test_reports_cannot_target_application_tree(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root, _ = self.fixture(tmp)
|
||||
with self.assertRaises(ValueError):
|
||||
verify_access(root, root / "test-results")
|
||||
@@ -0,0 +1,120 @@
|
||||
import copy
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
from netplan_v4.store import PlannerStore
|
||||
from netplan_v4.service import create_app
|
||||
from netplan_v4 import controlled_trial as t
|
||||
|
||||
AT=datetime(2026,10,2,12,tzinfo=timezone.utc)
|
||||
PLANT='00000000-0000-4000-8000-000000000001'
|
||||
SESSION='00000000-0000-4000-8000-000000000002'
|
||||
PLAN='00000000-0000-4000-8000-000000000003'
|
||||
TOKEN='synthetic-only-control-test-token'
|
||||
|
||||
def fixture():
|
||||
context={'batteries':{'b':{'capacityKwh':10.}},'limits':{'importW':10000.,'exportW':None,'managerMonthLimitsW':{}}}
|
||||
plan={'planId':PLAN,'installationId':PLANT,'sourceFamily':'3','configRevision':1,
|
||||
'runMode':'shadow','liveEnabled':False,'executable':True,'validUntil':(AT+timedelta(hours=1)).isoformat(),
|
||||
'controlContext':context,'inputQuality':{'loadBasis':'base_load','accountingEvidenceId':'synthetic-meter-boundary-v1'},
|
||||
'peakCostIsEstimate':True}
|
||||
view={'installationId':PLANT,'liveEnabled':False,'fresh':True,'plan':plan,'settings':{'family':'3','revision':1}}
|
||||
request={'sessionId':SESSION,'expectedPlanId':PLAN,'expectedRevision':1,'durationSeconds':300,
|
||||
'maxChargeW':5000.,'maxDischargeW':5000.,'assetId':'b','managerId':11111,'batteryInstanceId':22222,
|
||||
'acceptEstimatedPeak':True,'actuatorWatchdogEvidenceId':'synthetic-hardware-watchdog',
|
||||
'confirmation':'ARM_BOUNDED_CONTROL_TRIAL'}
|
||||
return view,request
|
||||
|
||||
class ControlledTrialTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.s=PlannerStore(':memory:');self.v,self.r=fixture()
|
||||
def tearDown(self): self.s.close()
|
||||
def arm(self,**kw):
|
||||
return t.arm(self.s,PLANT,kw.get('request',self.r),kw.get('view',self.v),kw.get('now',AT),kw.get('allowed',{PLANT}))
|
||||
def test_default_has_no_authority(self):
|
||||
self.assertIsNone(t.authority(self.s,PLANT,self.v,AT,set()))
|
||||
with self.assertRaises(ValueError):self.arm(allowed=set())
|
||||
def test_shadow_plan_never_becomes_live(self):
|
||||
before=copy.deepcopy(self.v);self.arm()
|
||||
a=t.authority(self.s,PLANT,self.v,AT,{PLANT})
|
||||
self.assertEqual(self.v,before);self.assertEqual(a['kind'],'controlled_trial_authority')
|
||||
self.assertTrue(a['sourcePlanRemainsShadow']);self.assertFalse(self.v['plan']['liveEnabled'])
|
||||
def test_short_authority_lease(self):
|
||||
self.arm();a=t.authority(self.s,PLANT,self.v,AT,{PLANT})
|
||||
self.assertEqual(t.timestamp(a['validUntil'])-AT,timedelta(seconds=90))
|
||||
def test_session_expiry(self):
|
||||
self.arm();self.assertIsNone(t.authority(self.s,PLANT,self.v,AT+timedelta(seconds=300),{PLANT}))
|
||||
def test_revoke(self):
|
||||
self.arm();t.revoke(self.s,PLANT,SESSION,AT);self.assertIsNone(t.authority(self.s,PLANT,self.v,AT,{PLANT}))
|
||||
def test_expired_or_revoked_session_not_resurrected(self):
|
||||
self.arm();t.revoke(self.s,PLANT,SESSION,AT)
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_active_session_cannot_be_extended(self):
|
||||
self.arm()
|
||||
with self.assertRaises(ValueError):self.arm(now=AT+timedelta(seconds=30))
|
||||
def test_other_plant(self):
|
||||
self.arm();self.assertIsNone(t.authority(self.s,SESSION,self.v,AT,{SESSION}))
|
||||
def test_revocation_mismatched_id_leaves_real_session(self):
|
||||
self.arm();t.revoke(self.s,PLANT,PLAN,AT);self.assertIsNotNone(t.authority(self.s,PLANT,self.v,AT,{PLANT}))
|
||||
def test_stale_source_plan_cannot_arm(self):
|
||||
self.v['fresh']=False
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_stale_source_removes_authority(self):
|
||||
self.arm();self.v['fresh']=False;self.assertIsNone(t.authority(self.s,PLANT,self.v,AT,{PLANT}))
|
||||
def test_revision_bool_not_an_integer(self):
|
||||
self.r['expectedRevision']=True
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_wrong_revision(self):
|
||||
self.r['expectedRevision']=2
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_revision_change_invalidates_authority(self):
|
||||
self.arm();self.v['settings']['revision']=2;self.v['plan']['configRevision']=2
|
||||
self.assertIsNone(t.authority(self.s,PLANT,self.v,AT,{PLANT}))
|
||||
def test_policy_change_invalidates_authority(self):
|
||||
self.arm();self.v['plan']['controlContext']['limits']['importW']=12000.
|
||||
self.assertIsNone(t.authority(self.s,PLANT,self.v,AT,{PLANT}))
|
||||
def test_newer_plan_same_policy_supported(self):
|
||||
self.arm();self.v['plan']['planId']=SESSION
|
||||
self.assertEqual(t.authority(self.s,PLANT,self.v,AT,{PLANT})['sourceShadowPlanId'],SESSION)
|
||||
def test_auto_family_not_enabled_for_first_trial(self):
|
||||
self.v['settings']['family']='auto'
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_aggregate_house_refused(self):
|
||||
self.v['plan']['inputQuality']['loadBasis']='house_total'
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_no_accounting_evidence_no_trial(self):
|
||||
del self.v['plan']['inputQuality']['accountingEvidenceId']
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_no_watchdog_evidence_no_trial(self):
|
||||
self.r['actuatorWatchdogEvidenceId']=''
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_no_implicit_estimate_acceptance(self):
|
||||
self.r['acceptEstimatedPeak']=False
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_power_cap_and_nonfinite(self):
|
||||
for v in (5001, float('nan'),float('inf'),True,0,'5000'):
|
||||
with self.subTest(v=v),self.assertRaises(ValueError):self.arm(request={**self.r,'maxChargeW':v})
|
||||
def test_duration_bound(self):
|
||||
for v in (0,1801,True,30.5):
|
||||
with self.subTest(v=v),self.assertRaises(ValueError):self.arm(request={**self.r,'durationSeconds':v})
|
||||
def test_cannot_replace_asset(self):
|
||||
self.r['assetId']='other'
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_two_assets_not_supported(self):
|
||||
self.v['plan']['controlContext']['batteries']['other']={}
|
||||
with self.assertRaises(ValueError):self.arm()
|
||||
def test_operator_endpoint_requires_auth(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
app=create_app(Path(td)/'test.sqlite',TOKEN,[PLANT],start_worker=False)
|
||||
with TestClient(app) as c:
|
||||
self.assertEqual(c.post(f'/internal/v2/prognosis/{PLANT}/planner/trial/arm',json=self.r).status_code,401)
|
||||
r=c.post(f'/internal/v2/prognosis/{PLANT}/planner/trial/arm',headers={'X-Enelix-Service-Token':TOKEN},json=self.r)
|
||||
self.assertEqual(r.status_code,409);self.assertIn('allowlist',r.text)
|
||||
self.assertIsNone(c.get(f'/internal/v2/prognosis/{PLANT}/planner',headers={'X-Enelix-Service-Token':TOKEN}).json()['controlledTrial'])
|
||||
def test_trial_allowlist_cannot_expand_installation_access(self):
|
||||
with tempfile.TemporaryDirectory() as td,self.assertRaises(ValueError):
|
||||
create_app(Path(td)/'test.sqlite',TOKEN,[PLANT],start_worker=False,controlled_trial_plants=[SESSION])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import copy
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
from netplan_v4.service import create_app, ingest, run_once
|
||||
from test_v4 import inputs, AT
|
||||
from test_controlled_trial import fixture, PLANT, SESSION, TOKEN
|
||||
|
||||
class ControlledTrialPipelineTest(unittest.TestCase):
|
||||
def test_real_planner_to_internal_trial_api_and_revoke(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
app=create_app(Path(td)/'trial.sqlite',TOKEN,[PLANT],start_worker=False,controlled_trial_plants=[PLANT])
|
||||
store=app.state.store_factory()
|
||||
op,fc,tar=inputs()
|
||||
for f in fc['families'].values():f['accountingEvidenceId']='synthetic-accounting-review'
|
||||
for k,v in [('operation',op),('forecast',fc),('tariffs',tar)]:ingest(store,PLANT,k,v,AT)
|
||||
plan=run_once(store,AT);self.assertEqual(plan['status'],'optimal');store.close()
|
||||
headers={'X-Enelix-Service-Token':TOKEN};prefix=f'/internal/v2/prognosis/{PLANT}/planner'
|
||||
with patch('netplan_v4.service.datetime',wraps=datetime) as clock,TestClient(app) as c:
|
||||
clock.now.return_value=AT
|
||||
view=c.get(prefix,headers=headers).json()
|
||||
self.assertIsNone(view['controlledTrial']);self.assertTrue(view['fresh'])
|
||||
_,request=fixture();request.update(expectedPlanId=plan['planId'],expectedRevision=0,acceptEstimatedPeak=False)
|
||||
grant=c.post(prefix+'/trial/arm',headers=headers,json=request)
|
||||
self.assertEqual(grant.status_code,200,grant.text)
|
||||
view=c.get(prefix,headers=headers).json()
|
||||
self.assertEqual(view['controlledTrial']['sourceShadowPlanId'],plan['planId'])
|
||||
self.assertFalse(view['liveEnabled']);self.assertFalse(view['plan']['liveEnabled'])
|
||||
self.assertEqual(view['plan']['runMode'],'shadow')
|
||||
self.assertEqual(c.post(prefix+'/ack',headers=headers,json={'planId':plan['planId'],'revision':0,'status':'applied'}).status_code,400)
|
||||
self.assertEqual(c.post(prefix+'/trial/revoke',headers=headers,json={'sessionId':SESSION}).status_code,200)
|
||||
self.assertIsNone(c.get(prefix,headers=headers).json()['controlledTrial'])
|
||||
self.assertEqual(c.post(prefix+'/trial/arm',headers=headers,json=request).status_code,409)
|
||||
def test_accounting_marker_cannot_label_aggregate_load_verified(self):
|
||||
from netplan_v4.store import PlannerStore
|
||||
s=PlannerStore(':memory:');op,fc,tar=inputs()
|
||||
f=fc['families']['3'];f['loadBasis']='house_total';f['accountingEvidenceId']='synthetic-incorrect-label'
|
||||
with self.assertRaises(ValueError):ingest(s,PLANT,'forecast',fc,AT)
|
||||
s.close()
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import unittest
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
def module(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path);value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value);return value
|
||||
publisher=module('publisher',ROOT/'integrations/netplan_v4_publisher.py')
|
||||
hooks=module('hooks',ROOT/'install_hooks.py')
|
||||
AT=datetime(2026,10,1,12,0,tzinfo=timezone.utc)
|
||||
|
||||
class PublisherTest(unittest.TestCase):
|
||||
def test_tariff_null_preserved_and_mode_explicit(self):
|
||||
cfg={'tarif_bezug_modus':'statisch','tarif_bezug':'custom','tarif_bezug_fest':0.,'tarif_einspeisung_modus':'dynamisch','tarif_einspeisung':'dynamic','tarif_peak_fest':0.}
|
||||
p=publisher.tariff_payload(cfg,AT);self.assertEqual(p['import']['staticChfKwh'],0.);self.assertEqual(p['peakChfKwMonth'],0.)
|
||||
del cfg['tarif_bezug_modus']
|
||||
with self.assertRaises(ValueError):publisher.tariff_payload(cfg,AT)
|
||||
def test_native_ckw_unit_zero_and_delivery_range(self):
|
||||
rows=[{'start_timestamp':AT.isoformat(),'end_timestamp':(AT+timedelta(minutes=15)).isoformat(),'integrated':[{'unit':'CHF_kWh','value':0.}]}]
|
||||
p=publisher.ckw_payload('CKW Dynamisch Home',rows,AT-timedelta(hours=1),AT)
|
||||
self.assertEqual(p['periods'][0]['value'],0.);self.assertEqual(p['periods'][0]['unit'],'CHF/kWh');self.assertEqual(p['periods'][0]['sourceKind'],'published_interval')
|
||||
del rows[0]['end_timestamp']
|
||||
with self.assertRaises(ValueError):publisher.ckw_payload('CKW',rows,at=AT)
|
||||
def test_unknown_unit_and_future_publication_rejected(self):
|
||||
rows=[{'start_timestamp':AT.isoformat(),'end_timestamp':(AT+timedelta(minutes=15)).isoformat(),'integrated':[{'unit':'unknown','value':100.}]}]
|
||||
with self.assertRaises(ValueError):publisher.ckw_payload('CKW',rows,at=AT)
|
||||
rows[0]['integrated'][0]['unit']='CHF_kWh'
|
||||
with self.assertRaises(ValueError):publisher.ckw_payload('CKW',rows,AT+timedelta(minutes=1),AT)
|
||||
def test_forecast_not_relabelled_as_base_load(self):
|
||||
p=publisher.forecast_payload([(23,{AT:5000.},{AT:2000.})],AT)
|
||||
self.assertEqual(p['families']['23']['loadBasis'],'house_total');self.assertIsNone(p['families']['23']['trainedUntil'])
|
||||
|
||||
class InstallerTest(unittest.TestCase):
|
||||
def fixtures(self,root):
|
||||
files={'prognosis-manager-enelix2/api/main.py':'def f():\n _portal_store_configuration(anlagen_id, configuration)\n',
|
||||
'prognosis-manager-enelix2/forecast_engine/main.py':'def f():\n for a in []:\n for key, default in []:\n d[key] = float(d.get(key) or default)\n for c in []:\n p_3 = v3.predict(data_obj, p_1, p_2)\n',
|
||||
'prognosis-manager-enelix2/tariff_importer/main.py':'def f():\n for a in []:\n for b in []:\n for c in []:\n if True:\n return candidate_points\n',
|
||||
'license/server.mjs':'const server = createServer(async (req, res) => {\n if (url.pathname === "/healthz" && req.method === "GET") return json(res, 200, {status:"ok"});\n});',
|
||||
'license/public/app.js':'// Parallel GUI work. Must remain byte-identical.'}
|
||||
for name,data in files.items():p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(data)
|
||||
return files
|
||||
def test_install_idempotent_and_verified_rollback(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);originals=self.fixtures(root);receipt=hooks.apply(hooks.plan(root),root)
|
||||
self.assertFalse(hooks.plan(root));self.assertEqual((root/'license/public/app.js').read_text(),originals['license/public/app.js'])
|
||||
hooks.rollback(receipt)
|
||||
for name,data in originals.items():self.assertEqual((root/name).read_text(),data)
|
||||
self.assertFalse((root/'license/public/netplan-v4.html').exists())
|
||||
def test_concurrent_changes_never_overwritten(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);self.fixtures(root);entries=hooks.plan(root);p=root/'license/server.mjs';p.write_text(p.read_text()+'// Concurrent')
|
||||
with self.assertRaises(ValueError):hooks.apply(entries,root)
|
||||
self.assertTrue(p.read_text().endswith('// Concurrent'))
|
||||
def test_later_changes_block_rollback(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);self.fixtures(root);receipt=hooks.apply(hooks.plan(root),root);p=root/'license/server.mjs';p.write_text(p.read_text()+'// Later')
|
||||
with self.assertRaises(ValueError):hooks.rollback(receipt)
|
||||
self.assertTrue(p.read_text().endswith('// Later'))
|
||||
def test_changed_or_ambiguous_anchor_fails_closed(self):
|
||||
with self.assertRaises(ValueError):hooks.once('x x','x','y')
|
||||
with self.assertRaises(ValueError):hooks.patch_api('def f():pass')
|
||||
|
||||
class HorizonValidationTest(unittest.TestCase):
|
||||
def test_synthetic_48h_energy_soc_limits(self):
|
||||
import math
|
||||
from netplan_v4.domain import Battery,Step,Price,Limits
|
||||
from netplan_v4.optimizer import optimize
|
||||
steps=[]
|
||||
for i in range(576):
|
||||
solar=max(0.,math.sin(math.pi*((i%288)/12-6)/12))*10000.
|
||||
steps.append(Step(AT+timedelta(minutes=5*i),3000.,solar,Price(.15 if i%288<72 else .35),Price(.1)))
|
||||
b=Battery('synthetic',40.,20.,10.,90.,10000.,8000.,AT,grid_charging=True)
|
||||
p=optimize(steps,[b],at=AT,limits=Limits(import_w=12000.,export_w=10000.),observed_peaks={'2026-10':8.},peak_prices={'2026-10':5.})
|
||||
self.assertTrue(p['executable']);self.assertEqual(len(p['points']),576)
|
||||
for s,x in zip(steps,p['points']):
|
||||
self.assertAlmostEqual(x['gridTargetW'],s.residual_w+x['batteryTargetW']+x['pvCurtailmentW'],places=3)
|
||||
self.assertLessEqual(x['gridTargetW'],12000.001);self.assertGreaterEqual(x['gridTargetW'],-10000.001)
|
||||
self.assertTrue(9.999<=x['socEndPercent']['synthetic']<=90.001)
|
||||
self.assertGreaterEqual(p['points'][-1]['socEndPercent']['synthetic'],19.999)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from forecast_acceptance import source_paths, verify_forecast_bundle
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('forecast_release_test', ROOT / 'release_preflight.py')
|
||||
preflight = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(preflight)
|
||||
|
||||
|
||||
class ForecastAcceptanceTest(unittest.TestCase):
|
||||
def fixture(self, root):
|
||||
src, bundle = root / 'source', root / 'bundle'
|
||||
for path in (src, bundle):
|
||||
path.mkdir()
|
||||
for name, value in {'main.py': 'x=1\n', 'telemetry_quality.py': 'x=2\n', 'requirements.txt': 'pandas\n'}.items():
|
||||
(path / name).write_text(value)
|
||||
manifest = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in source_paths(src)}
|
||||
(bundle / 'SOURCE_MANIFEST.json').write_text(json.dumps(manifest))
|
||||
return src, bundle
|
||||
|
||||
def test_exact_source_is_verified(self):
|
||||
with TemporaryDirectory() as d:
|
||||
src, bundle = self.fixture(Path(d))
|
||||
self.assertEqual(len(verify_forecast_bundle(bundle, src)), 3)
|
||||
|
||||
def test_modified_development_source_blocks_test(self):
|
||||
with TemporaryDirectory() as d:
|
||||
src, bundle = self.fixture(Path(d))
|
||||
(src / 'main.py').write_text('x=3\n')
|
||||
with self.assertRaises(ValueError):
|
||||
verify_forecast_bundle(bundle, src)
|
||||
|
||||
def test_extra_staged_python_file_blocks_test(self):
|
||||
with TemporaryDirectory() as d:
|
||||
src, bundle = self.fixture(Path(d))
|
||||
(bundle / 'surprise.py').write_text('x=3\n')
|
||||
with self.assertRaises(ValueError):
|
||||
verify_forecast_bundle(bundle, src)
|
||||
|
||||
def test_symlink_is_rejected(self):
|
||||
with TemporaryDirectory() as d:
|
||||
src, bundle = self.fixture(Path(d))
|
||||
(bundle / 'main.py').unlink()
|
||||
(bundle / 'main.py').symlink_to(src / 'main.py')
|
||||
with self.assertRaises(ValueError):
|
||||
verify_forecast_bundle(bundle, src)
|
||||
|
||||
def test_models_secrets_and_databases_not_staged(self):
|
||||
with TemporaryDirectory() as d:
|
||||
src, bundle = self.fixture(Path(d))
|
||||
for name in ('.env', 'model.pkl', 'users.db'):
|
||||
(src / name).write_text('not a source file')
|
||||
self.assertEqual(len(verify_forecast_bundle(bundle, src)), 3)
|
||||
|
||||
def test_forecast_checks_have_no_network_or_live_mounts(self):
|
||||
args = next(args for name,args,cwd,timeout in preflight.command_list('p') if name=='forecast_candidate_python311')
|
||||
self.assertEqual(args[args.index('--network')+1], 'none')
|
||||
self.assertEqual(args[args.index('--user')+1], '1000:1000')
|
||||
self.assertIn('--read-only', args)
|
||||
for forbidden in ('--volume', '-v', '--env-file', '--privileged', 'up', 'restart'):
|
||||
self.assertNotIn(forbidden, args)
|
||||
|
||||
def test_runner_does_not_import_application_main(self):
|
||||
source = (ROOT / 'acceptance/check_forecast.py').read_text()
|
||||
tree = ast.parse(source)
|
||||
imports = {a.name for node in ast.walk(tree) if isinstance(node, ast.Import) for a in node.names}
|
||||
self.assertNotIn('main', imports)
|
||||
self.assertNotIn('run_forecast(', source)
|
||||
self.assertNotIn('get_configs(', source)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from netplan_v4.forecast_quality import assess_family
|
||||
from netplan_v4.service import ingest,run_once
|
||||
from netplan_v4.store import PlannerStore
|
||||
from test_v4 import inputs,AT,AID
|
||||
|
||||
class ForecastQualityTest(unittest.TestCase):
|
||||
def test_zero_load_not_free_energy_assumption(self):
|
||||
f={'points':[{'loadW':0.,'pvW':1000.} for _ in range(3)]}
|
||||
self.assertFalse(assess_family(f)['valid'])
|
||||
def test_confirmed_pv_only_plant_is_supported(self):
|
||||
f={'points':[{'loadW':0.,'pvW':1000.} for _ in range(3)],'zeroLoadConfirmed':True}
|
||||
self.assertTrue(assess_family(f)['valid'])
|
||||
def test_boolean_and_nan_are_not_forecast_powers(self):
|
||||
for value in (float('nan'),True,-1.,None):
|
||||
f={'points':[{'loadW':value,'pvW':1000.} for _ in range(3)]}
|
||||
self.assertFalse(assess_family(f)['valid'])
|
||||
def test_selected_invalid_family_not_silently_replaced(self):
|
||||
with TemporaryDirectory() as d:
|
||||
s=PlannerStore(str(Path(d)/'p.sqlite'));op,fc,tar=inputs()
|
||||
for p in fc['families']['3']['points']:p['loadW']=0.
|
||||
for k,v in zip(('operation','forecast','tariffs'),(op,fc,tar)):ingest(s,AID,k,v,AT)
|
||||
plan=run_once(s,AT)
|
||||
self.assertEqual(plan['status'],'awaiting_inputs');self.assertIn('all-zero',plan['reason']);self.assertIsNone(s.current(AID));s.close()
|
||||
def test_nonzero_family_remains_usable(self):
|
||||
f={'points':[{'loadW':50.,'pvW':0.} for _ in range(3)]}
|
||||
self.assertTrue(assess_family(f)['valid'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import contextlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('integrated_deployment_test',ROOT/'deploy_integrated_shadow.py')
|
||||
d=importlib.util.module_from_spec(spec);spec.loader.exec_module(d)
|
||||
|
||||
|
||||
class IntegratedDeploymentTest(unittest.TestCase):
|
||||
def test_exact_server_scope_and_no_symcon(self):
|
||||
scope=d.compose_groups()
|
||||
self.assertEqual(set(scope),{'v4','forecast','portal'})
|
||||
self.assertEqual(scope['forecast'][2],['api','forecast-engine','tariff-importer'])
|
||||
self.assertNotIn('license-admin',scope['portal'][2])
|
||||
self.assertNotIn('/var/lib/symcon',(ROOT/'deploy_integrated_shadow.py').read_text())
|
||||
def test_sqlite_backup_is_consistent_and_source_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
source=Path(temp)/'before.sqlite';dest=Path(temp)/'backup.sqlite'
|
||||
con=sqlite3.connect(source);con.execute('CREATE TABLE test(value INTEGER)')
|
||||
con.execute('INSERT INTO test VALUES (37)');con.commit();con.close()
|
||||
before=source.read_bytes()
|
||||
with patch.object(d.os,'geteuid',return_value=1000):result=d.backup_database(source,dest)
|
||||
self.assertEqual(result['integrity'],'ok');self.assertEqual(before,source.read_bytes())
|
||||
check=sqlite3.connect(dest);self.assertEqual(check.execute('SELECT value FROM test').fetchone()[0],37);check.close()
|
||||
self.assertEqual(result['sha256'],hashlib.sha256(dest.read_bytes()).hexdigest())
|
||||
def test_missing_database_not_fabricated(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
dst=Path(temp)/'backup'
|
||||
self.assertEqual(d.backup_database(Path(temp)/'missing',dst),{'exists':False})
|
||||
self.assertFalse(dst.exists())
|
||||
def test_scope_uses_no_environment_dump_or_control_activation(self):
|
||||
text=(ROOT/'deploy_integrated_shadow.py').read_text()
|
||||
self.assertNotIn('.Config.Env',text)
|
||||
self.assertNotIn('NetzfahrplanAktiv',text)
|
||||
self.assertNotIn('IPS_RequestAction',text)
|
||||
self.assertNotIn('PROGNOSIS_SERVICE_TOKEN',text)
|
||||
ast.parse(text)
|
||||
def test_build_and_tests_precede_all_service_recreation(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root=Path(temp)/'netplan';root.mkdir();project=Path(temp)/'forecast';project.mkdir()
|
||||
portal=Path(temp)/'portal';portal.mkdir();(root/'acceptance').mkdir()
|
||||
commands=[]
|
||||
def fake(args,**kwargs):
|
||||
commands.append(list(args));out=''
|
||||
if 'ps' in args:out='0123456789abcdef\n'
|
||||
elif 'inspect' in args:
|
||||
out='sha256:'+('a'*64) if '{{.Image}}' in args else '/some/compose.yaml'
|
||||
elif 'exec' in args:
|
||||
out=json.dumps({'health':{'mode':'shadow','liveEnabled':False},'inputEventsByKind':{},'lastRunStatus':None})
|
||||
return SimpleNamespace(returncode=0,stdout=out)
|
||||
with patch.object(d,'ROOT',root),patch.object(d,'PROJECT',project),patch.object(d,'PORTAL',portal),patch.object(d,'verify_sources',return_value={}),patch.object(d.os,'geteuid',return_value=1000),patch.object(d.subprocess,'run',side_effect=fake),patch('install_hooks.plan',return_value={}),patch('install_hooks.apply',return_value=None),contextlib.redirect_stdout(io.StringIO()):
|
||||
d.run('e3a08f9e-af12-4695-99bd-8b51c0520021')
|
||||
ups=[i for i,c in enumerate(commands) if 'up' in c]
|
||||
self.assertEqual(len(ups),3)
|
||||
test_at=next(i for i,c in enumerate(commands) if '--test-only' in c)
|
||||
build_at=max(i for i,c in enumerate(commands) if 'build' in c)
|
||||
self.assertLess(test_at,min(ups));self.assertLess(build_at,min(ups))
|
||||
self.assertTrue(all('--no-build' in commands[i] and '--no-deps' in commands[i] for i in ups))
|
||||
report=json.loads(next((root/'deployment-reports').glob('*/DEPLOYMENT.json')).read_text())
|
||||
self.assertEqual(report['status'],'server_shadow_installed')
|
||||
self.assertFalse(report['liveEnabled']);self.assertFalse(report['productionReady'])
|
||||
@@ -0,0 +1,161 @@
|
||||
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_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<NOW-150 else 6000) for t in range(NOW-300,NOW+1,30)]
|
||||
w=m.reconstruct(self.projected(rows),self.c)[0]
|
||||
self.assertEqual(w['coveredSeconds'],300);self.assertAlmostEqual(w['loadW'],7500)
|
||||
def test_gap_remains_explicit(self):
|
||||
rows=[record(t) for t in range(NOW-300,NOW+1,30) if t!=NOW-120]
|
||||
w=m.reconstruct(self.projected(rows),self.c)[0]
|
||||
self.assertLess(w['coverage'],1);self.assertFalse(w['profileUsable'])
|
||||
def test_stale_source_expires(self):
|
||||
rows=[record(t) for t in range(NOW-300,NOW+1,30)]
|
||||
for r in rows:r['raw']['pv']['sourceUpdatedAt']=NOW-300
|
||||
w=m.reconstruct(self.projected(rows),self.c)[0]
|
||||
self.assertEqual(w['coveredSeconds'],60)
|
||||
def test_virtual_missing_not_veto_physical(self):
|
||||
rows=[record(t,sdl=None) for t in range(NOW-300,NOW+1,30)]
|
||||
w=m.reconstruct(self.projected(rows),self.c)[0];self.assertTrue(w['profileUsable'])
|
||||
def test_last_bin_no_extrapolation(self):
|
||||
rows=[record(t) for t in range(NOW-300,NOW,30)]
|
||||
w=m.reconstruct(self.projected(rows),self.c)[0];self.assertEqual(w['coveredSeconds'],270);self.assertFalse(w['profileUsable'])
|
||||
def test_short_history_collects(self):
|
||||
self.batch([record(NOW-30),record(NOW)])
|
||||
m.advance(self.s.con,AID,'physical-v1',self.s.settings(AID),NOW)
|
||||
self.assertIsNone(m.current_model(self.s.con,AID,'physical-v1',NOW))
|
||||
def test_trained_model_persistent_and_versioned(self):
|
||||
self.history();m.advance(self.s.con,AID,'physical-v1',self.s.settings(AID),NOW)
|
||||
model=m.current_model(self.s.con,AID,'physical-v1',NOW)
|
||||
self.assertIsNotNone(model);self.assertEqual(m.predict(model,'3',NOW),6000)
|
||||
self.assertEqual(model['validation']['status'],'bootstrap_insufficient_holdout')
|
||||
self.s.close();self.s=PlannerStore(self.path)
|
||||
self.assertEqual(m.current_model(self.s.con,AID,'physical-v1',NOW)['modelId'],model['modelId'])
|
||||
def test_daily_training_not_every_sample(self):
|
||||
self.history();settings=self.s.settings(AID);m.advance(self.s.con,AID,'physical-v1',settings,NOW)
|
||||
m.advance(self.s.con,AID,'physical-v1',settings,NOW+300)
|
||||
self.assertEqual(self.s.con.execute('SELECT COUNT(*) FROM planner_load_models').fetchone()[0],1)
|
||||
def test_corrected_source_never_falls_back(self):
|
||||
op,fc,tar=inputs(datetime.fromtimestamp(NOW,timezone.utc))
|
||||
with self.assertRaises(ValueError):m.apply_load_forecast(self.s.con,AID,'physical-v1',fc,NOW)
|
||||
def test_external_is_labelled_persistence_not_published(self):
|
||||
self.history();m.advance(self.s.con,AID,'physical-v1',self.s.settings(AID),NOW)
|
||||
op,fc,tar=inputs(datetime.fromtimestamp(NOW,timezone.utc));out=m.apply_load_forecast(self.s.con,AID,'physical-v1',fc,NOW)
|
||||
self.assertEqual(out['families']['3']['points'][0]['loadW'],6000)
|
||||
self.assertFalse(out['families']['3']['dataPipeline']['futureSdlPublished'])
|
||||
self.assertNotIn('accountingEvidenceId',out['families']['3'])
|
||||
self.assertEqual(fc['families']['3']['points'][0]['loadW'],2000)
|
||||
def test_unknown_external_no_fake_zero(self):
|
||||
self.history();m.advance(self.s.con,AID,'physical-v1',self.s.settings(AID),NOW)
|
||||
r=record(NOW+30,sdl=None);self.batch([r],NOW+30)
|
||||
op,fc,tar=inputs(datetime.fromtimestamp(NOW+30,timezone.utc))
|
||||
with self.assertRaises(ValueError):m.apply_load_forecast(self.s.con,AID,'physical-v1',fc,NOW+30)
|
||||
def test_native_data_to_model_to_existing_optimizer(self):
|
||||
self.history();now=datetime.fromtimestamp(NOW,timezone.utc)
|
||||
self.s.save_settings(AID,{'forecastSource':'corrected_profile','measurementDataset':'physical-v1'},0,now)
|
||||
for kind,v in zip(('operation','forecast','tariffs'),inputs(now)):ingest(self.s,AID,kind,v,now)
|
||||
p=run_once(self.s,now)
|
||||
self.assertTrue(p.get('executable'),p);self.assertFalse(p['liveEnabled'])
|
||||
self.assertEqual(p['inputQuality']['dataPipeline']['datasetId'],'physical-v1')
|
||||
self.assertTrue(status(self.s,AID,now)['dataPipeline']['datasets'][0]['records'])
|
||||
def test_model_not_available_before_training_time(self):
|
||||
self.history();m.advance(self.s.con,AID,'physical-v1',self.s.settings(AID),NOW)
|
||||
self.assertIsNone(m.current_model(self.s.con,AID,'physical-v1',NOW-1))
|
||||
def test_dataset_setting_explicit(self):
|
||||
with self.assertRaises(ValueError):self.s.save_settings(AID,{'forecastSource':'corrected_profile'},0,datetime.fromtimestamp(NOW,timezone.utc))
|
||||
def test_http_scoped_ingest_and_configuration(self):
|
||||
app=create_app(str(Path(self.tmp.name)/'api.sqlite'),TOKEN,[AID],start_worker=False)
|
||||
path=f'/internal/v2/prognosis/{AID}/planner';h={'X-Enelix-Service-Token':TOKEN}
|
||||
with TestClient(app) as client:
|
||||
self.assertEqual(client.put(path+'/datasets/physical-v1',json=self.c).status_code,401)
|
||||
self.assertEqual(client.put(path+'/datasets/physical-v1',json=self.c,headers=h).status_code,200)
|
||||
now=int(datetime.now(timezone.utc).timestamp());payload={'version':1,'datasetId':'physical-v1','records':[record(now)]}
|
||||
self.assertEqual(client.post(path+'/measurements',json=payload,headers=h).status_code,200)
|
||||
s=client.get(path,headers=h).json();self.assertEqual(s['dataPipeline']['datasets'][0]['records'],1)
|
||||
self.assertFalse(s['liveEnabled'])
|
||||
def test_solar_terminal_not_battery_twice(self):
|
||||
c=copy.deepcopy(self.c);c['formula']='solar_terminal_v1'
|
||||
c['solarReference']={'pvKey':'pv','batteryKey':'battery','rawKey':'ac','scaleKey':'sf'}
|
||||
c['sources'] += [{'key':k,'role':role,'variableId':i,'factorToW':1,'maxAgeSeconds':60} for k,role,i in [('ac','solar_raw',105),('sf','solar_scale',106)]]
|
||||
m.validate_config(c)
|
||||
self.assertEqual(m.physical_value({'grid':3000,'ac':-4000,'sf':-2},c),2960)
|
||||
def test_solar_invalid_sentinel(self):
|
||||
c=copy.deepcopy(self.c);c['formula']='solar_terminal_v1';c['solarReference']={'pvKey':'pv','batteryKey':'battery','rawKey':'ac','scaleKey':'sf'}
|
||||
with self.assertRaises(ValueError):m.physical_value({'grid':3000,'ac':-32768,'sf':0},c)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import json
|
||||
import unittest
|
||||
from netplan_v4.metering import CounterSeries,Reading,MeterEvidence,audit_capture,native_meter_id
|
||||
|
||||
UTC=timezone.utc
|
||||
# October begins at September 30 22:00 UTC in Europe/Zurich.
|
||||
BEGIN=datetime(2026,9,30,22,0,tzinfo=UTC)
|
||||
END=BEGIN+timedelta(minutes=15)
|
||||
SOURCES=[{'VariableID':59607,'ElternID':11490,'Ident':'Energy_0','FaktorZuKWh':1.,'Messgroesse':'WirkenergieBezug'},
|
||||
{'VariableID':26620,'ElternID':11490,'Ident':'Energy_1','FaktorZuKWh':1.,'Messgroesse':'WirkenergieBezug'}]
|
||||
|
||||
def stream(name,pairs,verified=False,**kwargs):
|
||||
return CounterSeries(name,[Reading(BEGIN+timedelta(seconds=t),v) for t,v in pairs],
|
||||
timestamp_verified=verified,identity_verified=verified,chronology_verified=verified,**kwargs)
|
||||
|
||||
def meter(t1=None,t2=None,verified=False):
|
||||
return MeterEvidence([stream('symcon:59607',t1 or [(0,100.),(900,102.)],verified),
|
||||
stream('symcon:26620',t2 or [(0,0.),(900,0.)],verified)],native_sources=SOURCES)
|
||||
|
||||
class MeterEvidenceTest(unittest.TestCase):
|
||||
def test_t1_t2_sum_and_average(self):
|
||||
result=meter(t2=[(0,10.),(900,11.)],verified=True).interval(BEGIN,END)
|
||||
self.assertAlmostEqual(result['lowerKwh'],3.)
|
||||
self.assertAlmostEqual(result['upperAverageKw'],12.)
|
||||
self.assertTrue(result['billingEvidence'])
|
||||
def test_known_zero_is_valid_not_missing(self):
|
||||
self.assertEqual(meter(verified=True).strict_contract(END)['measuredPeaks']['2026-10']['kw'],8.)
|
||||
def test_missing_tariff_not_assumed_zero(self):
|
||||
m=MeterEvidence([stream('a',[(0,1.),(900,2.)]),stream('b',[])])
|
||||
self.assertEqual(m.interval(BEGIN,END)['status'],'missing')
|
||||
def test_current_zero_does_not_prove_past(self):
|
||||
m=MeterEvidence([stream('a',[(0,1.),(900,2.)]),stream('b',[(900,0.)])])
|
||||
self.assertFalse(m.month(END)['historyComplete'])
|
||||
def test_missing_month_peak_not_zero(self):
|
||||
result=meter(t2=[(900,0.)]).month(END)
|
||||
self.assertIsNone(result['verifiedMonthPeakKw'])
|
||||
self.assertIsNone(result['monthPeakUpperKw'])
|
||||
def test_first_quarter_not_already_paid_peak(self):
|
||||
result=meter(verified=True).month(BEGIN+timedelta(minutes=5))
|
||||
self.assertEqual(result['completedQuarters'],0)
|
||||
self.assertFalse(result['billingEvidence'])
|
||||
self.assertIsNone(result['verifiedMonthPeakKw'])
|
||||
def test_reset_quarantines_interval(self):
|
||||
result=meter(t1=[(0,100.),(400,0.),(900,2.)]).interval(BEGIN,END)
|
||||
self.assertEqual(result['status'],'missing')
|
||||
def test_unverified_archive_never_becomes_billing_evidence(self):
|
||||
m=meter()
|
||||
self.assertTrue(m.month(END)['historyComplete'])
|
||||
with self.assertRaises(ValueError):m.strict_contract(END)
|
||||
def test_boundaries_not_interpolated_into_fact(self):
|
||||
s=stream('a',[(-2,100.),(2,100.1),(898,101.9),(902,102.)],verified=True)
|
||||
e=MeterEvidence([s]).interval(BEGIN,END)
|
||||
self.assertEqual(e['status'],'bounded_records')
|
||||
self.assertAlmostEqual(e['lowerKwh'],1.8)
|
||||
self.assertAlmostEqual(e['upperKwh'],2.)
|
||||
self.assertFalse(e['billingEvidence'])
|
||||
def test_wide_boundary_gap_is_unknown(self):
|
||||
s=stream('a',[(-200,100.),(200,100.1),(900,101.)])
|
||||
self.assertIsNone(s.energy(BEGIN,END))
|
||||
def test_no_extrapolation(self):
|
||||
self.assertIsNone(stream('a',[(0,1.),(900,2.)]).energy(BEGIN,END+timedelta(seconds=1)))
|
||||
def test_duplicate_identical_records_are_idempotent(self):
|
||||
self.assertEqual(len(stream('a',[(0,1.),(0,1.),(900,2.)]).times),2)
|
||||
def test_duplicate_conflicting_records_fail(self):
|
||||
with self.assertRaises(ValueError):stream('a',[(0,1.),(0,1.1)])
|
||||
def test_invalid_counter_values_fail(self):
|
||||
for value in (float('nan'),float('inf'),True,-1.,'2'):
|
||||
with self.subTest(value=value),self.assertRaises(ValueError):stream('a',[(0,value)])
|
||||
def test_duplicate_tariffs_fail(self):
|
||||
with self.assertRaises(ValueError):MeterEvidence([stream('a',[]),stream('a',[])])
|
||||
def test_native_identity_ignores_order_not_factor(self):
|
||||
self.assertEqual(native_meter_id(SOURCES),native_meter_id(list(reversed(SOURCES))))
|
||||
changed=[dict(s) for s in SOURCES];changed[0]['FaktorZuKWh']=.001
|
||||
self.assertNotEqual(native_meter_id(SOURCES),native_meter_id(changed))
|
||||
def test_identity_matches_php_encoder(self):
|
||||
# Captured from PHP 8.3 on the test host, not calculated by this Python code.
|
||||
cases={1.:'29f33f47e5c3a312e9c3c85735f95ea16d237860d76c0e3720c59e567d77ec8c',
|
||||
.001:'5600bff9a5d82aa6230196f90f836c69c966374a1458864e770083656ed65f4c',
|
||||
.000001:'477aca4ce6eb1079b58114911125675137a5f7d0ec8b0b3a2025e3323cb269a4'}
|
||||
for factor,digest in cases.items():
|
||||
sources=[dict(s) for s in SOURCES];sources[0]['FaktorZuKWh']=factor
|
||||
with self.subTest(factor=factor):self.assertEqual(native_meter_id(sources),'symcon-active-import:'+digest)
|
||||
def test_native_source_set_must_match(self):
|
||||
with self.assertRaises(ValueError):MeterEvidence([stream('symcon:53476',[])],native_sources=SOURCES)
|
||||
def test_no_legacy_evidence_identity(self):
|
||||
m=MeterEvidence([stream('a',[(0,1.),(900,2.)],verified=True)])
|
||||
self.assertTrue(m.meter_id.startswith('audit-only:'))
|
||||
with self.assertRaises(ValueError):m.strict_contract(END)
|
||||
def test_native_other_meter_or_quantity_rejected(self):
|
||||
for field,value in [('ElternID',1),('Messgroesse','Blindenergie')]:
|
||||
sources=[dict(s) for s in SOURCES];sources[0][field]=value
|
||||
with self.subTest(field=field),self.assertRaises(ValueError):native_meter_id(sources)
|
||||
def test_partial_quarter_must_be_verified(self):
|
||||
at=END+timedelta(minutes=5)
|
||||
m=meter(t1=[(0,100.),(900,102.),(1200,103.)],t2=[(0,0.),(900,0.),(1200,0.)],verified=True)
|
||||
contract=m.strict_contract(at)
|
||||
self.assertEqual(contract['quarterPast']['measuredSeconds'],300)
|
||||
self.assertEqual(contract['quarterPast']['importKwh'],1.)
|
||||
def test_partial_quarter_not_extrapolated(self):
|
||||
with self.assertRaises(ValueError):meter(verified=True).strict_contract(END+timedelta(seconds=1))
|
||||
def test_zurich_month_not_utc_month(self):
|
||||
self.assertEqual(meter().month(END)['month'],'2026-10')
|
||||
def test_dst_repeated_hour_not_merged(self):
|
||||
from zoneinfo import ZoneInfo
|
||||
zone=ZoneInfo('Europe/Zurich')
|
||||
a=datetime(2026,10,25,2,0,tzinfo=zone,fold=0)
|
||||
b=datetime(2026,10,25,2,0,tzinfo=zone,fold=1)
|
||||
s=CounterSeries('a',[Reading(a,1.),Reading(b,2.)])
|
||||
self.assertEqual(len(s.times),2)
|
||||
self.assertEqual((s.times[1]-s.times[0]).total_seconds(),3600)
|
||||
def test_naive_timestamps_rejected(self):
|
||||
with self.assertRaises(ValueError):CounterSeries('a',[Reading(datetime(2026,10,1),1.)])
|
||||
def test_capture_is_only_an_audit(self):
|
||||
channels={}
|
||||
for var,ident,value in [('59607','Energy_0',2.),('26620','Energy_1',0.)]:
|
||||
channels[var]={'ident':ident,'logging':True,'queryComplete':True,
|
||||
'history':[{'TimeStamp':int(BEGIN.timestamp()),'Value':0.},
|
||||
{'TimeStamp':int(END.timestamp()),'Value':value}],
|
||||
'snapshotConsistent':True,'snapshot':{'capturedAt':END.isoformat(),'value':value}}
|
||||
result=audit_capture({'schemaVersion':1,'kind':'v4_meter_capture',
|
||||
'capturedAt':END.isoformat(),'channels':channels})
|
||||
self.assertTrue(result['month']['historyComplete'])
|
||||
self.assertFalse(result['billingEvidence'])
|
||||
self.assertFalse(result['liveEnabled'])
|
||||
def test_missing_logging_survives_without_fake_1970(self):
|
||||
channels={str(var):{'ident':ident,'logging':False,'queryComplete':False,
|
||||
'history':[],'snapshotConsistent':True,'snapshot':{'capturedAt':END.isoformat(),'value':0.}}
|
||||
for var,ident in [(59607,'Energy_0'),(26620,'Energy_1')]}
|
||||
result=audit_capture({'schemaVersion':1,'kind':'v4_meter_capture','capturedAt':END.isoformat(),'channels':channels})
|
||||
self.assertFalse(result['month']['historyComplete'])
|
||||
self.assertNotIn('1970-',json.dumps(result))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
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()
|
||||
@@ -0,0 +1,45 @@
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from netplan_v4.receiver_contract import control_context, provenance
|
||||
from netplan_v4.store import PlannerStore
|
||||
from netplan_v4.service import ingest, run_once, status
|
||||
from test_v4 import inputs, AT, AID
|
||||
|
||||
class ReceiverContractTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.s=PlannerStore(str(Path(self.tmp.name)/'qa.sqlite'))
|
||||
op,fc,tar=inputs()
|
||||
for kind,value in zip(('operation','forecast','tariffs'),(op,fc,tar)):ingest(self.s,AID,kind,value,AT)
|
||||
def tearDown(self):self.s.close();self.tmp.cleanup()
|
||||
def test_published_plan_and_response_bound_to_same_plant(self):
|
||||
p=run_once(self.s,AT);r=status(self.s,AID,AT)
|
||||
self.assertEqual(p['installationId'],AID);self.assertEqual(r['installationId'],AID)
|
||||
self.assertEqual(r['receiverProtocolVersion'],1);self.assertTrue(r['fresh'])
|
||||
self.assertFalse(r['liveEnabled']);self.assertFalse(p['liveEnabled'])
|
||||
self.assertEqual(p['inputRefs'],{'operation':'op1','forecast':'fc1','tariffs':'tar1'})
|
||||
def test_control_context_tracks_stable_policy_not_volatile_soc(self):
|
||||
op=inputs()[0];a=control_context(op);op['batteries'][0]['socPercent']=80.;op['gridW']=1000.
|
||||
self.assertEqual(a,control_context(op));op['batteries'][0]['minSocPercent']=15.
|
||||
self.assertNotEqual(a,control_context(op))
|
||||
def test_context_is_snapshot_not_reference_to_future_changes(self):
|
||||
op,fc,tar=inputs();p=provenance({'operation':op,'forecast':fc,'tariffs':tar})
|
||||
op['limits']['importW']=100.;self.assertEqual(p['controlContext']['limits']['importW'],10000.)
|
||||
def test_no_plan_for_another_installation(self):
|
||||
run_once(self.s,AT);r=status(self.s,'00000000-0000-4000-8000-000000000002',AT)
|
||||
self.assertIsNone(r['plan']);self.assertFalse(r['fresh'])
|
||||
def test_shadow_ack_is_not_execution(self):
|
||||
p=run_once(self.s,AT)
|
||||
self.s.acknowledge(AID,p['planId'],p['configRevision'],AT,step=p['points'][0]['time'],status='shadow_seen')
|
||||
self.assertEqual(status(self.s,AID,AT)['acknowledgement']['status'],'shadow_seen')
|
||||
with self.assertRaises(ValueError):self.s.acknowledge(AID,p['planId'],p['configRevision'],AT,status='applied')
|
||||
def test_new_configuration_invalidates_fresh_flag(self):
|
||||
run_once(self.s,AT);self.s.save_settings(AID,{'family':'23'},0,AT+timedelta(seconds=1))
|
||||
self.assertFalse(status(self.s,AID,AT+timedelta(seconds=2))['fresh'])
|
||||
def test_rearm_default_matches_php_contract(self):
|
||||
op=inputs()[0];op['batteries'][0]['rearmSocPercent']=None
|
||||
self.assertEqual(control_context(op)['batteries']['b']['rearmSocPercent'],10.)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('release_preflight_test',ROOT/'release_preflight.py')
|
||||
preflight=importlib.util.module_from_spec(spec);spec.loader.exec_module(preflight)
|
||||
|
||||
class ReleasePreflightTest(unittest.TestCase):
|
||||
def test_preflight_never_restarts_existing_services(self):
|
||||
for name,args,cwd,timeout in preflight.command_list('e3a08f9e-af12-4695-99bd-8b51c0520021'):
|
||||
self.assertNotIn('up',args);self.assertNotIn('restart',args);self.assertNotIn('--with-bridges',args)
|
||||
self.assertGreater(timeout,0)
|
||||
first=preflight.command_list('e3a08f9e-af12-4695-99bd-8b51c0520021')[0][1]
|
||||
self.assertIn('--test-only',first)
|
||||
def test_php_runs_without_network_or_writeable_root(self):
|
||||
args=next(args for name,args,cwd,timeout in preflight.command_list('p') if name=='php83_offline')
|
||||
self.assertIn('--read-only',args);self.assertEqual(args[args.index('--network')+1],'none')
|
||||
self.assertEqual(args[args.index('--user')+1],'1000:1000')
|
||||
def test_native_bundle_requires_same_development_source(self):
|
||||
with TemporaryDirectory() as d:
|
||||
root=Path(d)/'root';repo=Path(d)/'repo';out=root/'acceptance/php-src'
|
||||
out.mkdir(parents=True);repo.mkdir()
|
||||
source=b'<?php // test';(out/'a.php').write_bytes(source);(repo/'a.php').write_bytes(source)
|
||||
(out/'SOURCE_MANIFEST.json').write_text(json.dumps({'a.php':hashlib.sha256(source).hexdigest()}))
|
||||
with patch.object(preflight,'ROOT',root),patch.object(preflight,'REPO',repo):
|
||||
preflight.verify_native_bundle()
|
||||
(repo/'a.php').write_text('<?php // changed')
|
||||
with self.assertRaises(ValueError):preflight.verify_native_bundle()
|
||||
def test_native_probe_has_no_training_or_publish_calls(self):
|
||||
tree=ast.parse((ROOT/'acceptance/read_native_forecasts.py').read_text())
|
||||
calls={n.func.attr for n in ast.walk(tree) if isinstance(n,ast.Call) and isinstance(n.func,ast.Attribute)}
|
||||
self.assertFalse(calls & {'train','run_forecast','run_forecast_isolated','get_configs','publish_forecasts','write_api'})
|
||||
text=(ROOT/'acceptance/read_native_forecasts.py').read_text()
|
||||
self.assertIn('?mode=ro',text);self.assertNotIn('ALTER TABLE',text)
|
||||
def test_gui_labels_estimates_and_incomplete_features(self):
|
||||
text=(ROOT/'gui/netplan-v4.js').read_text()
|
||||
self.assertIn('SCHATTENBETRIEB',text)
|
||||
self.assertIn('measurementPolicy',text)
|
||||
self.assertIn('wartet weiterhin auf vergleichbare Kosten-Replays',text)
|
||||
self.assertIn('forecastSource',text)
|
||||
self.assertIn('trainingCadence',text)
|
||||
self.assertIn('Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe',text)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,184 @@
|
||||
import copy
|
||||
from dataclasses import replace
|
||||
from datetime import datetime,timedelta,timezone
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from netplan_v4.domain import Battery,Family,Limits,Price,QuarterPast,Step,default_registry,split_base_load,priced_prefix
|
||||
from netplan_v4.optimizer import optimize
|
||||
from netplan_v4.selection import ReplayScore,choose_family,training_due,promote_candidate
|
||||
from netplan_v4.store import PlannerStore
|
||||
from netplan_v4.service import ingest,latest,run_once,status,create_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
AT=datetime(2026,10,1,12,0,tzinfo=timezone.utc)
|
||||
AID='e3a08f9e-af12-4695-99bd-8b51c0520021'
|
||||
TOKEN='synthetic-test-token-not-real-credentials'
|
||||
|
||||
def seq(load,pv=None,buy=None,sell=None,at=AT):
|
||||
n=len(load);pv=pv or [0.]*n;buy=buy or [.3]*n;sell=sell or [.1]*n
|
||||
return [Step(at+timedelta(minutes=5*i),load[i],pv[i],Price(buy[i]),Price(sell[i])) for i in range(n)]
|
||||
def batt(**kw):
|
||||
values=dict(asset_id='b',capacity_kwh=10.,soc_percent=20.,min_soc_percent=10.,max_soc_percent=90.,max_charge_w=5000.,max_discharge_w=4000.,measured_at=AT,grid_charging=True)
|
||||
values.update(kw);return Battery(**values)
|
||||
def solve(steps,assets=None,**kw):
|
||||
kw.setdefault('observed_peaks',{s.start.astimezone(__import__('zoneinfo').ZoneInfo('Europe/Zurich')).strftime('%Y-%m'):5. for s in steps})
|
||||
kw.setdefault('peak_prices',{m:0. for m in kw['observed_peaks']})
|
||||
return optimize(steps,assets or [],at=steps[0].start,**kw)
|
||||
|
||||
def inputs(at=AT):
|
||||
env=lambda identifier:{'version':1,'eventId':identifier,'observedAt':at.isoformat()}
|
||||
op={**env('op1'),'gridW':-3000.,'meteringBoundary':'common_pcc','batteries':[{'id':'b','capacityKwh':10.,'socPercent':20.,'minSocPercent':10.,'maxSocPercent':90.,'maxChargeW':5000.,'maxDischargeW':4000.,'measuredAt':at.isoformat(),'gridCharging':True}],
|
||||
'limits':{'importW':10000.,'exportW':None,'managerMonthLimitsW':{}},'measuredPeaks':{'2026-10':{'kw':5.,'source':'meter_month_register'}},'quarterPast':None}
|
||||
points=[{'time':(at+timedelta(minutes=5*i)).isoformat(),'loadW':2000.,'pvW':5000. if i<3 else 0.} for i in range(6)]
|
||||
forecast={**env('fc1'),'families':{k:{'loadBasis':'base_load','trainedUntil':(at-timedelta(days=1)).isoformat(),'points':copy.deepcopy(points)} for k in ('3','13','23')}}
|
||||
tariff={**env('tar1'),'import':{'mode':'static','tariffId':'buy','staticChfKwh':.3},'export':{'mode':'static','tariffId':'sell','staticChfKwh':.1},'peakChfKwMonth':5.}
|
||||
return op,forecast,tariff
|
||||
|
||||
class OptimizerTest(unittest.TestCase):
|
||||
def test_grid_price_arbitrage(self):
|
||||
p=solve(seq([1000.]*6,buy=[.01]*3+[.7]*3),[batt()]);self.assertTrue(p['executable']);self.assertGreater(p['points'][0]['batteryTargetW'],0);self.assertLess(p['points'][-1]['batteryTargetW'],0)
|
||||
def test_no_future_energy(self):
|
||||
p=solve(seq([3000.]*3+[0.]*3,pv=[0.]*3+[5000.]*3),[batt(soc_percent=10.,grid_charging=False)])
|
||||
self.assertTrue(all(x['batteryTargetW']>=-1e-5 for x in p['points'][:3]))
|
||||
def test_roundtrip_90_percent(self):
|
||||
p=solve(seq([0.]*3+[5000.]*3,pv=[5000.]*3+[0.]*3,buy=[.3]*6,sell=[0.]*6),[batt(soc_percent=10.,grid_charging=False,max_discharge_w=5000.)])
|
||||
c=sum(max(0,x['batteryTargetW']) for x in p['points']);d=sum(max(0,-x['batteryTargetW']) for x in p['points']);self.assertAlmostEqual(d/c,.9,places=5)
|
||||
def test_nulleinspeisung(self):
|
||||
p=solve(seq([0.]*3,pv=[10000.]*3),limits=Limits(export_w=0.));self.assertTrue(p['executable']);self.assertTrue(all(abs(x['gridTargetW'])<1e-4 for x in p['points']))
|
||||
def test_nulltariffs(self):
|
||||
p=solve(seq([1000.]*3,buy=[0.]*3,sell=[0.]*3));self.assertEqual(p['cashCostChf'],0.)
|
||||
def test_negative_prices(self):
|
||||
p=solve(seq([1000.]*3,buy=[-.1]*3));self.assertLess(p['energyCostChf'],0.)
|
||||
def test_asymmetric_limits_and_balance(self):
|
||||
steps=seq([0.]*3+[3000.]*3,pv=[5000.]*3+[0.]*3)
|
||||
p=solve(steps,[batt(max_charge_w=700.,max_discharge_w=300.)]);self.assertTrue(p['executable'])
|
||||
for s,x in zip(steps,p['points']):
|
||||
self.assertLessEqual(x['batteryTargetW'],700.001);self.assertGreaterEqual(x['batteryTargetW'],-300.001)
|
||||
self.assertAlmostEqual(x['gridTargetW'],s.residual_w+x['batteryTargetW']+x['pvCurtailmentW'],places=4)
|
||||
self.assertTrue(9.999<=x['socEndPercent']['b']<=90.001)
|
||||
def test_stale_soc_rejected(self):self.assertFalse(solve(seq([0.]*3),[batt(measured_at=AT-timedelta(hours=1))])['executable'])
|
||||
def test_soc_not_fabricated(self):self.assertFalse(solve(seq([0.]*3),[batt(soc_percent=5.)])['executable'])
|
||||
def test_peak_increment_only_and_baseline(self):
|
||||
p=solve(seq([2000.]*3),observed_peaks={'2026-10':1.5},peak_prices={'2026-10':5.})
|
||||
self.assertAlmostEqual(p['additionalPeakCostChf'],2.5);self.assertAlmostEqual(p['baselineCashCostChf'],2.65)
|
||||
def test_peak_blocks_unprofitable_charging(self):
|
||||
p=solve(seq([1000.]*6,buy=[.01]*3+[.5]*3),[batt(soc_percent=10.)],observed_peaks={'2026-10':1.},peak_prices={'2026-10':20.})
|
||||
self.assertLessEqual(p['plannedPeaksKw']['2026-10'],1.0001)
|
||||
def test_month_switch(self):
|
||||
at=datetime(2026,9,30,21,45,tzinfo=timezone.utc)
|
||||
p=solve(seq([2000.]*6,at=at),observed_peaks={'2026-09':2.,'2026-10':.5},peak_prices={'2026-09':5.,'2026-10':5.})
|
||||
self.assertAlmostEqual(p['additionalPeakCostChf'],7.5)
|
||||
def test_manager_cap_is_not_measured_peak(self):
|
||||
p=solve(seq([1000.]*3),limits=Limits(manager_month_limits_w={10:1500.}),observed_peaks={'2026-10':0.},peak_prices={'2026-10':5.})
|
||||
self.assertEqual(p['measuredPeaksKw']['2026-10'],0.);self.assertAlmostEqual(p['additionalPeakCostChf'],5.)
|
||||
def test_infeasible_not_fake_residual(self):
|
||||
p=solve(seq([10000.]*3),limits=Limits(import_w=1000.));self.assertFalse(p['executable']);self.assertEqual(p['points'],[])
|
||||
def test_elapsed_quarter_required(self):
|
||||
s=seq([1000.],at=AT+timedelta(minutes=10));self.assertFalse(solve(s)['executable'])
|
||||
p=solve(s,quarter_history={AT:QuarterPast(.5,600)},peak_prices={'2026-10':5.},observed_peaks={'2026-10':0.})
|
||||
self.assertAlmostEqual(p['plannedPeaksKw']['2026-10'],7/3)
|
||||
def test_partial_first_step(self):
|
||||
s=seq([1000.]*3);s[0]=replace(s[0],start=AT+timedelta(seconds=10),seconds=290)
|
||||
p=solve(s,quarter_history={AT:QuarterPast(.02,10)});self.assertTrue(p['executable']);self.assertEqual(p['validFrom'],s[0].start.isoformat())
|
||||
def test_terminal_guard(self):
|
||||
p=solve(seq([0.]*3,sell=[10.]*3),[batt(soc_percent=50.,grid_charging=False)]);self.assertGreaterEqual(p['points'][-1]['socEndPercent']['b'],49.999)
|
||||
def test_grid_charging_opt_in(self):
|
||||
p=solve(seq([1000.]*6,buy=[.01]*3+[.5]*3),[batt(soc_percent=10.,grid_charging=False)]);self.assertTrue(all(x['batteryTargetW']<=1e-5 for x in p['points']))
|
||||
|
||||
class StoreAndSelectionTest(unittest.TestCase):
|
||||
def setUp(self):self.temp=tempfile.TemporaryDirectory();self.store=PlannerStore(str(Path(self.temp.name)/'planner.sqlite'))
|
||||
def tearDown(self):self.store.close();self.temp.cleanup()
|
||||
def test_settings_revision_and_shadow_only(self):
|
||||
s=self.store.save_settings(AID,{'family':'23'},0,AT);self.assertEqual(s['revision'],1)
|
||||
with self.assertRaises(ValueError):self.store.save_settings(AID,{'family':'3'},0,AT)
|
||||
with self.assertRaises(ValueError):self.store.save_settings(AID,{'runMode':'live'},1,AT)
|
||||
def test_request_during_calculation_survives(self):
|
||||
self.store.request(AID,'manual',AT);claim=self.store.claim(AT);self.store.request(AID,'prices_changed',AT)
|
||||
p=solve(seq([0.]*3),config_revision=0)
|
||||
with self.assertRaises(ValueError):self.store.publish_shadow(AID,p,0,AT,claim['sequence'],claim['lease_token'])
|
||||
self.store.finish(claim);self.assertIsNotNone(self.store.claim(AT))
|
||||
def test_month_peak_persistent_and_monotonic(self):
|
||||
self.store.initialize_peak(AID,'2026-10',20.,'meter_month_register',AT)
|
||||
with self.assertRaises(ValueError):self.store.initialize_peak(AID,'2026-10',15.,'meter_month_register',AT)
|
||||
self.store.close();self.store=PlannerStore(str(Path(self.temp.name)/'planner.sqlite'));self.assertEqual(self.store.peaks(AID)['2026-10'],20.)
|
||||
def test_cap_is_not_valid_peak_source(self):
|
||||
with self.assertRaises(ValueError):self.store.initialize_peak(AID,'2026-10',20.,'configured_limit',AT)
|
||||
def test_meter_peak_requires_complete_quarter(self):
|
||||
self.store.initialize_peak(AID,'2026-10',0.,'verified_new_month',AT)
|
||||
for i in range(3):r=self.store.record_import_interval(AID,AT+timedelta(minutes=5*i),.5,AT+timedelta(hours=1))
|
||||
self.assertEqual(r['quarterPeakKw'],6.);self.assertEqual(self.store.peaks(AID)['2026-10'],6.)
|
||||
def test_shadow_ack_cannot_claim_applied(self):
|
||||
p=solve(seq([0.]*3),config_revision=0);self.store.publish_shadow(AID,p,0,AT)
|
||||
with self.assertRaises(ValueError):self.store.acknowledge(AID,p['planId'],0,AT,status='applied')
|
||||
self.store.acknowledge(AID,p['planId'],0,AT,status='shadow_seen')
|
||||
def test_family_registry_extensible(self):
|
||||
r=default_registry();r.register(Family('42','p42','l42','g42','Future'));self.assertEqual(r.get('42').pv,'p42')
|
||||
def test_sdl_removed_once(self):
|
||||
self.assertEqual(split_base_load(15000,[3000],2000),10000);self.assertEqual(split_base_load(13000,[3000],2000,True),10000)
|
||||
with self.assertRaises(ValueError):split_base_load(100,[1000],0)
|
||||
def test_auto_uses_comparable_cost_and_margin(self):
|
||||
scores=[ReplayScore(k,'same',AT-timedelta(days=14),AT,AT-timedelta(days=14),AT,c,1.,14) for k,c in [('3',10.),('13',9.5),('23',5.)]]
|
||||
self.assertEqual(choose_family('auto','3',scores,now=AT)['family'],'23');self.assertEqual(choose_family('auto','3',scores,now=AT,margin_chf=6.)['family'],'3')
|
||||
def test_auto_no_future_outcomes(self):
|
||||
scores=[ReplayScore(k,'same',AT-timedelta(days=14),AT,AT-timedelta(days=14),AT+timedelta(hours=1),c,1.,14) for k,c in [('3',10.),('13',9.5),('23',5.)]]
|
||||
self.assertEqual(choose_family('auto','3',scores,now=AT)['mode'],'collecting')
|
||||
def test_training_schedule_and_validation(self):
|
||||
self.assertTrue(training_due(AT-timedelta(days=1),AT));self.assertFalse(training_due(AT-timedelta(days=1),AT,'weekly'))
|
||||
self.assertFalse(promote_candidate(active_cost=10,candidate_cost=1,valid_coverage=True,no_data_leakage=False,constraints_passed=True))
|
||||
|
||||
class ServiceIntegrationTest(unittest.TestCase):
|
||||
def setUp(self):self.temp=tempfile.TemporaryDirectory();self.store=PlannerStore(str(Path(self.temp.name)/'planner.sqlite'))
|
||||
def tearDown(self):self.store.close();self.temp.cleanup()
|
||||
def load(self,values=None,now=AT):
|
||||
values=values or inputs()
|
||||
for kind,value in zip(('operation','forecast','tariffs'),values):ingest(self.store,AID,kind,value,now)
|
||||
def test_pipeline_shadow_plan(self):
|
||||
self.load();p=run_once(self.store,AT);self.assertTrue(p['executable']);self.assertEqual(p['runMode'],'shadow');self.assertTrue(status(self.store,AID,AT)['fresh']);self.assertEqual(self.store.current(AID)['planId'],p['planId'])
|
||||
def test_exact_model_selection(self):
|
||||
self.load();self.store.save_settings(AID,{'family':'23'},0,AT);self.assertEqual(run_once(self.store,AT)['sourceFamily'],'23')
|
||||
def test_one_tick_not_every_poll(self):
|
||||
self.load();run_once(self.store,AT);self.assertEqual(run_once(self.store,AT)['status'],'idle')
|
||||
def test_stale_operation_visible(self):
|
||||
self.load();p=run_once(self.store,AT+timedelta(minutes=5));self.assertEqual(p['status'],'awaiting_inputs');self.assertIsNone(self.store.current(AID))
|
||||
def test_missing_peak_not_invented(self):
|
||||
op,fc,tar=inputs();op['measuredPeaks']={};self.load((op,fc,tar));self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs')
|
||||
def test_static_zero_roundtrip(self):
|
||||
op,fc,tar=inputs();tar['import']['staticChfKwh']=0.;tar['export']['staticChfKwh']=0.;tar['peakChfKwMonth']=0.;self.load((op,fc,tar));self.assertEqual(run_once(self.store,AT)['cashCostChf'],0.)
|
||||
def test_immutable_id_conflict(self):
|
||||
op,fc,tar=inputs();self.load((op,fc,tar));self.assertEqual(ingest(self.store,AID,'operation',op,AT)['status'],'duplicate');op['gridW']=123.
|
||||
with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT)
|
||||
def test_older_input_never_overwrites_current(self):
|
||||
op,fc,tar=inputs();self.load((op,fc,tar));op['observedAt']=(AT-timedelta(seconds=1)).isoformat();op['eventId']='old'
|
||||
self.assertEqual(ingest(self.store,AID,'operation',op,AT)['status'],'archived_older');self.assertEqual(latest(self.store,AID,'operation')['eventId'],'op1')
|
||||
def test_unpublished_dynamic_tail_not_executed(self):
|
||||
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar));self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs')
|
||||
def test_published_negative_price_and_horizon(self):
|
||||
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
|
||||
price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(minutes=15)).isoformat(),'value':-2.,'unit':'Rp/kWh','observedAt':AT.isoformat(),'sourceKind':'published_interval'}]}
|
||||
ingest(self.store,AID,'prices',price,AT);p=run_once(self.store,AT);self.assertEqual(len(p['points']),3);self.assertEqual(p['points'][0]['importPriceChfKwh'],-.02)
|
||||
def test_carried_price_does_not_extend_horizon(self):
|
||||
op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar))
|
||||
price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(hours=48)).isoformat(),'value':.1,'unit':'CHF/kWh','observedAt':AT.isoformat(),'sourceKind':'carried_forward'}]}
|
||||
ingest(self.store,AID,'prices',price,AT);self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs')
|
||||
def test_aggregate_load_warning(self):
|
||||
op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total';self.load((op,fc,tar));self.assertTrue(run_once(self.store,AT)['warnings'])
|
||||
def test_extra_credentials_not_stored(self):
|
||||
op,fc,tar=inputs();op['password']='do-not-store'
|
||||
with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT)
|
||||
def test_http_auth_allowlist_and_revision_conflict(self):
|
||||
app=create_app(str(Path(self.temp.name)/'http.sqlite'),TOKEN,[AID],start_worker=False)
|
||||
with TestClient(app) as client:
|
||||
path=f'/internal/v2/prognosis/{AID}/planner';headers={'X-Enelix-Service-Token':TOKEN}
|
||||
self.assertEqual(client.get(path).status_code,401);self.assertEqual(client.get(path,headers=headers).status_code,200)
|
||||
other='30509683-4569-49e4-848f-4905e4cc813a';self.assertEqual(client.get(path.replace(AID,other),headers=headers).status_code,403)
|
||||
self.assertEqual(client.put(path+'/settings',headers=headers,json={'expectedRevision':0,'changes':{'family':'23'}}).status_code,200)
|
||||
self.assertEqual(client.put(path+'/settings',headers=headers,json={'expectedRevision':0,'changes':{'family':'3'}}).status_code,409)
|
||||
self.assertEqual(client.put(path+'/settings',headers=headers,json={'expectedRevision':1,'changes':{'runMode':'live'}}).status_code,400)
|
||||
def test_body_limit_and_no_legacy_db(self):
|
||||
with self.assertRaises(ValueError):create_app(str(Path(self.temp.name)/'users.db'),TOKEN,[AID])
|
||||
app=create_app(str(Path(self.temp.name)/'http.sqlite'),TOKEN,[AID],start_worker=False)
|
||||
with TestClient(app) as client:
|
||||
r=client.post(f'/internal/v2/prognosis/{AID}/planner/inputs/forecast',json={'x':'a'*2000001});self.assertEqual(r.status_code,413)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user