from __future__ import annotations from decimal import Decimal, InvalidOperation from datetime import datetime, time from statistics import mean from collections import defaultdict from agents.analysis.backend.app.models.schemas import AssetProfile class DeterministicAnalysisService: def summarize(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]: base = self._generic_summary(rows) domain_summary = self._asset_specific_summary(asset, rows) base["warnings"].extend(domain_summary["warnings"]) base["findings"] = domain_summary["findings"] return base def _generic_summary(self, rows: list[dict]) -> dict[str, object]: if not rows: return { "numeric_metrics": {}, "warnings": ["No telemetry rows were returned for the requested window."], "findings": [], } numeric_columns: dict[str, list[float]] = {} for row in rows: for key, value in row.items(): if isinstance(value, bool): continue numeric_value = self._as_float(value) if numeric_value is not None: numeric_columns.setdefault(key, []).append(numeric_value) metric_summary: dict[str, dict[str, float]] = {} warnings: list[str] = [] for column, values in numeric_columns.items(): if not values: continue avg = mean(values) metric_summary[column] = { "min": min(values), "max": max(values), "avg": round(avg, 3), "latest": values[-1], } if len(values) > 1 and avg != 0: spread = max(values) - min(values) if spread > abs(avg) * 0.2: warnings.append( f"{column} changed noticeably across the requested window " f"(min={min(values):.2f}, max={max(values):.2f})." ) return { "numeric_metrics": metric_summary, "warnings": warnings, "findings": self._generic_trend_findings(metric_summary), } def _asset_specific_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]: if asset.asset_type == "cold_room": return self._cold_room_summary(asset, rows) if asset.asset_type == "filling_machine": return self._filling_machine_summary(rows) if asset.asset_type == "tank": return self._tank_summary(asset, rows) if asset.asset_type == "smoke_alarm": return self._smoke_alarm_summary(asset, rows) return {"warnings": [], "findings": []} def _smoke_alarm_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]: findings: list[str] = [] warnings: list[str] = [] per_asset: dict[str, list[dict]] = defaultdict(list) for row in rows: asset_name = str(row.get("asset_name") or asset.name or "Unknown") per_asset[asset_name].append(row) if not per_asset: return {"warnings": ["No smoke-alarm telemetry rows were available for the requested window."], "findings": []} smoke_events: list[tuple[str, datetime | None, str]] = [] muted_events: list[tuple[str, datetime | None, str]] = [] fault_events: list[tuple[str, datetime | None, str]] = [] remove_events: list[tuple[str, datetime | None, str]] = [] low_voltage_assets: set[str] = set() unknown_assets: set[str] = set() latest_states: list[str] = [] for asset_name, asset_rows in sorted(per_asset.items()): ordered = sorted(asset_rows, key=lambda row: self._parse_datetime(row.get("recorded_at") or row.get("timestamp")) or datetime.min) per_sensor: dict[str, list[dict]] = defaultdict(list) for row in ordered: sensor_name = str(row.get("attribute_name") or row.get("sensor_name") or "sensor") per_sensor[sensor_name].append(row) for row in ordered: warn_type = self._normalize_warn_type(row.get("warn_type")) event_time = self._parse_datetime(row.get("recorded_at") or row.get("timestamp")) attribute_name = str(row.get("attribute_name") or row.get("sensor_name") or "sensor") event_ref = (asset_name, event_time, attribute_name) if warn_type == "warn": smoke_events.append(event_ref) elif warn_type == "mute": muted_events.append(event_ref) elif warn_type == "fault": fault_events.append(event_ref) elif warn_type == "remove": remove_events.append(event_ref) elif warn_type in {"low-vol", "low-vol-test"}: low_voltage_assets.add(asset_name) elif warn_type == "unknown": unknown_assets.add(asset_name) latest_sensor_summaries: list[str] = [] for sensor_name, sensor_rows in sorted(per_sensor.items()): latest_row = sensor_rows[-1] # Strict smoke detection: only if warn_type is 'warn' is_actual_smoke = any(str(r.get("warn_type", "")).lower() == "warn" for r in sensor_rows) latest_warn: str | None = "SMOKE DETECTED" if is_actual_smoke else self._normalize_warn_type(latest_row.get("warn_type")) if is_actual_smoke: smoke_events.append((asset_name, self._parse_datetime(latest_row.get("recorded_at") or latest_row.get("timestamp")), sensor_name)) latest_time = self._parse_datetime(latest_row.get("recorded_at") or latest_row.get("timestamp")) latest_temp = self._as_float(latest_row.get("temperature")) latest_humidity = self._as_float(latest_row.get("humidity")) latest_battery = self._as_float(latest_row.get("bat_percent")) raw_data = latest_row.get("sensor_data") if raw_data is None and latest_warn is None: # Explicit handling for assets with no data in the window state_fragments = [f"{sensor_name}: No recent telemetry found (Offline)"] latest_sensor_summaries.append(" ".join(state_fragments)) continue state_fragments = [f"{sensor_name}: {latest_warn or 'ok'}"] if latest_time is not None: state_fragments.append(f"at {latest_time.strftime('%d %b %Y, %I:%M %p')}") details: list[str] = [] if latest_temp is not None: details.append(f"{latest_temp:.1f}C") if latest_humidity is not None: details.append(f"{latest_humidity:.1f}% RH") if latest_battery is not None: details.append(f"battery {latest_battery:.0f}%") if isinstance(raw_data, dict): skip_keys = {"warn", "temp", "humi", "bat_percent", "bat_voltage", "name", "timestamp"} for k, v in raw_data.items(): if k.lower() in skip_keys: continue val = self._as_float(v) if val is not None: details.append(f"{k}: {val}") elif isinstance(v, str) and len(v) < 20: details.append(f"{k}: {v}") else: # Strict smoke detection: only if warn_type is 'warn' warn_type = str(latest_row.get("warn_type", "")).lower() is_actual_smoke = (warn_type == "warn") if is_actual_smoke: smoke_events.append(latest_row) latest_warn = "SMOKE DETECTED" # If it's a simple value, include it directly val = self._as_float(raw_data) if val is not None: details.append(f"value: {val}") elif raw_data is not None: details.append(f"value: {raw_data}") if details: state_fragments.append(f"({', '.join(details)})") # For 'ok' status, show sensor name and details but omit the word 'ok'. if not latest_warn or latest_warn == 'ok': # state_fragments[0] is like "data: ok" — keep the sensor name prefix, drop 'ok' sensor_label = state_fragments[0].split(":")[0].strip() + ":" ok_parts = [sensor_label] + state_fragments[1:] # sensor_name: