demeter

Autonomous Hydroponic Intelligence
commit a40228adf42a56246cd5cd12d61df20f2b02f37d
parent d790a441f23359b1cc6fe0e0bba26854aa53edca
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date:   Sun,  1 Mar 2026 10:28:16 +0000

fetcher-historian done

Diffstat:
ASentinel/Encoders/__init__.py | 0
ASentinel/__init__.py | 0
Aagent/Qdrant/Client.py | 9+++++++++
Aagent/Qdrant/Search.py | 0
Aagent/Qdrant/Setup.py | 18++++++++++++++++++
Aagent/Qdrant/Store.py | 21+++++++++++++++++++++
Aagent/Sentinel/Encoders/TimeSeries.py | 25+++++++++++++++++++++++++
Aagent/Sentinel/Encoders/Vision.py | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aagent/Sentinel/Encoders/__init__.py | 0
Aagent/Sentinel/Sample.png | 0
Aagent/Sentinel/Test.py | 24++++++++++++++++++++++++
Aagent/Sentinel/__init__.py | 0
Aagent/Sentinel/agent.py | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aagent/Sentinel/fmu.py | 9+++++++++
Aagent/__init__.py | 0
Aagent/sub_agents/__init__.py | 0
Magent/sub_agents/fetching_agent.py | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Aagent/tools/__init__.py | 0
18 files changed, 473 insertions(+), 44 deletions(-)

