Device Timeseries Processing Upgrade - October 2025
Executive Summary
This document outlines a major redesign of the timeseries CSV parsing logic to handle devices with mixed data frequencies over time. The current implementation assumes a single frequency per device based on ROUTE_CATEGORY or BILLING_CYCLE, but actual data shows devices transitioning from annual → semi-annual → quarterly readings over their lifetime.
Problem Statement
Current Implementation Issues
Frequency determination relies on unreliable fields:
ROUTE_CATEGORY(e.g., "METERED - ANNUAL STRATAS")BILLING_CYCLE- These fields don't accurately reflect actual reading frequency
Real-world data patterns (observed in annual metered devices):
- 2013-2018: Truly annual readings (period 1 only)
- One transition year: Semi-annual readings (2 periods per year)
- 2019-2023: Quarterly readings (4 periods per year)
One DeviceTag per device assumption:
- Current design creates one tag per device based on route category
- Cannot handle mixed frequencies within the same device's history
Data Example
REG_SERIAL: 70093784A
Year | Periods | Actual Frequency
------|---------|------------------
2013 | 1 | Annual
2014 | 1 | Annual
2015 | 1 | Annual
2016 | 1 | Annual
2017 | 1 | Annual
2018 | 1 | Annual
2019 | 2 | Semi-annual
2020 | 4 | Quarterly
2021 | 4 | Quarterly
2022 | 4 | Quarterly
2023 | 4 | Quarterly
Proposed Solution
Core Design Principles
- Infer frequency from actual data, not metadata fields
- Support multiple DeviceTags per device for different time frequencies
- Use pandas for efficient data grouping and analysis
- Determine frequency by counting periods per year per device
Frequency Classification
Based on period count per year:
| Periods/Year | Frequency | Tag Type |
|---|---|---|
| 1 | Annual | annual_consumption |
| 2 | Semi-annual | semiannual_consumption |
| 4 | Quarterly | quarterly_consumption |
| 12 | Monthly | monthly_consumption |
Note: "Semi-annual" is the proper term for twice per year (biannual can mean every two years, so avoid it).
DeviceTag Naming Convention
Format: {Device.Name}_{frequency_type}
Examples:
WTRMN00345_annual_consumptionWTRMN00345_semiannual_consumptionWTRMN00345_quarterly_consumptionWTRMN00345_monthly_consumption
Implementation Design
Step 1: Load Data into Pandas DataFrame
import pandas as pd
import psycopg2
from datetime import datetime, timezone
# Load CSV into DataFrame
df = pd.read_csv(csv_file_path, encoding='cp1252')
# Required columns
# - REG_SERIAL: Device serial number (join key)
# - READING_YEAR: Year of reading
# - READING_PERIOD: Period number (1-4 for quarterly, 1-12 for monthly, etc.)
# - CONSUMPTION: Consumption value
# - (optional) ROUTE_CATEGORY, BILLING_CYCLE for logging/debugging
Step 2: Analyze Frequency per Device per Year
# Group by device and year, count distinct periods
frequency_analysis = df.groupby(['REG_SERIAL', 'READING_YEAR']).agg({
'READING_PERIOD': 'nunique', # Count unique periods
'CONSUMPTION': 'count' # Count total records
}).reset_index()
frequency_analysis.columns = ['REG_SERIAL', 'READING_YEAR', 'PERIOD_COUNT', 'RECORD_COUNT']
# Classify frequency
def classify_frequency(period_count):
if period_count == 1:
return 'annual'
elif period_count == 2:
return 'semiannual'
elif period_count == 4:
return 'quarterly'
elif period_count == 12:
return 'monthly'
else:
# Log warning for unexpected counts
return f'unknown_{period_count}'
frequency_analysis['FREQUENCY_TYPE'] = frequency_analysis['PERIOD_COUNT'].apply(classify_frequency)
Step 3: Identify Unique Device-Frequency Combinations
# Get all unique device-frequency combinations
device_tags_needed = df.merge(frequency_analysis[['REG_SERIAL', 'READING_YEAR', 'FREQUENCY_TYPE']],
on=['REG_SERIAL', 'READING_YEAR'])
# Get unique combinations (one DeviceTag per combination)
unique_tags = device_tags_needed[['REG_SERIAL', 'FREQUENCY_TYPE']].drop_duplicates()
# Summary: How many tags per device?
tags_per_device = unique_tags.groupby('REG_SERIAL').size().reset_index(name='TAG_COUNT')
print(f"Devices with multiple frequencies: {(tags_per_device['TAG_COUNT'] > 1).sum()}")
Step 4: Bulk Create DeviceTags
# Join with Devices table to get Device IDs and Names
cursor.execute(f'SELECT "Id", "SerialNumber", "Name" FROM "{DEVICES_TABLE}"')
devices = pd.DataFrame(cursor.fetchall(), columns=['DeviceId', 'SerialNumber', 'DeviceName'])
# Merge to get device IDs
unique_tags = unique_tags.merge(devices,
left_on='REG_SERIAL',
right_on='SerialNumber',
how='inner')
# Generate DeviceTag records
device_tags = []
for _, row in unique_tags.iterrows():
tag_name = f"{row['DeviceName']}_{row['FREQUENCY_TYPE']}_consumption"
tag_type = f"{row['FREQUENCY_TYPE']}_consumption"
device_tags.append({
'Name': tag_name,
'TagType': tag_type,
'Unit': 'cubic feet',
'DeviceId': row['DeviceId']
})
# Bulk insert DeviceTags
# Use executemany or execute_values for performance
Step 5: Bulk Create DeviceTagDatum Records
# Merge frequency type back to main dataframe
df_with_freq = df.merge(frequency_analysis[['REG_SERIAL', 'READING_YEAR', 'FREQUENCY_TYPE']],
on=['REG_SERIAL', 'READING_YEAR'])
# Merge with device info
df_with_freq = df_with_freq.merge(devices,
left_on='REG_SERIAL',
right_on='SerialNumber',
how='inner')
# Get DeviceTag IDs (query what we just created)
cursor.execute(f'SELECT "Id", "DeviceId", "TagType" FROM "{DEVICE_TAGS_TABLE}"')
device_tags_db = pd.DataFrame(cursor.fetchall(), columns=['DeviceTagId', 'DeviceId', 'TagType'])
# Merge to get DeviceTagId
df_with_tags = df_with_freq.merge(device_tags_db,
left_on=['DeviceId', 'FREQUENCY_TYPE'],
right_on=['DeviceId', 'TagType'],
how='inner')
# Convert to Unix timestamps
def convert_reading_to_unix_time(year, period):
"""
Convert reading year and period to Unix timestamp.
Uses quarterly mapping and 15th day of month.
"""
if period <= 4:
month = (period - 1) * 3 + 1 # Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct
else:
month = 1
dt = datetime(year, month, 15, tzinfo=timezone.utc)
return int(dt.timestamp())
df_with_tags['UnixTime'] = df_with_tags.apply(
lambda row: convert_reading_to_unix_time(row['READING_YEAR'], row['READING_PERIOD']),
axis=1
)
# Prepare DeviceTagDatum records
datum_records = df_with_tags[['DeviceTagId', 'UnixTime', 'CONSUMPTION']].values.tolist()
# Bulk insert with ON CONFLICT handling
cursor.executemany(f"""
INSERT INTO "{DEVICE_TAG_DATA_TABLE}" ("DeviceTagId", "UnixTime", "Value")
VALUES (%s, %s, %s)
ON CONFLICT ("DeviceTagId", "UnixTime")
DO UPDATE SET "Value" = EXCLUDED."Value"
""", datum_records)
Data Quality Considerations
Validation Rules
Period count validation:
- Log warnings for unexpected period counts (e.g., 3, 5-11, >12)
- These may indicate data quality issues
Duplicate detection:
- Check for duplicate (REG_SERIAL, READING_YEAR, READING_PERIOD) combinations
- Keep most recent or highest value (TBD)
Missing devices:
- Track devices in CSV that don't exist in Devices table
- Export to CSV for investigation
Edge Cases
Devices with single year of data:
- Still classify based on period count
- A device with only 2023 data and 4 periods = quarterly
Gaps in years:
- Device may have readings in 2015, skip 2016-2018, resume 2019
- Each year classified independently
Period count changes mid-year:
- Shouldn't happen (year is grouping unit)
- If it does, log as data quality issue
Migration Strategy
Phase 1: Analysis (Pre-Implementation)
Run analysis script to profile existing data:
python analyze_frequency_patterns.py timeseries.csvGenerate report showing:
- Devices with mixed frequencies
- Distribution of frequency types
- Any unusual patterns
Phase 2: New Script Development
- Create
parse_timeseries_csv_v2.pywith pandas-based logic - Include dry-run mode to preview what will be created
- Add comprehensive logging
Phase 3: Testing
- Test on subset of data (e.g., 10 devices with mixed frequencies)
- Verify DeviceTag creation
- Verify DeviceTagDatum records
- Compare results with expected behavior
Phase 4: Full Processing
- Clear existing DeviceTag and DeviceTagDatum records (or use separate test DB)
- Run new script on full dataset
- Validate results
New Script Structure
parse_timeseries_csv_v2.py
parse_timeseries_csv_v2.py
├── load_csv_to_dataframe()
├── analyze_frequency_patterns()
│ ├── group_by_device_year()
│ ├── count_periods()
│ └── classify_frequency()
├── identify_device_tag_needs()
├── bulk_create_device_tags()
├── prepare_datum_records()
├── bulk_create_device_tag_data()
└── generate_summary_report()
Dependencies
Add to requirements.txt:
pandas>=2.0.0
numpy>=1.24.0
psycopg2-binary>=2.9.0
Expected Benefits
- Accurate frequency classification: Based on actual data, not metadata
- Support for evolving devices: Devices that change frequency over time
- Better data organization: Each frequency type has its own tag
- Improved query performance: Can query specific frequency types
- Data quality insights: Analysis reveals patterns and anomalies
Potential Challenges
- Increased DeviceTag count: Some devices may have 2-3 tags instead of 1
- UI considerations: UI may need to handle multiple tags per device
- Query complexity: Queries may need to join across multiple tags
- Historical data: Existing data may need migration/cleanup
Example Output
Before (Current Implementation)
Device: WTRMN00345
└── DeviceTag: "METERED - ANNUAL STRATAS Consumption" (annual_consumption)
└── 58 DeviceTagDatum records (mixed frequencies, timestamps misaligned)
After (New Implementation)
Device: WTRMN00345
├── DeviceTag: "WTRMN00345_annual_consumption"
│ └── 6 DeviceTagDatum records (2013-2018, period 1)
├── DeviceTag: "WTRMN00345_semiannual_consumption"
│ └── 2 DeviceTagDatum records (2019, periods 1-2)
└── DeviceTag: "WTRMN00345_quarterly_consumption"
└── 16 DeviceTagDatum records (2020-2023, periods 1-4 each year)
Timestamp Conversion (Unchanged)
The timestamp conversion logic remains the same as current implementation:
- All data treated as QUARTERLY for timestamp purposes
- Period 1-4 → Q1-Q4 (Jan, Apr, Jul, Oct)
- Uses 15th day of month to avoid timezone display issues
- Formula:
datetime(year, month, 15, tzinfo=timezone.utc)
This works because:
- Annual (period 1) → Jan 15
- Semi-annual (periods 1-2) → Jan 15, Apr 15
- Quarterly (periods 1-4) → Jan 15, Apr 15, Jul 15, Oct 15
- Monthly needs custom logic (period = month)
Success Criteria
- All devices successfully processed
- DeviceTags accurately reflect frequency types
- No timestamp misalignments
- Data quality report generated
- Performance acceptable (< 2 minutes for 50K records)
- Clear logging for debugging
- Summary statistics match expectations
Additional Upgrade: Liters Per Second (LPS) Tags
Overview
In addition to the consumption tags (in cubic feet), we will create a parallel set of DeviceTags that represent average demand in liters per second (lps) for each time period. These lps tags enable demand-based analysis and integration with hydraulic models that require flow rates rather than cumulative consumption.
Rationale
- Consumption tags show total volume consumed over a period (cubic feet)
- LPS tags show average flow rate during that period (liters/second)
- This dual representation supports both billing/consumption analysis and hydraulic modeling
Conversion Logic
For each consumption reading, calculate average lps:
# Conversion constants
CUBIC_FEET_TO_LITERS = 28.3168
# Seconds per period
SECONDS_PER_MONTH = 30.44 * 24 * 3600 # Average month: ~2,629,440 seconds
SECONDS_PER_QUARTER = 3 * SECONDS_PER_MONTH # ~7,888,320 seconds
SECONDS_PER_SEMIANNUAL = 6 * SECONDS_PER_MONTH # ~15,776,640 seconds
SECONDS_PER_ANNUAL = 365.25 * 24 * 3600 # ~31,557,600 seconds
def convert_cuft_to_lps(consumption_cuft, frequency_type):
"""
Convert cubic feet consumption to average liters per second.
Args:
consumption_cuft: Consumption value in cubic feet
frequency_type: 'monthly', 'quarterly', 'semiannual', or 'annual'
Returns:
Average flow rate in liters per second
"""
liters = consumption_cuft * CUBIC_FEET_TO_LITERS
period_seconds = {
'monthly': SECONDS_PER_MONTH,
'quarterly': SECONDS_PER_QUARTER,
'semiannual': SECONDS_PER_SEMIANNUAL,
'annual': SECONDS_PER_ANNUAL
}
seconds = period_seconds.get(frequency_type, SECONDS_PER_QUARTER)
return liters / seconds
Tag Naming Convention
For each existing frequency tag, create a corresponding lps tag:
| Original Tag Type | Unit | LPS Tag Type | Unit |
|---|---|---|---|
annual_consumption | cubic feet | annual_demand_lps | liters/second |
semiannual_consumption | cubic feet | semiannual_demand_lps | liters/second |
quarterly_consumption | cubic feet | quarterly_demand_lps | liters/second |
monthly_consumption | cubic feet | monthly_demand_lps | liters/second |
DeviceTag Examples
For a device WTRMN00345 with multiple frequencies:
Consumption Tags:
WTRMN00345_annual_consumption(cubic feet)WTRMN00345_quarterly_consumption(cubic feet)
Demand Tags:
WTRMN00345_annual_demand_lps(liters/second)WTRMN00345_quarterly_demand_lps(liters/second)
Implementation Notes
- Parallel tag creation: For every consumption DeviceTag created, create a corresponding demand_lps DeviceTag
- Same timestamps: Both tags use identical UnixTime values for their datum records
- Conversion during bulk insert: Apply conversion formula when creating DeviceTagDatum records for lps tags
- Unit field: Set
Unitto"liters/second"for lps tags vs"cubic feet"for consumption tags
Modified Step 4: Bulk Create DeviceTags (With LPS Tags)
# Generate DeviceTag records (both consumption and lps)
device_tags = []
for _, row in unique_tags.iterrows():
device_name = row['DeviceName']
freq_type = row['FREQUENCY_TYPE']
device_id = row['DeviceId']
# Consumption tag (cubic feet)
consumption_tag_name = f"{device_name}_{freq_type}_consumption"
device_tags.append({
'Name': consumption_tag_name,
'TagType': f"{freq_type}_consumption",
'Unit': 'cubic feet',
'DeviceId': device_id
})
# Demand tag (liters per second)
lps_tag_name = f"{device_name}_{freq_type}_demand_lps"
device_tags.append({
'Name': lps_tag_name,
'TagType': f"{freq_type}_demand_lps",
'Unit': 'liters/second',
'DeviceId': device_id
})
# Bulk insert DeviceTags (now 2x as many tags)
Modified Step 5: Bulk Create DeviceTagDatum Records (With LPS Values)
# Get DeviceTag IDs for both consumption and lps tags
cursor.execute(f'SELECT "Id", "DeviceId", "TagType" FROM "{DEVICE_TAGS_TABLE}"')
device_tags_db = pd.DataFrame(cursor.fetchall(), columns=['DeviceTagId', 'DeviceId', 'TagType'])
# Separate consumption and lps tags
consumption_tags = device_tags_db[device_tags_db['TagType'].str.endswith('_consumption')]
lps_tags = device_tags_db[device_tags_db['TagType'].str.endswith('_demand_lps')]
# Merge to get consumption DeviceTagId
df_with_consumption_tags = df_with_freq.merge(
consumption_tags,
left_on=['DeviceId', 'FREQUENCY_TYPE'],
right_on=['DeviceId', device_tags_db['TagType'].str.replace('_consumption', '')],
how='inner'
)
# Merge to get lps DeviceTagId
df_with_lps_tags = df_with_freq.merge(
lps_tags,
left_on=['DeviceId', 'FREQUENCY_TYPE'],
right_on=['DeviceId', device_tags_db['TagType'].str.replace('_demand_lps', '')],
how='inner'
)
# Convert readings to Unix timestamps (same for both)
df_with_consumption_tags['UnixTime'] = df_with_consumption_tags.apply(
lambda row: convert_reading_to_unix_time(row['READING_YEAR'], row['READING_PERIOD']),
axis=1
)
df_with_lps_tags['UnixTime'] = df_with_lps_tags.apply(
lambda row: convert_reading_to_unix_time(row['READING_YEAR'], row['READING_PERIOD']),
axis=1
)
# Prepare consumption datum records (cubic feet)
consumption_records = df_with_consumption_tags[['DeviceTagId', 'UnixTime', 'CONSUMPTION']].values.tolist()
# Convert and prepare lps datum records
df_with_lps_tags['LPS_VALUE'] = df_with_lps_tags.apply(
lambda row: convert_cuft_to_lps(row['CONSUMPTION'], row['FREQUENCY_TYPE']),
axis=1
)
lps_records = df_with_lps_tags[['DeviceTagId', 'UnixTime', 'LPS_VALUE']].values.tolist()
# Bulk insert both sets of records
cursor.executemany(f"""
INSERT INTO "{DEVICE_TAG_DATA_TABLE}" ("DeviceTagId", "UnixTime", "Value")
VALUES (%s, %s, %s)
ON CONFLICT ("DeviceTagId", "UnixTime")
DO UPDATE SET "Value" = EXCLUDED."Value"
""", consumption_records + lps_records)
Updated Example Output
Device: WTRMN00345
├── DeviceTag: "WTRMN00345_annual_consumption" (cubic feet)
│ └── 6 DeviceTagDatum records (2013-2018, period 1)
├── DeviceTag: "WTRMN00345_annual_demand_lps" (liters/second)
│ └── 6 DeviceTagDatum records (2013-2018, period 1)
├── DeviceTag: "WTRMN00345_semiannual_consumption" (cubic feet)
│ └── 2 DeviceTagDatum records (2019, periods 1-2)
├── DeviceTag: "WTRMN00345_semiannual_demand_lps" (liters/second)
│ └── 2 DeviceTagDatum records (2019, periods 1-2)
├── DeviceTag: "WTRMN00345_quarterly_consumption" (cubic feet)
│ └── 16 DeviceTagDatum records (2020-2023, periods 1-4 each year)
└── DeviceTag: "WTRMN00345_quarterly_demand_lps" (liters/second)
└── 16 DeviceTagDatum records (2020-2023, periods 1-4 each year)
Impact on Success Criteria
- All consumption tags have corresponding lps tags
- LPS conversion calculations validated against manual calculations
- Both tag types use identical timestamps for matching periods
- Total DeviceTag count is 2x expected (consumption + lps)
Future Enhancements
- Automatic frequency detection on import: Real-time classification
- Frequency transition alerts: Notify when device changes frequency
- Data quality dashboard: Visualize patterns and anomalies
- Historical analysis: Compare consumption across frequency types
- Peak demand analysis: Use lps tags to identify peak demand patterns
- Hydraulic model integration: Export lps data for EPANET or other hydraulic models
References
- Current implementation (v1):
v1/parse_timeseries_csv.pyandv1/parse_timeseries_csv_bulk.py - New implementation (v2): Will be in
v2/directory - Timestamp logic: Lines 194-228 in
v1/parse_timeseries_csv.py - Device models:
Models/directory - Documentation:
README.mdinpython/devices/
Document History
| Date | Author | Changes |
|---|---|---|
| 2025-10-22 | AI | Initial design document |
| 2025-11-03 | AI | Added LPS (liters per second) tag upgrade specification |
Next Steps
- Review and approve design (including LPS upgrade)
- Create analysis script to profile current data
- Implement
parse_timeseries_csv_v2.pywith:- Frequency-based tag creation (consumption tags)
- Parallel LPS tag creation (demand tags)
- Conversion logic for cubic feet to liters/second
- Test on subset (validate both consumption and lps values)
- Verify conversion calculations:
- Spot-check LPS values against manual calculations
- Confirm period-appropriate divisors (monthly, quarterly, etc.)
- Run on full dataset
- Update documentation