Repository navigation
Architecture
Complete technical overview of how XY-MD02 WebApp works.
┌──────────────────────────────────────────────────────────────┐
│ XY-MD02 WebApp │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Web Browser │◄────►│ Waitress │ │
│ │ (User UI) │ HTTP │ WSGI Server │ │
│ └──────────────┘ 8050 │ (8 threads) │ │
│ ▲ └───────┬──────┘ │
│ │ WebSocket/HTTP │ │
│ │ (Dash callbacks) │ │
│ │ ▼ │
│ ┌─────┴────────────────────────────────┐ │
│ │ Dash Application │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Layout │◄────►│ Callbacks │ │ │
│ │ │ (UI) │ │ (Logic) │ │ │
│ │ └──────────┘ └──────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Database │ │ │
│ │ │ Module │ │ │
│ │ └──────┬──────┘ │ │
│ └───────────────────────────┼──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ SQLite Database (WAL mode) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ measurements_20251208 │ │ │
│ │ │ measurements_20251209 │ │ │
│ │ │ measurements_20251210 │ │ │
│ │ │ ... │ │ │
│ │ └─────────────────────────┘ │ │
│ └────────────┬────────────────────┘ │
│ ▲ │
│ │ Write every 30s │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ Modbus Reader Thread │ │
│ │ (Background Daemon) │ │
│ └────────────┬────────────┘ │
│ │ Read every 1s │
│ ▼ │
└──────────────────────┼───────────────────────────────────────┘
│ Modbus RTU
│ (RS485 Serial)
▼
┌─────────────────┐
│ XY-MD02 Sensor │
│ (Temp/Humidity)│
└─────────────────┘
Purpose: Continuously read sensor data and store in database
Operation:
- Runs as background daemon thread (non-blocking)
- Reads temperature and humidity registers every 1 second
- Validates readings (temperature: -50 to 100°C, humidity: 0-100%)
- Calculates derived values (dewpoint, absolute humidity)
- Buffers 30 measurements in memory
- Batch writes to database every 30 seconds
- Performs cleanup every 24 hours (if DATA_RETENTION_DAYS > 0)
Error Handling:
- Retries on communication errors
- Skips invalid readings
- Continues running on errors (logged to console)
- Reconnects to serial port automatically
Key Functions:
read_modbus_data() # Main thread loop
_initialize_instrument() # Lazy initialization of Modbus connectionPurpose: Manage SQLite database with optimized storage
Features:
-
Table-per-day partitioning:
measurements_YYYYMMDDformat - WAL mode: Better concurrent read/write performance
- Indexed timestamps: Fast filtering on time ranges
- Smart queries: Only scan relevant tables
Key Functions:
init_database() # Setup with optimizations (WAL, PRAGMA settings)
get_table_name(date) # Generate table name for specific date
ensure_table_exists(cursor, table_name) # Create table if missing
build_union_query(...) # Build optimized UNION ALL across tables
get_tables_for_timerange(...) # Determine relevant tables
cleanup_old_data() # Delete tables older than retention periodDatabase Schema (per table):
CREATE TABLE measurements_20251209 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL, -- Unix timestamp
temperature REAL NOT NULL, -- Celsius
humidity REAL NOT NULL, -- Percentage
dewpoint REAL, -- Celsius (calculated)
absolute_humidity REAL -- g/m³ (calculated)
);
CREATE INDEX idx_measurements_20251209_timestamp ON measurements_20251209(timestamp);Purpose: Production-ready web server
Configuration:
serve(
server, # Dash application
host='0.0.0.0', # Listen on all interfaces
port=8050, # Default port
threads=8, # Concurrent request handlers
channel_timeout=60, # Idle connection timeout
cleanup_interval=10, # Connection cleanup
asyncore_use_poll=True # Windows optimization
)Why Waitress:
- Production-ready (not Flask dev server)
- Cross-platform (pure Python, no C compiler needed)
- Multi-threaded for multiple concurrent users
- Proper HTTP error handling
- Logging control (we suppress to ERROR level)
Startup Sequence:
- Load environment variables from
.env - Initialize database (
init_database()) - Run cleanup if retention policy active
- Start Modbus reader thread
- Initialize Dash app with layout
- Register callbacks
- Start Waitress server (blocks until CTRL+C)
Purpose: Interactive web interface
Layout Components (layout.py):
- Header: Title + language selector
- 5 Value Cards: Temperature, humidity, dewpoint, abs. humidity, comfort
- 5 Time-Series Graphs: Synchronized plots with unified hover
- Time Range Selector: Dropdown (1min to all data)
- Psychrometric Chart: Mollier diagram with current conditions
- Historical Replay: Modal + slider for time-travel analysis
- Stores: Hidden components for state management
Callbacks (callbacks.py):
| Callback | Trigger | Purpose |
|---|---|---|
update_language |
Language dropdown change | Update all UI text to selected language |
update_graph |
1-second interval / time range change | Main graphs, current values, comfort |
update_psychrometric_chart |
1-second interval | Live psychrometric diagram |
toggle_modal |
Button clicks | Show/hide historical range selector |
handle_range_selection |
Preset/custom range | Store selected time range |
load_historical_data |
Range selection | Query and prepare historical dataset |
update_historical_view |
Slider movement | Update chart with historical point |
Update Intervals:
- Main graphs: 1000ms (1 second)
- Graph updates check
n_intervalsto trigger
Purpose: Maintain performance with large datasets
Implementation (callbacks.py - update_graph):
if time_range_minutes > 60 or original_points > 5000:
df = df.set_index('timestamp')
# Select interval based on time range
if time_range_minutes == -1: # All data - dynamic
total_days = (df.index.max() - df.index.min()).days
if total_days > 365:
interval = '1D' # Daily averages
elif total_days > 90:
interval = '6h' # 6-hour averages
# ... etc
elif time_range_minutes <= 360:
interval = '1min' # 1-6 hours
elif time_range_minutes <= 1440:
interval = '5min' # 6-24 hours
# ... etc
df = df.resample(interval).mean()
df = df.dropna().reset_index()Performance Impact:
| Original Points | After Downsampling | Reduction |
|---|---|---|
| 18,720 (6h) | 360 | 98.1% |
| 374,400 (1wk) | 672 | 99.8% |
| 1,612,800 (1mo) | 720 | 99.96% |
| 27,374,400 (1yr) | 365 | 99.999% |
Key Principle: Keep displayed points between 1,000-2,000 for instant rendering while preserving visual trends.
Purpose: Quantify thermal comfort using Humidex
Formula (Environment Canada):
1. Dewpoint (Td) = T - ((100 - RH) / 5.0)
2. Vapor pressure (e) = 6.11 × exp(5417.7530 × (1/273.16 - 1/(273.15+Td)))
3. Humidex = T + 0.5555 × (e - 10)
Scoring:
if humidex < 20: return 0 # Too cold
elif humidex < 27: return 4 # Comfortable (cool)
elif humidex < 30: return 5 # Comfortable
elif humidex < 35: return 6 # Optimal
elif humidex < 40: return 3 # Slightly uncomfortable
elif humidex < 46: return 2 # Uncomfortable
elif humidex < 54: return 1 # Unhealthy
else: return 0 # Heatstroke riskImplementation:
- Calculated for each datapoint in
update_graph()callback - Applied AFTER downsampling (not averaged)
- Displayed in comfort card (current value) and graph (historical)
Purpose: Visual representation of air properties
Components:
- Saturation line (100% RH)
- RH curves (10%, 20%, ..., 90%)
- Comfort zone (20-26°C, 30-60% RH) - shaded green
- Current point (⭐ star marker)
- Axes: Temperature (°C) vs. Humidity Ratio (g/kg)
Variants:
-
create_psychrometric_chart(): For current conditions -
create_psychrometric_chart_historical(): For historical replay (different label)
Update:
- Every 1 second with latest sensor reading
- On-demand during historical replay slider movement
[Every 1 second]
1. Browser triggers callback via interval component (n_intervals += 1)
2. update_graph() callback executes:
- Queries database for selected time range
- Applies downsampling if needed
- Calculates comfort scores
- Returns figure + current values
3. Browser receives updates
4. Plotly re-renders graphs (with existing zoom/pan preserved)
5. Value cards update with new numbers
[User selects "Last 6 hours" from dropdown]
1. Browser triggers callback (time_range_dropdown value changes)
2. update_graph() callback executes:
- Calculates cutoff time (now - 6 hours)
- Builds UNION query across relevant tables
- Retrieves ~18,720 datapoints
- Applies 1-minute downsampling → 360 points
- Returns downsampled figure
3. Browser receives figure
4. Plotly renders 360 points instantly
5. Console shows: "Downsampled: 18720 → 360 punten (-98.1%)"
[User clicks "Choose range", selects "Last 1 hour", moves slider]
1. toggle_modal() opens modal
2. handle_range_selection() stores choice
3. load_historical_data() executes:
- Queries database for 1 hour
- Resamples to 1-minute intervals (60 points)
- Generates slider marks
- Stores data in browser (dcc.Store)
4. Slider appears with 60 positions
5. User drags slider to position 30 (halfway)
6. update_historical_view() executes:
- Retrieves stored data
- Gets temperature/humidity at position 30
- Calls create_psychrometric_chart_historical()
- Returns updated chart
7. Browser shows conditions at selected moment
- Modbus thread: ~1-5 MB (30-measurement buffer)
- Database: Varies by data volume (1 year ≈ 500 MB)
- Web server: ~50-100 MB per connected user
- Browser: ~50-200 MB (depends on graph complexity)
- Idle: < 1%
- Modbus reading: < 5% (1 read/second)
- Database write: < 10% spike every 30 seconds
- Graph update: < 20% spike when user interacts
- Downsampling: < 30% spike for large datasets (milliseconds)
- Initial page load: ~1-2 MB (includes all libraries)
- Graph update: ~10-100 KB (depends on point count)
- No constant polling: Updates only on user action or interval
- Modbus writes: Every 30 seconds (batch of 30 measurements)
- Graph queries: On-demand (when user changes time range)
- Cleanup: Once per 24 hours (if retention enabled)
- Waitress handles up to 8 concurrent requests
- Each user's browser updates independently
- Database WAL mode allows concurrent reads during writes
- Tested: Years of data (27+ million records)
- Performance: Instant rendering with downsampling
- Storage: ~500 MB per year of 1-second readings
- Cleanup: Automatic with DATA_RETENTION_DAYS
- Page load: < 2 seconds
- Graph update: < 500ms (with downsampling)
- Historical query: < 1 second (1 month of data)
- All data query: < 2 seconds (years of data)
Set in .env:
DEBUG_LOGGING=TrueConsole Output:
📊 Query voor laatste 360 minuten (vanaf 2025-12-09 14:00:00)
→ Aantal relevante tabellen: 1
→ Opgehaalde datapunten: 18720
→ Downsampled: 18720 → 360 punten (-98.1%)
Debug comfort #1: T=21.5°C, RH=55.0%, Humidex=24.3, Score=5
Debug comfort #2: T=21.6°C, RH=54.8%, Humidex=24.4, Score=5
Information Logged:
- Database queries (table count, timestamps)
- Downsampling statistics (before/after, reduction %)
- Comfort calculations (first 5 points)
- Modbus communication (readings, errors)
- Table creation/cleanup events
Check database contents:
sqlite3 src/modbus_sensor_data.db
.tables # List all tables
SELECT COUNT(*) FROM measurements_20251209;Verify Modbus communication:
- Look for continuous "Temperature: X°C, Humidity: Y%" messages
- Check for "No response from slave" errors
- Verify sensor LED is on (powered)
Test downsampling:
- Select "All data" from dropdown
- Check console for "Downsampled: X → Y punten"
- Should see dramatic reduction (>98%)
Profile performance:
- Enable DEBUG_LOGGING
- Change time ranges and note query times
- Check browser developer tools (F12) → Network tab
Potential improvements (not yet implemented):
- WebGL Rendering: For even larger datasets (millions of points)
- Server-Side Rendering: Pre-render graphs on server
- Progressive Loading: Load summary first, details on zoom
- Data Export: Built-in CSV/Excel export functionality
- Alerts: Email/push notifications for out-of-range conditions
- Multiple Sensors: Support multiple XY-MD02 units
- Database Vacuum: Automatic SQLite VACUUM on schedule
- Real-time Alerts: Browser notifications for comfort changes
- Mobile App: Native iOS/Android applications
- API Endpoint: REST API for external integrations
Current Implementation:
- No authentication (runs on localhost by default)
- Database is local file (no remote access)
- Waitress runs as single-user process
Production Deployment:
- Use reverse proxy (nginx/Apache) for HTTPS
- Implement authentication (Flask-Login, OAuth)
- Restrict
APP_HOSTto specific interface - Enable firewall rules for
APP_PORT - Regular backups of database file
- Monitor disk space for database growth
Daily:
- Automatic cleanup (if DATA_RETENTION_DAYS > 0)
- Log rotation (if logs are enabled)
Weekly:
- Check database size
- Review console logs for errors
Monthly:
- Update dependencies (Dependabot PRs)
- Vacuum database:
sqlite3 src/modbus_sensor_data.db "VACUUM;" - Check sensor calibration
Yearly:
- Full backup of database
- Review data retention policy
- Update Python version if needed