diff --git a/Sentinel/Encoders/__init__.py b/Sentinel/Encoders/__init__.py diff --git a/Sentinel/__init__.py b/Sentinel/__init__.py diff --git a/agent/Qdrant/Client.py b/agent/Qdrant/Client.py @@ -0,0 +1,8 @@ +from qdrant_client import QdrantClient + +client = QdrantClient( + url="https://2a9e6ab0-e572-4bfa-a50f-0a169f9753d3.europe-west3-0.gcp.cloud.qdrant.io:6333", + api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.RG2XaX6thvBqI6TCtUrFg8znHYbMuFGOvbxoxPgT020", +) + +# print(qdrant_client.get_collections()) +\ No newline at end of file diff --git a/agent/Qdrant/Search.py b/agent/Qdrant/Search.py diff --git a/agent/Qdrant/Setup.py b/agent/Qdrant/Setup.py @@ -0,0 +1,17 @@ +from qdrant_client import models +from Qdrant.Client import client # <--- FIXED IMPORT + +VECTOR_SIZE = 516 +COLLECTION_NAME = "Farm_Memory" + +# client = QdrantClient(url="http://localhost:6333") + +client.recreate_collection( + collection_name=COLLECTION_NAME, + vectors_config=models.VectorParams( + size=VECTOR_SIZE, + distance=models.Distance.COSINE + ) +) + +print("Collection created:", COLLECTION_NAME) +\ No newline at end of file diff --git a/agent/Qdrant/Store.py b/agent/Qdrant/Store.py @@ -0,0 +1,20 @@ +from Qdrant.Client import client # <--- FIXED IMPORT +from qdrant_client.models import PointStruct + +COLLECTION_NAME = "Farm_Memory" + +# client = QdrantClient(url="http://localhost:6333") + +def store_fmu(fmu): + point = PointStruct( + id=fmu.id, + vector=fmu.vector, + payload=fmu.metadata + ) + + client.upsert( + collection_name=COLLECTION_NAME, + points=[point] + ) + + print("Stored FMU:", fmu.id) +\ No newline at end of file diff --git a/agent/Sentinel/Encoders/TimeSeries.py b/agent/Sentinel/Encoders/TimeSeries.py @@ -0,0 +1,24 @@ +import numpy as np + +class SensorEncoder: # <--- Renamed to match agent.py + def encode(self, sensor_data: dict) -> np.ndarray: + """ + Encodes dictionary of sensor values/windows into a flat vector. + """ + features = [] + # Sort keys to ensure vector consistency + for key in sorted(sensor_data.keys()): + val = sensor_data[key] + + if isinstance(val, list) and val: + # Handle window of data (Mean, Std, Last) + arr = np.array(val) + features.extend([float(np.mean(arr)), float(np.std(arr)), float(arr[-1])]) + elif isinstance(val, (int, float)): + # Handle single value + features.append(float(val)) + else: + # Fallback + features.append(0.0) + + return np.array(features, dtype=np.float32) +\ No newline at end of file diff --git a/agent/Sentinel/Encoders/Vision.py b/agent/Sentinel/Encoders/Vision.py @@ -0,0 +1,102 @@ +# encoders/clip_encoder.py +import torch +import clip +from PIL import Image +import io +import base64 +from pathlib import Path + +class VisionEncoder: + def __init__(self, model_name="ViT-B/32"): + self.device = "cuda" if torch.cuda.is_available() else "cpu" + self.model, self.preprocess = clip.load(model_name, device=self.device) + self.model.eval() + + def encode(self, image_input): + """ + Encode an image from multiple input types: + - File path (str or Path) + - Base64 string + - BytesIO object + - PIL Image object + + Args: + image_input: File path, base64 string, BytesIO, or PIL Image + + Returns: + numpy array: Normalized image embedding vector + """ + # Convert input to PIL Image + pil_image = self._to_pil_image(image_input) + + # Preprocess and encode + image = self.preprocess(pil_image.convert("RGB")) \ + .unsqueeze(0).to(self.device) + + with torch.no_grad(): + vec = self.model.encode_image(image) + vec = vec / vec.norm(dim=-1, keepdim=True) + + return vec.cpu().numpy().flatten() + + def _to_pil_image(self, image_input): + """ + Convert various input types to PIL Image. + """ + # If already a PIL Image + if isinstance(image_input, Image.Image): + return image_input + + # If BytesIO object + if isinstance(image_input, io.BytesIO): + image_input.seek(0) # Reset to beginning + return Image.open(image_input) + + # If it's a string, determine if it's a path or base64 + if isinstance(image_input, (str, Path)): + # Check if it's a file path + if isinstance(image_input, Path) or Path(image_input).exists(): + return Image.open(image_input) + + # Otherwise, treat as base64 + return self._base64_to_pil(image_input) + + # If bytes object + if isinstance(image_input, bytes): + return Image.open(io.BytesIO(image_input)) + + raise TypeError(f"Unsupported image input type: {type(image_input)}") + + def _base64_to_pil(self, base64_string): + """ + Convert base64 string to PIL Image. + """ + # Remove header if present (e.g., "data:image/png;base64,...") + if "," in base64_string: + base64_string = base64_string.split(",")[1] + + # Add padding if necessary + missing_padding = len(base64_string) % 4 + if missing_padding: + base64_string += '=' * (4 - missing_padding) + + # Decode and open + image_bytes = base64.b64decode(base64_string) + return Image.open(io.BytesIO(image_bytes)) + + +# Example usage: +if __name__ == "__main__": + encoder = VisionEncoder() + + # Test with file path + # vec1 = encoder.encode("path/to/image.jpg") + + # Test with base64 + sample_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" + vec2 = encoder.encode(sample_base64) + print(f"✅ Encoded base64 image. Vector shape: {vec2.shape}") + + # Test with BytesIO + # image_stream = io.BytesIO(image_bytes) + # vec3 = encoder.encode(image_stream) +\ No newline at end of file diff --git a/agent/Sentinel/Encoders/__init__.py b/agent/Sentinel/Encoders/__init__.py diff --git a/agent/Sentinel/Sample.png b/agent/Sentinel/Sample.png Binary files differ. diff --git a/agent/Sentinel/Test.py b/agent/Sentinel/Test.py @@ -0,0 +1,24 @@ +# sentinel/test_sentinel.py +from agent import SentinelAgent + +agent = SentinelAgent() + +sensor_window = { + "pH": [5.8, 5.9, 6.0], + "EC": [1.2, 1.3, 1.25], + "temp": [24, 25, 24.5], + "humidity": [70, 72, 71] +} + +metadata = { + "crop": "lettuce", + "stage": "vegetative", + "rack": "A3" +} + +fmu = agent.create_fmu("sample_plant.jpg", sensor_window, metadata) + +print("FMU ID:", fmu.id) +print("Vector length:", len(fmu.vector)) +print("Quality:", fmu.quality) +print("Metadata:", fmu.metadata) diff --git a/agent/Sentinel/__init__.py b/agent/Sentinel/__init__.py diff --git a/agent/Sentinel/agent.py b/agent/Sentinel/agent.py @@ -0,0 +1,131 @@ +import uuid +import base64 +import io +from datetime import datetime +import numpy as np +from pathlib import Path + +# Ensure these imports match your project structure +from Sentinel.Encoders.Vision import VisionEncoder +from Sentinel.Encoders.TimeSeries import SensorEncoder +from Sentinel.fmu import FMU +from Qdrant.Store import store_fmu + +class FMUBuilder: + def __init__(self): + self.vision = VisionEncoder() + self.sensors = SensorEncoder() + + def create_fmu(self, image_input, sensor_data, metadata=None): + """ + Creates an FMU from either: + - A file path (str/Path) + - A Base64 encoded image string + + Args: + image_input: Either a file path string or base64 string + sensor_data: Dictionary of sensor readings + metadata: Optional metadata dictionary + """ + + # Detect if input is base64 or file path + if self._is_base64(image_input): + # Handle Base64 input + img_vec = self._encode_from_base64(image_input) + else: + # Handle file path input (original behavior) + img_vec = self.vision.encode(image_input) + + # Encode sensor data + sensor_vec = self.sensors.encode(sensor_data) + + # Combine vectors + fmu_vector = np.concatenate([img_vec, sensor_vec]).tolist() + + return FMU( + id=str(uuid.uuid4()), + vector=fmu_vector, + metadata={ + **(metadata or {}), + "timestamp": datetime.utcnow().isoformat(), + } + ) + + def _is_base64(self, s): + """ + Detect if string is base64 or a file path. + Returns True if it looks like base64, False if it looks like a path. + """ + if not isinstance(s, str): + return False + + # If it has path separators, it's probably a path + if '/' in s or '\\' in s or Path(s).exists(): + return False + + # If it has base64 header, it's definitely base64 + if s.startswith('data:image'): + return True + + # Check if it's valid base64 (after removing potential header) + test_str = s.split(',')[-1] if ',' in s else s + + # Base64 strings are typically very long and only contain valid b64 chars + if len(test_str) > 100: # Arbitrary threshold + try: + base64.b64decode(test_str, validate=True) + return True + except Exception: + return False + + return False + + def _encode_from_base64(self, image_base64): + """ + Decode base64 string and encode the image. + """ + # Remove header if present (e.g., "data:image/png;base64,...") + if "," in image_base64: + image_base64 = image_base64.split(",")[1] + + # Add padding if necessary (fix the "multiple of 4" error) + missing_padding = len(image_base64) % 4 + if missing_padding: + image_base64 += '=' * (4 - missing_padding) + + # Decode to bytes + image_bytes = base64.b64decode(image_base64) + + # Create file-like object + image_stream = io.BytesIO(image_bytes) + + # Encode using VisionEncoder + # If VisionEncoder only accepts paths, you may need to update it + # to also accept BytesIO objects or PIL Images + return self.vision.encode(image_stream) + + +if __name__ == "__main__": + builder = FMUBuilder() + + sensors = { + "pH": 5.9, + "EC": 1.3, + "temp": 25.0, + "humidity": 72.0 + } + + # Test with base64 + sample_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" + + fmu = builder.create_fmu(sample_base64, sensors, { + "crop": "lettuce", + "stage": "vegetative" + }) + + print("✅ FMU ID:", fmu.id) + print("✅ Vector length:", len(fmu.vector)) + print("✅ Metadata:", fmu.metadata) + + # Test with file path + # fmu2 = builder.create_fmu("path/to/image.png", sensors, {"crop": "basil"}) +\ No newline at end of file diff --git a/agent/Sentinel/fmu.py b/agent/Sentinel/fmu.py @@ -0,0 +1,9 @@ +# fmu.py +from dataclasses import dataclass +from typing import Dict, Any, List + +@dataclass +class FMU: + id: str + vector: List[float] + metadata: Dict[str, Any] diff --git a/agent/__init__.py b/agent/__init__.py diff --git a/agent/sub_agents/__init__.py b/agent/sub_agents/__init__.py diff --git a/agent/sub_agents/fetching_agent.py b/agent/sub_agents/fetching_agent.py @@ -1,64 +1,152 @@ +import sys +import os import requests import json +from pathlib import Path + +# --- PATH FIX --- +# Ensures Python can find 'Sentinel' and 'Qdrant' folders +current_file = Path(__file__).resolve() +project_root = current_file.parent.parent +sys.path.append(str(project_root)) +# ---------------- + +from qdrant_client import models from Sentinel.agent import FMUBuilder -from tools.db_tools import DBTools # Assuming you have the DB tool from previous steps +from Qdrant.Store import store_fmu, COLLECTION_NAME +from Qdrant.Client import client + +# ========================================== +# 🕵️ HISTORIAN AGENT +# ========================================== +class HistorianAgent: + def __init__(self): + self.client = client # Use the shared Qdrant client + self.collection = COLLECTION_NAME + + def consult_history(self, fmu): + """ + Takes an FMU, extracts its metadata/vector, and searches + for similar past instances with the same Crop & Stage. + """ + target_crop = fmu.metadata.get("crop") + target_stage = fmu.metadata.get("stage") + + print(f"\n[Historian] 📜 Consulting archives for {target_crop} ({target_stage})...") + + # 1. Create Context Filter + context_filter = models.Filter( + must=[ + models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)), + models.FieldCondition(key="stage", match=models.MatchValue(value=target_stage)) + ] + ) + + # 2. Search Qdrant + try: + # We use the vector from the FMU directly + results = self.client.search( + collection_name=self.collection, + query_vector=fmu.vector, + query_filter=context_filter, + limit=3, + with_payload=True + ) + except Exception as e: + print(f"[Historian] ⚠️ Search Error: {e}. Trying unfiltered search...") + # Fallback if filters fail (e.g. missing payload index) + results = self.client.search( + collection_name=self.collection, + query_vector=fmu.vector, + limit=3, + with_payload=True + ) + + # 3. Report Results + if not results: + print("[Historian] 🤷 No similar history found.") + return [] + + print(f"[Historian] ✅ Found {len(results)} matches:") + formatted_history = [] + for hit in results: + print(f" - ID: {hit.id} | Score: {hit.score:.4f}") + formatted_history.append(hit.payload) + + return formatted_history + +# ========================================== +# 📡 FETCHING AGENT +# ========================================== class FetchingAgent: def __init__(self, simulator_url="https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/state"): self.sim_url = simulator_url self.builder = FMUBuilder() - self.db = DBTools() - def fetch_and_process(self): - print(f"[Fetcher] 📡 Requesting data from {self.sim_url}...") + def run_cycle(self): + print(f"\n[Fetcher] 📡 Requesting data from {self.sim_url}...") try: - # 1. GET Request to Simulator - # Expecting JSON: { "sensors": {...}, "image": "base64...", "metadata": {...} } response = requests.get(self.sim_url) - if response.status_code == 200: - data = response.json() - - # Extract Data - sensors = data.get("sensors", {}) - image_b64 = data.get("image", "") # Expecting pure Base64 string - metadata = data.get("metadata", {}) - - print(f"[Fetcher] ✅ Data received. Sensors: {sensors}") - - # 2. Create Vector & FMU (Using Base64) - fmu = self.builder.create_fmu(image_b64, sensors, metadata) - print(f"[Fetcher] 🧠 FMU Created (ID: {fmu.id})") - - # 3. Store in Database (Optional step here, or return to Orchestrator) - self.db.store_fmu(fmu) - - return fmu - else: + if response.status_code != 200: print(f"[Fetcher] ❌ Error: Simulator returned {response.status_code}") return None + data = response.json() + + # --- 1. Filter Sensors (pH, EC, Temp, Humidity) --- + window_data = data.get("sensor_window", {}) + wanted_keys = {"ph": "pH", "ec": "EC", "humidity": "humidity", "temp": "temp", "air_temp": "temp"} + + sensor_snapshot = {} + for key, value_list in window_data.items(): + key_lower = key.lower() + if key_lower in wanted_keys: + out_name = wanted_keys[key_lower] + val = value_list[-1] if isinstance(value_list, list) and value_list else 0.0 + if out_name not in sensor_snapshot: + sensor_snapshot[out_name] = val + + # --- 2. Filter Metadata (Crop, Stage) --- + raw_meta = data.get("metadata", {}) + filtered_metadata = { + "crop": raw_meta.get("crop", "unknown"), + "stage": raw_meta.get("stage", "unknown") + } + + # --- 3. Create FMU --- + image_b64 = data.get("image", "") + fmu = self.builder.create_fmu(image_b64, sensor_snapshot, filtered_metadata) + + print(f"[Fetcher] 🧠 FMU Created (ID: {fmu.id})") + print(f"[Fetcher] Sensors: {sensor_snapshot}") + + # --- 4. Store in DB --- + store_fmu(fmu) + + return fmu + except Exception as e: - print(f"[Fetcher] ❌ Connection Failed: {e}") + print(f"[Fetcher] ❌ Critical Error: {e}") return None -# --- Quick Test --- + +# ========================================== +# 🎬 LOCAL ORCHESTRATION +# ========================================== if __name__ == "__main__": - # Mocking a server response for testing logic without a real server - from unittest.mock import MagicMock - - agent = FetchingAgent() - - # Mock request - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "sensors": {"pH": 5.9, "EC": 1.3, "temp": 25.0, "humidity": 72.0}, - "metadata": {"crop": "lettuce", "stage": "vegetative"}, - # A tiny white pixel in Base64 - "image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==" - } - requests.get = MagicMock(return_value=mock_response) + # 1. Instantiate Agents + fetcher = FetchingAgent() + historian = HistorianAgent() + + # 2. Run the Loop + print("--- Starting Combined Agent Cycle ---") - agent.fetch_and_process() -\ No newline at end of file + # Step A: Fetch & Process + current_fmu = fetcher.run_cycle() + + # Step B: Consult History (if Fetch was successful) + if current_fmu: + historian.consult_history(current_fmu) +\ No newline at end of file diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py