commit f294d8a2ce498bccf5e8bb516883ab553c0cf2b4
parent 6bd43d625c709b1856cc8f8008fb85785f5e608d
Author: maydayv7 <maydayv7@gmail.com>
Date: Sat, 28 Mar 2026 13:51:59 +0530
Fix crop lifecycle and FarmIntelligence
Diffstat:
7 files changed, 236 insertions(+), 135 deletions(-)
diff --git a/backend/node_server/schema/cropSchema.js b/backend/node_server/schema/cropSchema.js
@@ -27,6 +27,7 @@ const cropStateSchema = new mongoose.Schema({
sequence_number: { type: Number, default: 0 },
cycle_duration_hours: { type: Number, default: 1 },
total_crop_lifetime_days: { type: Number, default: 0 },
+ simulated_age_hours: { type: Number, default: 0 },
planted_at: { type: Date, default: Date.now },
last_updated: { type: Date, default: Date.now },
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -139,7 +139,8 @@ async def process_search(file: UploadFile, sensors_str: str, builder):
# Metadata construction
metadata = {
"crop": target_crop,
- "stage": raw_sensor_data.get("stage", "Unknown"),
+ "stage": raw_sensor_data.get("stage")
+ or raw_sensor_data.get("metadata", {}).get("stage", "seedling"),
"crop_id": target_crop_id,
"sequence_number": seq_num,
"sensors": clean_sensors,
@@ -298,7 +299,8 @@ async def process_cycle_stream(file: UploadFile, sensors_str: str, builder):
metadata = {
"crop": target_crop,
- "stage": raw_sensor_data.get("stage", "Unknown"),
+ "stage": raw_sensor_data.get("stage")
+ or raw_sensor_data.get("metadata", {}).get("stage", "seedling"),
"crop_id": target_crop_id,
"sequence_number": seq_num,
"sensors": clean_sensors,
@@ -619,9 +621,8 @@ async def process_audio_search(file: UploadFile):
async def process_ask_query(query: str, context: str, language: str):
"""
- Directly answers user questions using farm data context.
- The context string already contains similar-crop data pre-built by the
- frontend; this function just passes it through to the LLM.
+ Answers natural language questions about the farm using pre-built context
+ from the frontend.
"""
try:
lang_instr = (
@@ -629,19 +630,23 @@ async def process_ask_query(query: str, context: str, language: str):
if language == "hi"
else "Respond entirely in English."
)
- system_prompt = f"""
- You are Demeter Intelligence, an expert AI agronomist for a hydroponic farm.
- Use the FARM DATA below to answer the user's question accurately and concisely.
- {context}
+ system_prompt = f"""You are Demeter Intelligence — an expert AI agronomist embedded in a hydroponic farm management system.
- Instructions:
- - Wrap your internal reasoning in <thinking>...</thinking> tags.
- - After </thinking>, give a clear direct answer.
- - When referencing specific crops, mention their crop_id in parentheses.
- - If the question requires comparing multiple crops, address each one.
- - CRITICAL: {lang_instr}
- """
+ROLE:
+- Answer questions about crop health, sensor readings, agent decisions, and farm trends
+- Compare crops when asked, citing their crop_id
+- Give actionable recommendations grounded in the data
+- Be concise: lead with the direct answer, then explain
+
+REASONING:
+Wrap your internal reasoning in <thinking>...</thinking> before your answer.
+Keep thinking brief — focus on which crops are relevant and what the data says.
+
+FARM DATA:
+{context}
+
+LANGUAGE: {lang_instr}"""
response = supervisor.model.invoke(
[SystemMessage(content=system_prompt), HumanMessage(content=query)]
@@ -651,129 +656,121 @@ async def process_ask_query(query: str, context: str, language: str):
thinking = ""
answer = raw_text
- think_match = re.search(r"<thinking>(.*?)</thinking>", raw_text, re.DOTALL)
+ # Support both <thinking> and <think> tags
+ think_match = re.search(
+ r"<think(?:ing)?>(.*?)</think(?:ing)?>", raw_text, re.DOTALL
+ )
if think_match:
thinking = think_match.group(1).strip()
answer = re.sub(
- r"<thinking>.*?</thinking>", "", raw_text, flags=re.DOTALL
+ r"<think(?:ing)?>(.*?)</think(?:ing)?>", "", raw_text, flags=re.DOTALL
).strip()
return {"status": "success", "thinking": thinking, "answer": answer}
- except Exception as e:
- import traceback
+ except Exception as e:
traceback.print_exc()
return {"status": "error", "message": str(e)}
async def process_similar_crops(crop_id: str, crop_name: str, payload_json: str):
"""
- Find cosine-similar crops using the ACTUAL stored vector for crop_id
+ Find cosine-similar crops using the latest stored vector for crop_id.
+ Falls back to a zero-padded sensor vector if no stored vector is found.
"""
import json as _json
import numpy as np
try:
- # Find the latest point for this crop
- filter_latest = models.Filter(
- must=[
- models.FieldCondition(
- key="crop_id",
- match=models.MatchValue(value=crop_id),
- )
- ]
- )
-
+ # Step 1: Scroll all points for this crop, requesting vectors
points, _ = client.scroll(
collection_name=COLLECTION_NAME,
- scroll_filter=filter_latest,
+ scroll_filter=models.Filter(
+ must=[
+ models.FieldCondition(
+ key="crop_id",
+ match=models.MatchValue(value=crop_id),
+ )
+ ]
+ ),
limit=100,
with_payload=True,
- with_vectors=True, # <-- we need the actual stored vector
+ with_vectors=True,
)
- query = None
+ query_vector = None
if points:
# Pick the point with the highest sequence_number
best = max(points, key=lambda p: p.payload.get("sequence_number", 0))
v = best.vector
if v is not None:
- # v may be a list or a dict (named vectors); handle both
+ # Handle named-vector collections (dict) vs plain list
if isinstance(v, dict):
- # Named vector collections — grab the default/first key
v = next(iter(v.values()))
- query = list(v)
+ if len(v) == 516:
+ query_vector = list(v)
+ else:
+ print(
+ f"[SimilarCrops] Unexpected vector length {len(v)} for {crop_id}"
+ )
- # Fallback: build sensor vector from payload JSON
- if query is None:
+ # Step 2: Sensor-only fallback — build a 516-dim vector
+ # Vision dims (0–511) stay zero; sensor dims (512–515) are filled in
+ if query_vector is None:
print(
- f"[SimilarCrops] No stored vector for {crop_id}, falling back to sensor encoding"
+ f"[SimilarCrops] No usable stored vector for {crop_id}, using sensor fallback"
)
- try:
- from Sentinel.Encoders.TimeSeries import SensorEncoder
-
- payload = _json.loads(payload_json) if payload_json else {}
- raw_sensors = payload.get("sensor_data") or payload.get("sensors") or {}
- # Keep only the four numeric sensors
- clean = {}
- for k, v in raw_sensors.items():
- if k in {"pH", "EC", "temp", "humidity"}:
- try:
- clean[k] = float(v)
- except (TypeError, ValueError):
- pass
+ payload = _json.loads(payload_json) if payload_json else {}
+ raw_sensors = payload.get("sensor_data") or payload.get("sensors") or {}
+
+ SENSOR_ORDER = ["pH", "EC", "temp", "humidity"]
+ SENSOR_DEFAULTS = {"pH": 6.0, "EC": 1.5, "temp": 23.0, "humidity": 65.0}
+
+ full_vec = np.zeros(516, dtype=np.float32)
+ for i, key in enumerate(SENSOR_ORDER):
+ raw = raw_sensors.get(key)
+ val = raw[-1] if isinstance(raw, list) and raw else raw
+ try:
+ full_vec[512 + i] = (
+ float(val) if val is not None else SENSOR_DEFAULTS[key]
+ )
+ except (TypeError, ValueError):
+ full_vec[512 + i] = SENSOR_DEFAULTS[key]
- if clean:
- encoder = SensorEncoder()
- sensor_vec = encoder.encode(clean) # shape: (4,) or (12,)
- # Pad to COLLECTION vector size (516) with zeros
- full_vec = np.zeros(516, dtype=np.float32)
- full_vec[-len(sensor_vec) :] = sensor_vec
- query = full_vec.tolist()
- except Exception as enc_err:
- print(f"[SimilarCrops] Sensor encoding fallback failed: {enc_err}")
-
- if query is None:
- return {
- "status": "error",
- "message": f"Could not build a query vector for crop_id={crop_id}",
- "results": [],
- }
+ query_vector = full_vec.tolist()
- # Cector search, excluding this crop
- exclude_filter = models.Filter(
- must_not=[
- models.FieldCondition(
- key="crop_id",
- match=models.MatchValue(value=crop_id),
- )
- ]
- )
-
- search_results = client.query_points(
+ # Step 3: Vector search, excluding the source crop
+ results = client.query_points(
collection_name=COLLECTION_NAME,
- query=query,
- query_filter=exclude_filter,
+ query=query_vector,
+ query_filter=models.Filter(
+ must_not=[
+ models.FieldCondition(
+ key="crop_id",
+ match=models.MatchValue(value=crop_id),
+ )
+ ]
+ ),
limit=6,
with_payload=True,
with_vectors=False,
)
+ hits = results.points if hasattr(results, "points") else results
+
return {
"status": "success",
"results": [
{
"id": str(r.id),
- "score": float(r.score),
+ "score": round(float(r.score), 4),
"payload": r.payload,
}
- for r in search_results
+ for r in hits
],
}
except Exception as e:
- import traceback
-
traceback.print_exc()
return {"status": "error", "message": str(e), "results": []}
diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx
@@ -38,6 +38,7 @@ import {
calculateMaturity,
getDaysRemaining,
getCurrentStage,
+ getEffectiveElapsedHours,
CROP_LIFECYCLES,
CROP_CYCLE_HOURS,
} from "../utils/dataUtils";
@@ -447,11 +448,7 @@ function InfoTab({ cropDoc, cropId, navigate, t }) {
day: "numeric",
})
: "-";
- const daysSince = cropDoc.planted_at
- ? Math.floor(
- (Date.now() - new Date(cropDoc.planted_at).getTime()) / 86400000,
- )
- : null;
+ const daysSince = Math.floor(getEffectiveElapsedHours(cropDoc) / 24) || null;
const sensorIds = cropDoc.sensor_ids || {};
const SENSORS_DISPLAY = [
diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx
@@ -23,7 +23,17 @@ import {
ExternalLink,
} from "lucide-react";
import { agentService } from "../api/agentApi";
-import { extractSensors, deriveCropStatus } from "../utils/dataUtils";
+import { fetchCropDetails } from "../api/farmApi";
+import {
+ parsePythonString,
+ extractSensors,
+ deriveCropStatus,
+ calculateMaturity,
+ getEffectiveElapsedHours,
+ getCurrentStage,
+ getDaysRemaining,
+ isReadyToHarvest,
+} from "../utils/dataUtils";
import {
AgentActionWidget,
AgentOutcomeWidget,
@@ -955,7 +965,7 @@ export default function FarmIntelligence() {
// Build rich context for LLM
const buildLLMContext = useCallback(
- (cropCtx, similarCrops = []) => {
+ (cropCtx, similarCrops = [], cropHistory = []) => {
const allCrops = dashboard || [];
if (cropCtx) {
@@ -975,24 +985,79 @@ export default function FarmIntelligence() {
.join("\n")
: "";
+ const historySection =
+ cropHistory.length > 1
+ ? `\nACTION HISTORY (last ${cropHistory.length} cycles, newest first):\n` +
+ cropHistory
+ .map((h) => {
+ const p = h.payload || {};
+ const rawAction = p.action_taken;
+ const actionStr =
+ !rawAction || rawAction === "PENDING_ACTION"
+ ? "None"
+ : typeof rawAction === "object"
+ ? JSON.stringify(rawAction)
+ : (() => {
+ const parsed = parsePythonString(rawAction);
+ return parsed && typeof parsed === "object"
+ ? JSON.stringify(parsed)
+ : rawAction;
+ })();
+ const explanation =
+ p.explanation_log &&
+ p.explanation_log !== "PENDING_ANALYSIS"
+ ? p.explanation_log.trim()
+ : null;
+ const stratIntent = p.strategic_intent || null;
+ return [
+ ` Seq #${p.sequence_number ?? "?"} | Stage: ${p.stage || "?"} | Reward: ${p.reward_score ?? "N/A"}`,
+ ` Action: ${actionStr}`,
+ ` Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"}`,
+ stratIntent ? ` Intent: ${stratIntent}` : null,
+ explanation ? ` Explain: ${explanation}` : null,
+ ]
+ .filter(Boolean)
+ .join("\n");
+ })
+ .join("\n\n")
+ : "";
+
+ const elapsedH = getEffectiveElapsedHours(p);
+ const maturity = calculateMaturity(p);
+ const currentStage = getCurrentStage(p) || p.stage || "Unknown";
+ const daysRemaining = getDaysRemaining(p);
+ const readyToHarvest = isReadyToHarvest(p);
+
return `CROP CONTEXT:
- Crop: ${p.crop || cropCtx.crop}
- Batch ID: ${p.crop_id || cropCtx.cropId}
-- Growth Stage: ${p.stage || "Unknown"}
+- Growth Stage: ${currentStage}
+- Maturity: ${maturity}%${readyToHarvest ? " ⚠️ READY TO HARVEST" : ""}
+- Simulated Age: ${Math.round(elapsedH)} hours (${Math.floor(elapsedH / 24)} days)
+- Days Until Harvest: ${daysRemaining !== null ? daysRemaining : "N/A"}
- Sequence Number: ${p.sequence_number || "-"}
-- Last Updated: ${p.timestamp ? new Date(p.timestamp).toLocaleString() : "Unknown"}
+- Last Updated: ${p.last_updated ? new Date(p.last_updated).toLocaleString() : "Unknown"}
LATEST SENSOR READINGS:
- pH: ${sensors.ph}
- EC: ${sensors.ec} dS/m
- Temperature: ${sensors.temp}°C
- Humidity: ${sensors.humidity}%
LATEST AGENT DECISION:
-- Action Taken: ${p.action_taken && p.action_taken !== "PENDING_ACTION" ? p.action_taken : "None recorded"}
+- Action Taken: ${(() => {
+ const a = p.action_taken;
+ if (!a || a === "PENDING_ACTION") return "None recorded";
+ if (typeof a === "object") return JSON.stringify(a, null, 2);
+ const parsed = parsePythonString(a);
+ return parsed && typeof parsed === "object"
+ ? JSON.stringify(parsed, null, 2)
+ : a;
+ })()}
- Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"}
- Reward Score: ${p.reward_score ?? "N/A"}
- Strategic Intent: ${p.strategic_intent || "N/A"}
EXPLANATION LOG:
-${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}${similarSection}
+${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}
+${similarSection}${historySection}
FLEET OVERVIEW (for comparison):
- Total crops: ${fleetStats.total}
- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}`.trim();
@@ -1003,7 +1068,10 @@ FLEET OVERVIEW (for comparison):
.map((d) => {
const p = d.payload || {};
const s = extractSensors(p);
- return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${p.stage}, pH=${s.ph}, EC=${s.ec}, T=${s.temp}°, H=${s.humidity}%, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}, Action=${p.action_taken || "Pending"}`;
+ const maturity = calculateMaturity(p);
+ const stage = getCurrentStage(p) || p.stage || "Unknown";
+ const ready = isReadyToHarvest(p);
+ return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${stage}, Maturity=${maturity}%${ready ? " [HARVEST READY]" : ""}, pH=${s.ph}, EC=${s.ec}, T=${s.temp}°, H=${s.humidity}%, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}`;
})
.join("\n");
return `FLEET OVERVIEW:
@@ -1043,15 +1111,12 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
selectedCrop.crop,
selectedCrop.payload,
);
- if (searchData.results) {
- similarCrops = searchData.results
- .filter((r) => r.payload?.crop_id !== selectedCrop.cropId)
- .slice(0, 5)
- .map((r) => ({
- id: r.id,
- score: r.score || 0,
- payload: r.payload,
- }));
+ if (searchData.status === "success" && searchData.results?.length) {
+ similarCrops = searchData.results.slice(0, 5).map((r) => ({
+ id: r.id,
+ score: r.score || 0,
+ payload: r.payload,
+ }));
setRelatedCrops(similarCrops.slice(0, 3));
}
} catch (e) {
@@ -1059,7 +1124,22 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
}
}
- const context = buildLLMContext(selectedCrop, similarCrops);
+ let cropHistory = [];
+ if (selectedCrop) {
+ try {
+ const hist = await fetchCropDetails(selectedCrop.cropId);
+ // hist is sorted desc by sequence_number, take last 10 for context
+ cropHistory = (hist || []).slice(0, 10);
+ } catch (e) {
+ console.warn("History fetch failed:", e);
+ }
+ }
+
+ const context = buildLLMContext(
+ selectedCrop,
+ similarCrops,
+ cropHistory,
+ );
const data = await agentService.askDemeter(query, context, lang);
setLlmThinking(data.thinking || "");
@@ -1129,16 +1209,13 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
selectedCrop.crop,
selectedCrop.payload,
);
- if (simData.results) {
+ if (simData.status === "success" && simData.results?.length) {
setRelatedCrops(
- simData.results
- .filter((r) => r.payload?.crop_id !== selectedCrop.cropId)
- .slice(0, 3)
- .map((r) => ({
- id: r.id,
- score: r.score || 0,
- payload: r.payload,
- })),
+ simData.results.slice(0, 3).map((r) => ({
+ id: r.id,
+ score: r.score || 0,
+ payload: r.payload,
+ })),
);
}
} catch (e) {
diff --git a/frontend/src/pages/RunCycle.jsx b/frontend/src/pages/RunCycle.jsx
@@ -469,7 +469,7 @@ export default function RunCycle() {
alignItems: "center",
justifyContent: "center",
gap: 8,
- padding: "8px 16px",
+ padding: "8px 0px",
borderBottom: "1px solid rgba(74,222,128,0.15)",
background: "rgba(74,222,128,0.06)",
}}
@@ -659,7 +659,7 @@ export default function RunCycle() {
<div
key={s.name}
style={{ width: `${w}%`, textAlign: "center" }}
- title={`${s.name}: ${Math.round((s.endH ?? lifecycle.totalHours - s.startH) / 24)}d`}
+ title={`${s.name}: ${Math.round(((s.endH ?? lifecycle.totalHours) - s.startH) / 24)}d`}
>
<div
style={{
diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js
@@ -133,6 +133,36 @@ export const extractSensors = (payload) => {
};
};
+// Effective elapsed hours
+export const getEffectiveElapsedHours = (payload) => {
+ if (!payload) return 0;
+
+ // PRIMARY: sequence_number × cycle_duration_hours
+ const seqNum = payload.sequence_number || 0;
+ if (seqNum > 0) {
+ const crop = (payload.crop || "").toLowerCase();
+ const cycleDuration =
+ payload.cycle_duration_hours || CROP_CYCLE_HOURS[crop] || 1;
+ return seqNum * cycleDuration;
+ }
+
+ // SECONDARY: simulated_age_hours (for crops with 0 sequences)
+ if (
+ typeof payload.simulated_age_hours === "number" &&
+ payload.simulated_age_hours > 0
+ ) {
+ return payload.simulated_age_hours;
+ }
+
+ // FALLBACK: real wall-clock age
+ if (payload.planted_at) {
+ return (
+ (Date.now() - new Date(payload.planted_at).getTime()) / (1000 * 60 * 60)
+ );
+ }
+ return 0;
+};
+
// Maturity
export const calculateMaturity = (payload) => {
// Legacy call: calculateMaturity(seqNumber)
@@ -142,15 +172,12 @@ export const calculateMaturity = (payload) => {
const crop = (payload.crop || "").toLowerCase();
const lifecycle = CROP_LIFECYCLES[crop];
- const plantedAt = payload.planted_at;
- if (lifecycle && plantedAt) {
- const elapsedH =
- (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60);
+ if (lifecycle) {
+ const elapsedH = getEffectiveElapsedHours(payload);
const pct = Math.min((elapsedH / lifecycle.totalHours) * 100, 100);
return Math.round(pct);
}
-
// Fallback: sequence-based estimate
return Math.min((payload.sequence_number || 0) * 10, 100);
};
@@ -160,11 +187,9 @@ export const getDaysRemaining = (payload) => {
if (!payload) return null;
const crop = (payload.crop || "").toLowerCase();
const lifecycle = CROP_LIFECYCLES[crop];
- const plantedAt = payload.planted_at;
- if (!lifecycle || !plantedAt) return null;
+ if (!lifecycle) return null;
- const elapsedH =
- (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60);
+ const elapsedH = getEffectiveElapsedHours(payload);
const remainH = lifecycle.totalHours - elapsedH;
if (remainH <= 0) return 0;
return Math.ceil(remainH / 24);
@@ -175,11 +200,9 @@ export const getCurrentStage = (payload) => {
if (!payload) return null;
const crop = (payload.crop || "").toLowerCase();
const lifecycle = CROP_LIFECYCLES[crop];
- const plantedAt = payload.planted_at;
- if (!lifecycle || !plantedAt) return payload.stage || null;
+ if (!lifecycle) return payload.stage || null;
- const elapsedH =
- (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60);
+ const elapsedH = getEffectiveElapsedHours(payload);
const stages = lifecycle.stages;
for (let i = stages.length - 1; i >= 0; i--) {
if (elapsedH >= stages[i].startH) return stages[i].name;
diff --git a/simulator/main.py b/simulator/main.py
@@ -20,10 +20,13 @@ load_dotenv(env_path)
MONGO_URI = os.environ.get("MONGODB_URI")
mongo_client = MongoClient(MONGO_URI)
-db = mongo_client["test"]
+db = mongo_client.get_default_database()
crops_collection = db["cropstates"]
sim_state_collection = db["simulator_state"]
+print(f"[DEBUG] MongoDB connected to DB: '{db.name}'")
+print(f"[DEBUG] MONGO_URI = {MONGO_URI[:40] if MONGO_URI else 'NOT SET'}...")
+
MODEL_PATH = "models/PPO/lettuce_brain_v1.zip"
HISTORY_LEN = 20
@@ -234,7 +237,10 @@ async def get_all_states():
for crop in db_crops:
cid = crop.get("crop_id")
- if not cid or cid not in simulators:
+ if not cid:
+ continue
+ if cid not in simulators:
+ print(f"⚠️ Crop {cid} not in simulators after sync — skipping")
continue
crop_type = crop.get("crop", "lettuce").lower()
@@ -259,7 +265,7 @@ async def get_all_states():
f" current_tick: {current_tick} | crop_id: {cid} | age_hours: {age_hours} | stage: {new_stage} | cycle_duration: {cycle_duration}"
)
- if current_tick % cycle_duration != 0:
+ if current_tick % cycle_duration != 0 and current_tick != 1:
continue
sim = simulators[cid]