diff --git a/README.md b/README.md index a664512..cc05372 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,22 @@ -# EnergiaMesh (Prototype MVP) +# EnergiaMesh: Federated, Contract-Driven Microgrid Orchestration (MVP) -EnergiaMesh is a prototype for federated, contract-driven microgrid optimization with on-device forecasting. This MVP focuses on the core data primitives and two starter adapters to bootstrap the CatOpt bridge in a minimal, testable form. +This repository provides a minimal, production-ready MVP scaffold for EnergiaMesh's +contract-driven federation model. It defines core primitives (LocalProblem, +SharedVariables, DualVariables, PlanDelta, AuditLog), a simple Graph-of-Contracts +registry, a lightweight DSL sketch, and two starter adapters (DER controller and +weather station). -What you can expect in this MVP: -- Core primitives: LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog -- A simple Graph-of-Contracts registry for versioned adapters -- Two starter adapters: DER controller and Weather station -- A small DSL sketch placeholder for LocalProblem/SharedVariables/PlanDelta -- Basic tests and packaging scaffolding to enable pytest and python build +What you get in this MVP: +- Core primitives with a small, testable API surface +- In-memory Graph-of-Contracts registry with versioning hooks +- Minimal DSL sketch mapping LocalProblem/SharedVariables/PlanDelta into a canonical form +- Two starter adapters with TLS-ready interfaces (no real hardware integration yet) +- Tests verifying core behavior and interoperability -- MVP Blueprint (EnergiaMesh-CatOpt Integration) -- This repository ships a production-ready MVP that aligns with the contract-driven federation concept. The core primitives are implemented and bridged to a canonical CatOpt-like representation via CatOptBridge. -- Phase 0: protocol skeleton + two starter adapters with TLS transport; end-to-end delta-sync scaffolding. -- Phase 1: governance ledger skeleton and secure aggregation defaults; adapter conformance tests. -- Phase 2: cross-domain demo with a simulated second domain; publish a reference EnergiaMesh SDK and a canonical transport. -- Phase 3: hardware-in-the-loop validation with Gazebo/ROS; measure convergence time, delta-sync latency, and adapter conformance. -- Artifacts delivered: LocalProblem, SharedVariables, DualVariables, PlanDelta, AuditLog, GraphOfContracts, SafetyBudget, PrivacyBudget; two starter adapters; a CatOpt bridge; minimal DSL sketch; toy adapters. -- If helpful, I can draft sample DSL sketches and toy adapters to bootstrap EnergiaMesh-CatOpt integration. -- Getting started -- Install dependencies and run tests: - - bash test.sh -- To explore the MVP, look under src/energiamesh/ +How to run tests and build: +- Ensure dependencies are installed: `pip install -e .` (in a clean env) +- Run tests: `pytest -q` +- Build: `python3 -m build` -Packaging and publishing -- This repository uses a Python packaging layout under src/ with pyproject.toml. -- See READY_TO_PUBLISH when you are ready to publish the MVP as a package. +This is an MVP. Future work includes governance ledger, secure aggregation, and +more adapters to bootstrap real pilots. diff --git a/pyproject.toml b/pyproject.toml index f179c08..cd92dc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,14 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "energiamesh_federated_contract_driven_mi" +name = "energiamesh-federated-contract-driven-mv" version = "0.1.0" -description = "Prototype: Federated, contract-driven microgrid orchestration with on-device forecasting (CatOpt-inspired)." +description = "MVP: Federated, contract-driven microgrid orchestration primitives with on-device forecasting" readme = "README.md" +license = { text = "MIT" } requires-python = ">=3.9" - +dependencies = [ + "dataclasses; python_version<'3.7'", # fallback, not strictly needed for 3.9+ but harmless + "pydantic>=1.9.0,<2.0.0", + "attrs>=23.1.0", +] diff --git a/src/energiamesh/__init__.py b/src/energiamesh/__init__.py index 9f0a70c..b19a36c 100644 --- a/src/energiamesh/__init__.py +++ b/src/energiamesh/__init__.py @@ -1,5 +1,17 @@ -"""EnergiaMesh: Federated, Contract-Driven Microgrid Orchestration (Prototype) -Public API surface is purposely small for MVP build. +"""EnergiaMesh - Microgrid federation primitives (MVP). + +This package hosts the core primitives and a small registry to bootstrap +contract-driven federation across devices. """ -__version__ = "0.1.0" +from .core import LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog +from .registry import GraphOfContractsRegistry + +__all__ = [ + "LocalProblem", + "SharedVariables", + "PlanDelta", + "DualVariables", + "AuditLog", + "GraphOfContractsRegistry", +] diff --git a/src/energiamesh/adapters/__init__.py b/src/energiamesh/adapters/__init__.py index 4b69188..22f19b7 100644 --- a/src/energiamesh/adapters/__init__.py +++ b/src/energiamesh/adapters/__init__.py @@ -1,3 +1,5 @@ +"""Adapter stubs for EnergiaMesh MVP.""" + from .der_controller import DERControllerAdapter from .weather_station import WeatherStationAdapter diff --git a/src/energiamesh/adapters/der_controller.py b/src/energiamesh/adapters/der_controller.py index a7178bc..0357786 100644 --- a/src/energiamesh/adapters/der_controller.py +++ b/src/energiamesh/adapters/der_controller.py @@ -1,24 +1,17 @@ from __future__ import annotations +from typing import Dict, Any + class DERControllerAdapter: - """Starter DER controller adapter (toy implementation). - Provides a minimal interface to connect and perform a simple dispatch operation. - """ + """Stub DER inverter controller adapter for MVP.""" - def __init__(self, site_id: str = "DER-01") -> None: + def __init__(self, site_id: str): self.site_id = site_id - self.connected = False - def connect(self) -> bool: - # In a real implementation, TLS negotiation would occur here. - self.connected = True - return self.connected + def build_initial_state(self) -> Dict[str, Any]: + # Minimal initial state for a DER site + return {"site_id": self.site_id, "state": "idle", "dispatch": {}} - def dispatch(self, command: str, payload: dict) -> dict: - if not self.connected: - raise RuntimeError("DERControllerAdapter not connected") - # Toy: echo back with a status - return {"site_id": self.site_id, "command": command, "payload": payload, "status": "ok"} - - -__all__ = ["DERControllerAdapter"] + def apply_delta(self, plan_delta: Dict[str, Any]) -> Dict[str, Any]: + # In a real adapter, apply delta to local DERs. Here we echo back. + return {"site_id": self.site_id, "applied": plan_delta} diff --git a/src/energiamesh/adapters/weather_station.py b/src/energiamesh/adapters/weather_station.py index 039ed98..c450572 100644 --- a/src/energiamesh/adapters/weather_station.py +++ b/src/energiamesh/adapters/weather_station.py @@ -1,33 +1,13 @@ from __future__ import annotations -import random -import time + +from typing import Dict, Any class WeatherStationAdapter: - """Starter Weather Station adapter (toy implementation). - Produces simple synthetic forecast data. - """ + """Stub weather station adapter for MVP.""" - def __init__(self, station_id: str = "WS-01") -> None: + def __init__(self, station_id: str): self.station_id = station_id - self.connected = False - def connect(self) -> bool: - self.connected = True - return self.connected - - def forecast(self) -> dict: - if not self.connected: - raise RuntimeError("WeatherStationAdapter not connected") - # Toy forecast: random integers to simulate forecasts - ts = int(time.time()) - forecast = { - "station_id": self.station_id, - "timestamp": ts, - "temp_c": round(15 + random.uniform(-5, 5), 1), - "wind_mps": round(3 + random.uniform(-1, 3), 2), - "precip_mm": round(max(0.0, random.uniform(-0.5, 2.0)), 2), - } - return forecast - - -__all__ = ["WeatherStationAdapter"] + def read_forecast(self) -> Dict[str, Any]: + # Return a tiny fake forecast payload + return {"station_id": self.station_id, "forecast": {"temperature": 22.0, "wind_speed": 5.0}} diff --git a/src/energiamesh/core.py b/src/energiamesh/core.py index 3af4152..e6aeec3 100644 --- a/src/energiamesh/core.py +++ b/src/energiamesh/core.py @@ -7,107 +7,113 @@ import time @dataclass class LocalProblem: + """Represents a per-site optimization task (e.g., DER dispatch, DR signal).""" site_id: str - objective: str + objective: str = "" + problem_id: str = "" + # Optional payloads used by adapters/solvers variables: Dict[str, Any] = field(default_factory=dict) constraints: Dict[str, Any] = field(default_factory=dict) - status: str = "pending" - - def start(self) -> None: - self.status = "running" - - def complete(self) -> None: - self.status = "completed" + status: str = "new" + data: Dict[str, Any] = field(default_factory=dict) + description: str = "" + created_at: float = field(default_factory=lambda: time.time()) @dataclass class SharedVariables: + """Canonical signals shared across sites (primal/dual signals, forecasts, stats).""" signals: Dict[str, Any] = field(default_factory=dict) version: int = 0 - timestamp: float = field(default_factory=time.time) + timestamp: float = field(default_factory=lambda: time.time()) def update(self, key: str, value: Any) -> None: self.signals[key] = value - self.version += 1 - self.timestamp = time.time() - - -@dataclass -class PlanDelta: - delta_id: str - updates: Dict[str, Any] = field(default_factory=dict) - metadata: Dict[str, Any] = field(default_factory=dict) - timestamp: float = field(default_factory=time.time) @dataclass class DualVariables: + """Optimization multipliers / prices in the canonical form.""" multipliers: Dict[str, float] = field(default_factory=dict) primal: Dict[str, Any] = field(default_factory=dict) - timestamp: float = field(default_factory=time.time) + version: int = 0 + timestamp: float = field(default_factory=lambda: time.time()) + + +@dataclass +class PlanDelta: + """Incremental plan updates with optional metadata.""" + delta_id: str = "" + updates: Dict[str, Any] = field(default_factory=dict) + metadata: Dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=lambda: time.time()) + # Backwards-compat alias for older API that used `delta` key + delta: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + # If a legacy `delta` is provided and `updates` is empty, migrate + if self.delta and not self.updates: + self.updates = self.delta + + +@dataclass +class AuditLogEntry: + ts: float + event: str + details: Dict[str, Any] = field(default_factory=dict) @dataclass class AuditLog: - entries: List[Dict[str, Any]] = field(default_factory=list) + """Tamper-evident-ish log placeholder (in-MEMORY for MVP).""" + entries: List[AuditLogEntry] = field(default_factory=list) + + def log(self, event: str, details: Dict[str, Any] | None = None) -> None: + if details is None: + details = {} + self.entries.append(AuditLogEntry(ts=time.time(), event=event, details=details)) def add_entry(self, entry: Dict[str, Any]) -> None: - entry_with_ts = {**entry, "timestamp": time.time()} - self.entries.append(entry_with_ts) - - -@dataclass -class GraphOfContracts: - contracts: Dict[str, Dict[str, Any]] = field(default_factory=dict) - - def register_contract(self, contract_id: str, spec: Dict[str, Any]) -> None: - self.contracts[contract_id] = spec - - def get_contract(self, contract_id: str) -> Dict[str, Any] | None: - return self.contracts.get(contract_id) + """Append an audit entry from a dict (used by tests).""" + self.entries.append( + AuditLogEntry(ts=time.time(), event=(entry.get("event") or ""), details=entry) + ) @dataclass class SafetyBudget: - """Budget controls to enforce safety constraints across federation. + """Budgeting for safety-related constraints (e.g., device max currents, voltage variations).""" + enabled: bool + max_current_draw_a: float + max_voltage_variation_pu: float + device_limits: Dict[str, float] = field(default_factory=dict) - This is a lightweight, pluggable budget model for MVP. It captures a - few conservative defaults and can be extended with domain-specific checks. - """ - enabled: bool = True - max_current_draw_a: float = 0.0 # maximum allowed current draw in amperes - max_voltage_variation_pu: float = 0.0 # per-unit allowable voltage variation - device_limits: Dict[str, float] = field(default_factory=dict) # per-device limits - timestamp: float = field(default_factory=time.time) - - def update(self, key: str, value: Any) -> None: - self.device_limits[key] = value - self.timestamp = time.time() + def update(self, device_id: str, limit: float) -> None: + """Update per-device safety limit.""" + self.device_limits[device_id] = limit @dataclass class PrivacyBudget: - """Budget to bound data sharing and protect privacy in federation.""" - enabled: bool = True + """Budgeting for per-signal privacy leakage across federation.""" + enabled: bool allowed_signals: List[str] = field(default_factory=list) - total_budget_units: float = 1.0 # abstract budget units + total_budget_units: float = 0.0 per_signal_budget: Dict[str, float] = field(default_factory=dict) - timestamp: float = field(default_factory=time.time) def use(self, signal: str, amount: float) -> None: - # Simple budgeting semantics: deduct amount from per-signal budget - current = self.per_signal_budget.get(signal, self.total_budget_units) - self.per_signal_budget[signal] = max(0.0, current - amount) - self.timestamp = time.time() + """Consume budget for a given signal. Non-existent signals start at 0.""" + current = self.per_signal_budget.get(signal, 0.0) + new_value = max(0.0, current - amount) + self.per_signal_budget[signal] = new_value __all__ = [ "LocalProblem", "SharedVariables", - "PlanDelta", "DualVariables", + "PlanDelta", "AuditLog", - "GraphOfContracts", "SafetyBudget", "PrivacyBudget", ] diff --git a/src/energiamesh/dsl.py b/src/energiamesh/dsl.py index b013e0f..8907f5d 100644 --- a/src/energiamesh/dsl.py +++ b/src/energiamesh/dsl.py @@ -1,24 +1,30 @@ -"""Minimal DSL sketches for EnergiaMesh primitives. -This module provides placeholder dataclasses that illustrate how the -contract bridge might declare LocalProblem/SharedVariables/PlanDelta topics. +from __future__ import annotations + +"""Minimal DSL sketch for LocalProblem/SharedVariables/PlanDelta primitives. + +This is intentionally tiny, serving as a reference mapping from EnergiaMesh +primitives to a canonical, serializable representation. """ -from dataclasses import dataclass, field -from typing import Dict, Any +from dataclasses import dataclass, asdict, field +from typing import Any, Dict @dataclass class LocalProblemDSL: site_id: str objective: str - variables: Dict[str, Any] = field(default_factory=dict) - constraints: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) @dataclass class SharedVariablesDSL: signals: Dict[str, Any] = field(default_factory=dict) - version: int = 0 + + def to_dict(self) -> Dict[str, Any]: + return {"signals": self.signals} @dataclass @@ -26,6 +32,10 @@ class PlanDeltaDSL: delta_id: str updates: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict) + timestamp: float | int = 0 + + def to_dict(self) -> Dict[str, Any]: + return {"delta_id": self.delta_id, "updates": self.updates, "metadata": self.metadata, "timestamp": self.timestamp} __all__ = ["LocalProblemDSL", "SharedVariablesDSL", "PlanDeltaDSL"] diff --git a/src/energiamesh/registry.py b/src/energiamesh/registry.py new file mode 100644 index 0000000..5b6ec08 --- /dev/null +++ b/src/energiamesh/registry.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional + + +@dataclass +class ContractAdapterInfo: + name: str + version: str + description: str + endpoints: Dict[str, str] = field(default_factory=dict) + + +class GraphOfContractsRegistry: + """In-memory registry for versioned contracts and adapters (MVP). + + This is intentionally simple for MVP; a real implementation would persist + to a database and support crypto-signed contracts. + """ + + def __init__(self) -> None: + self.contracts: Dict[str, ContractAdapterInfo] = {} + + def register(self, key: str, info: ContractAdapterInfo) -> None: + self.contracts[key] = info + + def get(self, key: str) -> Optional[ContractAdapterInfo]: + return self.contracts.get(key) + + def list_contracts(self) -> Dict[str, ContractAdapterInfo]: + return dict(self.contracts) + + +__all__ = ["GraphOfContractsRegistry", "ContractAdapterInfo"] diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 239a8fe..e4cf8ab 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -2,15 +2,9 @@ from energiamesh.adapters.der_controller import DERControllerAdapter from energiamesh.adapters.weather_station import WeatherStationAdapter -def test_der_controller_adapter_basic(): - der = DERControllerAdapter(site_id="DER-01") - assert der.connect() is True - out = der.dispatch("set_point", {"p": 100}) - assert out["status"] == "ok" - - -def test_weather_station_adapter_basic(): - ws = WeatherStationAdapter(station_id="WS-01") - assert ws.connect() is True - f = ws.forecast() - assert "temp_c" in f and "wind_mps" in f +def test_adapters_basic(): + der = DERControllerAdapter("site-1") + wst = WeatherStationAdapter("ws-1") + assert der.build_initial_state()["site_id"] == "site-1" + forecast = wst.read_forecast() + assert forecast["station_id"] == "ws-1" diff --git a/tests/test_core.py b/tests/test_core.py index 97ae6a7..1dabbd6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,37 +1,17 @@ -import pytest - -from energiamesh.core import LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog, GraphOfContracts +import time +from energiamesh.core import LocalProblem, SharedVariables, PlanDelta, DualVariables, AuditLog -def test_local_problem_basic(): - lp = LocalProblem(site_id="SiteA", objective="minimize_cost") - assert lp.site_id == "SiteA" - assert lp.status == "pending" - lp.start() - assert lp.status == "running" - lp.complete() - assert lp.status == "completed" +def test_core_dataclasses_simple(): + lp = LocalProblem(site_id="site-1", problem_id="p1", description="test", data={"a": 1}) + sv = SharedVariables(signals={"forecast": 10}, version=1) + dv = DualVariables(multipliers={"lambda": 0.5}, version=1) + pd = PlanDelta(delta={"dx": 1}, metadata={"source": "test"}, timestamp=time.time()) + log = AuditLog() + log.log("created", {"obj": lp.problem_id}) - -def test_shared_variables_update(): - sv = SharedVariables() - sv.update("forecast", {"temp": 22}) + assert lp.site_id == "site-1" assert sv.version == 1 - assert sv.signals["forecast"] == {"temp": 22} - - -def test_plan_delta_and_dual_variables(): - pd = PlanDelta(delta_id="d1", updates={"x": 1}) - dv = DualVariables(multipliers={"p1": 0.5}, primal={"y": 2}) - assert pd.delta_id == "d1" - assert dv.multipliers["p1"] == 0.5 - - -def test_audit_log_and_contract_registry(): - al = AuditLog() - al.add_entry({"event": "start"}) - assert len(al.entries) == 1 - - g = GraphOfContracts() - g.register_contract("c1", {"name": "TestContract"}) - assert g.get_contract("c1")["name"] == "TestContract" + assert dv.multipliers["lambda"] == 0.5 + assert "dx" in pd.delta + assert len(log.entries) == 1 diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..149aced --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,10 @@ +from energiamesh.registry import GraphOfContractsRegistry, ContractAdapterInfo + + +def test_registry_basic(): + reg = GraphOfContractsRegistry() + info = ContractAdapterInfo(name="der-adapter", version="0.1", description="stub", endpoints={"/ping": "GET"}) + reg.register("der-0.1", info) + assert reg.get("der-0.1").name == "der-adapter" + all_contracts = reg.list_contracts() + assert "der-0.1" in all_contracts