build(agent): semicolon#54de0b iteration
This commit is contained in:
parent
434601d06d
commit
4d5024f8e5
19
README.md
19
README.md
|
|
@ -8,6 +8,8 @@ It coordinates solar, wind, and thermal storage sites through summarized signals
|
||||||
- Canonical protocol primitives for local optimization, shared signals, dual variables, and plan deltas
|
- Canonical protocol primitives for local optimization, shared signals, dual variables, and plan deltas
|
||||||
- A SQLite-backed graph-of-contracts registry for adapter versioning and schema conformance
|
- A SQLite-backed graph-of-contracts registry for adapter versioning and schema conformance
|
||||||
- An ADMM-lite solver for federated dispatch planning
|
- An ADMM-lite solver for federated dispatch planning
|
||||||
|
- A federation bridge that packages solver rounds, audit records, and privacy-budget charges
|
||||||
|
- Deterministic starter adapters for solar inverter and thermal storage controllers
|
||||||
- Signed message envelopes and DID-style identities for short-lived trust
|
- Signed message envelopes and DID-style identities for short-lived trust
|
||||||
- Audit logging and privacy-budget accounting
|
- Audit logging and privacy-budget accounting
|
||||||
- Delta-sync journaling for islanded/offline operation
|
- Delta-sync journaling for islanded/offline operation
|
||||||
|
|
@ -18,6 +20,8 @@ It coordinates solar, wind, and thermal storage sites through summarized signals
|
||||||
- `solfuse.identity`: identity generation, signing, and verification
|
- `solfuse.identity`: identity generation, signing, and verification
|
||||||
- `solfuse.registry`: adapter registry and schema conformance checks
|
- `solfuse.registry`: adapter registry and schema conformance checks
|
||||||
- `solfuse.solver`: federated consensus solver
|
- `solfuse.solver`: federated consensus solver
|
||||||
|
- `solfuse.bridge`: round orchestration and governance-aware packaging
|
||||||
|
- `solfuse.adapters`: starter inverter and thermal controller adapters
|
||||||
- `solfuse.delta_sync`: incremental plan journal and replay helpers
|
- `solfuse.delta_sync`: incremental plan journal and replay helpers
|
||||||
- `solfuse.governance`: audit log and privacy budget ledger
|
- `solfuse.governance`: audit log and privacy budget ledger
|
||||||
- `solfuse.transport`: canonical envelope serialization and TLS context helpers
|
- `solfuse.transport`: canonical envelope serialization and TLS context helpers
|
||||||
|
|
@ -34,16 +38,15 @@ solfuse demo
|
||||||
## Minimal Example
|
## Minimal Example
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from solfuse.models import LocalProblem
|
from solfuse.bridge import FederationBridge
|
||||||
from solfuse.solver import ADMMLiteSolver
|
from solfuse.adapters import SolarInverterAdapter, ThermalStorageAdapter
|
||||||
|
|
||||||
solver = ADMMLiteSolver(rho=1.0, tolerance=1e-6, max_iterations=100)
|
bridge = FederationBridge()
|
||||||
result = solver.solve([
|
solar = SolarInverterAdapter().to_local_problem({"site_id": "solar-a", "forecast_kw": [4.0, 4.5]}, round_id=1)
|
||||||
LocalProblem(site_id="solar-a", preferred_dispatch=[4.0, 4.5], quadratic_weight=2.0),
|
thermal = ThermalStorageAdapter().to_local_problem({"site_id": "storage-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0}, round_id=1)
|
||||||
LocalProblem(site_id="storage-b", preferred_dispatch=[1.0, 1.5], quadratic_weight=1.5),
|
result = bridge.solve_round([solar, thermal], round_id=1)
|
||||||
])
|
|
||||||
|
|
||||||
print(result.consensus)
|
print(result.solver_result.consensus)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Governance Model
|
## Governance Model
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""SolFuse canonical federation primitives."""
|
"""SolFuse canonical federation primitives."""
|
||||||
|
|
||||||
from .delta_sync import DeltaSyncJournal
|
from .delta_sync import DeltaSyncJournal
|
||||||
|
from .bridge import FederationBridge, RoundSummary
|
||||||
|
from .adapters import AdapterRuntime, SolarInverterAdapter, ThermalStorageAdapter
|
||||||
from .governance import AuditLedger, PrivacyBudgetLedger
|
from .governance import AuditLedger, PrivacyBudgetLedger
|
||||||
from .identity import AgentIdentity, generate_identity, sign_message, verify_message
|
from .identity import AgentIdentity, generate_identity, sign_message, verify_message
|
||||||
from .models import (
|
from .models import (
|
||||||
AdapterContract,
|
AdapterContract,
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
DualVariable,
|
DualVariable,
|
||||||
|
FederationSnapshot,
|
||||||
LocalProblem,
|
LocalProblem,
|
||||||
PlanAction,
|
PlanAction,
|
||||||
PlanDelta,
|
PlanDelta,
|
||||||
|
|
@ -20,9 +23,12 @@ from .solver import ADMMLiteSolver
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ADMMLiteSolver",
|
"ADMMLiteSolver",
|
||||||
"AdapterContract",
|
"AdapterContract",
|
||||||
|
"AdapterRuntime",
|
||||||
"AgentIdentity",
|
"AgentIdentity",
|
||||||
"AuditLedger",
|
"AuditLedger",
|
||||||
"AuditLogEntry",
|
"AuditLogEntry",
|
||||||
|
"FederationBridge",
|
||||||
|
"FederationSnapshot",
|
||||||
"DeltaSyncJournal",
|
"DeltaSyncJournal",
|
||||||
"DualVariable",
|
"DualVariable",
|
||||||
"GraphOfContractsRegistry",
|
"GraphOfContractsRegistry",
|
||||||
|
|
@ -31,8 +37,11 @@ __all__ = [
|
||||||
"PlanDelta",
|
"PlanDelta",
|
||||||
"PrivacyBudget",
|
"PrivacyBudget",
|
||||||
"PrivacyBudgetLedger",
|
"PrivacyBudgetLedger",
|
||||||
|
"RoundSummary",
|
||||||
"SharedVariable",
|
"SharedVariable",
|
||||||
"SolverResult",
|
"SolverResult",
|
||||||
|
"SolarInverterAdapter",
|
||||||
|
"ThermalStorageAdapter",
|
||||||
"generate_identity",
|
"generate_identity",
|
||||||
"sign_message",
|
"sign_message",
|
||||||
"verify_message",
|
"verify_message",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from .models import AdapterContract, LocalProblem, PlanAction, PlanDelta, SolfuseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterTelemetry(SolfuseModel):
|
||||||
|
site_id: str
|
||||||
|
round_id: int = Field(default=0, ge=0)
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterRuntime(ABC):
|
||||||
|
contract: AdapterContract
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def to_local_problem(self, telemetry: dict[str, Any], *, round_id: int = 0) -> LocalProblem:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def apply_plan(self, delta: PlanDelta) -> list[dict[str, Any]]:
|
||||||
|
return [action.model_dump(mode="json") for action in delta.actions]
|
||||||
|
|
||||||
|
|
||||||
|
class SolarInverterAdapter(AdapterRuntime):
|
||||||
|
contract = AdapterContract(
|
||||||
|
name="solar-inverter",
|
||||||
|
version="1.0.0",
|
||||||
|
adapter_type="inverter",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"site_id": {"type": "string"},
|
||||||
|
"forecast_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||||
|
"upper_bound_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||||
|
"lower_bound_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||||
|
},
|
||||||
|
"required": ["site_id", "forecast_kw"],
|
||||||
|
"additionalProperties": True,
|
||||||
|
},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"site_id": {"type": "string"}, "dispatch_kw": {"type": "array", "items": {"type": "number"}}},
|
||||||
|
"required": ["site_id", "dispatch_kw"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
capabilities=["dispatch", "telemetry", "forecasting"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_local_problem(self, telemetry: dict[str, Any], *, round_id: int = 0) -> LocalProblem:
|
||||||
|
forecast = [float(value) for value in telemetry["forecast_kw"]]
|
||||||
|
lower = telemetry.get("lower_bound_kw")
|
||||||
|
upper = telemetry.get("upper_bound_kw")
|
||||||
|
return LocalProblem(
|
||||||
|
site_id=str(telemetry["site_id"]),
|
||||||
|
preferred_dispatch=forecast,
|
||||||
|
lower_bounds=None if lower is None else [float(value) for value in lower],
|
||||||
|
upper_bounds=None if upper is None else [float(value) for value in upper],
|
||||||
|
quadratic_weight=float(telemetry.get("quadratic_weight", 1.0)),
|
||||||
|
round_id=round_id,
|
||||||
|
metadata={"adapter": self.contract.name, "source": "solar"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThermalStorageAdapter(AdapterRuntime):
|
||||||
|
contract = AdapterContract(
|
||||||
|
name="thermal-storage",
|
||||||
|
version="1.0.0",
|
||||||
|
adapter_type="thermal",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"site_id": {"type": "string"},
|
||||||
|
"cooling_load_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||||
|
"capacity_kw": {"type": "number"},
|
||||||
|
"min_kw": {"type": "number"},
|
||||||
|
"max_kw": {"type": "number"},
|
||||||
|
},
|
||||||
|
"required": ["site_id", "cooling_load_kw", "capacity_kw"],
|
||||||
|
"additionalProperties": True,
|
||||||
|
},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"site_id": {"type": "string"}, "dispatch_kw": {"type": "array", "items": {"type": "number"}}},
|
||||||
|
"required": ["site_id", "dispatch_kw"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
capabilities=["dispatch", "thermal-storage", "demand-shaping"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_local_problem(self, telemetry: dict[str, Any], *, round_id: int = 0) -> LocalProblem:
|
||||||
|
load = [float(value) for value in telemetry["cooling_load_kw"]]
|
||||||
|
capacity = float(telemetry["capacity_kw"])
|
||||||
|
min_kw = float(telemetry.get("min_kw", 0.0))
|
||||||
|
max_kw = float(telemetry.get("max_kw", capacity))
|
||||||
|
preferred = [min(max(value, min_kw), max_kw) for value in load]
|
||||||
|
return LocalProblem(
|
||||||
|
site_id=str(telemetry["site_id"]),
|
||||||
|
preferred_dispatch=preferred,
|
||||||
|
lower_bounds=[min_kw] * len(preferred),
|
||||||
|
upper_bounds=[max_kw] * len(preferred),
|
||||||
|
quadratic_weight=float(telemetry.get("quadratic_weight", 1.0)),
|
||||||
|
round_id=round_id,
|
||||||
|
metadata={"adapter": self.contract.name, "source": "thermal"},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .governance import AuditLedger, PrivacyBudgetLedger
|
||||||
|
from .identity import AgentIdentity, sign_message
|
||||||
|
from .delta_sync import DeltaSyncJournal
|
||||||
|
from .models import DualVariable, FederationSnapshot, LocalProblem, SharedVariable
|
||||||
|
from .solver import ADMMLiteSolver
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoundSummary:
|
||||||
|
round_id: int
|
||||||
|
consensus: list[float]
|
||||||
|
delta_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class FederationBridge:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
solver: ADMMLiteSolver | None = None,
|
||||||
|
journal: DeltaSyncJournal | None = None,
|
||||||
|
audit_ledger: AuditLedger | None = None,
|
||||||
|
privacy_ledger: PrivacyBudgetLedger | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.solver = solver or ADMMLiteSolver()
|
||||||
|
self.journal = journal
|
||||||
|
self.audit_ledger = audit_ledger
|
||||||
|
self.privacy_ledger = privacy_ledger
|
||||||
|
|
||||||
|
def solve_round(
|
||||||
|
self,
|
||||||
|
problems: list[LocalProblem],
|
||||||
|
*,
|
||||||
|
round_id: int,
|
||||||
|
shared_variables: list[SharedVariable] | None = None,
|
||||||
|
dual_variables: list[DualVariable] | None = None,
|
||||||
|
actor_id: str = "federation",
|
||||||
|
identity: AgentIdentity | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> FederationSnapshot:
|
||||||
|
solver_result = self.solver.solve(
|
||||||
|
problems,
|
||||||
|
shared_variables=shared_variables,
|
||||||
|
dual_variables=dual_variables,
|
||||||
|
)
|
||||||
|
snapshot = FederationSnapshot(
|
||||||
|
round_id=round_id,
|
||||||
|
local_problems=problems,
|
||||||
|
shared_variables=shared_variables or [],
|
||||||
|
dual_variables=dual_variables or [],
|
||||||
|
solver_result=solver_result,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.journal is not None:
|
||||||
|
self.journal.merge(solver_result.deltas)
|
||||||
|
|
||||||
|
if self.privacy_ledger is not None and self.privacy_ledger.get_budget(actor_id) is not None:
|
||||||
|
epsilon = 0.01 * max(1, len(solver_result.deltas))
|
||||||
|
delta = 1e-8 * max(1, sum(len(delta.actions) for delta in solver_result.deltas))
|
||||||
|
self.privacy_ledger.charge(actor_id, epsilon, delta)
|
||||||
|
|
||||||
|
if self.audit_ledger is not None:
|
||||||
|
payload = snapshot.model_dump(mode="json")
|
||||||
|
signature = sign_message(identity, payload) if identity is not None else None
|
||||||
|
self.audit_ledger.append(
|
||||||
|
self._build_audit_entry(
|
||||||
|
actor_id=actor_id,
|
||||||
|
payload=payload,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def summarize_round(self, snapshot: FederationSnapshot) -> RoundSummary:
|
||||||
|
return RoundSummary(
|
||||||
|
round_id=snapshot.round_id,
|
||||||
|
consensus=snapshot.solver_result.consensus,
|
||||||
|
delta_count=len(snapshot.solver_result.deltas),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_audit_entry(self, *, actor_id: str, payload: dict[str, Any], signature: str | None):
|
||||||
|
from .models import AuditLogEntry
|
||||||
|
|
||||||
|
return AuditLogEntry(
|
||||||
|
actor_id=actor_id,
|
||||||
|
event_type="federation_round",
|
||||||
|
payload=payload,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
|
@ -3,6 +3,8 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from .adapters import SolarInverterAdapter, ThermalStorageAdapter
|
||||||
|
from .bridge import FederationBridge
|
||||||
from .models import LocalProblem
|
from .models import LocalProblem
|
||||||
from .solver import ADMMLiteSolver
|
from .solver import ADMMLiteSolver
|
||||||
|
|
||||||
|
|
@ -11,6 +13,7 @@ def main() -> None:
|
||||||
parser = argparse.ArgumentParser(prog="solfuse")
|
parser = argparse.ArgumentParser(prog="solfuse")
|
||||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||||
subcommands.add_parser("demo")
|
subcommands.add_parser("demo")
|
||||||
|
subcommands.add_parser("microgrid-demo")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "demo":
|
if args.command == "demo":
|
||||||
|
|
@ -22,3 +25,15 @@ def main() -> None:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
||||||
|
if args.command == "microgrid-demo":
|
||||||
|
bridge = FederationBridge(solver=ADMMLiteSolver())
|
||||||
|
solar = SolarInverterAdapter().to_local_problem(
|
||||||
|
{"site_id": "solar-a", "forecast_kw": [4.0, 4.5], "upper_bound_kw": [5.0, 5.0]},
|
||||||
|
round_id=1,
|
||||||
|
)
|
||||||
|
thermal = ThermalStorageAdapter().to_local_problem(
|
||||||
|
{"site_id": "thermal-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0},
|
||||||
|
round_id=1,
|
||||||
|
)
|
||||||
|
snapshot = bridge.solve_round([solar, thermal], round_id=1)
|
||||||
|
print(json.dumps(snapshot.model_dump(mode="json"), indent=2))
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,12 @@ class SolverResult(SolfuseModel):
|
||||||
dual_residual: float
|
dual_residual: float
|
||||||
deltas: list[PlanDelta] = Field(default_factory=list)
|
deltas: list[PlanDelta] = Field(default_factory=list)
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FederationSnapshot(SolfuseModel):
|
||||||
|
round_id: int = Field(ge=0)
|
||||||
|
local_problems: list[LocalProblem]
|
||||||
|
shared_variables: list[SharedVariable] = Field(default_factory=list)
|
||||||
|
dual_variables: list[DualVariable] = Field(default_factory=list)
|
||||||
|
solver_result: SolverResult
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .models import LocalProblem, PlanAction, PlanDelta, SolverResult
|
from .models import DualVariable, LocalProblem, PlanAction, PlanDelta, SharedVariable, SolverResult
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -19,11 +19,19 @@ class ADMMLiteSolver:
|
||||||
def __init__(self, *, rho: float = 1.0, tolerance: float = 1e-6, max_iterations: int = 100) -> None:
|
def __init__(self, *, rho: float = 1.0, tolerance: float = 1e-6, max_iterations: int = 100) -> None:
|
||||||
if rho <= 0:
|
if rho <= 0:
|
||||||
raise ValueError("rho must be positive")
|
raise ValueError("rho must be positive")
|
||||||
|
if max_iterations <= 0:
|
||||||
|
raise ValueError("max_iterations must be positive")
|
||||||
self.rho = rho
|
self.rho = rho
|
||||||
self.tolerance = tolerance
|
self.tolerance = tolerance
|
||||||
self.max_iterations = max_iterations
|
self.max_iterations = max_iterations
|
||||||
|
|
||||||
def solve(self, problems: list[LocalProblem]) -> SolverResult:
|
def solve(
|
||||||
|
self,
|
||||||
|
problems: list[LocalProblem],
|
||||||
|
*,
|
||||||
|
shared_variables: list[SharedVariable] | None = None,
|
||||||
|
dual_variables: list[DualVariable] | None = None,
|
||||||
|
) -> SolverResult:
|
||||||
if not problems:
|
if not problems:
|
||||||
raise ValueError("at least one local problem is required")
|
raise ValueError("at least one local problem is required")
|
||||||
|
|
||||||
|
|
@ -39,11 +47,21 @@ class ADMMLiteSolver:
|
||||||
|
|
||||||
x = preferred.copy()
|
x = preferred.copy()
|
||||||
z = preferred.mean(axis=0)
|
z = preferred.mean(axis=0)
|
||||||
|
if shared_variables:
|
||||||
|
candidate = np.asarray(shared_variables[0].values, dtype=float)
|
||||||
|
if candidate.shape == (horizon,):
|
||||||
|
z = candidate
|
||||||
u = np.zeros_like(x)
|
u = np.zeros_like(x)
|
||||||
|
if dual_variables:
|
||||||
|
candidate = np.asarray(dual_variables[0].values, dtype=float)
|
||||||
|
if candidate.shape == (horizon,):
|
||||||
|
u = np.broadcast_to(candidate, x.shape).copy()
|
||||||
primal_residual = float("inf")
|
primal_residual = float("inf")
|
||||||
dual_residual = float("inf")
|
dual_residual = float("inf")
|
||||||
|
last_iteration = 0
|
||||||
|
|
||||||
for iteration in range(1, self.max_iterations + 1):
|
for current_iteration in range(1, self.max_iterations + 1):
|
||||||
|
last_iteration = current_iteration
|
||||||
previous_z = z.copy()
|
previous_z = z.copy()
|
||||||
x = np.clip((weights * preferred + self.rho * (z - u)) / (weights + self.rho), lower, upper)
|
x = np.clip((weights * preferred + self.rho * (z - u)) / (weights + self.rho), lower, upper)
|
||||||
z = np.mean(x + u, axis=0)
|
z = np.mean(x + u, axis=0)
|
||||||
|
|
@ -64,9 +82,9 @@ class ADMMLiteSolver:
|
||||||
PlanDelta(
|
PlanDelta(
|
||||||
site_id=problem.site_id,
|
site_id=problem.site_id,
|
||||||
adapter_id=f"adapter:{problem.site_id}",
|
adapter_id=f"adapter:{problem.site_id}",
|
||||||
revision=iteration,
|
revision=last_iteration,
|
||||||
round_id=problem.round_id,
|
round_id=problem.round_id,
|
||||||
parent_revision=max(0, iteration - 1),
|
parent_revision=max(0, last_iteration - 1),
|
||||||
actions=actions,
|
actions=actions,
|
||||||
metadata={"solver": "admm-lite", "round_id": problem.round_id},
|
metadata={"solver": "admm-lite", "round_id": problem.round_id},
|
||||||
)
|
)
|
||||||
|
|
@ -74,7 +92,7 @@ class ADMMLiteSolver:
|
||||||
|
|
||||||
return SolverResult(
|
return SolverResult(
|
||||||
consensus=[float(value) for value in z],
|
consensus=[float(value) for value in z],
|
||||||
iterations=iteration,
|
iterations=last_iteration,
|
||||||
primal_residual=primal_residual,
|
primal_residual=primal_residual,
|
||||||
dual_residual=dual_residual,
|
dual_residual=dual_residual,
|
||||||
deltas=deltas,
|
deltas=deltas,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from solfuse import (
|
||||||
|
AuditLedger,
|
||||||
|
DeltaSyncJournal,
|
||||||
|
FederationBridge,
|
||||||
|
PrivacyBudget,
|
||||||
|
PrivacyBudgetLedger,
|
||||||
|
DualVariable,
|
||||||
|
SolarInverterAdapter,
|
||||||
|
ThermalStorageAdapter,
|
||||||
|
SharedVariable,
|
||||||
|
generate_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_records_rounds_and_respects_governance():
|
||||||
|
journal = DeltaSyncJournal()
|
||||||
|
audit = AuditLedger()
|
||||||
|
budgets = PrivacyBudgetLedger()
|
||||||
|
budgets.set_budget("federation", PrivacyBudget(epsilon=1.0, delta=1e-5))
|
||||||
|
bridge = FederationBridge(journal=journal, audit_ledger=audit, privacy_ledger=budgets)
|
||||||
|
|
||||||
|
solar = SolarInverterAdapter().to_local_problem({"site_id": "solar-a", "forecast_kw": [4.0, 4.5]}, round_id=3)
|
||||||
|
thermal = ThermalStorageAdapter().to_local_problem(
|
||||||
|
{"site_id": "thermal-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0},
|
||||||
|
round_id=3,
|
||||||
|
)
|
||||||
|
shared = SharedVariable(name="forecast", version="1.0", values=[3.5, 3.5], round_id=3)
|
||||||
|
dual = DualVariable(name="dispatch-dual", version="1.0", values=[0.0, 0.0], round_id=3)
|
||||||
|
|
||||||
|
snapshot = bridge.solve_round(
|
||||||
|
[solar, thermal],
|
||||||
|
round_id=3,
|
||||||
|
shared_variables=[shared],
|
||||||
|
dual_variables=[dual],
|
||||||
|
identity=generate_identity("federation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.round_id == 3
|
||||||
|
assert snapshot.shared_variables[0].name == "forecast"
|
||||||
|
assert snapshot.dual_variables[0].name == "dispatch-dual"
|
||||||
|
assert snapshot.solver_result.deltas
|
||||||
|
assert journal.load()
|
||||||
|
assert audit.coverage() == 1.0
|
||||||
|
updated_budget = budgets.get_budget("federation")
|
||||||
|
assert updated_budget is not None
|
||||||
|
assert updated_budget.spent_epsilon > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_starter_adapters_generate_local_problems_and_plan_frames():
|
||||||
|
solar = SolarInverterAdapter()
|
||||||
|
thermal = ThermalStorageAdapter()
|
||||||
|
|
||||||
|
solar_problem = solar.to_local_problem({"site_id": "solar-a", "forecast_kw": [3.0, 3.5], "upper_bound_kw": [4.0, 4.0]})
|
||||||
|
thermal_problem = thermal.to_local_problem({"site_id": "thermal-b", "cooling_load_kw": [1.0, 2.0], "capacity_kw": 2.5})
|
||||||
|
|
||||||
|
assert solar_problem.site_id == "solar-a"
|
||||||
|
assert thermal_problem.upper_bounds == [2.5, 2.5]
|
||||||
Loading…
Reference in New Issue