From 3ffc0f6dd1806f6d15fdf6d73576788443cea466 Mon Sep 17 00:00:00 2001 From: agent-54de0bcc6a17828b Date: Fri, 24 Apr 2026 18:20:49 +0200 Subject: [PATCH] build(agent): semicolon#54de0b iteration --- .gitignore | 21 +++ AGENTS.md | 31 ++++ README.md | 43 +++++- pyproject.toml | 25 +++ .../__init__.py | 27 ++++ .../contracts.py | 90 +++++++++++ .../registry.py | 145 ++++++++++++++++++ .../solver.py | 124 +++++++++++++++ .../sync.py | 82 ++++++++++ test.sh | 6 + tests/test_contracts.py | 27 ++++ tests/test_registry.py | 36 +++++ tests/test_sync_and_solver.py | 53 +++++++ 13 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 src/idea154_civicmesh_studio_federated/__init__.py create mode 100644 src/idea154_civicmesh_studio_federated/contracts.py create mode 100644 src/idea154_civicmesh_studio_federated/registry.py create mode 100644 src/idea154_civicmesh_studio_federated/solver.py create mode 100644 src/idea154_civicmesh_studio_federated/sync.py create mode 100644 test.sh create mode 100644 tests/test_contracts.py create mode 100644 tests/test_registry.py create mode 100644 tests/test_sync_and_solver.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..645ad04 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# AGENTS.md + +## Architecture + +This repository contains `idea154-civicmesh-studio-federated`, a Python package for federated urban resilience coordination. + +Core modules: +- `contracts.py`: canonical Pydantic contracts for `LocalModel`, `SharedSignals`, `PlanDelta`, and `DualVariable`. +- `registry.py`: Graph-of-Contracts adapter registry plus starter GIS, weather, and pump adapters. +- `sync.py`: deterministic delta log with audit hashes and replay. +- `solver.py`: lightweight ADMM-style coordinator for cross-neighborhood plan updates. + +## Tech Stack + +- Python 3.11+ +- `pydantic` for validation and canonical serialization +- `pytest` for tests +- `setuptools` build backend + +## Rules + +- Keep contracts canonical and deterministic. +- Prefer small, explicit changes over broad abstractions. +- Preserve reproducible serialization and replay behavior. +- Add or update tests when changing contract shapes, adapter behavior, or solver math. + +## Testing + +- `bash test.sh` +- `python3 -m pytest` +- `python3 -m build` diff --git a/README.md b/README.md index d9db029..1df13c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ -# idea154-civicmesh-studio-federated +# CivicMesh Studio Federated -Source logic for Idea #154 \ No newline at end of file +CivicMesh Studio Federated is a Python package for coordinating neighborhood-scale resilience planning without requiring full data centralization. + +It provides three core building blocks: + +- Canonical contracts for `LocalModel`, `SharedSignals`, `PlanDelta`, and `DualVariable` +- A Graph-of-Contracts registry for adapter compatibility and versioning +- A deterministic delta-sync log and a lightweight ADMM-style coordination solver + +## What it does + +The package models each neighborhood as a local optimization problem with its own assets, limits, and signals. Neighborhoods can exchange compact summaries instead of raw operational data, then coordinate incremental plan updates under a shared district constraint. + +Included starter adapters: + +- `gis-layer`: turns simple GeoJSON-like feature collections into a `LocalModel` +- `weather-forecast`: converts weather feed summaries into `SharedSignals` +- `water-pump-controller`: emits `PlanDelta` updates from pump telemetry + +## Repository layout + +- `src/idea154_civicmesh_studio_federated/` +- `tests/` +- `test.sh` +- `AGENTS.md` + +## How to run + +```bash +bash test.sh +``` + +## Packaging + +The published project name is `idea154-civicmesh-studio-federated`. + +## Notes for contributors + +- Keep serialized outputs deterministic. +- Keep adapter input/output contracts explicit. +- Expand tests when changing solver behavior or contract fields. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9c8457 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea154-civicmesh-studio-federated" +version = "0.1.0" +description = "Federated urban resilience planning primitives for cross-neighborhood coordination." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.7,<3", +] + +[project.optional-dependencies] +test = [ + "build>=1.2", + "pytest>=8", +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/idea154_civicmesh_studio_federated/__init__.py b/src/idea154_civicmesh_studio_federated/__init__.py new file mode 100644 index 0000000..8e112cb --- /dev/null +++ b/src/idea154_civicmesh_studio_federated/__init__.py @@ -0,0 +1,27 @@ +"""CivicMesh Studio federated resilience primitives.""" + +from .contracts import DualVariable, LocalModel, PlanDelta, SharedSignals +from .registry import ( + GISLayerAdapter, + GraphOfContractsRegistry, + WaterPumpControllerAdapter, + WeatherForecastAdapter, +) +from .solver import FederatedResilienceSolver, NeighborhoodProblem, SolverResult +from .sync import DeltaSyncLog, SyncRecord + +__all__ = [ + "DualVariable", + "LocalModel", + "PlanDelta", + "SharedSignals", + "GISLayerAdapter", + "GraphOfContractsRegistry", + "WaterPumpControllerAdapter", + "WeatherForecastAdapter", + "FederatedResilienceSolver", + "NeighborhoodProblem", + "SolverResult", + "DeltaSyncLog", + "SyncRecord", +] diff --git a/src/idea154_civicmesh_studio_federated/contracts.py b/src/idea154_civicmesh_studio_federated/contracts.py new file mode 100644 index 0000000..742020f --- /dev/null +++ b/src/idea154_civicmesh_studio_federated/contracts.py @@ -0,0 +1,90 @@ +"""Canonical data contracts for CivicMesh Studio.""" + +from __future__ import annotations + +from hashlib import sha256 +import json +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +def _canonical_json(model: BaseModel) -> str: + return json.dumps(model.model_dump(mode="json"), sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +class Asset(BaseModel): + model_config = ConfigDict(frozen=True) + + asset_id: str + asset_type: str + neighborhood_id: str + capacity_kw: float = 0.0 + capacity_m3: float = 0.0 + service_priority: float = 1.0 + metadata: dict[str, Any] = Field(default_factory=dict) + + +class LocalModel(BaseModel): + model_config = ConfigDict(frozen=True) + + neighborhood_id: str + version: str = "1.0" + assets: list[Asset] = Field(default_factory=list) + energy_limit_kw: float = 0.0 + water_limit_m3: float = 0.0 + objective_weights: dict[str, float] = Field(default_factory=lambda: {"service_disruption": 1.0, "energy_use": 1.0}) + + def canonical_json(self) -> str: + return _canonical_json(self) + + def content_hash(self) -> str: + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +class SharedSignals(BaseModel): + model_config = ConfigDict(frozen=True) + + neighborhood_id: str + source: str + horizon_hours: int = 1 + forecast_demand_kw: float = 0.0 + forecast_water_m3: float = 0.0 + available_capacity_kw: float = 0.0 + available_capacity_m3: float = 0.0 + weather_risk: float = 0.0 + delay_steps: int = 0 + + def canonical_json(self) -> str: + return _canonical_json(self) + + def content_hash(self) -> str: + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +class PlanDelta(BaseModel): + model_config = ConfigDict(frozen=True) + + neighborhood_id: str + iteration: int = 0 + actions: dict[str, float] = Field(default_factory=dict) + notes: str = "" + affected_assets: list[str] = Field(default_factory=list) + + def canonical_json(self) -> str: + return _canonical_json(self) + + def content_hash(self) -> str: + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +class DualVariable(BaseModel): + model_config = ConfigDict(frozen=True) + + neighborhood_id: str + value: float = 0.0 + version: int = 0 + + def bump(self, delta: float) -> "DualVariable": + return self.model_copy(update={"value": self.value + delta, "version": self.version + 1}) + diff --git a/src/idea154_civicmesh_studio_federated/registry.py b/src/idea154_civicmesh_studio_federated/registry.py new file mode 100644 index 0000000..45562a0 --- /dev/null +++ b/src/idea154_civicmesh_studio_federated/registry.py @@ -0,0 +1,145 @@ +"""Graph-of-Contracts adapter registry and starter adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +from pydantic import BaseModel, ConfigDict, Field + +from .contracts import Asset, LocalModel, PlanDelta, SharedSignals + + +class AdapterContext(BaseModel): + model_config = ConfigDict(frozen=True) + + neighborhood_id: str + metadata: dict[str, Any] = Field(default_factory=dict) + + +@dataclass(frozen=True) +class ContractSpec: + name: str + version: str + input_contract: str + output_contract: str + description: str + + +class ContractAdapter(Protocol): + spec: ContractSpec + + def adapt(self, payload: dict[str, Any], context: AdapterContext) -> BaseModel: ... + + +class GraphOfContractsRegistry: + def __init__(self) -> None: + self._adapters: dict[str, ContractAdapter] = {} + + def register(self, adapter: ContractAdapter) -> None: + if adapter.spec.name in self._adapters: + raise ValueError(f"adapter already registered: {adapter.spec.name}") + self._adapters[adapter.spec.name] = adapter + + def get(self, name: str) -> ContractAdapter: + return self._adapters[name] + + def route(self, name: str, payload: dict[str, Any], context: AdapterContext) -> BaseModel: + adapter = self.get(name) + return adapter.adapt(payload, context) + + def compatibility_report(self) -> list[dict[str, str]]: + return [ + { + "adapter": adapter.spec.name, + "input_contract": adapter.spec.input_contract, + "output_contract": adapter.spec.output_contract, + "version": adapter.spec.version, + } + for adapter in self._adapters.values() + ] + + +class GISLayerAdapter: + spec = ContractSpec( + name="gis-layer", + version="1.0", + input_contract="GeoJSONFeatureCollection", + output_contract="LocalModel", + description="Translate a GIS feature collection into a local neighborhood asset model.", + ) + + def adapt(self, payload: dict[str, Any], context: AdapterContext) -> LocalModel: + features = payload.get("features", []) + assets = [] + for index, feature in enumerate(features): + properties = feature.get("properties", {}) + geometry = feature.get("geometry", {}) + asset_type = properties.get("asset_type", "unknown") + assets.append( + Asset( + asset_id=properties.get("id", f"{context.neighborhood_id}-asset-{index}"), + asset_type=asset_type, + neighborhood_id=context.neighborhood_id, + capacity_kw=float(properties.get("capacity_kw", 0.0)), + capacity_m3=float(properties.get("capacity_m3", 0.0)), + service_priority=float(properties.get("service_priority", 1.0)), + metadata={"geometry": geometry, "source": "gis"}, + ) + ) + return LocalModel( + neighborhood_id=context.neighborhood_id, + assets=assets, + energy_limit_kw=float(payload.get("energy_limit_kw", 0.0)), + water_limit_m3=float(payload.get("water_limit_m3", 0.0)), + ) + + +class WeatherForecastAdapter: + spec = ContractSpec( + name="weather-forecast", + version="1.0", + input_contract="WeatherForecast", + output_contract="SharedSignals", + description="Convert weather feed summaries into shared resilience signals.", + ) + + def adapt(self, payload: dict[str, Any], context: AdapterContext) -> SharedSignals: + severity = float(payload.get("precipitation_mm", 0.0)) + abs(float(payload.get("temperature_c", 0.0)) - 20.0) * 0.1 + return SharedSignals( + neighborhood_id=context.neighborhood_id, + source=str(payload.get("source", "weather")), + horizon_hours=int(payload.get("horizon_hours", 1)), + forecast_demand_kw=float(payload.get("forecast_demand_kw", 0.0)), + forecast_water_m3=float(payload.get("forecast_water_m3", 0.0)), + available_capacity_kw=float(payload.get("available_capacity_kw", 0.0)), + available_capacity_m3=float(payload.get("available_capacity_m3", 0.0)), + weather_risk=severity, + delay_steps=int(payload.get("delay_steps", 0)), + ) + + +class WaterPumpControllerAdapter: + spec = ContractSpec( + name="water-pump-controller", + version="1.0", + input_contract="PumpTelemetry", + output_contract="PlanDelta", + description="Create incremental pump dispatch actions from telemetry.", + ) + + def adapt(self, payload: dict[str, Any], context: AdapterContext) -> PlanDelta: + pressure = float(payload.get("pressure_bar", 0.0)) + level = float(payload.get("reservoir_level_m", 0.0)) + demand = float(payload.get("demand_m3", 0.0)) + action = max(0.0, demand - level) + if pressure < 2.0: + action *= 1.25 + return PlanDelta( + neighborhood_id=context.neighborhood_id, + iteration=int(payload.get("iteration", 0)), + actions={"pump_dispatch_m3": round(action, 3), "pressure_support_kw": round(max(0.0, 2.0 - pressure), 3)}, + notes="Generated from pump telemetry", + affected_assets=[str(payload.get("asset_id", "pump-0"))], + ) + diff --git a/src/idea154_civicmesh_studio_federated/solver.py b/src/idea154_civicmesh_studio_federated/solver.py new file mode 100644 index 0000000..9c0c467 --- /dev/null +++ b/src/idea154_civicmesh_studio_federated/solver.py @@ -0,0 +1,124 @@ +"""Lightweight ADMM-style federated coordination solver.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import sqrt +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from .contracts import DualVariable, LocalModel, PlanDelta, SharedSignals + + +class NeighborhoodProblem(BaseModel): + model_config = ConfigDict(frozen=True) + + local_model: LocalModel + signals: SharedSignals + dual: DualVariable = Field(default_factory=lambda: DualVariable(neighborhood_id="unknown")) + + +class SolverResult(BaseModel): + model_config = ConfigDict(frozen=True) + + iterations: int + primal_residual: float + dual_residual: float + objective_value: float + deltas: list[PlanDelta] + history: list[dict[str, Any]] + + +def _project_nonnegative_simplex(values: list[float], limit: float) -> list[float]: + clipped = [max(0.0, value) for value in values] + total = sum(clipped) + if total <= limit or total == 0.0: + return clipped + scale = limit / total + return [value * scale for value in clipped] + + +def _norm(values: list[float]) -> float: + return sqrt(sum(value * value for value in values)) + + +def _local_target(problem: NeighborhoodProblem) -> float: + demand_gap = max(0.0, problem.signals.forecast_demand_kw - problem.signals.available_capacity_kw) + weather_penalty = problem.signals.weather_risk * 0.5 + return demand_gap + weather_penalty + + +@dataclass +class FederatedResilienceSolver: + problems: list[NeighborhoodProblem] + district_energy_limit_kw: float + rho: float = 1.0 + max_iterations: int = 25 + tolerance: float = 1e-4 + delay_steps: int = 0 + + def solve(self) -> SolverResult: + if not self.problems: + raise ValueError("at least one neighborhood problem is required") + + targets = [_local_target(problem) for problem in self.problems] + local = [0.0 for _ in self.problems] + consensus = [0.0 for _ in self.problems] + duals = [problem.dual.value for problem in self.problems] + history: list[dict[str, Any]] = [] + + for iteration in range(1, self.max_iterations + 1): + stale_index = max(0, iteration - 1 - self.delay_steps) + stale_consensus = history[stale_index]["consensus"] if history and stale_index < len(history) else consensus + previous_consensus = consensus + + for index, problem in enumerate(self.problems): + weight = max(1.0, problem.local_model.objective_weights.get("service_disruption", 1.0)) + numerator = weight * targets[index] + self.rho * (stale_consensus[index] - duals[index]) + denominator = weight + self.rho + upper_bound = max(problem.local_model.energy_limit_kw, 0.0) or max(targets[index], 1.0) + local[index] = min(upper_bound, max(0.0, numerator / denominator)) + + consensus = _project_nonnegative_simplex([value + dual for value, dual in zip(local, duals)], self.district_energy_limit_kw) + for index in range(len(duals)): + duals[index] += local[index] - consensus[index] + + primal_residual = _norm([local_value - consensus_value for local_value, consensus_value in zip(local, consensus)]) + dual_residual = _norm([current - previous for current, previous in zip(consensus, previous_consensus)]) if history else _norm(consensus) + objective_value = sum(0.5 * (value - target) ** 2 for value, target in zip(consensus, targets)) + history.append( + { + "iteration": iteration, + "local": list(local), + "consensus": list(consensus), + "duals": list(duals), + "primal_residual": primal_residual, + "dual_residual": dual_residual, + "objective_value": objective_value, + } + ) + if primal_residual <= self.tolerance and dual_residual <= self.tolerance: + break + + deltas = [] + for index, problem in enumerate(self.problems): + action = consensus[index] + deltas.append( + PlanDelta( + neighborhood_id=problem.local_model.neighborhood_id, + iteration=len(history), + actions={"load_shift_kw": round(action, 6), "energy_budget_kw": round(max(0.0, problem.local_model.energy_limit_kw - action), 6)}, + notes="Consensus plan from federated solver", + affected_assets=[asset.asset_id for asset in problem.local_model.assets], + ) + ) + + return SolverResult( + iterations=len(history), + primal_residual=history[-1]["primal_residual"], + dual_residual=history[-1]["dual_residual"], + objective_value=history[-1]["objective_value"], + deltas=deltas, + history=history, + ) diff --git a/src/idea154_civicmesh_studio_federated/sync.py b/src/idea154_civicmesh_studio_federated/sync.py new file mode 100644 index 0000000..1402c33 --- /dev/null +++ b/src/idea154_civicmesh_studio_federated/sync.py @@ -0,0 +1,82 @@ +"""Deterministic delta sync with replay and auditability.""" + +from __future__ import annotations + +from hashlib import sha256 +import json +from typing import Any, Callable + +from pydantic import BaseModel, ConfigDict + +from .contracts import PlanDelta + + +class SyncRecord(BaseModel): + model_config = ConfigDict(frozen=True) + + sequence: int + payload_type: str + payload: dict[str, Any] + payload_hash: str + previous_hash: str + record_hash: str + + +def _canonical_payload(payload: dict[str, Any]) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +class DeltaSyncLog: + def __init__(self) -> None: + self._records: list[SyncRecord] = [] + + def append(self, delta: PlanDelta) -> SyncRecord: + payload = delta.model_dump(mode="json") + payload_hash = sha256(_canonical_payload(payload).encode("utf-8")).hexdigest() + previous_hash = self._records[-1].record_hash if self._records else "0" * 64 + sequence = len(self._records) + 1 + record_hash = sha256(f"{sequence}:{payload_hash}:{previous_hash}".encode("utf-8")).hexdigest() + record = SyncRecord( + sequence=sequence, + payload_type=delta.__class__.__name__, + payload=payload, + payload_hash=payload_hash, + previous_hash=previous_hash, + record_hash=record_hash, + ) + self._records.append(record) + return record + + def export_since(self, sequence: int) -> list[SyncRecord]: + return [record for record in self._records if record.sequence > sequence] + + def verify(self) -> bool: + previous_hash = "0" * 64 + for expected_sequence, record in enumerate(self._records, start=1): + if record.sequence != expected_sequence: + return False + payload_hash = sha256(_canonical_payload(record.payload).encode("utf-8")).hexdigest() + if payload_hash != record.payload_hash: + return False + if previous_hash != record.previous_hash: + return False + candidate_hash = sha256(f"{record.sequence}:{record.payload_hash}:{record.previous_hash}".encode("utf-8")).hexdigest() + if candidate_hash != record.record_hash: + return False + previous_hash = record.record_hash + return True + + def replay(self, applier: Callable[[PlanDelta], Any] | None = None) -> list[PlanDelta]: + if not self.verify(): + raise ValueError("sync log failed verification") + replayed: list[PlanDelta] = [] + for record in self._records: + delta = PlanDelta.model_validate(record.payload) + replayed.append(delta) + if applier is not None: + applier(delta) + return replayed + + def snapshot(self) -> list[SyncRecord]: + return list(self._records) + diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..e4d8371 --- /dev/null +++ b/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m pip install -e ".[test]" build +python3 -m pytest +python3 -m build diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..8c980b8 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,27 @@ +from idea154_civicmesh_studio_federated.contracts import Asset, LocalModel, PlanDelta, SharedSignals + + +def test_contract_hashes_are_stable(): + model = LocalModel( + neighborhood_id="north", + assets=[Asset(asset_id="light-1", asset_type="street-light", neighborhood_id="north", capacity_kw=1.2)], + energy_limit_kw=10.0, + ) + + same_model = LocalModel( + neighborhood_id="north", + assets=[Asset(asset_id="light-1", asset_type="street-light", neighborhood_id="north", capacity_kw=1.2)], + energy_limit_kw=10.0, + ) + + assert model.content_hash() == same_model.content_hash() + + +def test_plan_delta_serializes_canonically(): + delta = PlanDelta(neighborhood_id="south", iteration=2, actions={"pump_dispatch_m3": 4.5}, notes="ok") + assert '"pump_dispatch_m3":4.5' in delta.canonical_json() + + +def test_shared_signals_round_trip(): + signals = SharedSignals(neighborhood_id="east", source="weather", forecast_demand_kw=5.0, available_capacity_kw=3.0) + assert signals.model_dump()["neighborhood_id"] == "east" diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..4904c85 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,36 @@ +from idea154_civicmesh_studio_federated.registry import ( + AdapterContext, + GISLayerAdapter, + GraphOfContractsRegistry, + WaterPumpControllerAdapter, + WeatherForecastAdapter, +) + + +def test_registry_routes_adapters(): + registry = GraphOfContractsRegistry() + registry.register(GISLayerAdapter()) + registry.register(WeatherForecastAdapter()) + registry.register(WaterPumpControllerAdapter()) + + local_model = registry.route( + "gis-layer", + { + "energy_limit_kw": 12.0, + "features": [ + {"properties": {"id": "light-1", "asset_type": "street-light", "capacity_kw": 1.0}}, + {"properties": {"id": "pump-2", "asset_type": "pump", "capacity_m3": 3.5}}, + ], + }, + AdapterContext(neighborhood_id="n-1"), + ) + + assert local_model.neighborhood_id == "n-1" + assert len(local_model.assets) == 2 + + +def test_registry_compatibility_report_contains_versions(): + registry = GraphOfContractsRegistry() + registry.register(GISLayerAdapter()) + report = registry.compatibility_report() + assert report[0]["version"] == "1.0" diff --git a/tests/test_sync_and_solver.py b/tests/test_sync_and_solver.py new file mode 100644 index 0000000..23c90b4 --- /dev/null +++ b/tests/test_sync_and_solver.py @@ -0,0 +1,53 @@ +from idea154_civicmesh_studio_federated.contracts import Asset, DualVariable, LocalModel, PlanDelta, SharedSignals +from idea154_civicmesh_studio_federated.solver import FederatedResilienceSolver, NeighborhoodProblem +from idea154_civicmesh_studio_federated.sync import DeltaSyncLog + + +def _problem(neighborhood_id: str, demand: float, capacity: float, energy_limit: float) -> NeighborhoodProblem: + return NeighborhoodProblem( + local_model=LocalModel( + neighborhood_id=neighborhood_id, + assets=[Asset(asset_id=f"{neighborhood_id}-asset", asset_type="microgrid", neighborhood_id=neighborhood_id, capacity_kw=capacity)], + energy_limit_kw=energy_limit, + ), + signals=SharedSignals( + neighborhood_id=neighborhood_id, + source="weather", + forecast_demand_kw=demand, + available_capacity_kw=capacity, + weather_risk=2.0, + ), + dual=DualVariable(neighborhood_id=neighborhood_id), + ) + + +def test_delta_sync_replays_deterministically(): + log = DeltaSyncLog() + first = log.append(PlanDelta(neighborhood_id="a", iteration=1, actions={"load_shift_kw": 2.5}, notes="alpha")) + second = log.append(PlanDelta(neighborhood_id="b", iteration=1, actions={"load_shift_kw": 1.5}, notes="beta")) + assert first.sequence == 1 + assert second.sequence == 2 + assert log.verify() + + replayed = log.replay() + assert len(replayed) == 2 + assert replayed[0].neighborhood_id == "a" + + +def test_solver_converges_under_district_limit(): + solver = FederatedResilienceSolver( + problems=[ + _problem("north", 8.0, 2.0, 6.0), + _problem("south", 7.0, 1.0, 5.0), + ], + district_energy_limit_kw=6.0, + max_iterations=50, + tolerance=1e-5, + delay_steps=1, + ) + + result = solver.solve() + + assert result.iterations >= 1 + assert sum(delta.actions["load_shift_kw"] for delta in result.deltas) <= 6.000001 + assert result.primal_residual < 1.0