Added Comfort Index widget, updated stylings on other widgets, GPT Service with problems.

This commit is contained in:
2025-11-17 23:52:28 +01:00
parent 7f2aec96dd
commit 8c699bd121
14 changed files with 880 additions and 28 deletions

View File

@@ -58,6 +58,23 @@ export const devicesApi = {
apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>( apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>(
`/devices/${id}/metrics/` `/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<string, number>;
}>(`/devices/${id}/comfort_index/`),
}; };
// Telemetry API // Telemetry API

View File

@@ -236,6 +236,19 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<div className="text-xs opacity-70">Pulse.eco</div> <div className="text-xs opacity-70">Pulse.eco</div>
</div> </div>
</button> </button>
<button
className={`btn ${widgetType === 'comfort-index' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('comfort-index')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-left">
<div className="font-semibold">Comfort Index</div>
<div className="text-xs opacity-70">Multi-factor</div>
</div>
</button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { WidgetConfig } from '../../hooks' import type { WidgetConfig } from '../../hooks'
import { weatherApi } from '../../api' import { weatherApi } from '../../api'
import './widget-styles.css'
interface AirQualityWidgetProps { interface AirQualityWidgetProps {
config: WidgetConfig config: WidgetConfig
@@ -22,7 +23,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="card bg-base-100 shadow-lg h-full"> <div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center"> <div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
</div> </div>
@@ -32,9 +33,9 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
if (error) { if (error) {
return ( return (
<div className="card bg-base-100 shadow-lg h-full"> <div className="widget-card card bg-base-100 h-full">
<div className="card-body"> <div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2> <h2 className="card-title text-sm truncate">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1"> <div className="flex flex-col items-center justify-center flex-1">
<p className="text-error text-sm text-center"> <p className="text-error text-sm text-center">
Failed to load air quality data for {city} Failed to load air quality data for {city}
@@ -73,14 +74,14 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
const pm25 = airQuality.measurements.pm25 const pm25 = airQuality.measurements.pm25
return ( return (
<div className="card bg-base-100 shadow-lg h-full"> <div className="widget-card card bg-base-100 h-full">
<div className="card-body"> <div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2> <h2 className="card-title text-sm truncate">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1"> <div className="flex flex-col items-center justify-center flex-1">
{/* Air quality icon */} {/* Air quality icon */}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={`h-16 w-16 text-${statusColor} mb-2`} className={`h-12 w-12 text-${statusColor} mb-1`}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -94,28 +95,28 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
</svg> </svg>
{/* PM Values */} {/* PM Values */}
<div className="grid grid-cols-2 gap-4 w-full mb-3"> <div className="grid grid-cols-2 gap-2 w-full mb-2">
{pm10 && ( {pm10 && (
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold">{pm10.average.toFixed(1)}</div> <div className="text-xl font-bold">{pm10.average.toFixed(1)}</div>
<div className="text-xs text-base-content/60">PM10 μg/m³</div> <div className="text-xs text-base-content/60">PM10 μg/m³</div>
</div> </div>
)} )}
{pm25 && ( {pm25 && (
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold">{pm25.average.toFixed(1)}</div> <div className="text-xl font-bold">{pm25.average.toFixed(1)}</div>
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div> <div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
</div> </div>
)} )}
</div> </div>
{/* AQI Status badge */} {/* AQI Status badge */}
<div className={`badge badge-${statusColor} badge-lg`}> <div className={`badge badge-${statusColor} truncate max-w-full`}>
{airQuality.status} {airQuality.status}
</div> </div>
{/* Additional pollutants */} {/* Additional pollutants */}
<div className="grid grid-cols-2 gap-2 mt-3 w-full text-xs"> <div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
{Object.entries(airQuality.measurements).map(([pollutant, data]) => { {Object.entries(airQuality.measurements).map(([pollutant, data]) => {
if (pollutant === 'pm10' || pollutant === 'pm25') return null if (pollutant === 'pm10' || pollutant === 'pm25') return null
return ( return (
@@ -128,8 +129,10 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
</div> </div>
{/* City and sensor count */} {/* City and sensor count */}
<div className="text-xs text-base-content/40 mt-3"> <div className="text-xs text-base-content/40 mt-2 px-2 w-full overflow-hidden">
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} {airQuality.sensor_count} sensors <div className="truncate text-center">
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} {airQuality.sensor_count} sensors
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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 (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center">
<div className="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Failed to load comfort index</span>
</div>
</div>
</div>
)
}
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 (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body p-3 gap-2">
{/* Title */}
<h2 className="card-title text-sm mb-1">{config.title}</h2>
{/* Overall Score */}
<div className="text-center">
<div className={`text-4xl font-bold ${getScoreColor(data.overall_score)}`}>
{data.overall_score}
</div>
<div className={`text-lg font-semibold ${getRatingColor(data.rating)} mt-0.5`}>
{data.rating}
</div>
<div className="text-xs text-base-content/60">Comfort Index</div>
</div>
{/* Component Scores */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">🌡</span>
<span className="truncate">Temperature</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
{data.components.temperature}
</span>
</div>
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">💧</span>
<span className="truncate">Humidity</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
{data.components.humidity}
</span>
</div>
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">🌬</span>
<span className="truncate">Air Quality</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
{data.components.air_quality}
</span>
</div>
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">🔊</span>
<span className="truncate">Acoustic</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
{data.components.acoustic}
</span>
</div>
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">💡</span>
<span className="truncate">Lighting</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
{data.components.light}
</span>
</div>
</div>
{/* Suggestions */}
{data.suggestions.length > 0 && (
<div className="mt-auto">
<div className="text-xs font-semibold mb-1">💡 Suggestions</div>
<div className="space-y-1 max-h-20 overflow-y-auto">
{data.suggestions.map((suggestion, i) => (
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
{suggestion}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -223,7 +223,9 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
dataKey={metric} dataKey={metric}
stroke={colors[index % colors.length]} stroke={colors[index % colors.length]}
strokeWidth={2} strokeWidth={2}
dot={false} dot={{ r: 2, strokeWidth: 0 }}
activeDot={{ r: 4 }}
connectNulls={true}
name={formatMetricName(metric)} name={formatMetricName(metric)}
/> />
))} ))}

View File

@@ -22,7 +22,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="card bg-base-100 shadow-lg h-full"> <div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center"> <div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
</div> </div>
@@ -32,9 +32,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
if (error) { if (error) {
return ( return (
<div className="card bg-base-100 shadow-lg h-full"> <div className="widget-card card bg-base-100 h-full">
<div className="card-body"> <div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2> <h2 className="card-title text-sm truncate">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1"> <div className="flex flex-col items-center justify-center flex-1">
<p className="text-error">Failed to load weather data</p> <p className="text-error">Failed to load weather data</p>
</div> </div>
@@ -61,26 +61,26 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
} }
return ( return (
<div className="card bg-base-100 shadow-lg h-full"> <div className="widget-card card bg-base-100 h-full">
<div className="card-body"> <div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2> <h2 className="card-title text-sm truncate">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1"> <div className="flex flex-col items-center justify-center flex-1">
{/* Weather Icon */} {/* Weather Icon */}
<div className="text-6xl mb-2">{getWeatherIcon(weather.weather_code)}</div> <div className="text-5xl mb-1">{getWeatherIcon(weather.weather_code)}</div>
{/* Temperature */} {/* Temperature */}
<div className="text-4xl font-bold">{weather.temperature.toFixed(1)}°C</div> <div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
<div className="text-sm text-base-content/60"> <div className="text-xs text-base-content/60">
Feels like {weather.apparent_temperature.toFixed(1)}°C Feels like {weather.apparent_temperature.toFixed(1)}°C
</div> </div>
{/* Weather Description */} {/* Weather Description */}
<div className="badge badge-primary badge-lg mt-2"> <div className="badge badge-primary mt-1 truncate max-w-full">
{weather.weather_description} {weather.weather_description}
</div> </div>
{/* Additional Info */} {/* Additional Info */}
<div className="grid grid-cols-2 gap-4 mt-4 w-full text-sm"> <div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="opacity-60">💧</span> <span className="opacity-60">💧</span>
<span>{weather.humidity}%</span> <span>{weather.humidity}%</span>
@@ -102,7 +102,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
</div> </div>
{/* Location */} {/* Location */}
<div className="text-xs text-base-content/40 mt-3">{weather.location}</div> <div className="text-xs text-base-content/40 mt-3 px-2 w-full overflow-hidden">
<div className="truncate text-center">{weather.location}</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import GaugeWidget from './GaugeWidget'
import AiInsightWidget from './AiInsightWidget' import AiInsightWidget from './AiInsightWidget'
import AirQualityWidget from './AirQualityWidget' import AirQualityWidget from './AirQualityWidget'
import WeatherWidget from './WeatherWidget' import WeatherWidget from './WeatherWidget'
import ComfortIndexWidget from './ComfortIndexWidget'
interface WidgetProps { interface WidgetProps {
config: WidgetConfig config: WidgetConfig
@@ -17,7 +18,8 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
'stat': StatWidget, 'stat': StatWidget,
'gauge': GaugeWidget, 'gauge': GaugeWidget,
'ai-insight': AiInsightWidget, 'ai-insight': AiInsightWidget,
'bar-chart': LineChartWidget, // Placeholder - implement later 'bar-chart': LineChartWidget,
'air-quality': AirQualityWidget, 'air-quality': AirQualityWidget,
'weather': WeatherWidget, 'weather': WeatherWidget,
'comfort-index': ComfortIndexWidget,
} }

View File

@@ -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 */
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' 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 { export interface WidgetConfig {
id: string id: string

View File

@@ -161,9 +161,10 @@ export default function Dashboard() {
preventCollision={false} preventCollision={false}
isResizable={true} isResizable={true}
isDraggable={true} isDraggable={true}
margin={[12, 12]}
> >
{config.widgets.map((widget) => ( {config.widgets.map((widget) => (
<div key={widget.id}> <div key={widget.id} className="h-full">
<WidgetContainer <WidgetContainer
config={widget} config={widget}
onRemove={() => removeWidget(widget.id)} onRemove={() => removeWidget(widget.id)}

View File

@@ -15,6 +15,11 @@ from iotDashboard.device_manager_client import (
) )
from iotDashboard import gpt_service_client from iotDashboard import gpt_service_client
from iotDashboard import weather_client from iotDashboard import weather_client
from iotDashboard.comfort_index import (
ComfortMetrics,
ComfortIndexCalculator,
calculate_comfort_index_from_telemetry,
)
from .serializers import ( from .serializers import (
DeviceSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceCreateSerializer,
@@ -213,6 +218,66 @@ class DeviceViewSet(viewsets.ModelViewSet):
'metrics': list(metrics) '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): class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for telemetry data queries.""" """ViewSet for telemetry data queries."""

View File

@@ -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)

View File

@@ -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
)

View File

@@ -33,6 +33,8 @@ POSTGRES_USER = os.getenv("POSTGRES_USER")
POSTGRES_DB = os.getenv("POSTGRES_DB") POSTGRES_DB = os.getenv("POSTGRES_DB")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD") 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True