From 8c699bd121b217685a02617733409df84b6f66e1 Mon Sep 17 00:00:00 2001 From: ferdzo Date: Mon, 17 Nov 2025 23:52:28 +0100 Subject: [PATCH] Added Comfort Index widget, updated stylings on other widgets, GPT Service with problems. --- frontend/src/api/index.ts | 17 + frontend/src/components/AddWidgetModal.tsx | 13 + .../components/widgets/AirQualityWidget.tsx | 29 +- .../components/widgets/ComfortIndexWidget.tsx | 153 +++++++++ .../components/widgets/LineChartWidget.tsx | 4 +- .../src/components/widgets/WeatherWidget.tsx | 24 +- frontend/src/components/widgets/registry.ts | 4 +- .../src/components/widgets/widget-styles.css | 25 ++ frontend/src/hooks/useDashboardConfig.ts | 2 +- frontend/src/pages/Dashboard.tsx | 3 +- iotDashboard/api/views.py | 65 ++++ iotDashboard/comfort_index.py | 300 ++++++++++++++++++ iotDashboard/gpt_service_client.py | 267 ++++++++++++++++ iotDashboard/settings.py | 2 + 14 files changed, 880 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/widgets/ComfortIndexWidget.tsx create mode 100644 frontend/src/components/widgets/widget-styles.css create mode 100644 iotDashboard/comfort_index.py diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 49e3e2a..910b1ba 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -58,6 +58,23 @@ export const devicesApi = { apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>( `/devices/${id}/metrics/` ), + + getComfortIndex: (id: string) => + apiClient.get<{ + device_id: string; + device_name: string; + overall_score: number; + rating: string; + components: { + temperature: number; + humidity: number; + air_quality: number; + acoustic: number; + light: number; + }; + suggestions: string[]; + raw_readings: Record; + }>(`/devices/${id}/comfort_index/`), }; // Telemetry API diff --git a/frontend/src/components/AddWidgetModal.tsx b/frontend/src/components/AddWidgetModal.tsx index 1b40784..8b9cd43 100644 --- a/frontend/src/components/AddWidgetModal.tsx +++ b/frontend/src/components/AddWidgetModal.tsx @@ -236,6 +236,19 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
Pulse.eco
+ + diff --git a/frontend/src/components/widgets/AirQualityWidget.tsx b/frontend/src/components/widgets/AirQualityWidget.tsx index 4497c80..9d952b9 100644 --- a/frontend/src/components/widgets/AirQualityWidget.tsx +++ b/frontend/src/components/widgets/AirQualityWidget.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import type { WidgetConfig } from '../../hooks' import { weatherApi } from '../../api' +import './widget-styles.css' interface AirQualityWidgetProps { config: WidgetConfig @@ -22,7 +23,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) { if (isLoading) { return ( -
+
@@ -32,9 +33,9 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) { if (error) { return ( -
+
-

{config.title}

+

{config.title}

Failed to load air quality data for {city} @@ -73,14 +74,14 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) { const pm25 = airQuality.measurements.pm25 return ( -

+
-

{config.title}

+

{config.title}

{/* Air quality icon */} {/* PM Values */} -
+
{pm10 && (
-
{pm10.average.toFixed(1)}
+
{pm10.average.toFixed(1)}
PM10 μg/m³
)} {pm25 && (
-
{pm25.average.toFixed(1)}
+
{pm25.average.toFixed(1)}
PM2.5 μg/m³
)}
{/* AQI Status badge */} -
+
{airQuality.status}
{/* Additional pollutants */} -
+
{Object.entries(airQuality.measurements).map(([pollutant, data]) => { if (pollutant === 'pm10' || pollutant === 'pm25') return null return ( @@ -128,8 +129,10 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
{/* City and sensor count */} -
- {airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors +
+
+ {airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors +
diff --git a/frontend/src/components/widgets/ComfortIndexWidget.tsx b/frontend/src/components/widgets/ComfortIndexWidget.tsx new file mode 100644 index 0000000..00f6bf0 --- /dev/null +++ b/frontend/src/components/widgets/ComfortIndexWidget.tsx @@ -0,0 +1,153 @@ +import { useQuery } from '@tanstack/react-query' +import { devicesApi } from '../../api' +import type { WidgetConfig } from '../../hooks' +import './widget-styles.css' + +interface ComfortIndexWidgetProps { + config: WidgetConfig +} + +export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps) { + const deviceId = config.deviceIds[0] + + const { data, isLoading, error } = useQuery({ + queryKey: ['comfort-index', deviceId], + queryFn: async () => { + const response = await devicesApi.getComfortIndex(deviceId) + return response.data + }, + refetchInterval: 60000, // Refresh every minute + enabled: !!deviceId, + }) + + if (isLoading) { + return ( +
+
+ +
+
+ ) + } + + if (error || !data) { + return ( +
+
+
+ + + + Failed to load comfort index +
+
+
+ ) + } + + const getRatingColor = (rating: string) => { + switch (rating) { + case 'Excellent': return 'text-success' + case 'Good': return 'text-info' + case 'Fair': return 'text-warning' + case 'Poor': return 'text-error' + case 'Very Poor': return 'text-error' + default: return 'text-base-content' + } + } + + const getScoreColor = (score: number) => { + if (score >= 90) return 'text-success' + if (score >= 75) return 'text-info' + if (score >= 60) return 'text-warning' + if (score >= 40) return 'text-error' + return 'text-error' + } + + return ( +
+
+ {/* Title */} +

{config.title}

+ + {/* Overall Score */} +
+
+ {data.overall_score} +
+
+ {data.rating} +
+
Comfort Index
+
+ + {/* Component Scores */} +
+
+
+ 🌡️ + Temperature +
+ + {data.components.temperature} + +
+ +
+
+ 💧 + Humidity +
+ + {data.components.humidity} + +
+ +
+
+ 🌬️ + Air Quality +
+ + {data.components.air_quality} + +
+ +
+
+ 🔊 + Acoustic +
+ + {data.components.acoustic} + +
+ +
+
+ 💡 + Lighting +
+ + {data.components.light} + +
+
+ + {/* Suggestions */} + {data.suggestions.length > 0 && ( +
+
💡 Suggestions
+
+ {data.suggestions.map((suggestion, i) => ( +
+ {suggestion} +
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/LineChartWidget.tsx b/frontend/src/components/widgets/LineChartWidget.tsx index 40a1bbc..564a57b 100644 --- a/frontend/src/components/widgets/LineChartWidget.tsx +++ b/frontend/src/components/widgets/LineChartWidget.tsx @@ -223,7 +223,9 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) { dataKey={metric} stroke={colors[index % colors.length]} strokeWidth={2} - dot={false} + dot={{ r: 2, strokeWidth: 0 }} + activeDot={{ r: 4 }} + connectNulls={true} name={formatMetricName(metric)} /> ))} diff --git a/frontend/src/components/widgets/WeatherWidget.tsx b/frontend/src/components/widgets/WeatherWidget.tsx index a8e05ce..eda97c3 100644 --- a/frontend/src/components/widgets/WeatherWidget.tsx +++ b/frontend/src/components/widgets/WeatherWidget.tsx @@ -22,7 +22,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) { if (isLoading) { return ( -
+
@@ -32,9 +32,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) { if (error) { return ( -
+
-

{config.title}

+

{config.title}

Failed to load weather data

@@ -61,26 +61,26 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) { } return ( -
+
-

{config.title}

+

{config.title}

{/* Weather Icon */} -
{getWeatherIcon(weather.weather_code)}
+
{getWeatherIcon(weather.weather_code)}
{/* Temperature */} -
{weather.temperature.toFixed(1)}°C
-
+
{weather.temperature.toFixed(1)}°C
+
Feels like {weather.apparent_temperature.toFixed(1)}°C
{/* Weather Description */} -
+
{weather.weather_description}
{/* Additional Info */} -
+
💧 {weather.humidity}% @@ -102,7 +102,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
{/* Location */} -
{weather.location}
+
+
{weather.location}
+
diff --git a/frontend/src/components/widgets/registry.ts b/frontend/src/components/widgets/registry.ts index a8c5f31..58dade1 100644 --- a/frontend/src/components/widgets/registry.ts +++ b/frontend/src/components/widgets/registry.ts @@ -7,6 +7,7 @@ import GaugeWidget from './GaugeWidget' import AiInsightWidget from './AiInsightWidget' import AirQualityWidget from './AirQualityWidget' import WeatherWidget from './WeatherWidget' +import ComfortIndexWidget from './ComfortIndexWidget' interface WidgetProps { config: WidgetConfig @@ -17,7 +18,8 @@ export const widgetRegistry: Record> = { 'stat': StatWidget, 'gauge': GaugeWidget, 'ai-insight': AiInsightWidget, - 'bar-chart': LineChartWidget, // Placeholder - implement later + 'bar-chart': LineChartWidget, 'air-quality': AirQualityWidget, 'weather': WeatherWidget, + 'comfort-index': ComfortIndexWidget, } diff --git a/frontend/src/components/widgets/widget-styles.css b/frontend/src/components/widgets/widget-styles.css new file mode 100644 index 0000000..660b7de --- /dev/null +++ b/frontend/src/components/widgets/widget-styles.css @@ -0,0 +1,25 @@ +/* Widget-specific styling overrides */ + +/* Reduce card body padding for widgets - default is 2rem (32px), we want less */ +.widget-card .card-body { + padding: 1rem; /* 16px instead of 32px */ +} + +/* Lighter shadow for widgets */ +.widget-card { + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); +} + +/* Ensure cards fill their container properly */ +.widget-card { + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-card .card-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; /* Allow flex children to shrink */ +} diff --git a/frontend/src/hooks/useDashboardConfig.ts b/frontend/src/hooks/useDashboardConfig.ts index 152bbe1..5be927a 100644 --- a/frontend/src/hooks/useDashboardConfig.ts +++ b/frontend/src/hooks/useDashboardConfig.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' +export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' export interface WidgetConfig { id: string diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1d1a167..218a520 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -161,9 +161,10 @@ export default function Dashboard() { preventCollision={false} isResizable={true} isDraggable={true} + margin={[12, 12]} > {config.widgets.map((widget) => ( -
+
removeWidget(widget.id)} diff --git a/iotDashboard/api/views.py b/iotDashboard/api/views.py index 4f575cf..06b83ed 100644 --- a/iotDashboard/api/views.py +++ b/iotDashboard/api/views.py @@ -15,6 +15,11 @@ from iotDashboard.device_manager_client import ( ) from iotDashboard import gpt_service_client from iotDashboard import weather_client +from iotDashboard.comfort_index import ( + ComfortMetrics, + ComfortIndexCalculator, + calculate_comfort_index_from_telemetry, +) from .serializers import ( DeviceSerializer, DeviceCreateSerializer, @@ -212,6 +217,66 @@ class DeviceViewSet(viewsets.ModelViewSet): 'device_name': device.name, 'metrics': list(metrics) }) + + @action(detail=True, methods=['get']) + def comfort_index(self, request, pk=None): + """ + Calculate comfort index from latest telemetry data. + + Returns overall comfort score (0-100) and component breakdowns. + """ + device = self.get_object() + + # Get latest reading for each metric + latest_readings = {} + metrics_to_check = ['temperature', 'humidity', 'co2', 'CO2', 'noise', 'sound', + 'pm2.5', 'PM2.5', 'pm10', 'PM10', 'light', 'lux'] + + for metric in metrics_to_check: + reading = ( + Telemetry.objects + .filter(device_id=device.id, metric=metric) + .order_by('-time') + .first() + ) + if reading: + latest_readings[metric] = reading.value + + if not latest_readings: + return Response( + {'error': 'No telemetry data available for comfort calculation'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Calculate comfort index + comfort_metrics = ComfortMetrics( + temperature=latest_readings.get('temperature'), + humidity=latest_readings.get('humidity'), + co2=latest_readings.get('co2') or latest_readings.get('CO2'), + noise=latest_readings.get('noise') or latest_readings.get('sound'), + pm25=latest_readings.get('pm2.5') or latest_readings.get('PM2.5'), + pm10=latest_readings.get('pm10') or latest_readings.get('PM10'), + light=latest_readings.get('light') or latest_readings.get('lux'), + ) + + comfort_score = ComfortIndexCalculator.calculate(comfort_metrics) + + return Response({ + 'device_id': device.id, + 'device_name': device.name, + 'overall_score': comfort_score.overall_score, + 'rating': comfort_score.rating, + 'components': { + 'temperature': comfort_score.temperature_score, + 'humidity': comfort_score.humidity_score, + 'air_quality': comfort_score.air_quality_score, + 'acoustic': comfort_score.acoustic_score, + 'light': comfort_score.light_score, + }, + 'suggestions': comfort_score.suggestions, + 'raw_readings': latest_readings, + }) + class TelemetryViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/iotDashboard/comfort_index.py b/iotDashboard/comfort_index.py new file mode 100644 index 0000000..064f910 --- /dev/null +++ b/iotDashboard/comfort_index.py @@ -0,0 +1,300 @@ +""" +Comfort Index Calculation Module + +Combines temperature, humidity, CO₂, noise, and air quality into a weighted comfort score. +Score range: 0-100 (100 = optimal comfort) +""" + +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class ComfortMetrics: + """Raw environmental metrics for comfort calculation.""" + temperature: Optional[float] = None + humidity: Optional[float] = None + co2: Optional[float] = None + noise: Optional[float] = None + pm25: Optional[float] = None + pm10: Optional[float] = None + light: Optional[float] = None + timestamp: Optional[datetime] = None + + +@dataclass +class ComfortScore: + """Comfort index results with component scores.""" + overall_score: float # 0-100 + temperature_score: float + humidity_score: float + air_quality_score: float + acoustic_score: float + light_score: float + rating: str # "Excellent", "Good", "Fair", "Poor", "Very Poor" + suggestions: List[str] + +class ComfortIndexCalculator: + """Calculate multi-factor comfort index from environmental sensors.""" + + # Optimal ranges (comfort score = 100 at these values) + OPTIMAL_TEMP_RANGE = (20.0, 24.0) # °C + OPTIMAL_HUMIDITY_RANGE = (40.0, 60.0) # % + OPTIMAL_CO2_MAX = 800 # ppm (< 1000 is good) + OPTIMAL_NOISE_MAX = 40 # dB (office environment) + OPTIMAL_PM25_MAX = 12 # μg/m³ (WHO guideline) + OPTIMAL_PM10_MAX = 20 # μg/m³ + OPTIMAL_LIGHT_RANGE = (300, 500) # Lux (office work) + + # Weights for overall score (must sum to 1.0) + WEIGHTS = { + 'temperature': 0.25, + 'humidity': 0.15, + 'air_quality': 0.30, + 'acoustic': 0.15, + 'light': 0.15, + } + + @staticmethod + def calculate_temperature_score(temp: float) -> Tuple[float, List[str]]: + """Score temperature comfort (0-100).""" + suggestions = [] + optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_TEMP_RANGE + + if optimal_min <= temp <= optimal_max: + score = 100.0 + elif temp < optimal_min: + deviation = optimal_min - temp + score = max(0, 100 - (deviation * 10)) # -10 points per degree below + if deviation > 2: + suggestions.append(f"Temperature too cold ({temp:.1f}°C). Increase heating to {optimal_min}-{optimal_max}°C") + else: + deviation = temp - optimal_max + score = max(0, 100 - (deviation * 10)) # -10 points per degree above + if deviation > 2: + suggestions.append(f"Temperature too warm ({temp:.1f}°C). Increase cooling to {optimal_min}-{optimal_max}°C") + + return score, suggestions + + @staticmethod + def calculate_humidity_score(humidity: float) -> Tuple[float, List[str]]: + """Score humidity comfort (0-100).""" + suggestions = [] + optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_HUMIDITY_RANGE + + if optimal_min <= humidity <= optimal_max: + score = 100.0 + elif humidity < optimal_min: + # Too dry + deviation = optimal_min - humidity + score = max(0, 100 - (deviation * 2)) # -2 points per % below + if deviation > 10: + suggestions.append(f"Air too dry ({humidity:.1f}%). Use humidifier to reach {optimal_min}-{optimal_max}%") + else: + # Too humid + deviation = humidity - optimal_max + score = max(0, 100 - (deviation * 2)) # -2 points per % above + if deviation > 10: + suggestions.append(f"Air too humid ({humidity:.1f}%). Use dehumidifier to reach {optimal_min}-{optimal_max}%") + + return score, suggestions + + @staticmethod + def calculate_air_quality_score(co2: Optional[float], pm25: Optional[float], pm10: Optional[float]) -> Tuple[float, List[str]]: + """Score air quality based on CO₂ and particulate matter (0-100).""" + suggestions = [] + scores = [] + + # CO₂ score + if co2 is not None: + if co2 <= ComfortIndexCalculator.OPTIMAL_CO2_MAX: + co2_score = 100.0 + elif co2 <= 1000: + # Acceptable range (800-1000 ppm) + co2_score = 100 - ((co2 - 800) / 2) # -0.5 points per ppm + else: + # Poor air quality + co2_score = max(0, 50 - ((co2 - 1000) / 20)) # Drops to 0 at 2000 ppm + if co2 > 1500: + suggestions.append(f"CO₂ level very high ({co2:.0f} ppm). Increase ventilation immediately") + else: + suggestions.append(f"CO₂ level elevated ({co2:.0f} ppm). Improve ventilation") + scores.append(co2_score) + + # PM2.5 score + if pm25 is not None: + if pm25 <= ComfortIndexCalculator.OPTIMAL_PM25_MAX: + pm25_score = 100.0 + elif pm25 <= 35.4: + pm25_score = 100 - ((pm25 - 12) / 0.234) + else: + pm25_score = 0 + suggestions.append(f"PM2.5 unhealthy ({pm25:.1f} μg/m³). Use air purifier and avoid outdoor air") + scores.append(pm25_score) + + # PM10 score + if pm10 is not None: + if pm10 <= ComfortIndexCalculator.OPTIMAL_PM10_MAX: + pm10_score = 100.0 + elif pm10 <= 50: # Acceptable + pm10_score = 100 - ((pm10 - 20) / 0.3) + else: + pm10_score = max(0, 50 - (pm10 - 50)) + if pm10 > 100: + suggestions.append(f"PM10 very high ({pm10:.1f} μg/m³). Close windows and use filtration") + scores.append(pm10_score) + + score = sum(scores) / len(scores) if scores else 50.0 + return score, suggestions + + @staticmethod + def calculate_acoustic_score(noise: float) -> Tuple[float, List[str]]: + """Score acoustic comfort based on noise level (0-100).""" + suggestions = [] + + if noise <= ComfortIndexCalculator.OPTIMAL_NOISE_MAX: + score = 100.0 + elif noise <= 55: + score = 100 - ((noise - 40) * 3) + elif noise <= 70: # Noisy + score = max(0, 55 - ((noise - 55) * 2)) + suggestions.append(f"Noise level high ({noise:.1f} dB). Consider noise-canceling or quieter environment") + else: # Very noisy + score = 0 + suggestions.append(f"Noise level very high ({noise:.1f} dB). Immediate action needed for hearing protection") + + return score, suggestions + + @staticmethod + def calculate_light_score(light: float) -> Tuple[float, List[str]]: + """Score lighting comfort (0-100).""" + suggestions = [] + optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_LIGHT_RANGE + + if optimal_min <= light <= optimal_max: + score = 100.0 + elif light < optimal_min: + # Too dark + if light < 100: + score = 0 + suggestions.append(f"Lighting very dim ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux") + else: + score = (light / optimal_min) * 100 + suggestions.append(f"Lighting insufficient ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux") + else: + # Too bright + if light > 1000: + score = max(0, 100 - ((light - 1000) / 10)) + suggestions.append(f"Lighting very bright ({light:.0f} lux). May cause glare. Reduce to {optimal_min}-{optimal_max} lux") + else: + score = 100 - ((light - optimal_max) / 5) + + return score, suggestions + + @staticmethod + def get_rating(score: float) -> str: + """Convert numeric score to rating.""" + if score >= 90: + return "Excellent" + elif score >= 75: + return "Good" + elif score >= 60: + return "Fair" + elif score >= 40: + return "Poor" + else: + return "Very Poor" + + @classmethod + def calculate(cls, metrics: ComfortMetrics) -> ComfortScore: + """ + Calculate overall comfort index from environmental metrics. + + Args: + metrics: ComfortMetrics with sensor readings + + Returns: + ComfortScore with overall score and component breakdowns + """ + all_suggestions = [] + component_scores = {} + + # Temperature + if metrics.temperature is not None: + temp_score, temp_suggestions = cls.calculate_temperature_score(metrics.temperature) + component_scores['temperature'] = temp_score + all_suggestions.extend(temp_suggestions) + else: + component_scores['temperature'] = 50.0 # + + # Humidity + if metrics.humidity is not None: + humidity_score, humidity_suggestions = cls.calculate_humidity_score(metrics.humidity) + component_scores['humidity'] = humidity_score + all_suggestions.extend(humidity_suggestions) + else: + component_scores['humidity'] = 50.0 + + # Air Quality (CO₂ + PM) + air_score, air_suggestions = cls.calculate_air_quality_score( + metrics.co2, metrics.pm25, metrics.pm10 + ) + component_scores['air_quality'] = air_score + all_suggestions.extend(air_suggestions) + + # Acoustic + if metrics.noise is not None: + acoustic_score, acoustic_suggestions = cls.calculate_acoustic_score(metrics.noise) + component_scores['acoustic'] = acoustic_score + all_suggestions.extend(acoustic_suggestions) + else: + component_scores['acoustic'] = 50.0 + + # Light + if metrics.light is not None: + light_score, light_suggestions = cls.calculate_light_score(metrics.light) + component_scores['light'] = light_score + all_suggestions.extend(light_suggestions) + else: + component_scores['light'] = 50.0 + + # Calculate weighted overall score + overall = sum( + component_scores[key] * cls.WEIGHTS[key] + for key in cls.WEIGHTS.keys() + ) + + return ComfortScore( + overall_score=round(overall, 1), + temperature_score=round(component_scores['temperature'], 1), + humidity_score=round(component_scores['humidity'], 1), + air_quality_score=round(component_scores['air_quality'], 1), + acoustic_score=round(component_scores['acoustic'], 1), + light_score=round(component_scores['light'], 1), + rating=cls.get_rating(overall), + suggestions=all_suggestions, + ) + + +def calculate_comfort_index_from_telemetry(telemetry_data: Dict[str, float]) -> ComfortScore: + """ + Convenience function to calculate comfort index from telemetry dictionary. + + Args: + telemetry_data: Dict with metric names as keys, e.g. {"temperature": 22.5, "humidity": 45} + + Returns: + ComfortScore + """ + metrics = ComfortMetrics( + temperature=telemetry_data.get('temperature'), + humidity=telemetry_data.get('humidity'), + co2=telemetry_data.get('co2') or telemetry_data.get('CO2'), + noise=telemetry_data.get('noise') or telemetry_data.get('sound'), + pm25=telemetry_data.get('pm2.5') or telemetry_data.get('PM2.5'), + pm10=telemetry_data.get('pm10') or telemetry_data.get('PM10'), + light=telemetry_data.get('light') or telemetry_data.get('lux'), + ) + + return ComfortIndexCalculator.calculate(metrics) diff --git a/iotDashboard/gpt_service_client.py b/iotDashboard/gpt_service_client.py index e69de29..ecffa4a 100644 --- a/iotDashboard/gpt_service_client.py +++ b/iotDashboard/gpt_service_client.py @@ -0,0 +1,267 @@ +""" +Client for GPT Service microservice. + +Handles communication between Django and the GPT analysis service. +""" + +import httpx +import logging +from typing import List, Dict, Any, Optional, Literal +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class GPTServiceError(Exception): + """Exception raised when GPT service request fails.""" + + def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict] = None): + self.message = message + self.status_code = status_code + self.details = details or {} + super().__init__(self.message) + + +class GPTServiceClient: + """Client for interacting with the GPT analysis microservice.""" + + def __init__(self, base_url: Optional[str] = None, timeout: float = 30.0): + """ + Initialize GPT service client. + + Args: + base_url: Base URL of GPT service (default: from settings or http://localhost:8001) + timeout: Request timeout in seconds + """ + self.base_url = base_url or getattr(settings, 'GPT_SERVICE_URL', 'http://localhost:8001') + self.timeout = timeout + self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout) + logger.info(f"Initialized GPTServiceClient with base_url={self.base_url}") + + async def health_check(self) -> Dict[str, Any]: + """ + Check if GPT service is healthy. + + Returns: + Dict with status information + + Raises: + httpx.HTTPError: If service is unreachable + """ + try: + response = await self.client.get("/health") + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"GPT service health check failed: {e}") + raise + + async def analyze_telemetry( + self, + telemetry_data: List[Dict[str, Any]], + device_info: Optional[Dict[str, Any]] = None, + prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary", + custom_prompt: Optional[str] = None + ) -> Dict[str, Any]: + """ + Analyze telemetry data using GPT service. + + Args: + telemetry_data: List of telemetry data points with keys: + - device_id: str + - metric: str + - value: float + - timestamp: str (ISO format) + device_info: Optional device metadata (name, location, etc.) + prompt_type: Type of analysis to perform + custom_prompt: Custom prompt for 'custom' type + + Returns: + Dict containing: + - analysis: str (AI-generated analysis) + - prompt_type: str + - data_points_analyzed: int + + Raises: + ValueError: If telemetry_data is empty + httpx.HTTPError: If service request fails + """ + if not telemetry_data: + raise ValueError("telemetry_data cannot be empty") + + payload = { + "telemetry_data": telemetry_data, + "device_info": device_info or {}, + "prompt_type": prompt_type, + } + + if custom_prompt: + payload["custom_prompt"] = custom_prompt + + try: + logger.info( + f"Requesting {prompt_type} analysis for {len(telemetry_data)} data points" + ) + response = await self.client.post("/analyze", json=payload) + response.raise_for_status() + result = response.json() + logger.info( + f"Analysis completed: {result.get('data_points_analyzed')} points analyzed" + ) + return result + except httpx.HTTPStatusError as e: + error_detail = e.response.text + logger.error( + f"GPT service returned error {e.response.status_code}: {error_detail}" + ) + raise GPTServiceError( + message=f"GPT service error: {error_detail}", + status_code=e.response.status_code, + details={"response": error_detail} + ) + except httpx.RequestError as e: + logger.error(f"Failed to connect to GPT service: {e}") + raise GPTServiceError( + message=f"GPT service unavailable: {str(e)}", + status_code=503, + details={"error": str(e)} + ) + except Exception as e: + logger.error(f"Failed to analyze telemetry: {e}") + raise GPTServiceError( + message=f"Analysis failed: {str(e)}", + details={"error": str(e)} + ) + + async def detect_anomalies( + self, + telemetry_data: List[Dict[str, Any]], + device_info: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Detect anomalies in telemetry data. + + Convenience method for anomaly_detection analysis. + + Returns: + Dict with analysis containing: + - status: normal|warning|critical + - summary: Brief overview + - anomalies: List of detected anomalies + - impacts: List of potential impacts + - actions: List of recommended actions + """ + result = await self.analyze_telemetry( + telemetry_data=telemetry_data, + device_info=device_info, + prompt_type="anomaly_detection" + ) + return result + + async def summarize_trends( + self, + telemetry_data: List[Dict[str, Any]], + device_info: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Summarize trends in telemetry data. + + Convenience method for trend_summary analysis. + + Returns: + Dict with analysis containing: + - status: excellent|good|fair|poor + - summary: Brief overview + - trends: List of metric trends + - comfort_score: Overall comfort rating + - patterns: Identified patterns + - recommendations: Suggested actions + """ + result = await self.analyze_telemetry( + telemetry_data=telemetry_data, + device_info=device_info, + prompt_type="trend_summary" + ) + return result + + async def close(self): + """Close the HTTP client connection.""" + await self.client.aclose() + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + +def format_telemetry_for_gpt(queryset, device_id: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Format Django Telemetry queryset for GPT service. + + Args: + queryset: Django queryset of Telemetry objects + device_id: Optional device_id to include in each point + + Returns: + List of dicts formatted for GPT service + """ + data = [] + for point in queryset: + data.append({ + "device_id": device_id or point.device_id, + "metric": point.metric, + "value": float(point.value), + "timestamp": point.time.isoformat(), + }) + return data + + +_client_instance = None + + +def get_gpt_client() -> GPTServiceClient: + """ + Get or create singleton GPT service client instance. + + Returns: + GPTServiceClient instance + """ + global _client_instance + if _client_instance is None: + _client_instance = GPTServiceClient() + return _client_instance + + +async def analyze_telemetry( + telemetry_data: List[Dict[str, Any]], + device_info: Optional[Dict[str, Any]] = None, + prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary", + custom_prompt: Optional[str] = None +) -> Dict[str, Any]: + """ + Module-level function to analyze telemetry data. + + Uses singleton client instance. Convenience wrapper for Django views. + + Args: + telemetry_data: List of telemetry data points + device_info: Optional device metadata + prompt_type: Type of analysis to perform + custom_prompt: Custom prompt for 'custom' type + + Returns: + Dict containing analysis results + + Raises: + GPTServiceError: If analysis fails + """ + client = get_gpt_client() + return await client.analyze_telemetry( + telemetry_data=telemetry_data, + device_info=device_info, + prompt_type=prompt_type, + custom_prompt=custom_prompt + ) \ No newline at end of file diff --git a/iotDashboard/settings.py b/iotDashboard/settings.py index 62cf78a..0d5124e 100644 --- a/iotDashboard/settings.py +++ b/iotDashboard/settings.py @@ -33,6 +33,8 @@ POSTGRES_USER = os.getenv("POSTGRES_USER") POSTGRES_DB = os.getenv("POSTGRES_DB") POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD") +GPT_SERVICE_URL = os.getenv("GPT_SERVICE_URL", "http://localhost:8001") + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True