fix(history): model sparse publication without relaxing live freshness

This commit is contained in:
ENELIX Agent
2026-10-03 08:04:28 +00:00
parent 6d9aba74b0
commit 5890093b80
15 changed files with 1787 additions and 22 deletions
@@ -0,0 +1,204 @@
"""Synthetic publication gaps plus full source-reference/application integration."""
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.history_timing import validate_policy
from netplan_v4.store import PlannerStore
from netplan_v4.service import create_app, ingest, run_once
from test_measurement_pipeline import config, record, NOW, AID
from test_v4 import inputs, TOKEN
def timed_config(**kw):
return config(historyTimingPolicy={'version':1,'method':'equal_endpoint_v1',
'sources':{'pv':{'maxSpanSeconds':130,'evidenceId':'synthetic-publication-policy'}}}, **kw)
def projected(span=360, period=120, c=None):
c = c or timed_config()
result=[]
for delta in range(0,span+1,30):
r=record(NOW+delta,c)
r['raw']['pv']['sourceUpdatedAt']=NOW+(delta//period)*period
result.append(m.project(r,c,AID,NOW+span))
return result
class HistoricalTimingTest(unittest.TestCase):
def test_strict_policy_unchanged(self):
rows=projected(); result=m.reconstruct(rows,config())
self.assertEqual(result[0]['coveredSeconds'],180)
self.assertEqual(result[0]['publicationEstimatedSeconds'],0)
def test_equal_endpoint_bounded_tail_estimate(self):
w=m.reconstruct(projected(),timed_config())[0]
self.assertEqual(w['coveredSeconds'],300)
self.assertEqual(w['publicationEstimatedSeconds'],120)
self.assertEqual(w['publicationEstimatedBySourceSeconds'],{'pv':120})
self.assertEqual(w['loadW'],6000)
self.assertFalse(w['fullPhysicalIntervalMeasured'])
self.assertFalse(w['meterBoundaryVerified'])
def test_no_extension_at_open_end(self):
rows=projected(span=300)
for r in rows: r['raw']['pv']['sourceUpdatedAt']=NOW
w=m.reconstruct(rows,timed_config())[0]
self.assertEqual(w['coveredSeconds'],60)
def test_equal_zero_requires_new_original_timestamp(self):
rows=projected()
for r in rows:r['raw']['pv']['value']=0
self.assertEqual(m.reconstruct(rows,timed_config())[0]['coveredSeconds'],300)
for r in rows:r['raw']['pv']['sourceUpdatedAt']=NOW
self.assertEqual(m.reconstruct(rows,timed_config())[0]['coveredSeconds'],60)
def test_changed_endpoints_not_interpolated(self):
rows=projected()
for r in rows:r['raw']['pv']['value']+=r['raw']['pv']['sourceUpdatedAt']-NOW
self.assertEqual(m.reconstruct(rows,timed_config())[0]['publicationEstimatedSeconds'],0)
def test_no_floating_tolerance(self):
rows=projected()
for r in rows:r['raw']['pv']['value']+=1e-9*(r['raw']['pv']['sourceUpdatedAt']-NOW)
self.assertEqual(m.reconstruct(rows,timed_config())[0]['publicationEstimatedSeconds'],0)
def test_long_gap_not_recovered_by_equal_zero(self):
rows=projected(span=720,period=660)
for r in rows:r['raw']['pv']['value']=0
out=m.reconstruct(rows,timed_config())
self.assertEqual(sum(w['publicationEstimatedSeconds'] for w in out),0)
self.assertEqual(out[0]['coveredSeconds'],60)
def test_collector_gap_not_bridged(self):
rows=projected(); rows=[r for r in rows if r['capturedAt']!=NOW+90]
w=m.reconstruct(rows,timed_config())[0]
self.assertLess(w['coveredSeconds'],300)
self.assertEqual(w['publicationEstimatedSeconds'],60)
def test_invalid_source_blocks_equality_inference(self):
rows=projected();rows[3]['raw']['pv']['valid']=False
w=m.reconstruct(rows,timed_config())[0]
self.assertEqual(w['publicationEstimatedSeconds'],60)
self.assertLess(w['coverage'],1)
def test_conflicting_same_timestamp_blocks_equality(self):
rows=projected();rows[1]['raw']['pv']['value']+=10
w=m.reconstruct(rows,timed_config())[0]
self.assertEqual(w['publicationEstimatedSeconds'],60)
def test_invalid_other_physical_input_still_vetoes(self):
rows=projected();rows[3]['raw']['battery']['valid']=False
w=m.reconstruct(rows,timed_config())[0]
self.assertLess(w['coverage'],1)
def test_future_receipt_not_available_early(self):
rows=projected()
for r in rows:r['_receivedAt']=NOW+900
w=m.reconstruct(rows,timed_config())[0]
self.assertEqual(w['availableNotBefore'],NOW+900)
def test_unrelated_virtual_failure_does_not_veto(self):
rows=projected()
for r in rows:r['raw']['sdl']['valid']=False
self.assertEqual(m.reconstruct(rows,timed_config())[0]['coverage'],1)
def test_no_input_mutation_or_source_age_change(self):
rows=projected();c=timed_config();before=copy.deepcopy((rows,c))
m.reconstruct(rows,c)
self.assertEqual((rows,c),before)
self.assertEqual(c['sources'][1]['maxAgeSeconds'],60)
def test_policy_requires_trusted_explicit_bounds(self):
for change in ({'version':True},{'method':'always_fill'},{'sources':{'pv':{'maxSpanSeconds':600,'evidenceId':'test-policy'}}},
{'sources':{'grid':{'maxSpanSeconds':90,'evidenceId':'test-policy'}}},
{'sources':{'sdl':{'maxSpanSeconds':90,'evidenceId':'test-policy'}}}):
c=timed_config();c['historyTimingPolicy'].update(change)
with self.subTest(change=change),self.assertRaises(ValueError):m.validate_config(c)
def test_profile_values_stay_estimates(self):
w=m.reconstruct(projected(),timed_config())[0]
self.assertTrue(w['estimated'])
self.assertNotIn('controlEnabled',w)
self.assertNotIn('accountingEvidenceId',w)
class HistoryReferenceIntegrationTest(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.store=PlannerStore(str(Path(self.tmp.name)/'test.sqlite'))
self.c=config();self.new=timed_config(datasetId='physical-v2',sourceDatasetId='physical-v1')
m.register_dataset(self.store.con,AID,self.c,NOW)
def tearDown(self):self.store.close();self.tmp.cleanup()
def register(self):m.register_dataset(self.store.con,AID,self.new,NOW)
def fill(self):
raw=[]
for t in range(NOW-7200,NOW+1,30):
r=record(t);r['raw']['pv']['sourceUpdatedAt']=t-(t-(NOW-7200))%120;raw.append(r)
for i in range(0,len(raw),120):m.ingest_batch(self.store.con,AID,{'version':1,'datasetId':'physical-v1','records':raw[i:i+120]},NOW)
return len(raw)
def test_reference_shares_original_immutable_observations(self):
n=self.fill();self.register()
self.assertEqual(m.pipeline_status(self.store.con,AID)['datasets'][1]['records'],n)
self.assertEqual(self.store.con.execute('SELECT count(*) FROM planner_observations').fetchone()[0],n)
self.assertEqual(m.configuration(self.store.con,AID,'physical-v1'),self.c)
def test_no_append_into_derived_dataset(self):
self.register()
with self.assertRaises(ValueError):m.ingest_batch(self.store.con,AID,{'version':1,'datasetId':'physical-v2','records':[record(NOW)]},NOW)
def test_source_mapping_cannot_change_in_reference(self):
for key,value in [('mappingSha256','c'*64),('formula','solar_terminal_v1'),('minimumCoverage',.9)]:
c=copy.deepcopy(self.new);c[key]=value
with self.subTest(key=key),self.assertRaises(ValueError):m.register_dataset(self.store.con,AID,c,NOW)
def test_no_reference_to_other_plant(self):
with self.assertRaises(ValueError):m.register_dataset(self.store.con,'00000000-0000-4000-8000-000000000099',self.new,NOW)
def test_no_cycles_or_reference_chains(self):
self.register(); c={**self.new,'datasetId':'physical-v3','sourceDatasetId':'physical-v2'}
with self.assertRaises(ValueError):m.register_dataset(self.store.con,AID,c,NOW)
def test_training_model_is_versioned_and_policy_annotated(self):
self.fill();self.register();settings=self.store.settings(AID)
m.advance(self.store.con,AID,'physical-v1',settings,NOW+60)
m.advance(self.store.con,AID,'physical-v2',settings,NOW+60)
self.assertIsNone(m.current_model(self.store.con,AID,'physical-v1',NOW+60))
model=m.current_model(self.store.con,AID,'physical-v2',NOW+60)
self.assertIsNotNone(model);self.assertEqual(model['historyTimingMethod'],'equal_endpoint_v1')
self.assertFalse(model['measurementBoundaryVerified'])
state=m.pipeline_status(self.store.con,AID)['datasets'][1]['detail']
self.assertGreater(state['publicationEstimatedSeconds'],0)
self.assertFalse(state['originalFreshnessLimitsChanged'])
def test_no_future_receipt_leaks_into_model(self):
self.fill();self.register()
m.advance(self.store.con,AID,'physical-v2',self.store.settings(AID),NOW-60)
self.assertIsNone(m.current_model(self.store.con,AID,'physical-v2',NOW-60))
def test_existing_sdl_freshness_not_relaxed_by_history(self):
self.fill();self.register();m.advance(self.store.con,AID,'physical-v2',self.store.settings(AID),NOW+30)
op,fc,tar=inputs(datetime.fromtimestamp(NOW+180,timezone.utc))
with self.assertRaises(ValueError):m.apply_load_forecast(self.store.con,AID,'physical-v2',fc,NOW+180)
def test_full_application_plan_uses_reference_model(self):
self.fill();self.register();settings=self.store.settings(AID)
m.advance(self.store.con,AID,'physical-v2',settings,NOW)
op,fc,tar=inputs(datetime.fromtimestamp(NOW,timezone.utc))
for kind,val in zip(('operation','forecast','tariffs'),(op,fc,tar)):ingest(self.store,AID,kind,val,datetime.fromtimestamp(NOW,timezone.utc))
self.store.save_settings(AID,{'forecastSource':'corrected_profile','measurementDataset':'physical-v2'},0,datetime.fromtimestamp(NOW,timezone.utc))
out=run_once(self.store,datetime.fromtimestamp(NOW,timezone.utc))
self.assertTrue(out['executable'],out);self.assertFalse(out['liveEnabled'])
self.assertEqual(out['inputQuality']['dataPipeline']['historyTimingMethod'],'equal_endpoint_v1')
def test_backfilled_or_late_confirmed_training_is_not_causal_holdout(self):
# Completed synthetic windows learned only NOW cannot validate a model at a past split.
with self.store.con:
for t in range(NOW-3*86400,NOW,300):
window={'start':t,'profileUsable':True,'coverage':1.,'loadW':5000.,'availableNotBefore':NOW}
self.store.con.execute('INSERT INTO planner_load_windows VALUES(?,?,?,?,?,?)',(AID,'physical-v1',t,NOW,1.,m.canonical(window)))
m.advance(self.store.con,AID,'physical-v1',self.store.settings(AID),NOW)
model=m.current_model(self.store.con,AID,'physical-v1',NOW)
self.assertEqual(model['validation']['status'],'bootstrap_insufficient_holdout')
def test_v1_raw_ingest_works_after_reference_added(self):
self.register()
r=m.ingest_batch(self.store.con,AID,{'version':1,'datasetId':'physical-v1','records':[record(NOW)]},NOW)
self.assertEqual(r['stored'],1)
if __name__=='__main__':unittest.main()
@@ -0,0 +1,94 @@
"""Installer ordering/recovery tests; subprocesses, privileges and DB backup are simulated."""
import hashlib
import importlib.util
import json
import subprocess
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPT=Path(__file__).resolve().parents[1]/'commissioning/deploy_history_timing.py'
spec=importlib.util.spec_from_file_location('history_deploy_tests',SCRIPT)
d=importlib.util.module_from_spec(spec);spec.loader.exec_module(d)
class TimingDeploymentTest(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name)
self.package=self.root/'commissioning/history-timing-source';self.package.mkdir(parents=True)
files={}
for index,name in enumerate(d.TARGETS):
original=(b'old:'+name.encode()) if index%2==0 else None
target=self.root/name;target.parent.mkdir(parents=True,exist_ok=True)
if original is not None:target.write_bytes(original)
candidate=self.package/'source'/name;candidate.parent.mkdir(parents=True,exist_ok=True);candidate.write_bytes(b'new:'+name.encode())
files[name]={'before':hashlib.sha256(original).hexdigest() if original else None,'after':d.digest(candidate)}
(self.package/'dataset.json').write_text('{}')
(self.root/'commissioning/deploy_application.py').write_text('synthetic helper')
self.manifest={'scope':'historical_publication_only','installationId':'plant','files':files,
'existingDeploymentHelperSha256':d.digest(self.root/'commissioning/deploy_application.py'),
'datasetSha256':d.digest(self.package/'dataset.json'),'installerSha256':d.digest(SCRIPT)}
(self.package/'RELEASE.json').write_text(json.dumps(self.manifest))
self.context=patch.multiple(d,ROOT=self.root,PACKAGE=self.package);self.context.start()
self.calls=[]
def tearDown(self):self.context.stop();self.tmp.cleanup()
def fake_run(self,args,**kwargs):
self.calls.append(args)
if 'inspect' in args:out='sha256:test-old-image'
elif 'ps' in args:out='test-container'
elif 'exec' in args:out=json.dumps({'settingsUnchanged':True,'liveEnabled':False})
else:out=''
return subprocess.CompletedProcess(args,0,out,'')
def execute(self,fail_at=None):
def run(args,**kwargs):
out=self.fake_run(args,**kwargs)
if fail_at and fail_at in args:raise subprocess.CalledProcessError(1,args)
return out
def atomic(path,data,*args,**kwargs):path.write_bytes(data)
def backup(source,dest):self.calls.append(['test-backup']);dest.write_text('synthetic backup')
helpers=types.SimpleNamespace(atomic=atomic,backup_database=backup)
with patch.object(d.os,'geteuid',return_value=0),patch.object(d.os,'chown'),patch.object(d.subprocess,'run',side_effect=run),patch.dict(sys.modules,{'deploy_application':helpers}):
d.run('plant',True)
def report(self):return json.loads(next(self.root.glob('history-timing-releases/*/REPORT.json')).read_text())
def test_default_checks_only(self):
d.run('plant');self.assertEqual(self.calls,[])
self.assertFalse((self.root/'history-timing-releases').exists())
def test_order_tests_backup_then_only_v4_replacement(self):
self.execute()
test=next(i for i,c in enumerate(self.calls) if '/app/run_tests.py' in c)
backup=next(i for i,c in enumerate(self.calls) if c==['test-backup'])
up=next(i for i,c in enumerate(self.calls) if 'up' in c)
self.assertLess(test,backup);self.assertLess(backup,up)
self.assertTrue(all('license-portal' not in c and 'forecast-engine' not in c for c in self.calls))
self.assertEqual(self.report()['status'],'historical_timing_installed_no_actuation')
def test_failed_tests_restore_host_source_without_restart(self):
with self.assertRaises(subprocess.CalledProcessError):self.execute('/app/run_tests.py')
self.assertFalse(any('up' in c for c in self.calls))
for name,v in self.manifest['files'].items():self.assertEqual(d.digest(self.root/name),v['before'])
def test_failed_registration_rolls_image_back(self):
with self.assertRaises(subprocess.CalledProcessError):self.execute('exec')
self.assertIn('previous_image_restored',self.report()['rollback'])
self.assertTrue(any('--pull' in c and 'never' in c for c in self.calls))
def test_source_drift_refused_before_actions(self):
(self.root/d.TARGETS[0]).write_text('parallel change')
with self.assertRaises(ValueError):d.verify()
self.assertEqual(self.calls,[])
def test_source_already_installed_idempotent(self):
for name in d.TARGETS:(self.root/name).write_bytes((self.package/'source'/name).read_bytes())
self.assertEqual(d.verify()['scope'],'historical_publication_only')
self.execute()
self.assertEqual(self.report()['status'],'historical_timing_installed_no_actuation')
def test_no_production_selection_or_permission_in_bootstrap(self):
self.assertNotIn("'/settings'",d.BOOTSTRAP)
self.assertNotIn('/trial/arm',d.BOOTSTRAP)
self.assertIn("before['settings']==after['settings']",d.BOOTSTRAP)
self.assertIn("historyTimingVersion",d.BOOTSTRAP)
def test_target_image_contains_installer_test_dependency(self):
dockerfile=(SCRIPT.parents[1]/'Dockerfile').read_text()
self.assertIn('COPY commissioning/deploy_history_timing.py ./commissioning/deploy_history_timing.py',dockerfile)
if __name__=='__main__':unittest.main()