mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 01:06:24 +00:00
Added Comfort Index widget, updated stylings on other widgets, GPT Service with problems.
This commit is contained in:
@@ -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<string, number>;
|
||||
}>(`/devices/${id}/comfort_index/`),
|
||||
};
|
||||
|
||||
// Telemetry API
|
||||
|
||||
@@ -236,6 +236,19 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
<div className="text-xs opacity-70">Pulse.eco</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
@@ -32,9 +33,9 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<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">
|
||||
<p className="text-error text-sm text-center">
|
||||
Failed to load air quality data for {city}
|
||||
@@ -73,14 +74,14 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
const pm25 = airQuality.measurements.pm25
|
||||
|
||||
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">
|
||||
<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">
|
||||
{/* Air quality icon */}
|
||||
<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"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -94,28 +95,28 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
</svg>
|
||||
|
||||
{/* 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 && (
|
||||
<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>
|
||||
)}
|
||||
{pm25 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AQI Status badge */}
|
||||
<div className={`badge badge-${statusColor} badge-lg`}>
|
||||
<div className={`badge badge-${statusColor} truncate max-w-full`}>
|
||||
{airQuality.status}
|
||||
</div>
|
||||
|
||||
{/* 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]) => {
|
||||
if (pollutant === 'pm10' || pollutant === 'pm25') return null
|
||||
return (
|
||||
@@ -128,8 +129,10 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
</div>
|
||||
|
||||
{/* City and sensor count */}
|
||||
<div className="text-xs text-base-content/40 mt-3">
|
||||
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
||||
<div className="text-xs text-base-content/40 mt-2 px-2 w-full overflow-hidden">
|
||||
<div className="truncate text-center">
|
||||
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
153
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
153
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
|
||||
if (isLoading) {
|
||||
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">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
@@ -32,9 +32,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<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">
|
||||
<p className="text-error">Failed to load weather data</p>
|
||||
</div>
|
||||
@@ -61,26 +61,26 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
{/* 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 */}
|
||||
<div className="text-4xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||
<div className="text-sm text-base-content/60">
|
||||
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||
<div className="text-xs text-base-content/60">
|
||||
Feels like {weather.apparent_temperature.toFixed(1)}°C
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span className="opacity-60">💧</span>
|
||||
<span>{weather.humidity}%</span>
|
||||
@@ -102,7 +102,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
@@ -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<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'stat': StatWidget,
|
||||
'gauge': GaugeWidget,
|
||||
'ai-insight': AiInsightWidget,
|
||||
'bar-chart': LineChartWidget, // Placeholder - implement later
|
||||
'bar-chart': LineChartWidget,
|
||||
'air-quality': AirQualityWidget,
|
||||
'weather': WeatherWidget,
|
||||
'comfort-index': ComfortIndexWidget,
|
||||
}
|
||||
|
||||
25
frontend/src/components/widgets/widget-styles.css
Normal file
25
frontend/src/components/widgets/widget-styles.css
Normal 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 */
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -161,9 +161,10 @@ export default function Dashboard() {
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={[12, 12]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<div key={widget.id} className="h-full">
|
||||
<WidgetContainer
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
|
||||
@@ -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,
|
||||
@@ -213,6 +218,66 @@ class DeviceViewSet(viewsets.ModelViewSet):
|
||||
'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):
|
||||
"""ViewSet for telemetry data queries."""
|
||||
|
||||
300
iotDashboard/comfort_index.py
Normal file
300
iotDashboard/comfort_index.py
Normal 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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user