feat(application): integrate measured-load ingestion training and planner source

This commit is contained in:
ENELIX Agent
2026-10-02 21:00:05 +00:00
parent 44362e4bf5
commit fc024fcfba
96 changed files with 8933 additions and 0 deletions
@@ -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";
+32
View File
@@ -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()
+134
View File
@@ -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()
+184
View File
@@ -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()