build(agent): c3po#b883b4 iteration
This commit is contained in:
parent
a87998d1e8
commit
a155f6a0ce
|
|
@ -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
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Architecture
|
||||
|
||||
- Python 3.11 package under `src/idea170_agrimesh_federated_privacy`.
|
||||
- SQLite-backed governance ledger for farms, adapters, contracts, deltas, and sync state.
|
||||
- Pydantic models validate local problems, shared signals, plan deltas, and adapter contracts.
|
||||
- `solver.py` contains the ADMM-lite allocator for cross-farm irrigation planning.
|
||||
- `canonical.py` maps AgriMesh primitives into a CatOpt-style intermediate representation.
|
||||
- `demo.py` assembles a drought scenario for 2 to 3 farms.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Standard library: `sqlite3`, `json`, `hashlib`, `hmac`, `math`, `random`, `dataclasses`, `pathlib`.
|
||||
- External dependency: `pydantic` for strict data validation.
|
||||
- Tests: `pytest`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep changes minimal and deterministic.
|
||||
- Preserve SQLite schema compatibility unless a migration is explicitly added.
|
||||
- Any new public data model should have validation and at least one test.
|
||||
- Preserve canonical JSON ordering when hashing or signing payloads.
|
||||
- Do not bypass the solver or ledger abstractions with ad hoc state.
|
||||
|
||||
## Testing
|
||||
|
||||
- `bash test.sh`
|
||||
- `pytest -q`
|
||||
- `python3 -m build`
|
||||
47
README.md
47
README.md
|
|
@ -1,3 +1,46 @@
|
|||
# idea170-agrimesh-federated-privacy
|
||||
# AgriMesh Federated Privacy
|
||||
|
||||
Source logic for Idea #170
|
||||
AgriMesh is a Python package for privacy-preserving coordination across neighboring farms. It models local irrigation and resource constraints, exchanges aggregated signals, records tamper-evident plan deltas in SQLite, and produces coordinated irrigation plans for drought conditions.
|
||||
|
||||
## What It Includes
|
||||
|
||||
- Pydantic models for farms, local problems, shared signals, regional constraints, adapters, and plan deltas.
|
||||
- A SQLite governance ledger for farms, contracts, adapters, events, and offline delta sync.
|
||||
- An ADMM-lite irrigation allocator that respects farm budgets and regional water and energy caps.
|
||||
- A canonical mapping layer that converts AgriMesh inputs into a CatOpt-style intermediate representation.
|
||||
- A drought demo for 2 to 3 farms.
|
||||
|
||||
## Package Layout
|
||||
|
||||
- `src/idea170_agrimesh_federated_privacy/models.py` - validated data models.
|
||||
- `src/idea170_agrimesh_federated_privacy/ledger.py` - SQLite governance ledger.
|
||||
- `src/idea170_agrimesh_federated_privacy/solver.py` - cross-farm allocator.
|
||||
- `src/idea170_agrimesh_federated_privacy/canonical.py` - CatOpt mapping.
|
||||
- `src/idea170_agrimesh_federated_privacy/demo.py` - drought scenario builder.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from idea170_agrimesh_federated_privacy import build_drought_demo
|
||||
|
||||
result = build_drought_demo()
|
||||
for delta in result.deltas:
|
||||
print(delta.farm_id, delta.irrigation_liters)
|
||||
```
|
||||
|
||||
```bash
|
||||
bash test.sh
|
||||
python3 -m idea170_agrimesh_federated_privacy
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The repository is wired for:
|
||||
|
||||
- `bash test.sh`
|
||||
- `pytest -q`
|
||||
- `python3 -m build`
|
||||
|
||||
## Packaging
|
||||
|
||||
This project is published as `idea170-agrimesh-federated-privacy` and uses `README.md` as its package description source.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "idea170-agrimesh-federated-privacy"
|
||||
version = "0.1.0"
|
||||
description = "AgriMesh federated, privacy-preserving cross-farm resource optimization platform"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pydantic>=2.7,<3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8,<9"]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""AgriMesh federated optimization primitives."""
|
||||
|
||||
from .canonical import AgriMeshCanonicalMapper, CatOptProblem
|
||||
from .demo import build_drought_demo
|
||||
from .ledger import GovernanceLedger
|
||||
from .models import (
|
||||
AdapterSpec,
|
||||
FarmIdentity,
|
||||
LocalProblem,
|
||||
PlanDelta,
|
||||
RegionalConstraint,
|
||||
SharedSignals,
|
||||
)
|
||||
from .solver import solve_cross_farm_plan
|
||||
|
||||
__all__ = [
|
||||
"AgriMeshCanonicalMapper",
|
||||
"AdapterSpec",
|
||||
"CatOptProblem",
|
||||
"FarmIdentity",
|
||||
"GovernanceLedger",
|
||||
"LocalProblem",
|
||||
"PlanDelta",
|
||||
"RegionalConstraint",
|
||||
"SharedSignals",
|
||||
"build_drought_demo",
|
||||
"solve_cross_farm_plan",
|
||||
]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from .demo import build_drought_demo
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = build_drought_demo()
|
||||
for delta in result.deltas:
|
||||
print(delta.model_dump_json())
|
||||
print(f"total_irrigation_liters={result.total_irrigation_liters}")
|
||||
print(f"total_energy_kwh={result.total_energy_kwh}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .models import CatOptConstraint, CatOptProblem, CatOptVariable, LocalProblem, PlanDelta, RegionalConstraint
|
||||
|
||||
|
||||
class AgriMeshCanonicalMapper:
|
||||
def to_catopt(self, local_problem: LocalProblem, regional_constraint: RegionalConstraint) -> CatOptProblem:
|
||||
need = max(local_problem.target_soil_moisture_pct - local_problem.current_soil_moisture_pct, 0.0)
|
||||
return CatOptProblem(
|
||||
problem_id=f"{local_problem.farm_id}:{regional_constraint.regional_water_quota_id}",
|
||||
variables=[
|
||||
CatOptVariable(
|
||||
name="irrigation_liters",
|
||||
lower=0.0,
|
||||
upper=local_problem.irrigation_budget_liters,
|
||||
target=min(local_problem.irrigation_budget_liters, need * local_problem.plot_area_hectares * 10.0),
|
||||
)
|
||||
],
|
||||
constraints=[
|
||||
CatOptConstraint(
|
||||
name="water_quota",
|
||||
expression=f"irrigation_liters <= {regional_constraint.water_quota_liters}",
|
||||
),
|
||||
CatOptConstraint(
|
||||
name="energy_cap",
|
||||
expression=f"energy_kwh <= {regional_constraint.energy_cap_kwh}",
|
||||
),
|
||||
],
|
||||
objective="minimize squared deviation from target soil moisture",
|
||||
metadata={
|
||||
"farm_id": local_problem.farm_id,
|
||||
"drought_target_moisture_pct": regional_constraint.drought_target_moisture_pct,
|
||||
"plot_area_hectares": local_problem.plot_area_hectares,
|
||||
},
|
||||
)
|
||||
|
||||
def from_plan_delta(self, delta: PlanDelta) -> dict[str, float | str]:
|
||||
return {
|
||||
"farm_id": delta.farm_id,
|
||||
"irrigation_liters": delta.irrigation_liters,
|
||||
"fertilizer_kg": delta.fertilizer_kg,
|
||||
"energy_kwh": delta.energy_kwh,
|
||||
"tag": delta.tag,
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .models import LocalProblem, RegionalConstraint, SharedSignals
|
||||
from .solver import SolveResult, solve_cross_farm_plan
|
||||
|
||||
|
||||
def build_drought_demo() -> SolveResult:
|
||||
farms = [
|
||||
LocalProblem(
|
||||
farm_id="farm-a",
|
||||
current_soil_moisture_pct=18.0,
|
||||
target_soil_moisture_pct=31.0,
|
||||
irrigation_budget_liters=3200.0,
|
||||
energy_budget_kwh=90.0,
|
||||
plot_area_hectares=18.0,
|
||||
crop_priority=1.0,
|
||||
fertilizer_cap_kg=30.0,
|
||||
),
|
||||
LocalProblem(
|
||||
farm_id="farm-b",
|
||||
current_soil_moisture_pct=22.0,
|
||||
target_soil_moisture_pct=30.0,
|
||||
irrigation_budget_liters=2800.0,
|
||||
energy_budget_kwh=80.0,
|
||||
plot_area_hectares=14.0,
|
||||
crop_priority=0.9,
|
||||
fertilizer_cap_kg=25.0,
|
||||
),
|
||||
LocalProblem(
|
||||
farm_id="farm-c",
|
||||
current_soil_moisture_pct=26.0,
|
||||
target_soil_moisture_pct=32.0,
|
||||
irrigation_budget_liters=2400.0,
|
||||
energy_budget_kwh=75.0,
|
||||
plot_area_hectares=12.0,
|
||||
crop_priority=0.8,
|
||||
fertilizer_cap_kg=20.0,
|
||||
),
|
||||
]
|
||||
regional = RegionalConstraint(water_quota_liters=5200.0, drought_target_moisture_pct=30.0, energy_cap_kwh=170.0)
|
||||
signals = SharedSignals(mean_soil_moisture_pct=22.0, drought_pressure=0.78, forecast_rain_mm=1.5, local_dp_epsilon=1.0)
|
||||
return solve_cross_farm_plan(farms, regional, signals)
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .models import AdapterSpec, FarmIdentity, GovernanceEvent, PlanDelta
|
||||
from .utils import canonical_json, sha256_hex
|
||||
|
||||
|
||||
class GovernanceLedger:
|
||||
def __init__(self, path: str | Path = ":memory:") -> None:
|
||||
self.path = str(path)
|
||||
self._conn = sqlite3.connect(self.path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._ensure_schema()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
self._conn.executescript(
|
||||
"""
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS farms (
|
||||
farm_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS adapters (
|
||||
adapter_id TEXT PRIMARY KEY,
|
||||
farm_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(farm_id) REFERENCES farms(farm_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS plan_deltas (
|
||||
delta_id TEXT PRIMARY KEY,
|
||||
farm_id TEXT NOT NULL,
|
||||
step_index INTEGER NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
previous_tag TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(farm_id) REFERENCES farms(farm_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
delta_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
FOREIGN KEY(delta_id) REFERENCES plan_deltas(delta_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contracts (
|
||||
contract_id TEXT PRIMARY KEY,
|
||||
farm_id TEXT NOT NULL,
|
||||
adapter_id TEXT NOT NULL,
|
||||
contract_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(farm_id) REFERENCES farms(farm_id),
|
||||
FOREIGN KEY(adapter_id) REFERENCES adapters(adapter_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def register_farm(self, identity: FarmIdentity) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO farms (farm_id, name, public_key, created_at) VALUES (?, ?, ?, ?)",
|
||||
(identity.farm_id, identity.name, identity.public_key, identity.created_at.isoformat()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def upsert_adapter(self, adapter: AdapterSpec) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO adapters
|
||||
(adapter_id, farm_id, name, kind, endpoint, capabilities_json, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
adapter.adapter_id,
|
||||
adapter.farm_id,
|
||||
adapter.name,
|
||||
adapter.kind,
|
||||
adapter.endpoint,
|
||||
canonical_json(adapter.capabilities),
|
||||
canonical_json(adapter.metadata),
|
||||
adapter.created_at.isoformat(),
|
||||
adapter.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def record_event(self, event: GovernanceEvent) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO events (event_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?)",
|
||||
(str(event.event_id), event.event_type, canonical_json(event.payload), event.created_at.isoformat()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def record_plan_delta(self, delta: PlanDelta, secret: str) -> PlanDelta:
|
||||
payload = delta.model_dump(mode="json", exclude={"tag"})
|
||||
tag = delta.tag or self._compute_tag(delta.previous_tag, payload, secret)
|
||||
tagged = delta.model_copy(update={"tag": tag})
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO plan_deltas
|
||||
(delta_id, farm_id, step_index, payload_json, previous_tag, tag, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(tagged.delta_id),
|
||||
tagged.farm_id,
|
||||
tagged.step_index,
|
||||
canonical_json(tagged.model_dump(mode="json")),
|
||||
tagged.previous_tag,
|
||||
tagged.tag,
|
||||
tagged.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO sync_outbox (delta_id, payload_json, created_at) VALUES (?, ?, ?)",
|
||||
(str(tagged.delta_id), canonical_json(tagged.model_dump(mode="json")), tagged.created_at.isoformat()),
|
||||
)
|
||||
self._conn.commit()
|
||||
return tagged
|
||||
|
||||
def queue_delta(self, delta: PlanDelta) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT INTO sync_outbox (delta_id, payload_json, created_at) VALUES (?, ?, ?)",
|
||||
(str(delta.delta_id), canonical_json(delta.model_dump(mode="json")), delta.created_at.isoformat()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def pending_deltas(self) -> list[dict[str, Any]]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT delta_id, payload_json, created_at FROM sync_outbox WHERE delivered_at IS NULL ORDER BY id ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def mark_delivered(self, delta_id: str) -> None:
|
||||
self._conn.execute(
|
||||
"UPDATE sync_outbox SET delivered_at = CURRENT_TIMESTAMP WHERE delta_id = ? AND delivered_at IS NULL",
|
||||
(delta_id,),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def record_contract(self, farm_id: str, adapter_id: str, contract_type: str, payload: dict[str, Any]) -> str:
|
||||
contract_id = sha256_hex({"farm_id": farm_id, "adapter_id": adapter_id, "type": contract_type, "payload": payload})
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO contracts
|
||||
(contract_id, farm_id, adapter_id, contract_type, payload_json, content_hash, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(contract_id, farm_id, adapter_id, contract_type, canonical_json(payload), contract_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
return contract_id
|
||||
|
||||
def list_contracts(self) -> list[dict[str, Any]]:
|
||||
rows = self._conn.execute("SELECT * FROM contracts ORDER BY created_at ASC").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def chain_is_valid(self, farm_id: str, secret: str) -> bool:
|
||||
rows = self._conn.execute(
|
||||
"SELECT payload_json, previous_tag, tag FROM plan_deltas WHERE farm_id = ? ORDER BY created_at ASC", (farm_id,)
|
||||
).fetchall()
|
||||
previous = ""
|
||||
for row in rows:
|
||||
payload = dict(__import__("json").loads(row["payload_json"]))
|
||||
payload.pop("tag", None)
|
||||
expected = self._compute_tag(previous, payload, secret)
|
||||
if row["previous_tag"] != previous or row["tag"] != expected:
|
||||
return False
|
||||
previous = row["tag"]
|
||||
return True
|
||||
|
||||
def snapshot(self) -> dict[str, int]:
|
||||
tables = ["farms", "adapters", "events", "plan_deltas", "sync_outbox", "contracts"]
|
||||
return {name: int(self._conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0]) for name in tables}
|
||||
|
||||
@staticmethod
|
||||
def _compute_tag(previous_tag: str, payload: dict[str, Any], secret: str) -> str:
|
||||
return sha256_hex({"previous_tag": previous_tag, "payload": payload, "secret": secret})
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FarmIdentity(BaseModel):
|
||||
farm_id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
public_key: str = Field(min_length=8)
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class LocalProblem(BaseModel):
|
||||
farm_id: str = Field(min_length=1)
|
||||
current_soil_moisture_pct: float = Field(ge=0.0, le=100.0)
|
||||
target_soil_moisture_pct: float = Field(ge=0.0, le=100.0)
|
||||
irrigation_budget_liters: float = Field(gt=0.0)
|
||||
energy_budget_kwh: float = Field(ge=0.0)
|
||||
plot_area_hectares: float = Field(gt=0.0)
|
||||
crop_priority: float = Field(default=1.0, ge=0.0)
|
||||
fertilizer_cap_kg: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
@field_validator("target_soil_moisture_pct")
|
||||
@classmethod
|
||||
def validate_target(cls, value: float, info):
|
||||
current = info.data.get("current_soil_moisture_pct")
|
||||
if current is not None and value < current * 0.5:
|
||||
raise ValueError("target moisture is implausibly low relative to current moisture")
|
||||
return value
|
||||
|
||||
|
||||
class SharedSignals(BaseModel):
|
||||
timestamp: datetime = Field(default_factory=utc_now)
|
||||
mean_soil_moisture_pct: float = Field(ge=0.0, le=100.0)
|
||||
drought_pressure: float = Field(ge=0.0, le=1.0)
|
||||
forecast_rain_mm: float = Field(ge=0.0)
|
||||
secure_aggregation_version: Literal["v1"] = "v1"
|
||||
local_dp_epsilon: float | None = Field(default=None, gt=0.0)
|
||||
|
||||
|
||||
class RegionalConstraint(BaseModel):
|
||||
water_quota_liters: float = Field(gt=0.0)
|
||||
drought_target_moisture_pct: float = Field(ge=0.0, le=100.0)
|
||||
energy_cap_kwh: float = Field(ge=0.0)
|
||||
regional_water_quota_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class PlanDelta(BaseModel):
|
||||
delta_id: UUID = Field(default_factory=uuid4)
|
||||
farm_id: str = Field(min_length=1)
|
||||
step_index: int = Field(ge=0)
|
||||
irrigation_liters: float = Field(ge=0.0)
|
||||
fertilizer_kg: float = Field(ge=0.0)
|
||||
energy_kwh: float = Field(ge=0.0)
|
||||
rationale: str = Field(min_length=1)
|
||||
previous_tag: str = Field(default="")
|
||||
tag: str = Field(default="")
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class AdapterSpec(BaseModel):
|
||||
adapter_id: str = Field(min_length=1)
|
||||
farm_id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
kind: Literal["irrigation_controller", "soil_sensor_gateway", "weather_feed", "farm_management_system"]
|
||||
endpoint: str = Field(min_length=1)
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
updated_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class GovernanceEvent(BaseModel):
|
||||
event_id: UUID = Field(default_factory=uuid4)
|
||||
event_type: str = Field(min_length=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class CatOptVariable(BaseModel):
|
||||
name: str
|
||||
lower: float
|
||||
upper: float
|
||||
target: float
|
||||
|
||||
|
||||
class CatOptConstraint(BaseModel):
|
||||
name: str
|
||||
expression: str
|
||||
|
||||
|
||||
class CatOptProblem(BaseModel):
|
||||
problem_id: str
|
||||
variables: list[CatOptVariable]
|
||||
constraints: list[CatOptConstraint]
|
||||
objective: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
from .models import LocalProblem, PlanDelta, RegionalConstraint, SharedSignals
|
||||
from .utils import clamp, laplace_noise
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolveResult:
|
||||
deltas: list[PlanDelta]
|
||||
total_irrigation_liters: float
|
||||
total_energy_kwh: float
|
||||
|
||||
|
||||
def _project_capped_simplex(values: Sequence[float], quota: float, caps: Sequence[float]) -> list[float]:
|
||||
clipped = [clamp(v, 0.0, cap) for v, cap in zip(values, caps, strict=True)]
|
||||
total = sum(clipped)
|
||||
if total <= quota:
|
||||
return clipped
|
||||
|
||||
lo = min(v - cap for v, cap in zip(clipped, caps, strict=True)) - quota
|
||||
hi = max(clipped)
|
||||
for _ in range(80):
|
||||
mid = (lo + hi) / 2.0
|
||||
projected = [clamp(v - mid, 0.0, cap) for v, cap in zip(clipped, caps, strict=True)]
|
||||
current = sum(projected)
|
||||
if current > quota:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return [clamp(v - hi, 0.0, cap) for v, cap in zip(clipped, caps, strict=True)]
|
||||
|
||||
|
||||
def solve_cross_farm_plan(
|
||||
local_problems: Sequence[LocalProblem],
|
||||
regional_constraint: RegionalConstraint,
|
||||
shared_signals: SharedSignals,
|
||||
*,
|
||||
farm_secret: str = "agrimesh-secret",
|
||||
iterations: int = 12,
|
||||
) -> SolveResult:
|
||||
if not local_problems:
|
||||
return SolveResult([], 0.0, 0.0)
|
||||
|
||||
needs = []
|
||||
caps = []
|
||||
for problem in local_problems:
|
||||
moisture_gap = max(problem.target_soil_moisture_pct - problem.current_soil_moisture_pct, 0.0)
|
||||
weather_adjustment = max(0.2, 1.0 - shared_signals.forecast_rain_mm / 50.0)
|
||||
drought_adjustment = 1.0 + shared_signals.drought_pressure * 0.35
|
||||
need = moisture_gap * problem.plot_area_hectares * 10.0 * weather_adjustment * drought_adjustment
|
||||
needs.append(need)
|
||||
caps.append(min(problem.irrigation_budget_liters, regional_constraint.water_quota_liters))
|
||||
|
||||
rho = 1.2
|
||||
x = [0.0 for _ in local_problems]
|
||||
z = [0.0 for _ in local_problems]
|
||||
u = [0.0 for _ in local_problems]
|
||||
|
||||
for _ in range(iterations):
|
||||
x = [clamp((need + rho * (zi - ui)) / (1.0 + rho), 0.0, cap) for need, zi, ui, cap in zip(needs, z, u, caps, strict=True)]
|
||||
z = _project_capped_simplex([xi + ui for xi, ui in zip(x, u, strict=True)], regional_constraint.water_quota_liters, caps)
|
||||
u = [ui + xi - zi for xi, zi, ui in zip(x, z, u, strict=True)]
|
||||
|
||||
total_irrigation = sum(z)
|
||||
if shared_signals.local_dp_epsilon is not None:
|
||||
total_irrigation = max(0.0, total_irrigation + laplace_noise(scale=1.0 / shared_signals.local_dp_epsilon, seed=7))
|
||||
|
||||
deltas: list[PlanDelta] = []
|
||||
total_energy = 0.0
|
||||
for index, (problem, irrigation) in enumerate(zip(local_problems, z, strict=True)):
|
||||
fertilizer = min(problem.fertilizer_cap_kg, irrigation / 200.0)
|
||||
energy = min(problem.energy_budget_kwh, irrigation * 0.02)
|
||||
total_energy += energy
|
||||
deltas.append(
|
||||
PlanDelta(
|
||||
farm_id=problem.farm_id,
|
||||
step_index=index,
|
||||
irrigation_liters=round(irrigation, 3),
|
||||
fertilizer_kg=round(fertilizer, 3),
|
||||
energy_kwh=round(energy, 3),
|
||||
rationale=f"Consensus allocation under water quota {regional_constraint.water_quota_liters:.1f}",
|
||||
)
|
||||
)
|
||||
|
||||
if total_energy > regional_constraint.energy_cap_kwh and total_energy > 0:
|
||||
scale = regional_constraint.energy_cap_kwh / total_energy
|
||||
deltas = [
|
||||
delta.model_copy(
|
||||
update={
|
||||
"irrigation_liters": round(delta.irrigation_liters * scale, 3),
|
||||
"fertilizer_kg": round(delta.fertilizer_kg * scale, 3),
|
||||
"energy_kwh": round(delta.energy_kwh * scale, 3),
|
||||
}
|
||||
)
|
||||
for delta in deltas
|
||||
]
|
||||
total_energy = regional_constraint.energy_cap_kwh
|
||||
total_irrigation = sum(delta.irrigation_liters for delta in deltas)
|
||||
|
||||
return SolveResult(deltas=deltas, total_irrigation_liters=round(total_irrigation, 3), total_energy_kwh=round(total_energy, 3))
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def sha256_hex(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def hmac_hex(secret: str, value: Any) -> str:
|
||||
return hmac.new(secret.encode("utf-8"), canonical_json(value).encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def laplace_noise(scale: float, seed: int | None = None) -> float:
|
||||
if scale <= 0:
|
||||
return 0.0
|
||||
rng = random.Random(seed)
|
||||
u = rng.random() - 0.5
|
||||
return -scale * math.copysign(math.log1p(-2 * abs(u)), u)
|
||||
|
||||
|
||||
def clamp(value: float, lower: float, upper: float) -> float:
|
||||
return min(max(value, lower), upper)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
python3 -m pip install --upgrade pip >/dev/null
|
||||
python3 -m pip install -e .[test] build >/dev/null
|
||||
pytest -q
|
||||
python3 -m build
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from idea170_agrimesh_federated_privacy import (
|
||||
AgriMeshCanonicalMapper,
|
||||
AdapterSpec,
|
||||
FarmIdentity,
|
||||
GovernanceLedger,
|
||||
LocalProblem,
|
||||
RegionalConstraint,
|
||||
SharedSignals,
|
||||
build_drought_demo,
|
||||
solve_cross_farm_plan,
|
||||
)
|
||||
|
||||
|
||||
def test_solver_respects_quota_and_energy_caps() -> None:
|
||||
farms = [
|
||||
LocalProblem(
|
||||
farm_id="farm-1",
|
||||
current_soil_moisture_pct=15.0,
|
||||
target_soil_moisture_pct=30.0,
|
||||
irrigation_budget_liters=3000.0,
|
||||
energy_budget_kwh=50.0,
|
||||
plot_area_hectares=10.0,
|
||||
fertilizer_cap_kg=10.0,
|
||||
),
|
||||
LocalProblem(
|
||||
farm_id="farm-2",
|
||||
current_soil_moisture_pct=20.0,
|
||||
target_soil_moisture_pct=29.0,
|
||||
irrigation_budget_liters=1800.0,
|
||||
energy_budget_kwh=45.0,
|
||||
plot_area_hectares=8.0,
|
||||
fertilizer_cap_kg=9.0,
|
||||
),
|
||||
]
|
||||
result = solve_cross_farm_plan(
|
||||
farms,
|
||||
RegionalConstraint(water_quota_liters=2500.0, drought_target_moisture_pct=28.0, energy_cap_kwh=40.0),
|
||||
SharedSignals(mean_soil_moisture_pct=18.0, drought_pressure=0.9, forecast_rain_mm=0.0),
|
||||
)
|
||||
|
||||
assert len(result.deltas) == 2
|
||||
assert result.total_irrigation_liters <= 2500.0 + 1e-6
|
||||
assert result.total_energy_kwh <= 40.0 + 1e-6
|
||||
assert all(delta.irrigation_liters <= farm.irrigation_budget_liters for delta, farm in zip(result.deltas, farms, strict=True))
|
||||
|
||||
|
||||
def test_ledger_persists_and_validates_chain(tmp_path: Path) -> None:
|
||||
ledger = GovernanceLedger(tmp_path / "ledger.sqlite")
|
||||
identity = FarmIdentity(farm_id="farm-a", name="Alpha Farm", public_key="public-key-123")
|
||||
ledger.register_farm(identity)
|
||||
|
||||
adapter = AdapterSpec(
|
||||
adapter_id="adapter-1",
|
||||
farm_id="farm-a",
|
||||
name="Drip Controller",
|
||||
kind="irrigation_controller",
|
||||
endpoint="mqtt://controller.local",
|
||||
capabilities=["set_flow_rate"],
|
||||
)
|
||||
ledger.upsert_adapter(adapter)
|
||||
ledger.record_contract("farm-a", "adapter-1", "controller-binding", {"protocol": "mqtt", "topic": "irrigation/set"})
|
||||
|
||||
delta = ledger.record_plan_delta(
|
||||
build_drought_demo().deltas[0].model_copy(update={"farm_id": "farm-a", "previous_tag": ""}),
|
||||
secret="shared-secret",
|
||||
)
|
||||
|
||||
assert ledger.snapshot()["farms"] == 1
|
||||
assert ledger.snapshot()["adapters"] == 1
|
||||
assert ledger.snapshot()["contracts"] == 1
|
||||
assert ledger.snapshot()["plan_deltas"] == 1
|
||||
assert ledger.chain_is_valid("farm-a", secret="shared-secret")
|
||||
assert delta.tag
|
||||
ledger.close()
|
||||
|
||||
|
||||
def test_canonical_mapper_and_demo_are_available() -> None:
|
||||
mapper = AgriMeshCanonicalMapper()
|
||||
local = LocalProblem(
|
||||
farm_id="farm-x",
|
||||
current_soil_moisture_pct=16.0,
|
||||
target_soil_moisture_pct=30.0,
|
||||
irrigation_budget_liters=1500.0,
|
||||
energy_budget_kwh=25.0,
|
||||
plot_area_hectares=7.0,
|
||||
)
|
||||
regional = RegionalConstraint(water_quota_liters=1200.0, drought_target_moisture_pct=29.0, energy_cap_kwh=20.0)
|
||||
problem = mapper.to_catopt(local, regional)
|
||||
|
||||
assert problem.variables[0].name == "irrigation_liters"
|
||||
assert problem.constraints[0].name == "water_quota"
|
||||
assert build_drought_demo().deltas
|
||||
Loading…
Reference in New Issue