commit a40228adf42a56246cd5cd12d61df20f2b02f37d
parent d790a441f23359b1cc6fe0e0bba26854aa53edca
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date: Sun, 1 Mar 2026 10:28:16 +0000
fetcher-historian done
Diffstat:
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