Device Data AI Predictions - Design Document
Overview
This document outlines the design for adding AI prediction support for Device (water meter) time-series data to the existing dwd-api-fastapi prediction service and dwd-frontend-react interface.
Date: October 23, 2025 Status: Design Phase
Table of Contents
- Current State Analysis
- Device Data Model
- Design Goals
- Architecture Overview
- Backend Implementation (FastAPI)
- Frontend Implementation (React)
- API Contracts
- Implementation Plan
- Testing Strategy
Current State Analysis
Existing AI Predictions Infrastructure
The system currently supports AI predictions for the following network entities:
- Tanks - Level, Head, Demand, Pressure metrics
- Reservoirs - Head, Pressure metrics
- Junctions - Head, Demand, Pressure metrics
- Pipes - Flow, Velocity metrics
- Pumps - Flow, Head metrics
- Valves - Flow, Pressure metrics
How It Works Currently
- Frontend Component:
PredictionButtoncomponent indwd-frontend-react/src/components/PredictionButton.jsx - Integration: Used in
QualityHydraulicsDropdowns.jsxfor node-based entities - Chart Component:
ForecastChart.jsxfetches data from FastAPI and displays predictions - FastAPI Service:
dwd-api-fastapi/main.pyprovides:- Generic
/{node_type}/{node_id}/graph-dataendpoint - TSAI-based predictions with scikit-learn fallback
- Configurable forecast percentage (0.1-2.0)
- Adds
isForecast: trueflag to predicted data points
- Generic
Prediction Algorithm
- Primary: TSAI InceptionTimePlus model for time series forecasting
- Fallback: Scikit-learn Linear Regression with sliding window
- Final Fallback: Simple moving average
- Window Size: Configurable (default 10-30% of data)
- Forecast Points: Configurable percentage of original data (default 50-100%)
Device Data Model
Database Schema
// Device - Main water meter entity
Device {
int Id (PK)
string Name
string SerialNumber
string AccountNumber
string AssetId
string MeterType
int DeviceTypeId (FK)
int DeviceGroupId (FK)
string Address
string Location
double XCoord
double YCoord
string Owner
string Department
string ContactName
string ContactNumber
}
// DeviceType - Device categorization
DeviceType {
int Id (PK)
string Name // e.g., "Water Meter"
string Description // e.g., "Water consumption meter"
string Unit // e.g., "cubic feet"
}
// DeviceGroup - Grouping and billing
DeviceGroup {
int Id (PK)
string Name // e.g., "METERED - MONTHLY"
string BillingCycle // e.g., "Monthly"
string Frequency // e.g., "Monthly", "Quarterly", "Yearly"
}
// DeviceTag - Measurement tags
DeviceTag {
int Id (PK)
string Name // e.g., "Consumption", "Water Usage"
string TagType // e.g., "monthly_consumption", "quarterly_consumption"
string Unit // e.g., "cubic feet"
int DeviceId (FK)
}
// DeviceTagDatum - Time-series data
DeviceTagDatum {
int DeviceTagId (PK, FK)
long UnixTime (PK)
double Value // Consumption value
}
Existing ASP.NET API Endpoints
The dwd-api-aspnet already provides the following Device-related endpoints:
GET /api/Device - Get all devices
GET /api/Device/{id} - Get device by ID
GET /api/Device/tagtypes - Get unique tag types
GET /api/Device/cluster/data?tagType={}&deviceGroupIds={}&unixtime={}
- Get device cluster data
GET /api/Device/cluster/timestamps?tagType={}&deviceGroupIds={}
- Get available timestamps
Key Observations:
- Device data is already time-series with
DeviceTagDatum.UnixTimeandDeviceTagDatum.Value - Data is organized by
TagType(e.g., "monthly_consumption", "quarterly_consumption") - Data can be filtered by
DeviceGroupfor different billing cycles - The structure is similar to SCADA data but simpler (single value per timestamp)
Design Goals
- Reuse Existing Infrastructure: Leverage the current TSAI prediction pipeline
- Minimal Changes: Extend, don't rebuild
- Consistency: Match the UX/API pattern of existing predictions
- Flexibility: Support different tag types and device groups
- Performance: Handle potentially large datasets efficiently
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌────────────────────┐ ┌──────────────────────────────────┐ │
│ │ DeviceChart.jsx │ │ QualityHydraulicsDropdowns.jsx │ │
│ │ (Existing) │ │ + PredictionButton Integration │ │
│ └────────────────────┘ └──────────────────────────────────┘ │
│ │ │ │
│ └────────┬───────────┘ │
└───────────────────────────┼──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ FastAPI Prediction Service (Python) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ NEW: /device/{device_id}/tag/{tag_id}/graph-data │ │
│ │ - Fetches DeviceTagDatum data from ASP.NET API │ │
│ │ - Applies TSAI prediction pipeline │ │
│ │ - Returns data with forecast points │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Shared Prediction Logic (predict_with_tsai) │ │
│ │ - Same algorithm used for all entity types │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ASP.NET API (C# .NET Core) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ NEW: /api/Device/{deviceId}/tag/{tagId}/data │ │
│ │ - Returns DeviceTagDatum for specific tag │ │
│ │ - Optional startTime/endTime filtering │ │
│ │ - Returns: [ { unixTime, value }, ... ] │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Backend Implementation (FastAPI)
1. New Data Models
Add to dwd-api-fastapi/main.py:
class DeviceTagData(BaseModel):
"""Model for device tag datum."""
deviceTagId: int
unixTime: int
value: float
class DeviceTagInfo(BaseModel):
"""Model for device tag information."""
id: int
name: str
tagType: str
unit: Optional[str] = None
deviceId: int
2. New FastAPI Endpoint
@app.get("/device/{device_id}/tag/{tag_id}/graph-data")
async def get_device_tag_graph_data(
device_id: int,
tag_id: int,
base_url: str = Query(description="Base URL for the ASP.NET API"),
start_time: Optional[int] = Query(
default=None, description="Start Unix timestamp"
),
end_time: Optional[int] = Query(
default=None, description="End Unix timestamp"
),
include_forecast: bool = Query(
default=True, description="Include forecast data"
),
forecast_percentage: float = Query(
default=1.0, description="Forecast percentage (0.1-2.0)"
)
):
"""
Get device tag data with optional AI forecasting.
This endpoint fetches time-series data for a specific device tag
and applies TSAI-based predictions to generate future values.
"""
try:
# Validate forecast percentage
if not validate_forecast_percentage(forecast_percentage):
raise HTTPException(
status_code=400,
detail=get_error_message("invalid_forecast_percentage")
)
async with httpx.AsyncClient() as client:
# Build URL for ASP.NET API
url = f"{base_url}Device/{device_id}/tag/{tag_id}/data"
params = {}
if start_time:
params["startTime"] = start_time
if end_time:
params["endTime"] = end_time
response = await client.get(url, params=params)
if response.status_code == 404:
raise HTTPException(
status_code=404,
detail=f"Device {device_id} or Tag {tag_id} not found"
)
elif response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail="Error fetching device tag data"
)
# Get raw data from ASP.NET API
tag_data = response.json()
# Transform to format expected by prediction pipeline
# Input: [{ unixTime: 1234, value: 123.45 }, ...]
# Transform to: [{ simUnixTime: 1234, value: 123.45 }, ...]
transformed_data = [
{
"simUnixTime": item["unixTime"],
"value": item["value"]
}
for item in tag_data
]
# Add forecast data if requested
if include_forecast and len(transformed_data) > 0:
transformed_data = add_forecast_data(
transformed_data,
forecast_percentage
)
# Transform back to original format
result_data = [
{
"deviceTagId": tag_id,
"unixTime": item["simUnixTime"],
"value": item["value"],
"isForecast": item.get("isForecast", False)
}
for item in transformed_data
]
return {
"device_id": device_id,
"tag_id": tag_id,
"include_forecast": include_forecast,
"data": result_data,
"forecast_info": {
"forecast_percentage": forecast_percentage,
"forecast_points_added": (
len(result_data) - len(tag_data)
if include_forecast else 0
),
"description": RESPONSE_MESSAGES["forecast_description"].format(
percentage=f"{forecast_percentage * 100}%"
)
}
}
except httpx.RequestError as e:
raise HTTPException(
status_code=500,
detail=get_error_message("error_connecting_api", error=str(e))
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error fetching device tag data: {str(e)}"
)
3. Update Configuration
Add to dwd-api-fastapi/config.py:
# Device-specific configuration
DEVICE_CONFIG = {
"supported_tag_types": [
"monthly_consumption",
"quarterly_consumption",
"yearly_consumption",
"daily_flow",
"hourly_flow"
],
"min_data_points": 3, # Minimum points needed for prediction
"default_forecast_percentage": 0.5 # 50% forecast by default
}
Backend Implementation (ASP.NET API)
New Controller Endpoint
Add to dwd-api-aspnet/DwdApiAspNet/Controllers/DeviceController.cs:
/// <summary>
/// Get time-series data for a specific device tag.
/// </summary>
/// <param name="deviceId">Device ID</param>
/// <param name="tagId">Device Tag ID</param>
/// <param name="startTime">Optional start Unix timestamp</param>
/// <param name="endTime">Optional end Unix timestamp</param>
/// <returns>List of device tag data points</returns>
[HttpGet("{deviceId}/tag/{tagId}/data")]
public async Task<ActionResult<IEnumerable<object>>> GetDeviceTagData(
int deviceId,
int tagId,
[FromQuery] long? startTime,
[FromQuery] long? endTime)
{
try
{
// Verify device exists
var deviceExists = await _context.Devices
.AnyAsync(d => d.Id == deviceId);
if (!deviceExists)
{
return NotFound($"Device with ID {deviceId} not found");
}
// Verify tag exists and belongs to device
var tag = await _context.DeviceTags
.Where(t => t.Id == tagId && t.DeviceId == deviceId)
.FirstOrDefaultAsync();
if (tag == null)
{
return NotFound($"Tag with ID {tagId} not found for device {deviceId}");
}
// Query tag data
var query = _context.DeviceTagData
.Where(d => d.DeviceTagId == tagId);
// Apply time filters if provided
if (startTime.HasValue)
{
query = query.Where(d => d.UnixTime >= startTime.Value);
}
if (endTime.HasValue)
{
query = query.Where(d => d.UnixTime <= endTime.Value);
}
var data = await query
.OrderBy(d => d.UnixTime)
.Select(d => new {
deviceTagId = d.DeviceTagId,
unixTime = d.UnixTime,
value = d.Value
})
.ToListAsync();
return Ok(data);
}
catch (Exception ex)
{
return StatusCode(500, "Internal server error");
}
}
/// <summary>
/// Get device tag information by ID.
/// </summary>
[HttpGet("{deviceId}/tag/{tagId}")]
public async Task<ActionResult<object>> GetDeviceTagInfo(
int deviceId,
int tagId)
{
try
{
var tag = await _context.DeviceTags
.Where(t => t.Id == tagId && t.DeviceId == deviceId)
.Select(t => new {
id = t.Id,
name = t.Name,
tagType = t.TagType,
unit = t.Unit,
deviceId = t.DeviceId
})
.FirstOrDefaultAsync();
if (tag == null)
{
return NotFound($"Tag with ID {tagId} not found for device {deviceId}");
}
return Ok(tag);
}
catch (Exception ex)
{
return StatusCode(500, "Internal server error");
}
}
Frontend Implementation (React)
1. Update DeviceChart Component
Modify dwd-frontend-react/src/components/DeviceChart.jsx:
import React, { useEffect, useState } from 'react';
import { Box, CircularProgress, Alert } from '@mui/material';
import GqcLineChart from './GqcLineChart';
import { FASTAPI_BASE_URL, API_BASE_URL } from '../constants';
const DeviceChart = ({
deviceId,
leftTag,
rightTag,
showPredictions = false // NEW: Accept predictions prop
}) => {
const [chartSeries, setChartSeries] = useState([]);
const [yAxis, setYAxis] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDeviceData = async () => {
if (!deviceId || (!leftTag?.id && !rightTag?.id)) {
return;
}
setLoading(true);
setError(null);
try {
const tasks = [];
// Left tag data
if (leftTag?.id) {
const url = showPredictions
? `${FASTAPI_BASE_URL}device/${deviceId}/tag/${leftTag.id}/graph-data`
: `${API_BASE_URL}Device/${deviceId}/tag/${leftTag.id}/data`;
const params = new URLSearchParams();
if (showPredictions) {
params.append('base_url', API_BASE_URL);
params.append('include_forecast', 'true');
params.append('forecast_percentage', '1.0');
}
tasks.push(
fetch(`${url}?${params}`)
.then(res => res.json())
.then(data => ({
tag: leftTag,
data: showPredictions ? data.data : data,
position: 'left'
}))
);
}
// Right tag data
if (rightTag?.id) {
const url = showPredictions
? `${FASTAPI_BASE_URL}device/${deviceId}/tag/${rightTag.id}/graph-data`
: `${API_BASE_URL}Device/${deviceId}/tag/${rightTag.id}/data`;
const params = new URLSearchParams();
if (showPredictions) {
params.append('base_url', API_BASE_URL);
params.append('include_forecast', 'true');
params.append('forecast_percentage', '1.0');
}
tasks.push(
fetch(`${url}?${params}`)
.then(res => res.json())
.then(data => ({
tag: rightTag,
data: showPredictions ? data.data : data,
position: 'right'
}))
);
}
const results = await Promise.all(tasks);
// Process results into chart format
const series = [];
const axes = [];
results.forEach((result, index) => {
const chartData = result.data.map(item => ({
x: item.unixTime * 1000, // Convert to milliseconds
y: item.value
}));
// Separate forecast data for styling
const historicalData = chartData.filter((_, i) =>
!result.data[i].isForecast
);
const forecastData = chartData.filter((_, i) =>
result.data[i].isForecast
);
if (historicalData.length > 0) {
series.push({
name: result.tag.name,
data: historicalData,
type: 'line'
});
}
if (forecastData.length > 0) {
series.push({
name: `${result.tag.name} (Predicted)`,
data: forecastData,
type: 'line',
// Style predictions differently
dashArray: 5,
strokeWidth: 2,
opacity: 0.7
});
}
axes.push({
seriesName: result.tag.name,
opposite: result.position === 'right',
title: {
text: `${result.tag.name} (${result.tag.unit || ''})`,
style: { fontSize: '14px' }
},
axisTicks: { show: true }
});
});
setChartSeries(series);
setYAxis(axes);
} catch (err) {
console.error('Error fetching device data:', err);
setError(`Failed to load device data: ${err.message}`);
} finally {
setLoading(false);
}
};
fetchDeviceData();
}, [deviceId, leftTag, rightTag, showPredictions]); // Re-fetch when predictions toggle
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ m: 2 }}>
{error}
</Alert>
);
}
return (
<Box sx={{ minHeight: '300px', height: '100%' }}>
<GqcLineChart
yAxis={yAxis}
series={chartSeries}
width="100%"
/>
</Box>
);
};
export default DeviceChart;
2. Update EsriCustomPopup
Modify the Device chart section in dwd-frontend-react/src/components/EsriCustomPopup.jsx:
// Around line 250-280, update the Device chart rendering
{selectedDisplay === 1 && entityType === DEVICE && (
<Box>
{/* Device Tag Dropdowns with Prediction Button */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
<DeviceTagDropdown
deviceTags={getLeftDeviceTagOptions()}
selectedTag={selectedLeftDeviceTag}
onChange={handleLeftDeviceTagChange}
label="Left Axis Tag"
/>
<DeviceTagDropdown
deviceTags={getRightDeviceTagOptions()}
selectedTag={selectedRightDeviceTag}
onChange={handleRightDeviceTagChange}
label="Right Axis Tag"
/>
{/* NEW: Add Prediction Button for Device data */}
{(selectedLeftDeviceTag?.id || selectedRightDeviceTag?.id) && (
<PredictionButton
onClick={handlePredictionToggle}
showPredictions={showPredictions}
loading={predictionLoading}
/>
)}
</Box>
{/* Device Chart - now with predictions support */}
<DeviceChart
deviceId={entityId}
leftTag={selectedLeftDeviceTag}
rightTag={selectedRightDeviceTag}
showPredictions={showPredictions} // NEW: Pass predictions state
/>
</Box>
)}
3. Add FastAPI Endpoints to Constants
Update dwd-frontend-react/src/constants.jsx:
export const FASTAPI_ENDPOINTS = {
GetTankGraphData: '/tanks/{tank_id}/graph-data',
GetNodeGraphData: '/{node_type}/{node_id}/graph-data',
// NEW: Device predictions endpoint
GetDeviceTagGraphData: '/device/{device_id}/tag/{tag_id}/graph-data'
};
API Contracts
ASP.NET API - Get Device Tag Data
Request:
GET /api/Device/{deviceId}/tag/{tagId}/data?startTime={unix}&endTime={unix}
Response:
[
{
"deviceTagId": 123,
"unixTime": 1698278400,
"value": 1234.56
},
{
"deviceTagId": 123,
"unixTime": 1700956800,
"value": 1150.23
}
]
FastAPI - Get Device Tag Graph Data with Predictions
Request:
GET /device/{device_id}/tag/{tag_id}/graph-data?base_url={url}&include_forecast=true&forecast_percentage=1.0
Response:
{
"device_id": 456,
"tag_id": 123,
"include_forecast": true,
"data": [
{
"deviceTagId": 123,
"unixTime": 1698278400,
"value": 1234.56,
"isForecast": false
},
{
"deviceTagId": 123,
"unixTime": 1700956800,
"value": 1150.23,
"isForecast": false
},
{
"deviceTagId": 123,
"unixTime": 1703635200,
"value": 1180.45,
"isForecast": true
}
],
"forecast_info": {
"forecast_percentage": 1.0,
"forecast_points_added": 1,
"description": "Forecast adds 100% additional data points using TSAI predictions"
}
}
Implementation Plan
Phase 1: Backend (ASP.NET API)
- Add new endpoint
/api/Device/{deviceId}/tag/{tagId}/data - Add new endpoint
/api/Device/{deviceId}/tag/{tagId}for tag metadata - Add unit tests for new endpoints
- Test with existing device data
Estimated Effort: 2-3 hours
Phase 2: Backend (FastAPI)
- Add
DeviceTagDataandDeviceTagInfoPydantic models - Add
/device/{device_id}/tag/{tag_id}/graph-dataendpoint - Integrate with existing
add_forecast_data()function - Update configuration with device-specific settings
- Test predictions with sample device data
Estimated Effort: 3-4 hours
Phase 3: Frontend (React)
- Update
DeviceChart.jsxto acceptshowPredictionsprop - Modify chart logic to call FastAPI when predictions enabled
- Add visual styling for forecast vs. historical data
- Update
EsriCustomPopup.jsxto include PredictionButton for device charts - Add new constant for device prediction endpoint
- Test UI/UX flow
Estimated Effort: 3-4 hours
Phase 4: Testing & Refinement
- End-to-end testing with real device data
- Performance testing with large datasets
- UI/UX refinement
- Documentation updates
Estimated Effort: 2-3 hours
Total Estimated Effort: 10-14 hours
Testing Strategy
Unit Tests
ASP.NET API:
- Test endpoint returns correct data for valid device/tag
- Test 404 responses for invalid IDs
- Test time filtering (startTime, endTime)
- Test empty data scenarios
FastAPI:
- Test prediction pipeline with device data
- Test fallback mechanisms
- Test error handling
- Test forecast percentage validation
Integration Tests
- Test full flow: Frontend → FastAPI → ASP.NET API → Database
- Test with different tag types (monthly, quarterly, yearly)
- Test with different forecast percentages
- Test with insufficient data scenarios
Performance Tests
- Test with large datasets (1000+ data points)
- Test concurrent requests
- Test memory usage during predictions
User Acceptance Tests
- Test UX flow for toggling predictions on/off
- Test visual distinction between historical and forecast data
- Test with different device types and tag configurations
- Test on mobile devices
Key Considerations
Data Characteristics
Device data differs from hydraulic model data:
- Temporal Granularity: Monthly/Quarterly vs. Hourly/Sub-hourly
- Data Volume: Much smaller datasets (12-48 points per year)
- Seasonality: Strong seasonal patterns in consumption
- Sparse Data: Potential gaps in readings
Implications for Predictions:
- May need to adjust
min_data_pointsthreshold (lower for devices) - Consider seasonal decomposition for better predictions
- May benefit from simpler models (linear regression) for sparse data
- Should handle missing data gracefully
Prediction Accuracy
Device consumption data typically has:
- Strong seasonal trends (summer/winter)
- Billing cycle patterns (monthly spikes)
- External factors (weather, holidays, occupancy)
Recommendations:
- Use longer historical windows for training
- Consider incorporating external features (temperature, day of week)
- Provide confidence intervals with predictions
- Allow users to adjust forecast horizon
Performance Optimization
For large numbers of devices:
- Consider caching predictions
- Implement batch prediction API
- Use database indexing on UnixTime
- Limit date ranges for initial requests
Future Enhancements
- Batch Predictions: Predict multiple device tags simultaneously
- Anomaly Detection: Identify unusual consumption patterns
- Clustering: Group similar consumption patterns
- External Features: Incorporate weather, occupancy data
- Model Selection: Auto-select best model based on data characteristics
- Confidence Intervals: Show prediction uncertainty
- Custom Forecast Horizons: Allow users to specify prediction length
- Comparison Mode: Compare predictions vs. actual for past periods
Conclusion
This design extends the existing AI prediction infrastructure to support Device (water meter) time-series data with minimal changes to the existing codebase. The approach:
- ✅ Reuses existing TSAI prediction pipeline
- ✅ Maintains consistent API patterns
- ✅ Follows established UX patterns
- ✅ Provides clear separation of concerns
- ✅ Scales to handle device data characteristics
The implementation can be completed in approximately 10-14 hours and will provide valuable forecasting capabilities for water consumption analysis.