from __future__ import annotations from datetime import date, timedelta from typing import Any from dashboard_data.card_utils import PERSIAN_WEEKDAYS INTERPOLATION_LIMIT = 3 QUALITY_REAL = "REAL" QUALITY_INTERPOLATED = "INTERPOLATED" QUALITY_MISSING = "MISSING" def _day_categories() -> list[date]: return [date.today() - timedelta(days=offset) for offset in range(6, -1, -1)] def _day_label(day: date) -> str: return PERSIAN_WEEKDAYS[day.weekday()] def _history_value_map(history: list[Any], field_name: str) -> dict[date, float | None]: value_map: dict[date, float | None] = {} for item in history: timestamp = getattr(item, "recorded_at", None) if timestamp is None: continue day = timestamp.date() if day in value_map: continue value = getattr(item, field_name, None) value_map[day] = float(value) if value is not None else None return value_map def _apply_linear_interpolation(points: list[dict[str, Any]], limit: int = INTERPOLATION_LIMIT) -> list[dict[str, Any]]: output = [dict(point) for point in points] known_indexes = [index for index, point in enumerate(output) if point["value"] is not None] for start_index, end_index in zip(known_indexes, known_indexes[1:]): gap = end_index - start_index - 1 if gap <= 0 or gap > limit: continue start_value = output[start_index]["value"] end_value = output[end_index]["value"] if start_value is None or end_value is None: continue step = (end_value - start_value) / (gap + 1) for offset in range(1, gap + 1): target_index = start_index + offset output[target_index]["value"] = round(start_value + (step * offset), 2) output[target_index]["quality_flag"] = QUALITY_INTERPOLATED return output def _build_week_points( history: list[Any], field_name: str, day_offset_start: int, ) -> list[dict[str, Any]]: days = [date.today() - timedelta(days=offset) for offset in range(day_offset_start + 6, day_offset_start - 1, -1)] value_map = _history_value_map(history, field_name) raw_points = [ { "timestamp": day.isoformat(), "value": round(value_map[day], 2) if day in value_map and value_map[day] is not None else None, "quality_flag": QUALITY_REAL if day in value_map and value_map[day] is not None else QUALITY_MISSING, } for day in days ] return _apply_linear_interpolation(raw_points) def _average_known(points: list[dict[str, Any]]) -> float | None: values = [point["value"] for point in points if point["value"] is not None] if not values: return None return sum(values) / len(values) def build_sensor_comparison_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: history = (context or {}).get("history", []) current_sensor = (context or {}).get("sensor") current_value = getattr(current_sensor, "soil_moisture", None) if current_sensor is not None else None current_value = round(float(current_value), 2) if current_value is not None else None this_week_points = _build_week_points(history, "soil_moisture", day_offset_start=0) last_week_points = _build_week_points(history, "soil_moisture", day_offset_start=7) this_week_avg = _average_known(this_week_points) last_week_avg = _average_known(last_week_points) delta = 0 if this_week_avg is not None and last_week_avg not in (None, 0): delta = round(((this_week_avg - last_week_avg) / last_week_avg) * 100) categories = [_day_label(day) for day in _day_categories()] return { "currentValue": current_value, "currentValueQuality": QUALITY_REAL if current_value is not None else QUALITY_MISSING, "vsLastWeek": f"{'+' if delta >= 0 else ''}{delta}%", "vsLastWeekValue": delta, "categories": categories, "series": [ { "name": "هفته جاری", "data": [point["value"] for point in this_week_points], "points": this_week_points, }, { "name": "هفته قبل", "data": [point["value"] for point in last_week_points], "points": last_week_points, }, ], "qualityLegend": { QUALITY_REAL: "اندازه‌گیری واقعی سنسور", QUALITY_INTERPOLATED: "برآورد خطی برای شکاف کوتاه داده", QUALITY_MISSING: "داده معتبر در دسترس نیست", }, }