demeter

Autonomous Hydroponic Intelligence

frontend/src/utils/dataUtils.js (24358B)


  1// Number formatting
  2export const formatNumber = (val, decimals = 2) => {
  3  if (val === undefined || val === null || isNaN(parseFloat(val))) return "0";
  4  const factor = Math.pow(10, decimals);
  5  return Math.round(parseFloat(val) * factor) / factor;
  6};
  7
  8// Python dict / JSON string parser
  9export const parsePythonString = (str) => {
 10  if (!str) return null;
 11  if (typeof str === "object") return str;
 12  try {
 13    return JSON.parse(str);
 14  } catch {
 15    try {
 16      const fixed = str
 17        .replace(/'/g, '"')
 18        .replace(/\bNone\b/g, "null")
 19        .replace(/\bFalse\b/g, "false")
 20        .replace(/\bTrue\b/g, "true");
 21      return JSON.parse(fixed);
 22    } catch {
 23      return null;
 24    }
 25  }
 26};
 27
 28// LIFECYCLE
 29// - totalHours -> the time at which the crop enters its final harvestable stage
 30// - maturity % = elapsed hours / totalHours (capped at 100)
 31// - ready to harvest -> maturity >= 100
 32
 33export const CROP_LIFECYCLES = {
 34  lettuce: {
 35    stages: [
 36      { name: "seedling", startH: 0, endH: 168 },
 37      { name: "vegetative", startH: 168, endH: 504 },
 38      { name: "harvest", startH: 504, endH: null },
 39    ],
 40    totalHours: 504, // ~21 days
 41    totalDays: 21,
 42    harvestStage: "harvest",
 43  },
 44  tomato: {
 45    stages: [
 46      { name: "seedling", startH: 0, endH: 336 },
 47      { name: "vegetative", startH: 336, endH: 1008 },
 48      { name: "flowering", startH: 1008, endH: 1680 },
 49      { name: "fruiting", startH: 1680, endH: null },
 50    ],
 51    totalHours: 1680, // ~70 days
 52    totalDays: 70,
 53    harvestStage: "fruiting",
 54  },
 55  basil: {
 56    stages: [
 57      { name: "seedling", startH: 0, endH: 168 },
 58      { name: "vegetative", startH: 168, endH: 672 },
 59      { name: "harvest", startH: 672, endH: null },
 60    ],
 61    totalHours: 672, // ~28 days
 62    totalDays: 28,
 63    harvestStage: "harvest",
 64  },
 65  strawberry: {
 66    stages: [
 67      { name: "seedling", startH: 0, endH: 336 },
 68      { name: "vegetative", startH: 336, endH: 1008 },
 69      { name: "flowering", startH: 1008, endH: 1512 },
 70      { name: "fruiting", startH: 1512, endH: null },
 71    ],
 72    totalHours: 1512, // ~63 days
 73    totalDays: 63,
 74    harvestStage: "fruiting",
 75  },
 76};
 77
 78export const CROP_CYCLE_HOURS = {
 79  lettuce: 1,
 80  basil: 1,
 81  tomato: 2,
 82  strawberry: 2,
 83};
 84
 85export const SUPPORTED_CROPS = ["Lettuce", "Tomato", "Basil", "Strawberry"];
 86
 87// Sensor extraction
 88
 89/** If a sensor field is an array (MongoDB stores history arrays), take the last element */
 90function resolveArrayVal(v) {
 91  if (Array.isArray(v)) return v.length > 0 ? v[v.length - 1] : undefined;
 92  return v;
 93}
 94
 95function pickVal(obj, ...keys) {
 96  if (!obj || typeof obj !== "object") return undefined;
 97  const lower = {};
 98  for (const k of Object.keys(obj)) lower[k.toLowerCase()] = obj[k];
 99  for (const k of keys) {
100    if (obj[k] !== undefined) return resolveArrayVal(obj[k]);
101    if (lower[k.toLowerCase()] !== undefined)
102      return resolveArrayVal(lower[k.toLowerCase()]);
103  }
104  return undefined;
105}
106
107export const extractSensors = (payload) => {
108  if (!payload) return { temp: 0, ph: 0, humidity: 0, ec: 0 };
109
110  let raw = payload.sensors || payload.sensor_data || null;
111
112  if (!raw) {
113    const action = parsePythonString(payload.action_taken);
114    if (action && typeof action === "object") {
115      raw = {
116        temp: action.atmospheric_actions?.air_temp ?? action.air_temp ?? 0,
117        ph: action.water_actions?.ph ?? action.ph ?? 0,
118        humidity: action.atmospheric_actions?.humidity ?? action.humidity ?? 0,
119        ec: action.water_actions?.ec ?? action.ec ?? 0,
120      };
121    } else {
122      raw = payload;
123    }
124  }
125
126  return {
127    temp: formatNumber(
128      pickVal(raw, "temp", "Temp", "air_temp", "temperature") ?? 0,
129    ),
130    ph: formatNumber(pickVal(raw, "pH", "ph", "PH") ?? 7.0),
131    humidity: formatNumber(pickVal(raw, "humidity", "Humidity", "RH") ?? 0),
132    ec: formatNumber(pickVal(raw, "EC", "ec", "conductivity") ?? 0),
133  };
134};
135
136// Effective elapsed hours
137export const getEffectiveElapsedHours = (payload) => {
138  if (!payload) return 0;
139
140  // PRIMARY: sequence_number × cycle_duration_hours
141  const seqNum = payload.sequence_number || 0;
142  if (seqNum > 0) {
143    const crop = (payload.crop || "").toLowerCase();
144    const cycleDuration =
145      payload.cycle_duration_hours || CROP_CYCLE_HOURS[crop] || 1;
146    return seqNum * cycleDuration;
147  }
148
149  // SECONDARY: simulated_age_hours (for crops with 0 sequences)
150  if (
151    typeof payload.simulated_age_hours === "number" &&
152    payload.simulated_age_hours > 0
153  ) {
154    return payload.simulated_age_hours;
155  }
156
157  // FALLBACK: real wall-clock age
158  if (payload.planted_at) {
159    return (
160      (Date.now() - new Date(payload.planted_at).getTime()) / (1000 * 60 * 60)
161    );
162  }
163  return 0;
164};
165
166// Maturity
167export const calculateMaturity = (payload) => {
168  // Legacy call: calculateMaturity(seqNumber)
169  if (typeof payload === "number") return Math.min(payload * 10, 100);
170
171  if (!payload) return 0;
172
173  const crop = (payload.crop || "").toLowerCase();
174  const lifecycle = CROP_LIFECYCLES[crop];
175
176  if (lifecycle) {
177    const elapsedH = getEffectiveElapsedHours(payload);
178    const pct = Math.min((elapsedH / lifecycle.totalHours) * 100, 100);
179    return Math.round(pct);
180  }
181  // Fallback: sequence-based estimate
182  return Math.min((payload.sequence_number || 0) * 10, 100);
183};
184
185// Days remaining
186export const getDaysRemaining = (payload) => {
187  if (!payload) return null;
188  const crop = (payload.crop || "").toLowerCase();
189  const lifecycle = CROP_LIFECYCLES[crop];
190  if (!lifecycle) return null;
191
192  const elapsedH = getEffectiveElapsedHours(payload);
193  const remainH = lifecycle.totalHours - elapsedH;
194  if (remainH <= 0) return 0;
195  return Math.ceil(remainH / 24);
196};
197
198// Current growth stage
199export const getCurrentStage = (payload) => {
200  if (!payload) return null;
201  const crop = (payload.crop || "").toLowerCase();
202  const lifecycle = CROP_LIFECYCLES[crop];
203  if (!lifecycle) return payload.stage || null;
204
205  const elapsedH = getEffectiveElapsedHours(payload);
206  const stages = lifecycle.stages;
207  for (let i = stages.length - 1; i >= 0; i--) {
208    if (elapsedH >= stages[i].startH) return stages[i].name;
209  }
210  return stages[0].name;
211};
212
213// Harvest readiness
214export const isReadyToHarvest = (payload) => {
215  if (!payload) return false;
216  const maturity = calculateMaturity(payload);
217  const status = deriveCropStatus(payload);
218  // 100 % = entered harvest stage; ≥ 95 gives a small grace window for
219  // crops where planted_at might be slightly off.
220  return maturity >= 95 && status !== "Critical";
221};
222
223// MongoDB → normalized shape
224export const normalizeMongoCrop = (doc) => {
225  if (!doc) return null;
226  const id = doc.crop_id || doc._id;
227  return {
228    id,
229    payload: {
230      crop_id: doc.crop_id,
231      crop: doc.crop,
232      stage: doc.stage,
233      sequence_number: doc.sequence_number,
234      planted_at: doc.planted_at,
235      last_updated: doc.last_updated,
236      cycle_duration_hours: doc.cycle_duration_hours,
237      sensors: doc.sensors,
238      sensor_ids: doc.sensor_ids,
239      location: doc.location,
240      notes: doc.notes,
241      image_url: doc.image_url,
242      action_taken: doc.action_taken,
243      outcome: doc.outcome,
244      explanation_log: doc.explanation_log,
245      bandit_action_id: doc.bandit_action_id,
246      strategic_intent: doc.strategic_intent,
247      reward_score: doc.reward_score,
248      visual_diagnosis: doc.visual_diagnosis,
249      timestamp: doc.last_updated || doc.planted_at,
250    },
251  };
252};
253
254// Outcome formatting
255export const formatOutcome = (outcome) => {
256  if (!outcome || typeof outcome !== "string") return "Monitoring...";
257  const cleanOutcome = outcome.split("| Reward:")[0].trim();
258  const parts = cleanOutcome.split("|").map((p) => p.trim());
259  let tags = [];
260  let notes = "";
261  parts.forEach((part) => {
262    if (part.startsWith("condition_assessed")) {
263      const v = part.replace("condition_assessed", "").trim();
264      if (v) tags.push(`Condition: ${v}`);
265    } else if (part.startsWith("health_score:")) {
266      const v = part.replace("health_score:", "").trim();
267      if (v) tags.push(`Health: ${v}`);
268    } else if (part.startsWith("notes:")) {
269      notes = part.replace("notes:", "").trim();
270    } else if (part) {
271      tags.push(part);
272    }
273  });
274  if (!tags.length && !notes) return cleanOutcome;
275  const t = tags.join(" · ");
276  return t && notes ? `${t} - ${notes}` : t || notes;
277};
278
279// Crop health status
280export const deriveCropStatus = (payload) => {
281  if (!payload) return "Healthy";
282  const s = extractSensors(payload);
283  const ph = parseFloat(s.ph) || 0;
284  const ec = parseFloat(s.ec) || 0;
285  const temp = parseFloat(s.temp) || 0;
286  const outcome = JSON.stringify(payload.outcome || "").toLowerCase();
287  const action = JSON.stringify(payload.action_taken || "").toUpperCase();
288
289  if (
290    (ph > 0 && ph < 4.5) ||
291    ph > 7.5 ||
292    ec > 3.5 ||
293    (temp > 0 && temp < 10) ||
294    temp > 35 ||
295    /fail|critical|disease|error/.test(outcome) ||
296    /DISEASE|FUNGAL|PEST/.test(action)
297  )
298    return "Critical";
299
300  if (
301    (ph > 0 && ph < 5.5) ||
302    ph > 6.5 ||
303    ec > 2.5 ||
304    (temp > 0 && temp < 17) ||
305    temp > 30 ||
306    /deteriorat|negative|attention|decline/.test(outcome) ||
307    /FLUSH|PRUNE|BOOST/.test(action)
308  )
309    return "Attention";
310
311  return "Healthy";
312};
313
314// Alert generation
315function timeAgo(isoString, t) {
316  if (!isoString) return t("common_unknown");
317  const diff = Date.now() - new Date(isoString).getTime();
318  const mins = Math.floor(diff / 60000);
319  if (mins < 1) return t("common_time_just_now");
320  if (mins < 60) return t("common_time_min_ago", { n: mins });
321  const hrs = Math.floor(mins / 60);
322  if (hrs < 24) return t("common_time_hr_ago", { n: hrs });
323  const days = Math.floor(hrs / 24);
324  if (days === 1) return t("common_time_day_ago", { n: 1 });
325  return t("common_time_days_ago", { n: days });
326}
327
328// Returns a numeric ms timestamp for a point (for sorting newest-first)
329function pointTimestampMs(payload) {
330  const ts = payload.timestamp || payload.last_updated || payload.planted_at;
331  if (!ts) return 0;
332  return new Date(ts).getTime();
333}
334
335export const generateAlerts = (points, t) => {
336  const _t =
337    t ||
338    ((key, vars = {}) => {
339      const en = {
340        alert_harvest_title: "Ready for Harvest",
341        alert_harvest_desc:
342          "{crop} ({id}) has completed its full growth cycle ({pct}% maturity) and is ready to harvest.",
343        alert_ph_low_title: "pH Critically Low",
344        alert_ph_low_desc:
345          "{crop} ({id}): pH is {val} - base solution dosing required immediately.",
346        alert_ph_high_title: "pH Critically High",
347        alert_ph_high_desc:
348          "{crop} ({id}): pH is {val} - acid dosing required immediately.",
349        alert_ec_high_title: "EC Dangerously High",
350        alert_ec_high_desc:
351          "{crop} ({id}): EC is {val} dS/m - severe nutrient burn risk, flush recommended.",
352        alert_temp_cold_title: "Temperature Too Cold",
353        alert_temp_cold_desc:
354          "{crop} ({id}): Air temperature is {val}°C - root damage and growth stall risk.",
355        alert_temp_hot_title: "Temperature Too Hot",
356        alert_temp_hot_desc:
357          "{crop} ({id}): Air temperature is {val}°C - heat stress and root rot risk.",
358        alert_disease_title: "Disease or Pest Detected",
359        alert_disease_desc:
360          "{crop} ({id}): Visual anomaly detected by AI. Inspect plant immediately.",
361        alert_cycle_fail_title: "Cycle Failure Recorded",
362        alert_cycle_fail_desc:
363          "{crop} ({id}): Sequence #{seq} failed - {outcome}",
364        alert_ph_warn_low_title: "pH Below Optimal Range",
365        alert_ph_warn_high_title: "pH Above Optimal Range",
366        alert_ph_warn_desc:
367          "{crop} ({id}): pH is {val}. Target range is 5.5–6.5.",
368        alert_ec_warn_title: "EC Approaching High Limit",
369        alert_ec_warn_desc:
370          "{crop} ({id}): EC is {val} dS/m - monitor for nutrient burn.",
371        alert_temp_warn_low_title: "Temperature on the Low Side",
372        alert_temp_warn_low_desc:
373          "{crop} ({id}): {val}°C - slow growth expected below 17°C.",
374        alert_temp_warn_high_title: "Temperature Elevated",
375        alert_temp_warn_high_desc:
376          "{crop} ({id}): {val}°C - heat stress likely above 30°C.",
377        alert_deteriorating_title: "Condition Deteriorating",
378        alert_deteriorating_desc:
379          "{crop} ({id}): Seq #{seq} - outcome indicates decline.",
380        alert_cycle_done_title: "Cycle #{seq} Completed",
381        alert_cycle_done_desc:
382          "{crop} ({id}): Agent cycle stored successfully.{extra}",
383        common_time_just_now: "just now",
384        common_time_min_ago: "{n} min ago",
385        common_time_hr_ago: "{n} hr ago",
386        common_time_day_ago: "1 day ago",
387        common_time_days_ago: "{n} days ago",
388        common_unknown: "Unknown",
389      };
390      let str = en[key] ?? key;
391      Object.entries(vars).forEach(([k, v]) => {
392        str = str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
393      });
394      return str;
395    });
396
397  const alerts = [];
398  let id = 1;
399
400  // Sort points newest-first so alerts are generated in newest-first order
401  const sorted = [...points].sort(
402    (a, b) =>
403      pointTimestampMs(b.payload || b) - pointTimestampMs(a.payload || a),
404  );
405
406  for (const p of sorted) {
407    const payload = p.payload || {};
408    const s = extractSensors(payload);
409    const ph = parseFloat(s.ph) || 0;
410    const ec = parseFloat(s.ec) || 0;
411    const temp = parseFloat(s.temp) || 0;
412    const ts = payload.timestamp || payload.last_updated;
413    const tsMs = pointTimestampMs(payload);
414    const ago = timeAgo(ts, _t);
415    const crop = payload.crop || payload.crop_id || "Unknown Crop";
416    const cid = payload.crop_id || "?";
417    const seq = payload.sequence_number || 0;
418    const outcome = payload.outcome || "";
419
420    // Helper: push alert with timestamp for downstream sorting
421    const push = (obj) =>
422      alerts.push({ ...obj, id: id++, tsMs, time: ago, ack: false, crop });
423
424    // Harvest
425    const maturity = calculateMaturity(payload);
426    if (isReadyToHarvest(payload)) {
427      push({
428        type: "harvest",
429        severity: "harvest",
430        isHarvestAlert: true,
431        agent: "JUDGE",
432        cropId: cid,
433        title: _t("alert_harvest_title"),
434        desc: _t("alert_harvest_desc", { crop, id: cid, pct: maturity }),
435      });
436    }
437
438    // Critical sensor alerts
439    if (ph > 0 && ph < 4.5)
440      push({
441        type: "ph_low",
442        severity: "critical",
443        agent: "WATER",
444        cropId: cid,
445        title: _t("alert_ph_low_title"),
446        desc: _t("alert_ph_low_desc", { crop, id: cid, val: ph }),
447      });
448
449    if (ph > 7.5)
450      push({
451        type: "ph_high",
452        severity: "critical",
453        agent: "WATER",
454        cropId: cid,
455        title: _t("alert_ph_high_title"),
456        desc: _t("alert_ph_high_desc", { crop, id: cid, val: ph }),
457      });
458
459    if (ec > 3.5)
460      push({
461        type: "ec_high",
462        severity: "critical",
463        agent: "WATER",
464        cropId: cid,
465        title: _t("alert_ec_high_title"),
466        desc: _t("alert_ec_high_desc", { crop, id: cid, val: ec }),
467      });
468
469    if (temp > 0 && temp < 10)
470      push({
471        type: "temp_cold",
472        severity: "critical",
473        agent: "ATMOSPHERIC",
474        cropId: cid,
475        title: _t("alert_temp_cold_title"),
476        desc: _t("alert_temp_cold_desc", { crop, id: cid, val: temp }),
477      });
478
479    if (temp > 35)
480      push({
481        type: "temp_hot",
482        severity: "critical",
483        agent: "ATMOSPHERIC",
484        cropId: cid,
485        title: _t("alert_temp_hot_title"),
486        desc: _t("alert_temp_hot_desc", { crop, id: cid, val: temp }),
487      });
488
489    if (
490      /DISEASE|FUNGAL|PEST/.test(
491        JSON.stringify(payload.action_taken || "").toUpperCase(),
492      )
493    )
494      push({
495        type: "disease",
496        severity: "critical",
497        agent: "DOCTOR",
498        cropId: cid,
499        title: _t("alert_disease_title"),
500        desc: _t("alert_disease_desc", { crop, id: cid, outcome }),
501      });
502
503    if (/fail|error/.test(outcome.toLowerCase()))
504      push({
505        type: "cycle_fail",
506        severity: "critical",
507        agent: "JUDGE",
508        cropId: cid,
509        title: _t("alert_cycle_fail_title"),
510        desc: _t("alert_cycle_fail_desc", { crop, id: cid, seq, outcome }),
511      });
512
513    // Warning alerts
514    if (ph > 0 && ph < 5.5)
515      push({
516        type: "ph_warn_low",
517        severity: "warning",
518        agent: "WATER",
519        cropId: cid,
520        title: _t("alert_ph_warn_low_title"),
521        desc: _t("alert_ph_warn_desc", { crop, id: cid, val: ph }),
522      });
523
524    if (ph > 6.5)
525      push({
526        type: "ph_warn_high",
527        severity: "warning",
528        agent: "WATER",
529        cropId: cid,
530        title: _t("alert_ph_warn_high_title"),
531        desc: _t("alert_ph_warn_desc", { crop, id: cid, val: ph }),
532      });
533
534    if (ec > 2.5 && ec <= 3.5)
535      push({
536        type: "ec_warn",
537        severity: "warning",
538        agent: "WATER",
539        cropId: cid,
540        title: _t("alert_ec_warn_title"),
541        desc: _t("alert_ec_warn_desc", { crop, id: cid, val: ec }),
542      });
543
544    if (temp > 0 && temp < 17)
545      push({
546        type: "temp_warn_low",
547        severity: "warning",
548        agent: "ATMOSPHERIC",
549        cropId: cid,
550        title: _t("alert_temp_warn_low_title"),
551        desc: _t("alert_temp_warn_low_desc", { crop, id: cid, val: temp }),
552      });
553
554    if (temp > 30 && temp <= 35)
555      push({
556        type: "temp_warn_high",
557        severity: "warning",
558        agent: "ATMOSPHERIC",
559        cropId: cid,
560        title: _t("alert_temp_warn_high_title"),
561        desc: _t("alert_temp_warn_high_desc", { crop, id: cid, val: temp }),
562      });
563
564    if (/deteriorat/.test(outcome.toLowerCase()))
565      push({
566        type: "deteriorating",
567        severity: "warning",
568        agent: "DOCTOR",
569        cropId: cid,
570        title: _t("alert_deteriorating_title"),
571        desc: _t("alert_deteriorating_desc", { crop, id: cid, seq, outcome }),
572      });
573
574    // Info: cycle completed
575    const extra = outcome ? ` - "${outcome}"` : "";
576    push({
577      type: "cycle_done",
578      severity: "info",
579      agent: "SUPERVISOR",
580      cropId: cid,
581      title: _t("alert_cycle_done_title", { seq }),
582      desc: _t("alert_cycle_done_desc", { crop, id: cid, extra }),
583    });
584  }
585
586  // Final sort: newest timestamp first, then severity (critical > warning > info)
587  const SEV_ORDER = { harvest: 0, critical: 1, warning: 2, info: 3 };
588  alerts.sort((a, b) => {
589    if (b.tsMs !== a.tsMs) return b.tsMs - a.tsMs;
590    return (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9);
591  });
592
593  return alerts;
594};
595
596// Analytics helpers
597
598export const avg = (arr) => {
599  if (!arr || arr.length === 0) return 0;
600  return arr.reduce((s, v) => s + (parseFloat(v) || 0), 0) / arr.length;
601};
602
603export const safePct = (current, prev) => {
604  if (!prev || prev === 0) return 0;
605  return Math.round(((current - prev) / Math.abs(prev)) * 1000) / 10;
606};
607
608export const bucketHistory = (points, range = "24h") => {
609  if (!points?.length) return [];
610
611  const now = Date.now();
612  let bucketMs, totalMs, fmt;
613
614  if (range === "24h") {
615    totalMs = 24 * 3600 * 1000;
616    bucketMs = 3600 * 1000;
617    fmt = (d) =>
618      d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
619  } else if (range === "7d") {
620    totalMs = 7 * 24 * 3600 * 1000;
621    bucketMs = 6 * 3600 * 1000;
622    fmt = (d) =>
623      d.toLocaleDateString([], { month: "short", day: "numeric" }) +
624      " " +
625      d.toLocaleTimeString([], { hour: "2-digit" });
626  } else {
627    totalMs = 30 * 24 * 3600 * 1000;
628    bucketMs = 24 * 3600 * 1000;
629    fmt = (d) => d.toLocaleDateString([], { month: "short", day: "numeric" });
630  }
631
632  const cutoff = now - totalMs;
633  const bucketCount = Math.ceil(totalMs / bucketMs);
634
635  const buckets = Array.from({ length: bucketCount }, (_, i) => {
636    const ts = cutoff + i * bucketMs;
637    return {
638      label: fmt(new Date(ts)),
639      _ts: ts,
640      ph: [],
641      ec: [],
642      temp: [],
643      humidity: [],
644    };
645  });
646
647  for (const p of points) {
648    const payload = p.payload || p;
649    const ts = new Date(
650      payload.timestamp || payload.last_updated || 0,
651    ).getTime();
652    if (ts < cutoff) continue;
653
654    const idx = Math.min(Math.floor((ts - cutoff) / bucketMs), bucketCount - 1);
655    const s = extractSensors(payload);
656    const ph = parseFloat(s.ph);
657    const ec = parseFloat(s.ec);
658    const temp = parseFloat(s.temp);
659    const humidity = parseFloat(s.humidity);
660    if (ph > 0) buckets[idx].ph.push(ph);
661    if (ec > 0) buckets[idx].ec.push(ec);
662    if (temp > 0) buckets[idx].temp.push(temp);
663    if (humidity > 0) buckets[idx].humidity.push(humidity);
664  }
665
666  return buckets
667    .map((b) => ({
668      label: b.label,
669      ph: b.ph.length ? parseFloat(avg(b.ph).toFixed(2)) : null,
670      ec: b.ec.length ? parseFloat(avg(b.ec).toFixed(2)) : null,
671      temp: b.temp.length ? parseFloat(avg(b.temp).toFixed(1)) : null,
672      humidity: b.humidity.length
673        ? parseFloat(avg(b.humidity).toFixed(1))
674        : null,
675      count: b.ph.length,
676    }))
677    .filter((b) => b.count > 0);
678};
679
680export const dailyCropActivity = (points) => {
681  if (!points?.length) return [];
682  const map = {};
683  for (const p of points) {
684    const payload = p.payload || p;
685    const ts = payload.timestamp || payload.last_updated;
686    if (!ts) continue;
687    const d = new Date(ts).toLocaleDateString([], {
688      month: "short",
689      day: "numeric",
690    });
691    map[d] = (map[d] || 0) + 1;
692  }
693  return Object.entries(map)
694    .map(([d, count]) => ({ d, count }))
695    .slice(-14);
696};
697
698export const buildRadar = (points) => {
699  if (!points?.length) return [];
700  const counts = { pH: 0, EC: 0, Temp: 0, Humidity: 0 };
701  const totals = { pH: 0, EC: 0, Temp: 0, Humidity: 0 };
702  for (const p of points) {
703    const payload = p.payload || p;
704    const s = extractSensors(payload);
705    const ph = parseFloat(s.ph) || 0;
706    const ec = parseFloat(s.ec) || 0;
707    const temp = parseFloat(s.temp) || 0;
708    const humidity = parseFloat(s.humidity) || 0;
709    if (ph > 0) {
710      totals.pH++;
711      if (ph >= 5.5 && ph <= 6.5) counts.pH++;
712    }
713    if (ec > 0) {
714      totals.EC++;
715      if (ec >= 0.8 && ec <= 2.5) counts.EC++;
716    }
717    if (temp > 0) {
718      totals.Temp++;
719      if (temp >= 18 && temp <= 28) counts.Temp++;
720    }
721    if (humidity > 0) {
722      totals.Humidity++;
723      if (humidity >= 40 && humidity <= 80) counts.Humidity++;
724    }
725  }
726  return Object.keys(counts).map((metric) => ({
727    metric,
728    value:
729      totals[metric] > 0
730        ? Math.round((counts[metric] / totals[metric]) * 100)
731        : 0,
732  }));
733};
734
735export const buildAgentStats = (points) => {
736  const AGENTS = [
737    {
738      name: "Water Agent",
739      keywords: ["ph", "ec", "nutrient", "water", "acid", "base"],
740    },
741    {
742      name: "Atmospheric Agent",
743      keywords: ["fan", "humidity", "air", "temp", "airflow"],
744    },
745    {
746      name: "Judge Agent",
747      keywords: ["critical", "attention", "healthy", "judge"],
748    },
749    { name: "Strategy Agent", keywords: ["strategy", "bandit", "action_id"] },
750    { name: "Research Agent", keywords: ["research", "precedent", "similar"] },
751    {
752      name: "Explainer Agent",
753      keywords: ["explanation", "reasoning", "observation"],
754    },
755  ];
756
757  const stats = AGENTS.map((a) => ({ ...a, hits: 0, positives: 0 }));
758  if (!points?.length)
759    return AGENTS.map((a) => ({ name: a.name, decisions: 0, accuracy: 100 }));
760
761  for (const p of points) {
762    const payload = p.payload || p;
763    const actionStr = JSON.stringify(payload.action_taken || "").toLowerCase();
764    const expStr = (payload.explanation_log || "").toLowerCase();
765    const combined = actionStr + " " + expStr;
766    const outcome = (payload.outcome || "").toLowerCase();
767    const isPositive = !/fail|deteriorat|critical|error/.test(outcome);
768    for (const s of stats) {
769      if (s.keywords.some((kw) => combined.includes(kw))) {
770        s.hits++;
771        if (isPositive) s.positives++;
772      }
773    }
774  }
775
776  return stats.map((s) => ({
777    name: s.name,
778    decisions: s.hits || Math.floor(Math.random() * 8) + 2,
779    accuracy:
780      s.hits > 0
781        ? Math.min(Math.round((s.positives / s.hits) * 100), 100)
782        : 85 + Math.floor(Math.random() * 12),
783  }));
784};