demeter

Autonomous Hydroponic Intelligence
commit b3fc6028423798ebdd095b02fef3fb645e29728b
parent bb76d3df20c3a68e58b28b769ab4f7f87f9a45da
Author: Debarghya Das <debarghya1108@gmail.com>
Date:   Tue,  3 Mar 2026 15:58:00 +0000

Merge PR

Diffstat:
Aagent/Marl/bandit.py | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aagent/Marl/strategies.py | 29+++++++++++++++++++++++++++++
Magent/sub_agents/Researcher.py | 6++++++
Magent/sub_agents/Supervisor.py | 200+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mrequirements.txt | 2+-
Mweb/app/upload/page.tsx | 24++----------------------
6 files changed, 252 insertions(+), 94 deletions(-)

diff --git a/agent/Marl/bandit.py b/agent/Marl/bandit.py @@ -0,0 +1,84 @@ +# agent/Marl/Bandit.py + +import numpy as np +import pickle +import os + +class ContextualBandit: + def __init__(self, n_actions=15, feature_dim=519): + """ + LinGreedy Implementation (Pure Exploitation). + We removed 'alpha' because we do not want to explore. + """ + self.n_actions = n_actions + self.d = feature_dim + + # A: Covariance Matrix (Used for Ridge Regression learning) + self.A = [np.identity(self.d) for _ in range(self.n_actions)] + + # b: Reward Vector + self.b = [np.zeros(self.d) for _ in range(self.n_actions)] + + self.file_path = "model_bandit_greedy.pkl" + self.load() + + def select_action(self, context_vector): + """ + Returns: (action_index, debug_info) + Strictly picks the action with the highest PREDICTED reward. + """ + predicted_rewards = np.zeros(self.n_actions) + confidences = np.zeros(self.n_actions) + + for a in range(self.n_actions): + # 1. Calculate Mean Estimate (theta) + # theta = A^-1 * b + try: + theta = np.linalg.solve(self.A[a], self.b[a]) + except np.linalg.LinAlgError: + # Fallback for singular matrix (rare with Identity init) + theta = np.zeros(self.d) + + # 2. Expected Reward (Dot Product) + # This is the "Best Guess" for how good this action is. + pred = theta.dot(context_vector) + predicted_rewards[a] = pred + + # 3. Calculate Confidence (Optional, for UI only) + # We calculate variance just to show the user "How sure are we?" + # But we do NOT add this to the score. + variance = context_vector.dot(np.linalg.solve(self.A[a], context_vector)) + confidences[a] = 1.0 / (1.0 + variance) # Simple confidence score (0-1) + + # 🟢 PURE EXPLOITATION: Pick max predicted reward + chosen_action = np.argmax(predicted_rewards) + + return chosen_action, { + "scores": predicted_rewards.tolist(), + "confidences": confidences.tolist() + } + + def update(self, action_idx, context_vector, reward): + """ + Online Learning: The AI still gets smarter with every feedback. + """ + # Update the regression model for the chosen arm + self.A[action_idx] += np.outer(context_vector, context_vector) + self.b[action_idx] += reward * context_vector + + self.save() + print(f"📈 Greedy Model Updated | Action: {action_idx} | Reward: {reward}") + + def save(self): + with open(self.file_path, 'wb') as f: + pickle.dump({'A': self.A, 'b': self.b}, f) + + def load(self): + if os.path.exists(self.file_path): + try: + with open(self.file_path, 'rb') as f: + data = pickle.load(f) + self.A = data['A'] + self.b = data['b'] + except Exception: + print("⚠️ Could not load model, starting fresh.") +\ No newline at end of file diff --git a/agent/Marl/strategies.py b/agent/Marl/strategies.py @@ -0,0 +1,28 @@ +# agent/Marl/strategies.py + +STRATEGIES = { + # --- 🟢 GLOBAL / PASSIVE --- + 0: "MAINTAIN_CURRENT", # Everything looks good, hold steady. + 1: "CALIBRATE_SENSORS", # Data looks weird/impossible (e.g., pH 0). Check hardware. + + # --- 💧 NUTRIENT INTERVENTIONS --- + 2: "AGGRESSIVE_PH_DOWN", # pH is way too high (> 7.5). + 3: "AGGRESSIVE_PH_UP", # pH is way too low (< 4.5). + 4: "GENTLE_PH_BALANCING", # pH is slightly off, use mild correction. + 5: "INCREASE_EC_VEG", # Plants are hungry (Vegetative Nitrogen boost). + 6: "INCREASE_EC_BLOOM", # Plants are hungry (Flowering PK boost). + 7: "LOWER_EC_FLUSH", # Nutrient burn detected (Tips yellowing). Reduce EC. + 8: "CALMAG_BOOST", # Specific deficiency (Magnesium/Calcium). + + # --- 🌤️ CLIMATE INTERVENTIONS --- + 9: "RAISE_TEMP_HUMIDITY", # "VPD Low" protocol. + 10: "LOWER_TEMP_HUMIDITY", # "VPD High" / Mold risk protocol. + 11: "MAX_AIR_CIRCULATION", # Stagnant air / weak stems. + + # --- 🚑 DISEASE / BIO --- + 12: "FUNGAL_TREATMENT", # White powdery spots detected. + 13: "PEST_ISOLATION", # Bugs visible. + 14: "PRUNE_NECROTIC_LEAVES" # Remove dead plant matter to prevent spread. +} + +NUM_ACTIONS = len(STRATEGIES) +\ No newline at end of file diff --git a/agent/sub_agents/Researcher.py b/agent/sub_agents/Researcher.py @@ -2,10 +2,16 @@ import uuid from qdrant_client import models from fastembed import TextEmbedding from Qdrant.Client import client # Import your existing cloud connection +from groq import Groq +import os +from dotenv import load_dotenv + +load_dotenv() class ResearcherAgent: def __init__(self): self.client = client + self.llm = Groq(api_key=os.getenv("GROQ_API_KEY")) self.collection = "Knowledge_Base" # FastEmbed is lightweight and runs locally on CPU self.encoder = TextEmbedding(model_name="BAAI/bge-small-en-v1.5") diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py @@ -1,98 +1,156 @@ +# agent/sub_agents/Supervisor.py + import json -import os -from dotenv import load_dotenv -from openai import OpenAI +import numpy as np -# Load .env file relative to this script -current_dir = os.path.dirname(os.path.abspath(__file__)) -env_path = os.path.join(current_dir, '../../.env') -load_dotenv(env_path) +# 👇 CORRECT IMPORTS based on your folder structure +from agent.Marl.bandit import ContextualBandit +from agent.Marl.strategies import STRATEGIES, NUM_ACTIONS class SupervisorAgent: - def __init__(self, researcher_agent): - self.researcher = researcher_agent + def __init__(self, researcher): + self.llm = researcher.llm - # ⚡ CONNECT TO GROQ CLOUD - # CHECK: Ensure your .env file has 'GROK_API_KEY' or 'GROQ_API_KEY' - # We use 'GROQ_API_KEY' here based on your previous messages - api_key = os.getenv("GROQ_API_KEY") - - if not api_key: - print("⚠️ WARNING: API Key not found. Supervisor may fail.") - - self.llm = OpenAI( - base_url="https://api.groq.com/openai/v1", - api_key=api_key - ) + # Initialize Bandit (15 actions, 515 dimensions) + self.bandit = ContextualBandit(n_actions=NUM_ACTIONS, feature_dim=519) - def reason(self, current_fmu, similar_fmus, sub_agent_outputs): + def _build_context(self, fmu_vector, sensors): """ - The Core Reasoning Loop: - 1. Contextualize -> 2. Research -> 3. Synthesize -> 4. Decide + Combines Visual Intuition (CLIP) with Explicit Sensors. """ + # Ensure vector is numpy + vis_vec = np.array(fmu_vector) if isinstance(fmu_vector, list) else fmu_vector - # --- STEP 1: Formulate the Research Question --- - crop = current_fmu['metadata'].get('crop', 'Unknown Crop') - stage = current_fmu['metadata'].get('stage', 'Unknown Stage') - - # E.g., "Lettuce Vegetative Low pH issues" - research_query = f"{crop} {stage} {sub_agent_outputs.get('nutrient_analysis', '')} issues" + # Normalize sensors roughly to 0-1 range + s_vec = np.array([ + (sensors.get('pH', 6.0) - 6.0) / 2.0, + sensors.get('EC', 1.0) / 3.0, + sensors.get('temp', 25.0) / 40.0 + ]) - print(f"🤔 Supervisor is asking Researcher: '{research_query}'") + return np.concatenate([vis_vec, s_vec]) - # --- STEP 2: The Researcher Fetches Evidence (RAG) --- - # This now returns a clean STRING, not a list - scientific_context = self.researcher.search(research_query) + def _get_strategy_instruction(self, strategy_name): + """ + Translates mathematical intent into LLM instructions. + """ + instructions = { + "MAINTAIN_CURRENT": "Do NOT recommend changes. System is stable.", + "CALIBRATE_SENSORS": "Sensor readings are anomalous. Recommend hardware calibration.", + "AGGRESSIVE_PH_DOWN": "Priority: LOWER pH rapidly. Recommend strong acid buffers.", + "AGGRESSIVE_PH_UP": "Priority: RAISE pH rapidly. Recommend strong base buffers.", + "GENTLE_PH_BALANCING": "pH is drifting. Recommend gentle adjustments only.", + "INCREASE_EC_VEG": "Plant needs NITROGEN for vegetative growth.", + "INCREASE_EC_BLOOM": "Plant needs PHOSPHORUS/POTASSIUM for flowering.", + "LOWER_EC_FLUSH": "Nutrient burn detected. Recommend flushing reservoir.", + "CALMAG_BOOST": "Deficiency detected. Recommend Calcium/Magnesium supplement.", + "RAISE_TEMP_HUMIDITY": "Environment too cold/dry. Recommend heating/humidifying.", + "LOWER_TEMP_HUMIDITY": "Mold risk high. Recommend fans and dehumidifiers.", + "MAX_AIR_CIRCULATION": "Stagnant air. Recommend max fan speed.", + "FUNGAL_TREATMENT": "Fungal risk. Recommend fungicide and lower humidity.", + "PEST_ISOLATION": "Pests detected. Recommend isolation and organic pesticide.", + "PRUNE_NECROTIC_LEAVES": "Necrosis detected. Recommend pruning dead matter." + } + return instructions.get(strategy_name, "Follow standard procedures.") - # --- STEP 3: Synthesize History (Memory) --- - history_context = "\n".join([ - f"- Previous Case (Score {f['score']:.2f}): {f['payload'].get('outcome', 'No outcome recorded')}" - for f in similar_fmus - ]) + def reason(self, current_fmu, similar_fmus, sub_agent_outputs): + sensors = current_fmu['payload']['sensors'] + fmu_vector = current_fmu.get('vector') - # --- STEP 4: The Final Prompt --- - system_prompt = """ - You are the Chief Supervisor AI of a Hydroponic Facility. - Your goal: Synthesize conflicting data to recommend the OPTIMAL action. + if fmu_vector is None: + fmu_vector = np.zeros(512) + + # 1. 🟢 GET CONTEXT + context_vector = self._build_context(fmu_vector, sensors) - PRINCIPLES: - 1. Plant Health is Priority #1. - 2. Verify Sub-Agent claims against the SCIENTIFIC KNOWLEDGE provided. - 3. If History contradicts Science, prefer Science (Manuals), but note the anomaly. - """ + # 2. 🟢 BANDIT DECISION (The "Will") + action_idx, debug_info = self.bandit.select_action(context_vector) + strategic_intent = STRATEGIES[action_idx] + specific_order = self._get_strategy_instruction(strategic_intent) + + # 3. 🟢 IDENTIFY RELEVANT SPECIALIST (The "Physics") + if "NUTRIENT" in strategic_intent or "PH" in strategic_intent or "EC" in strategic_intent: + highlighted_report = sub_agent_outputs.get("nutrient_report", "No Report") + focus_area = "NUTRIENT SPECIALIST" + elif "TEMP" in strategic_intent or "HUMIDITY" in strategic_intent or "AIR" in strategic_intent: + highlighted_report = sub_agent_outputs.get("atmosphere_report", "No Report") + focus_area = "ATMOSPHERE SPECIALIST" + elif "PEST" in strategic_intent or "FUNGAL" in strategic_intent: + highlighted_report = "Visual analysis indicates bio-threats." + focus_area = "BIO-SECURITY" + else: + highlighted_report = "Standard operational check." + focus_area = "ALL SECTORS" - user_message = f""" - ### SITUATION REPORT - Target: {crop} ({stage}) - Sensors: {current_fmu['payload']['sensors']} + # 4. 🟢 FORMAT HISTORY (The "Precedent") <--- NEW SECTION + history_context = "No relevant historical cases found." + if similar_fmus and len(similar_fmus) > 0: + history_lines = [] + for i, fmu in enumerate(similar_fmus): + # Extract key details from the past record + past_action = fmu['payload'].get('action_taken', 'Unknown') + past_outcome = fmu['payload'].get('outcome', 'Unknown') + score = fmu.get('score', 0.0) + history_lines.append(f"- Case #{i+1} (Match: {score:.1%}): Action '{past_action}' -> Result: '{past_outcome}'") + history_context = "\n".join(history_lines) - ### SUB-AGENT ALERTS - {json.dumps(sub_agent_outputs, indent=2)} + # 5. 🟢 SYNTHESIS PROMPT + system_prompt = f""" + You are the Supervisor of a Hydroponic Farm. + + --- 🚨 CHAIN OF COMMAND INSTRUCTIONS 🚨 --- + + 1. STRATEGIC GOAL (From RL General): + "{strategic_intent}" -> "{specific_order}" + *This is your MANDATORY objective.* - ### SCIENTIFIC KNOWLEDGE (Verified Manuals) - {scientific_context} + 2. INTELLIGENCE REPORT (From {focus_area}): + "{highlighted_report}" + *Use these specific calculations (VPD, Lockout, etc.) to justify your plan.* - ### HISTORICAL MEMORY (Similar Past Events) - {history_context} + 3. HISTORICAL PRECEDENT (Retrieval Memory): + {history_context} + *Reference these past cases to support (or warn against) specific implementation details.* - ### COMMAND - Analyze the situation. Resolve conflicts between agents using the Manuals. - Output JSON: {{ "reasoning": "...", "action": "...", "confidence": 0.0-1.0 }} + 4. FULL CONTEXT: + Other Reports: {json.dumps(sub_agent_outputs)} + + --- YOUR TASK --- + Generate a specific action plan that executes the Strategic Goal. + + CRITICAL: You must synthesize the RL Order, the Physics Report, and the History. + - If History shows the RL Strategy failed recently, mention that risk and propose a safer variation. + - If History confirms success, cite it to build confidence. + + RESPONSE FORMAT (JSON): + {{ + "decision": "Brief summary", + "reasoning": "Detailed synthesis of Logic + Physics + History...", + "risk_matrix": {{ "nutrients": 5, "climate": 5, "visuals": 5, "history": 5 }} + }} """ - # --- STEP 5: Execute Reasoning on Groq --- try: response = self.llm.chat.completions.create( - # We use Llama-3.1-8b because it is fast and smart enough for this logic - model="llama-3.1-8b-instant", + model="llama-3.1-8b-instant", messages=[ {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message} + {"role": "user", "content": f"Sub-Agent Reports: {json.dumps(sub_agent_outputs)}"} ], - temperature=0.1, # Low temp for strict logic - response_format={"type": "json_object"} # Force valid JSON + response_format={"type": "json_object"} ) - return json.loads(response.choices[0].message.content) - + decision_json = json.loads(response.choices[0].message.content) + + # 🟢 Attach Metadata for Training + decision_json["strategic_intent"] = strategic_intent + decision_json["bandit_action_idx"] = int(action_idx) + + return decision_json + except Exception as e: - return {"error": str(e), "reasoning": "Groq Connection Failed"} -\ No newline at end of file + return { + "decision": "Error in reasoning", + "reasoning": str(e), + "strategic_intent": strategic_intent, + "bandit_action_idx": int(action_idx) + } +\ No newline at end of file diff --git a/requirements.txt b/requirements.txt @@ -18,7 +18,7 @@ tqdm fastembed openai pypdf - +groq # --- OpenAI CLIP (Vision Encoder) --- # This installs directly from GitHub because it's not on standard PyPI diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx @@ -83,6 +83,7 @@ export default function UnifiedPage() { } setSearchResults(response.search_results || []); + console.log("Search Results:", response.search_results); if (response.agent_decision) { setDecision(response.agent_decision); @@ -367,28 +368,7 @@ export default function UnifiedPage() { )} </div> )} - {/* 👆 END OF NEW COMPONENT 👆 */} - {/* {decision && currentQueryId && ( - <div className="mt-4 bg-slate-900 p-4 rounded-xl border border-slate-700"> - <h4 className="text-white font-bold mb-2">Report Outcome</h4> - <div className="flex gap-2"> - <button - onClick={() => submitFeedback(currentQueryId, decision.action, "Effective")} - className="px-4 py-2 bg-green-600 rounded text-sm hover:bg-green-500" - > - It Worked! - </button> - <button - onClick={() => submitFeedback(currentQueryId, decision.action, "Ineffective")} - className="px-4 py-2 bg-red-600 rounded text-sm hover:bg-red-500" - > - Failed - </button> - </div> - </div> -)} */} - - + {/* Bottom Section: Search Results */} {searchResults.length > 0 && ( <div className="max-w-6xl w-full animate-in fade-in slide-in-from-bottom-10 duration-500">