build(agent): c3po#b883b4 iteration
This commit is contained in:
parent
7d79370b92
commit
92f4e61c36
44
README.md
44
README.md
|
|
@ -1,19 +1,35 @@
|
||||||
# GridGuard-Inspired EnergiBridge Skeleton (MVP)
|
# GridGuard-Inspired EnergiBridge Skeleton
|
||||||
|
|
||||||
This repository provides a production-ready skeleton for a canonical interop layer (EnergiBridge-like) to enable secure, verifiable telemetry and orchestration across multi-asset power facilities.
|
This repository implements a compact, verifiable telemetry orchestration layer for multi-asset power facilities.
|
||||||
|
|
||||||
- Phase 0 MVP: skeleton protocol plus two starter adapters (solar_meter and der_aggregator) over TLS (stubbed), a lightweight ADMM-lite local solver, deterministic delta-sync with replay capability, and a toy mesh-energy-balance objective.
|
## What it provides
|
||||||
- Phase 1–3: governance, identity, cross-domain pilots, SDKs, and hardware-in-the-loop validation.
|
|
||||||
|
|
||||||
What you’ll find:
|
- Canonical contract schemas for `LocalProblem`, `SharedVariables`, `PlanDelta`, `DualVariables`, `AuditLog`, `PrivacyBudget`, and `RegistryMetadata`
|
||||||
- energi_bridge: Python package containing core schema definitions (LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog, PrivacyBudget)
|
- A versioned `ContractRegistry` for Graph-of-Contracts style adapter/schema tracking
|
||||||
- energi_bridge/adapters: two toy adapters to simulate a solar-meter feed and a DER-aggregator
|
- A `VerifiableTelemOrchestrator` that collects adapter telemetry, signs plan deltas, and emits audit and registry metadata
|
||||||
- tests/: pytest-based tests for schema validation and adapter interactions
|
- Two deterministic starter adapters:
|
||||||
- test.sh: script to run tests and build the package
|
- `SolarMeterAdapter`
|
||||||
- AGENTS.md: project-wide architectural and testing guidelines
|
- `DERAggregatorAdapter`
|
||||||
|
- Pytest coverage for schema validation, registry versioning, adapter replay behavior, and orchestration output
|
||||||
|
|
||||||
How to run
|
## Layout
|
||||||
- Install dependencies and run tests: ./test.sh
|
|
||||||
- Build the package: python -m build
|
|
||||||
|
|
||||||
This is a minimal, production-oriented scaffold intended to be extended in subsequent sprint tasks.
|
- `energi_bridge/`: core package
|
||||||
|
- `energi_bridge/adapters/`: starter adapters
|
||||||
|
- `tests/`: pytest suite
|
||||||
|
- `test.sh`: installs the package, runs tests, and builds the distribution
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The code is deterministic by default to support replayable tests and audit trails.
|
||||||
|
- The registry records contract version, adapter identity, schema name, and replay hints for each adapter contract.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ This package provides minimal schema definitions used for Phase 0 MVP
|
||||||
and toy adapters that simulate telemetry and control planes.
|
and toy adapters that simulate telemetry and control planes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .contracts import ContractDefinition, ContractRegistry
|
||||||
from .schemas import LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog, PrivacyBudget, RegistryMetadata
|
from .schemas import LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog, PrivacyBudget, RegistryMetadata
|
||||||
from .orchestrator import VerifiableTelemOrchestrator
|
from .orchestrator import VerifiableTelemOrchestrator
|
||||||
|
|
||||||
|
|
@ -15,5 +16,7 @@ __all__ = [
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"PrivacyBudget",
|
"PrivacyBudget",
|
||||||
"RegistryMetadata",
|
"RegistryMetadata",
|
||||||
|
"ContractDefinition",
|
||||||
|
"ContractRegistry",
|
||||||
"VerifiableTelemOrchestrator",
|
"VerifiableTelemOrchestrator",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
"""Toy DER Aggregator Adapter (Phase 0 MVP)."""
|
"""Toy DER Aggregator Adapter with deterministic replay support."""
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Dict, Any
|
from dataclasses import dataclass, field
|
||||||
import time
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DERAggregatorAdapter:
|
class DERAggregatorAdapter:
|
||||||
aggregator_id: str
|
aggregator_id: str
|
||||||
|
samples: Optional[List[Dict[str, Any]]] = None
|
||||||
|
_cursor: int = field(init=False, default=0)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.samples is None:
|
||||||
|
self.samples = []
|
||||||
|
|
||||||
def aggregate(self) -> Dict[str, Any]:
|
def aggregate(self) -> Dict[str, Any]:
|
||||||
# Simple deterministic aggregation sample
|
if self.samples:
|
||||||
ts = int(time.time())
|
sample_list = self.samples
|
||||||
|
if self._cursor >= len(sample_list):
|
||||||
|
raise IndexError("No more DER aggregation samples available")
|
||||||
|
reading = dict(sample_list[self._cursor])
|
||||||
|
self._cursor += 1
|
||||||
|
reading.setdefault("aggregator_id", self.aggregator_id)
|
||||||
|
reading.setdefault("timestamp", self._cursor)
|
||||||
|
reading.setdefault("status", "nominal")
|
||||||
|
return reading
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"aggregator_id": self.aggregator_id,
|
"aggregator_id": self.aggregator_id,
|
||||||
"timestamp": ts,
|
"timestamp": self._cursor,
|
||||||
"total_power_kw": 500.0, # fixed placeholder for deterministic behavior
|
"total_power_kw": 500.0, # fixed placeholder for deterministic behavior
|
||||||
"status": "nominal",
|
"status": "nominal",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
"""Toy Solar Meter Adapter (Phase 0 MVP)."""
|
"""Toy Solar Meter Adapter with deterministic replay support."""
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Dict, Any
|
from dataclasses import dataclass, field
|
||||||
import random
|
from typing import Any, Dict, List, Optional
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SolarMeterAdapter:
|
class SolarMeterAdapter:
|
||||||
site_id: str
|
site_id: str
|
||||||
|
samples: Optional[List[Dict[str, Any]]] = None
|
||||||
|
_cursor: int = field(init=False, default=0)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.samples is None:
|
||||||
|
self.samples = []
|
||||||
|
|
||||||
def read_meter(self) -> Dict[str, Any]:
|
def read_meter(self) -> Dict[str, Any]:
|
||||||
# Simulate solar meter reading
|
if self.samples:
|
||||||
|
sample_list = self.samples
|
||||||
|
if self._cursor >= len(sample_list):
|
||||||
|
raise IndexError("No more solar meter samples available")
|
||||||
|
reading = dict(sample_list[self._cursor])
|
||||||
|
self._cursor += 1
|
||||||
|
reading.setdefault("site_id", self.site_id)
|
||||||
|
reading.setdefault("timestamp", self._cursor)
|
||||||
|
reading.setdefault("status", "ok")
|
||||||
|
return reading
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"site_id": self.site_id,
|
"site_id": self.site_id,
|
||||||
"timestamp": int(time.time()),
|
"timestamp": self._cursor,
|
||||||
"solar_output_kw": round(random.uniform(0, 1000), 2),
|
"solar_output_kw": 750.0,
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Graph-of-Contracts registry for versioned adapter/schema metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from energi_bridge.schemas import RegistryMetadata
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(version: str) -> Tuple[int, int, int, str]:
|
||||||
|
core, _, suffix = version.partition("-")
|
||||||
|
parts = core.split(".")
|
||||||
|
while len(parts) < 3:
|
||||||
|
parts.append("0")
|
||||||
|
major, minor, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||||
|
return major, minor, patch, suffix
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractDefinition:
|
||||||
|
contract_id: str
|
||||||
|
version: str
|
||||||
|
adapter_id: str
|
||||||
|
schema_name: str
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
replay_hint: Optional[str] = None
|
||||||
|
previous_version: Optional[str] = None
|
||||||
|
|
||||||
|
def to_metadata(self) -> RegistryMetadata:
|
||||||
|
return RegistryMetadata(
|
||||||
|
contract_version=self.version,
|
||||||
|
adapter_id=self.adapter_id,
|
||||||
|
contract_id=self.contract_id,
|
||||||
|
schema_name=self.schema_name,
|
||||||
|
created_at=self.created_at,
|
||||||
|
replay_hint=self.replay_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractRegistry:
|
||||||
|
"""Maintain contract versions for schemas and adapters."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._contracts: Dict[str, Dict[str, ContractDefinition]] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
contract_id: str,
|
||||||
|
version: str,
|
||||||
|
adapter_id: str,
|
||||||
|
schema_name: str,
|
||||||
|
replay_hint: Optional[str] = None,
|
||||||
|
) -> ContractDefinition:
|
||||||
|
versions = self._contracts.setdefault(contract_id, {})
|
||||||
|
if version in versions:
|
||||||
|
raise ValueError(f"Contract {contract_id}@{version} already exists")
|
||||||
|
|
||||||
|
previous_version = self.latest_version(contract_id)
|
||||||
|
definition = ContractDefinition(
|
||||||
|
contract_id=contract_id,
|
||||||
|
version=version,
|
||||||
|
adapter_id=adapter_id,
|
||||||
|
schema_name=schema_name,
|
||||||
|
replay_hint=replay_hint,
|
||||||
|
previous_version=previous_version,
|
||||||
|
)
|
||||||
|
versions[version] = definition
|
||||||
|
return definition
|
||||||
|
|
||||||
|
def latest_version(self, contract_id: str) -> Optional[str]:
|
||||||
|
versions = self._contracts.get(contract_id, {})
|
||||||
|
if not versions:
|
||||||
|
return None
|
||||||
|
return max(versions, key=_parse_version)
|
||||||
|
|
||||||
|
def resolve(self, contract_id: str, version: Optional[str] = None) -> ContractDefinition:
|
||||||
|
versions = self._contracts.get(contract_id, {})
|
||||||
|
if not versions:
|
||||||
|
raise KeyError(contract_id)
|
||||||
|
selected = version or self.latest_version(contract_id)
|
||||||
|
if selected is None or selected not in versions:
|
||||||
|
raise KeyError(f"Unknown contract version: {contract_id}@{version}")
|
||||||
|
return versions[selected]
|
||||||
|
|
||||||
|
def manifest(self) -> List[RegistryMetadata]:
|
||||||
|
metadata: List[RegistryMetadata] = []
|
||||||
|
for contract_versions in self._contracts.values():
|
||||||
|
for definition in contract_versions.values():
|
||||||
|
metadata.append(definition.to_metadata())
|
||||||
|
return sorted(metadata, key=lambda item: (item.contract_id or "", _parse_version(item.contract_version)))
|
||||||
|
|
||||||
|
def items(self) -> Iterable[ContractDefinition]:
|
||||||
|
for contract_versions in self._contracts.values():
|
||||||
|
yield from contract_versions.values()
|
||||||
|
|
@ -11,11 +11,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
import json
|
import json
|
||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
from energi_bridge.contracts import ContractRegistry
|
||||||
from energi_bridge.schemas import (
|
from energi_bridge.schemas import (
|
||||||
LocalProblem,
|
LocalProblem,
|
||||||
SharedVariables,
|
SharedVariables,
|
||||||
|
|
@ -31,35 +32,72 @@ def _sign_payload(payload: str, key: str) -> str:
|
||||||
return hmac.new(key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
return hmac.new(key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter_contract_id(adapter: Any) -> str:
|
||||||
|
if hasattr(adapter, "site_id"):
|
||||||
|
return f"solar_meter.{adapter.site_id}"
|
||||||
|
if hasattr(adapter, "aggregator_id"):
|
||||||
|
return f"der_aggregator.{adapter.aggregator_id}"
|
||||||
|
return adapter.__class__.__name__.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter_schema_name(adapter: Any) -> str:
|
||||||
|
if hasattr(adapter, "read_meter"):
|
||||||
|
return "SolarMeterReading"
|
||||||
|
if hasattr(adapter, "aggregate"):
|
||||||
|
return "DERAggregationSnapshot"
|
||||||
|
return "TelemetryEnvelope"
|
||||||
|
|
||||||
|
|
||||||
class VerifiableTelemOrchestrator:
|
class VerifiableTelemOrchestrator:
|
||||||
def __init__(self, adapters: List[Any], signing_key: str = "secret") -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapters: List[Any],
|
||||||
|
signing_key: str = "secret",
|
||||||
|
registry: Optional[ContractRegistry] = None,
|
||||||
|
clock: Optional[Callable[[], datetime]] = None,
|
||||||
|
) -> None:
|
||||||
self.adapters = adapters
|
self.adapters = adapters
|
||||||
self.signing_key = signing_key
|
self.signing_key = signing_key
|
||||||
|
self.registry = registry or ContractRegistry()
|
||||||
|
self.clock = clock or datetime.utcnow
|
||||||
|
|
||||||
def _collect_signals(self) -> Dict[str, Any]:
|
def _collect_signals(self) -> Dict[str, Any]:
|
||||||
signals: Dict[str, Any] = {}
|
signals: Dict[str, Any] = {}
|
||||||
for adapter in self.adapters:
|
for adapter in self.adapters:
|
||||||
if hasattr(adapter, "read_meter"):
|
if hasattr(adapter, "read_meter"):
|
||||||
try:
|
try:
|
||||||
signals["solar_meter"] = adapter.read_meter()
|
signals[_adapter_contract_id(adapter)] = adapter.read_meter()
|
||||||
except Exception:
|
except Exception:
|
||||||
signals["solar_meter"] = {"error": "read_failed"}
|
signals[_adapter_contract_id(adapter)] = {"error": "read_failed"}
|
||||||
if hasattr(adapter, "aggregate"):
|
if hasattr(adapter, "aggregate"):
|
||||||
try:
|
try:
|
||||||
signals["der_aggregator"] = adapter.aggregate()
|
signals[_adapter_contract_id(adapter)] = adapter.aggregate()
|
||||||
except Exception:
|
except Exception:
|
||||||
signals["der_aggregator"] = {"error": "aggregate_failed"}
|
signals[_adapter_contract_id(adapter)] = {"error": "aggregate_failed"}
|
||||||
return signals
|
return signals
|
||||||
|
|
||||||
def run_once(self) -> Dict[str, Any]:
|
def run_once(self) -> Dict[str, Any]:
|
||||||
# Gather telemetry from adapters
|
now = self.clock()
|
||||||
|
|
||||||
|
# Gather telemetry from adapters and register the contract graph.
|
||||||
signals = self._collect_signals()
|
signals = self._collect_signals()
|
||||||
|
for adapter in self.adapters:
|
||||||
|
contract_id = _adapter_contract_id(adapter)
|
||||||
|
schema_name = _adapter_schema_name(adapter)
|
||||||
|
if self.registry.latest_version(contract_id) is None:
|
||||||
|
self.registry.register(
|
||||||
|
contract_id=contract_id,
|
||||||
|
version="0.1.0",
|
||||||
|
adapter_id=adapter.__class__.__name__,
|
||||||
|
schema_name=schema_name,
|
||||||
|
replay_hint="deterministic",
|
||||||
|
)
|
||||||
|
|
||||||
# Phase-0 MVP Hard-coded skeletons to demonstrate flow
|
# Phase-0 MVP Hard-coded skeletons to demonstrate flow
|
||||||
lp = LocalProblem(
|
lp = LocalProblem(
|
||||||
id="lp_mvp",
|
id="lp_mvp",
|
||||||
domain="GridGuard",
|
domain="GridGuard",
|
||||||
assets=[getattr(a, "__class__", object).__name__ for a in self.adapters],
|
assets=[_adapter_contract_id(a) for a in self.adapters],
|
||||||
constraints={"max_dispatch_kw": 5000, "min_dispatch_kw": 0},
|
constraints={"max_dispatch_kw": 5000, "min_dispatch_kw": 0},
|
||||||
objective={"type": "mesh_energy_balance"},
|
objective={"type": "mesh_energy_balance"},
|
||||||
solver_hint=None,
|
solver_hint=None,
|
||||||
|
|
@ -73,13 +111,13 @@ class VerifiableTelemOrchestrator:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simple delta reflecting new signals; sign for verifiability
|
# Simple delta reflecting new signals; sign for verifiability
|
||||||
delta_payload = {"signals": signals, "ts": datetime.utcnow().isoformat()}
|
delta_payload = {"signals": signals, "ts": now.isoformat()}
|
||||||
delta_json = json.dumps(delta_payload, sort_keys=True)
|
delta_json = json.dumps(delta_payload, sort_keys=True)
|
||||||
signature = _sign_payload(delta_json, self.signing_key)
|
signature = _sign_payload(delta_json, self.signing_key)
|
||||||
|
|
||||||
pd = PlanDelta(
|
pd = PlanDelta(
|
||||||
delta=delta_payload,
|
delta=delta_payload,
|
||||||
timestamp=datetime.utcnow(),
|
timestamp=now,
|
||||||
author="VerifiableTelemOrchestrator",
|
author="VerifiableTelemOrchestrator",
|
||||||
contract_id="c0",
|
contract_id="c0",
|
||||||
signature=signature,
|
signature=signature,
|
||||||
|
|
@ -87,9 +125,17 @@ class VerifiableTelemOrchestrator:
|
||||||
)
|
)
|
||||||
|
|
||||||
dv = DualVariables()
|
dv = DualVariables()
|
||||||
al = AuditLog(entries=[{"event": "plan_delta_created", "ts": datetime.utcnow().isoformat()}], last_updated=datetime.utcnow())
|
al = AuditLog()
|
||||||
|
al.record("plan_delta_created", ts=now.isoformat(), contract_id=pd.contract_id)
|
||||||
pb = PrivacyBudget(budget=1.0, spent=0.0, policy={"phase": 0})
|
pb = PrivacyBudget(budget=1.0, spent=0.0, policy={"phase": 0})
|
||||||
rm = RegistryMetadata(contract_version="0.1.0", adapter_id="verifiable-telemetry-orchestrator", replay_hint=None)
|
rm = RegistryMetadata(
|
||||||
|
contract_version="0.1.0",
|
||||||
|
adapter_id="verifiable-telemetry-orchestrator",
|
||||||
|
contract_id="gridguard.orchestrator",
|
||||||
|
schema_name="VerifiableTelemetryCycle",
|
||||||
|
created_at=now,
|
||||||
|
replay_hint="deterministic",
|
||||||
|
)
|
||||||
|
|
||||||
# Return a dictionary with serializable payloads
|
# Return a dictionary with serializable payloads
|
||||||
return {
|
return {
|
||||||
|
|
@ -100,4 +146,5 @@ class VerifiableTelemOrchestrator:
|
||||||
"audit_log": asdict(al),
|
"audit_log": asdict(al),
|
||||||
"privacy_budget": asdict(pb),
|
"privacy_budget": asdict(pb),
|
||||||
"registry_metadata": asdict(rm),
|
"registry_metadata": asdict(rm),
|
||||||
|
"contract_registry": [asdict(item) for item in self.registry.manifest()],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -12,6 +12,14 @@ class LocalProblem:
|
||||||
objective: Dict[str, Any] = field(default_factory=dict)
|
objective: Dict[str, Any] = field(default_factory=dict)
|
||||||
solver_hint: Optional[str] = None
|
solver_hint: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.id:
|
||||||
|
raise ValueError("LocalProblem.id is required")
|
||||||
|
if not self.domain:
|
||||||
|
raise ValueError("LocalProblem.domain is required")
|
||||||
|
if not self.assets:
|
||||||
|
raise ValueError("LocalProblem.assets must not be empty")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SharedVariables:
|
class SharedVariables:
|
||||||
|
|
@ -20,6 +28,10 @@ class SharedVariables:
|
||||||
privacy_tag: Optional[str] = None
|
privacy_tag: Optional[str] = None
|
||||||
aggregation_method: str = "mean"
|
aggregation_method: str = "mean"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.version < 1:
|
||||||
|
raise ValueError("SharedVariables.version must be >= 1")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlanDelta:
|
class PlanDelta:
|
||||||
|
|
@ -30,6 +42,12 @@ class PlanDelta:
|
||||||
signature: Optional[str] = None
|
signature: Optional[str] = None
|
||||||
safety_tags: Optional[List[str]] = None
|
safety_tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.author:
|
||||||
|
raise ValueError("PlanDelta.author is required")
|
||||||
|
if not self.contract_id:
|
||||||
|
raise ValueError("PlanDelta.contract_id is required")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DualVariables:
|
class DualVariables:
|
||||||
|
|
@ -42,6 +60,10 @@ class AuditLog:
|
||||||
entries: List[Dict[str, Any]] = field(default_factory=list)
|
entries: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
last_updated: Optional[datetime] = None
|
last_updated: Optional[datetime] = None
|
||||||
|
|
||||||
|
def record(self, event: str, **details: Any) -> None:
|
||||||
|
self.entries.append({"event": event, **details})
|
||||||
|
self.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PrivacyBudget:
|
class PrivacyBudget:
|
||||||
|
|
@ -49,9 +71,19 @@ class PrivacyBudget:
|
||||||
spent: float = 0.0
|
spent: float = 0.0
|
||||||
policy: Dict[str, Any] = field(default_factory=dict)
|
policy: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def consume(self, amount: float) -> None:
|
||||||
|
if amount < 0:
|
||||||
|
raise ValueError("Privacy budget consumption must be positive")
|
||||||
|
if self.spent + amount > self.budget:
|
||||||
|
raise ValueError("Privacy budget exhausted")
|
||||||
|
self.spent += amount
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RegistryMetadata:
|
class RegistryMetadata:
|
||||||
contract_version: str
|
contract_version: str
|
||||||
adapter_id: str
|
adapter_id: str
|
||||||
|
contract_id: Optional[str] = None
|
||||||
|
schema_name: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
replay_hint: Optional[str] = None
|
replay_hint: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ version = "0.1.0"
|
||||||
description = "EnergiBridge-inspired canonical interop skeleton for GridGuard MVP"
|
description = "EnergiBridge-inspired canonical interop skeleton for GridGuard MVP"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = { text = "MIT" }
|
license = "MIT"
|
||||||
|
|
||||||
# Packaging configuration for setuptools can be discovered by the build tool.
|
# Packaging configuration for setuptools can be discovered by the build tool.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from energi_bridge import AuditLog, ContractRegistry, LocalProblem, PrivacyBudget
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_registry_tracks_versions() -> None:
|
||||||
|
registry = ContractRegistry()
|
||||||
|
|
||||||
|
first = registry.register(
|
||||||
|
contract_id="solar_meter.site-1",
|
||||||
|
version="0.1.0",
|
||||||
|
adapter_id="SolarMeterAdapter",
|
||||||
|
schema_name="SolarMeterReading",
|
||||||
|
)
|
||||||
|
second = registry.register(
|
||||||
|
contract_id="solar_meter.site-1",
|
||||||
|
version="0.2.0",
|
||||||
|
adapter_id="SolarMeterAdapter",
|
||||||
|
schema_name="SolarMeterReading",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert registry.latest_version("solar_meter.site-1") == "0.2.0"
|
||||||
|
assert registry.resolve("solar_meter.site-1").version == "0.2.0"
|
||||||
|
assert first.previous_version is None
|
||||||
|
assert second.previous_version == "0.1.0"
|
||||||
|
assert [item.contract_version for item in registry.manifest()] == ["0.1.0", "0.2.0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_validations_and_governance_helpers() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
LocalProblem(id="", domain="solar", assets=["panel-1"])
|
||||||
|
|
||||||
|
budget = PrivacyBudget(budget=1.0)
|
||||||
|
budget.consume(0.25)
|
||||||
|
assert budget.spent == 0.25
|
||||||
|
|
||||||
|
log = AuditLog()
|
||||||
|
log.record("cycle_completed", ts=datetime.utcnow().isoformat())
|
||||||
|
assert log.entries[0]["event"] == "cycle_completed"
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from energi_bridge.adapters import DERAggregatorAdapter, SolarMeterAdapter
|
||||||
|
from energi_bridge.orchestrator import VerifiableTelemOrchestrator
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_emits_registry_and_signed_plan() -> None:
|
||||||
|
adapters = [
|
||||||
|
SolarMeterAdapter(site_id="site-a"),
|
||||||
|
DERAggregatorAdapter(aggregator_id="agg-a"),
|
||||||
|
]
|
||||||
|
orchestrator = VerifiableTelemOrchestrator(
|
||||||
|
adapters=adapters,
|
||||||
|
signing_key="unit-test",
|
||||||
|
clock=lambda: datetime(2026, 4, 25, 12, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = orchestrator.run_once()
|
||||||
|
|
||||||
|
signals = result["shared_variables"]["signals"]
|
||||||
|
assert signals["solar_meter.site-a"]["solar_output_kw"] == 750.0
|
||||||
|
assert signals["der_aggregator.agg-a"]["total_power_kw"] == 500.0
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"signals": signals,
|
||||||
|
"ts": "2026-04-25T12:00:00",
|
||||||
|
}
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
b"unit-test",
|
||||||
|
json.dumps(payload, sort_keys=True).encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
assert result["plan_delta"]["signature"] == expected_signature
|
||||||
|
|
||||||
|
contract_ids = [item["contract_id"] for item in result["contract_registry"]]
|
||||||
|
assert contract_ids == ["der_aggregator.agg-a", "solar_meter.site-a"]
|
||||||
Loading…
Reference in New Issue