diff --git a/SENSOR_DATAPOINT_FEATURE.md b/SENSOR_DATAPOINT_FEATURE.md new file mode 100644 index 0000000..5807642 --- /dev/null +++ b/SENSOR_DATAPOINT_FEATURE.md @@ -0,0 +1,108 @@ +# Sensor Datapoint Access Feature + +This feature provides a simple GraphQL API for accessing single sensor datapoints before, after, or interpolated at a given datetime. + +## GraphQL Query + +```graphql +query GetSensorDatapoint($sensorId: String!, $targetTime: DateTime!, $direction: String = "before") { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: $direction) { + value + timestamp + isInterpolated + sourceReadings { + timestamp + value + } + } +} +``` + +## Parameters + +- `sensorId`: ID of the sensor +- `targetTime`: Target datetime in ISO format +- `direction`: One of: + - `"before"` - Returns the last reading before the target time + - `"after"` - Returns the first reading after the target time + - `"interpolate"` - Returns a linearly interpolated value at the target time + +## Response + +- `value`: The sensor value (float) +- `timestamp`: Timestamp of the reading (or target time for interpolated values) +- `isInterpolated`: Boolean indicating if the value was interpolated +- `sourceReadings`: Array of readings used to calculate the result + +## Examples + +### Get Last Reading Before a Time +```graphql +{ + sensorDatapoint( + sensorId: "sensor-123" + targetTime: "2024-01-15T12:00:00Z" + direction: "before" + ) { + value + timestamp + isInterpolated + } +} +``` + +### Get First Reading After a Time +```graphql +{ + sensorDatapoint( + sensorId: "sensor-123" + targetTime: "2024-01-15T12:00:00Z" + direction: "after" + ) { + value + timestamp + isInterpolated + } +} +``` + +### Get Interpolated Value at Exact Time +```graphql +{ + sensorDatapoint( + sensorId: "sensor-123" + targetTime: "2024-01-15T12:00:00Z" + direction: "interpolate" + ) { + value + timestamp + isInterpolated + sourceReadings { + timestamp + value + } + } +} +``` + +## Linear Interpolation + +When using `direction: "interpolate"`, the system finds the closest readings before and after the target time and calculates a linearly interpolated value: + +``` +interpolated_value = before_value + factor * (after_value - before_value) +``` + +Where `factor` is the time distance ratio: +``` +factor = (target_time - before_time) / (after_time - before_time) +``` + +If only one reading is available (before or after), it returns that reading without interpolation. + +## Use Cases + +- **Heat Pump Analysis**: Get precise energy consumption values at period boundaries +- **Historical Analysis**: Find sensor values at specific timestamps +- **Data Smoothing**: Interpolate missing values in time series data +- **Threshold Monitoring**: Check if sensor crossed a threshold at a specific time \ No newline at end of file diff --git a/app/graphql/resolvers.py b/app/graphql/resolvers.py index b160f29..27b8d72 100644 --- a/app/graphql/resolvers.py +++ b/app/graphql/resolvers.py @@ -20,7 +20,7 @@ CreateSensorReadingInput, CreateSensorTypeInput, Location, Sensor, SensorReading, SensorType, SensorDataStats, SensorDataRange, SensorReadingsAround, - UpdateAlertInput, UpdateLocationInput, + SingleDatapoint, UpdateAlertInput, UpdateLocationInput, UpdateSensorInput, UpdateSensorReadingInput, UpdateSensorStatusInput, UpdateSensorTypeInput) @@ -200,6 +200,117 @@ def sensor_readings_around( after=[SensorReading.from_model(model) for model in after_readings] ) + @strawberry.field + def sensor_datapoint( + self, + info: Info, + sensor_id: str, + target_time: datetime, + direction: str = "before", # "before", "after", or "interpolate" + ) -> Optional[SingleDatapoint]: + """Get a single sensor datapoint before, after, or interpolated at a target time. + + Args: + sensor_id: ID of the sensor + target_time: Target datetime + direction: "before" for last reading before target_time, + "after" for first reading after target_time, + "interpolate" for linear interpolation between closest readings + """ + + with get_db_session() as db: + if direction == "before": + # Get the last reading before the target time + reading = ( + db.query(SensorReadingModel) + .filter(SensorReadingModel.sensor_id == sensor_id) + .filter(SensorReadingModel.timestamp < target_time) + .order_by(SensorReadingModel.timestamp.desc()) + .first() + ) + + if reading: + return SingleDatapoint( + value=reading.value, + timestamp=reading.timestamp, + is_interpolated=False, + source_readings=[SensorReading.from_model(reading)] + ) + + elif direction == "after": + # Get the first reading after the target time + reading = ( + db.query(SensorReadingModel) + .filter(SensorReadingModel.sensor_id == sensor_id) + .filter(SensorReadingModel.timestamp > target_time) + .order_by(SensorReadingModel.timestamp.asc()) + .first() + ) + + if reading: + return SingleDatapoint( + value=reading.value, + timestamp=reading.timestamp, + is_interpolated=False, + source_readings=[SensorReading.from_model(reading)] + ) + + elif direction == "interpolate": + # Get the closest readings before and after target time + before_reading = ( + db.query(SensorReadingModel) + .filter(SensorReadingModel.sensor_id == sensor_id) + .filter(SensorReadingModel.timestamp < target_time) + .order_by(SensorReadingModel.timestamp.desc()) + .first() + ) + + after_reading = ( + db.query(SensorReadingModel) + .filter(SensorReadingModel.sensor_id == sensor_id) + .filter(SensorReadingModel.timestamp > target_time) + .order_by(SensorReadingModel.timestamp.asc()) + .first() + ) + + if before_reading and after_reading: + # Linear interpolation + t1 = before_reading.timestamp.timestamp() + t2 = after_reading.timestamp.timestamp() + target_t = target_time.timestamp() + + # Calculate interpolation factor + factor = (target_t - t1) / (t2 - t1) + interpolated_value = before_reading.value + factor * (after_reading.value - before_reading.value) + + return SingleDatapoint( + value=interpolated_value, + timestamp=target_time, + is_interpolated=True, + source_readings=[ + SensorReading.from_model(before_reading), + SensorReading.from_model(after_reading) + ] + ) + elif before_reading: + # Only before reading available, return it + return SingleDatapoint( + value=before_reading.value, + timestamp=before_reading.timestamp, + is_interpolated=False, + source_readings=[SensorReading.from_model(before_reading)] + ) + elif after_reading: + # Only after reading available, return it + return SingleDatapoint( + value=after_reading.value, + timestamp=after_reading.timestamp, + is_interpolated=False, + source_readings=[SensorReading.from_model(after_reading)] + ) + + return None + @strawberry.field def alerts( self, diff --git a/app/graphql/types.py b/app/graphql/types.py index 2b745f8..01690f4 100644 --- a/app/graphql/types.py +++ b/app/graphql/types.py @@ -408,3 +408,13 @@ class SensorReadingsAround: before: List[SensorReading] after: List[SensorReading] + + +@strawberry.type +class SingleDatapoint: + """A single sensor datapoint with optional interpolation.""" + + value: float + timestamp: datetime + is_interpolated: bool = False + source_readings: Optional[List[SensorReading]] = None diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index ae6461b..f1894a4 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -115,4 +115,18 @@ export const GET_SENSOR_READINGS_AROUND = gql` } } } +`; + +export const GET_SENSOR_DATAPOINT = gql` + query GetSensorDatapoint($sensorId: String!, $targetTime: DateTime!, $direction: String = "before") { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: $direction) { + value + timestamp + isInterpolated + sourceReadings { + timestamp + value + } + } + } `; \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 481fb71..92c1d7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ """ import os import pytest +from datetime import datetime, timedelta from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient @@ -135,6 +136,30 @@ def sample_sensor_reading(test_db, sample_sensor): return reading +@pytest.fixture +def sample_sensor_with_readings(test_db, sample_sensor): + """Create a sensor with multiple time-series readings for testing.""" + base_time = datetime.now() + readings = [] + + # Create 3 readings with 5-minute intervals and different values + for i in range(3): + reading = SensorReading( + sensor_id=sample_sensor.id, + value=20.0 + i * 2.5, # Values: 20.0, 22.5, 25.0 + raw_value=20.0 + i * 2.5, + timestamp=base_time + timedelta(minutes=i * 5) + ) + test_db.add(reading) + readings.append(reading) + + test_db.commit() + for reading in readings: + test_db.refresh(reading) + + return sample_sensor.id, readings + + # GraphQL Query Templates class GraphQLQueries: """Common GraphQL queries for testing.""" diff --git a/tests/test_sensor_datapoint.py b/tests/test_sensor_datapoint.py new file mode 100644 index 0000000..020e138 --- /dev/null +++ b/tests/test_sensor_datapoint.py @@ -0,0 +1,191 @@ +""" +Tests for sensor datapoint access functionality. +""" +import pytest +from datetime import datetime, timedelta +from fastapi.testclient import TestClient + + +class TestSensorDatapoint: + """Tests for the sensor_datapoint GraphQL resolver.""" + + def test_sensor_datapoint_before(self, client: TestClient, sample_sensor_with_readings): + """Test getting the last datapoint before a given time.""" + sensor_id, readings = sample_sensor_with_readings + + # Get a target time that's after the first reading but before the last + target_time = readings[1].timestamp + timedelta(minutes=1) + + query = """ + query TestSensorDatapointBefore($sensorId: String!, $targetTime: DateTime!) { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: "before") { + value + timestamp + isInterpolated + sourceReadings { + timestamp + value + } + } + } + """ + + response = client.post("/graphql", json={ + "query": query, + "variables": { + "sensorId": sensor_id, + "targetTime": target_time.isoformat() + } + }) + + assert response.status_code == 200 + data = response.json() + assert "errors" not in data + + datapoint = data["data"]["sensorDatapoint"] + assert datapoint is not None + assert datapoint["isInterpolated"] is False + assert len(datapoint["sourceReadings"]) == 1 + # Should return the reading at index 1 (the last one before target_time) + assert datapoint["value"] == readings[1].value + + def test_sensor_datapoint_after(self, client: TestClient, sample_sensor_with_readings): + """Test getting the first datapoint after a given time.""" + sensor_id, readings = sample_sensor_with_readings + + # Get a target time that's after the first reading but before the second + target_time = readings[0].timestamp + timedelta(minutes=1) + + query = """ + query TestSensorDatapointAfter($sensorId: String!, $targetTime: DateTime!) { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: "after") { + value + timestamp + isInterpolated + sourceReadings { + timestamp + value + } + } + } + """ + + response = client.post("/graphql", json={ + "query": query, + "variables": { + "sensorId": sensor_id, + "targetTime": target_time.isoformat() + } + }) + + assert response.status_code == 200 + data = response.json() + assert "errors" not in data + + datapoint = data["data"]["sensorDatapoint"] + assert datapoint is not None + assert datapoint["isInterpolated"] is False + assert len(datapoint["sourceReadings"]) == 1 + # Should return the reading at index 1 (the first one after target_time) + assert datapoint["value"] == readings[1].value + + def test_sensor_datapoint_interpolate(self, client: TestClient, sample_sensor_with_readings): + """Test linear interpolation between two datapoints.""" + sensor_id, readings = sample_sensor_with_readings + + # Get a target time exactly in the middle between first and second reading + time_diff = readings[1].timestamp - readings[0].timestamp + target_time = readings[0].timestamp + time_diff / 2 + + query = """ + query TestSensorDatapointInterpolate($sensorId: String!, $targetTime: DateTime!) { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: "interpolate") { + value + timestamp + isInterpolated + sourceReadings { + timestamp + value + } + } + } + """ + + response = client.post("/graphql", json={ + "query": query, + "variables": { + "sensorId": sensor_id, + "targetTime": target_time.isoformat() + } + }) + + assert response.status_code == 200 + data = response.json() + assert "errors" not in data + + datapoint = data["data"]["sensorDatapoint"] + assert datapoint is not None + assert datapoint["isInterpolated"] is True + assert len(datapoint["sourceReadings"]) == 2 + + # The interpolated value should be the average of the two readings + # since we're targeting the exact middle timestamp + expected_value = (readings[0].value + readings[1].value) / 2 + assert abs(datapoint["value"] - expected_value) < 0.001 + + def test_sensor_datapoint_no_data(self, client: TestClient, sample_sensor): + """Test behavior when no readings are available.""" + sensor_id = sample_sensor + target_time = datetime.now() + + query = """ + query TestSensorDatapointNoData($sensorId: String!, $targetTime: DateTime!) { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: "before") { + value + timestamp + isInterpolated + } + } + """ + + response = client.post("/graphql", json={ + "query": query, + "variables": { + "sensorId": sensor_id, + "targetTime": target_time.isoformat() + } + }) + + assert response.status_code == 200 + data = response.json() + assert "errors" not in data + assert data["data"]["sensorDatapoint"] is None + + def test_sensor_datapoint_invalid_direction(self, client: TestClient, sample_sensor_with_readings): + """Test behavior with invalid direction parameter.""" + sensor_id, readings = sample_sensor_with_readings + target_time = readings[0].timestamp + timedelta(minutes=1) + + query = """ + query TestSensorDatapointInvalid($sensorId: String!, $targetTime: DateTime!) { + sensorDatapoint(sensorId: $sensorId, targetTime: $targetTime, direction: "invalid") { + value + timestamp + isInterpolated + } + } + """ + + response = client.post("/graphql", json={ + "query": query, + "variables": { + "sensorId": sensor_id, + "targetTime": target_time.isoformat() + } + }) + + assert response.status_code == 200 + data = response.json() + assert "errors" not in data + # Should return None for invalid direction + assert data["data"]["sensorDatapoint"] is None \ No newline at end of file