From 0ca65e15543264c14f7bf2c191583cab56e22eac Mon Sep 17 00:00:00 2001 From: devsjc <47188100+devsjc@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:16:02 +0000 Subject: [PATCH] feat(api): Add pivot_timestamp to timeseries route --- internal/server/postgres/dataserverimpl.go | 5 +++ .../server/postgres/dataserverimpl_test.go | 36 ++++++++++++++----- .../postgres/sql/queries/predictions.sql | 1 + proto/ocf/dp/dp-data.messages.proto | 6 ++++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/internal/server/postgres/dataserverimpl.go b/internal/server/postgres/dataserverimpl.go index 4de71d6..a9e93b6 100644 --- a/internal/server/postgres/dataserverimpl.go +++ b/internal/server/postgres/dataserverimpl.go @@ -1417,6 +1417,10 @@ func (s *DataPlatformDataServiceServerImpl) GetForecastAsTimeseries( return nil, status.Error(codes.InvalidArgument, "Invalid time window.") } + if req.PivotTimestampUtc == nil { + req.PivotTimestampUtc = timestamppb.New(time.Now().UTC().Truncate(time.Minute)) + } + lpprms := db.ListPredictionsForLocationParams{ GeometryUuid: dbSource.GeometryUuid, ForecasterID: dbExistingForecaster.ForecasterID, @@ -1424,6 +1428,7 @@ func (s *DataPlatformDataServiceServerImpl) GetForecastAsTimeseries( HorizonMins: int32(req.HorizonMins), StartTimestampUtc: start, EndTimestampUtc: end, + PivotTimestamp: pgtype.Timestamp{Time: req.PivotTimestampUtc.AsTime(), Valid: true}, } dbValues, err := querier.ListPredictionsForLocation(ctx, lpprms) diff --git a/internal/server/postgres/dataserverimpl_test.go b/internal/server/postgres/dataserverimpl_test.go index c93a87b..1b5dafe 100644 --- a/internal/server/postgres/dataserverimpl_test.go +++ b/internal/server/postgres/dataserverimpl_test.go @@ -746,9 +746,12 @@ func TestGetForecastAsTimeseries(t *testing.T) { testcases := []struct { name string horizonMins int32 + pivotTime time.Time expectedValues []float32 }{ { + name: "Should return expected values for horizon 0 mins", + horizonMins: 0, // For horizon 0, we should get all the values from the latest forecast, // plus the values from the previous forecasts that have the lowest horizon // for each target time. @@ -760,8 +763,6 @@ func TestGetForecastAsTimeseries(t *testing.T) { // 0, 8, 16, 24, 32, 40 (horizons 0 to 25 minutes from forecast 3) // Then the same from forecast 2, as it's horizon is smaller - likewise then forecast 1 // 0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88 (horizons 0 to 55 minutes from forecast 0) - name: "Should return expected values for horizon 0 mins", - horizonMins: 0, expectedValues: []float32{ 0.00, 0.08, 0.16, 0.24, 0.32, 0.40, 0.00, 0.08, 0.16, 0.24, 0.32, 0.40, @@ -770,10 +771,10 @@ func TestGetForecastAsTimeseries(t *testing.T) { }, }, { - // For horizon of 14 minutes, anything with a lesser horizon should not be included. - // So the value for 0, 5, and 10 minutes should not be included. name: "Should return expected values for horizon 14 mins", horizonMins: 14, + // For horizon of 14 minutes, anything with a lesser horizon should not be included. + // So the value for 0, 5, and 10 minutes should not be included. expectedValues: []float32{ 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, @@ -795,15 +796,34 @@ func TestGetForecastAsTimeseries(t *testing.T) { name: "Shouldn't return successfully for horizon 60 mins", horizonMins: 60, }, + { + name: "Should return expected values for horizon 14 minutes with pivot time", + horizonMins: 14, + pivotTime: pivotTime.Add(-15 * time.Minute), + // For horizon of 14 minutes and a pivot time of 15 minutes before the latest, + // we should expect the same as for the 14 minute horizon no pivot time case, + // only this time the latest forecast should not be included at all. + // Hence we only see data for three forecasts. + expectedValues: []float32{ + 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, + 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, + 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, + }, + }, } for _, tc := range testcases { t.Run(fmt.Sprintf("Horizon %d mins", tc.horizonMins), func(t *testing.T) { + if tc.pivotTime.Equal((time.Time{})) { + tc.pivotTime = pivotTime + } + resp, err := dc.GetForecastAsTimeseries(t.Context(), &pb.GetForecastAsTimeseriesRequest{ - LocationUuid: siteResp.LocationUuid, - HorizonMins: uint32(tc.horizonMins), - Forecaster: forecasterResp.Forecaster, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + LocationUuid: siteResp.LocationUuid, + HorizonMins: uint32(tc.horizonMins), + Forecaster: forecasterResp.Forecaster, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + PivotTimestampUtc: timestamppb.New(tc.pivotTime), TimeWindow: &pb.TimeWindow{ StartTimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 48)), EndTimestampUtc: timestamppb.New(pivotTime.Add(time.Hour * 36)), diff --git a/internal/server/postgres/sql/queries/predictions.sql b/internal/server/postgres/sql/queries/predictions.sql index ed1a409..9caf104 100644 --- a/internal/server/postgres/sql/queries/predictions.sql +++ b/internal/server/postgres/sql/queries/predictions.sql @@ -189,6 +189,7 @@ WITH relevant_forecasts AS ( WHERE f.geometry_uuid = $1 AND f.source_type_id = $2 AND f.forecaster_id = $3 + AND f.init_time_utc <= sqlc.arg(pivot_timestamp)::TIMESTAMP AND f.target_period && TSRANGE( sqlc.arg(start_timestamp_utc)::TIMESTAMP, sqlc.arg(end_timestamp_utc)::TIMESTAMP, diff --git a/proto/ocf/dp/dp-data.messages.proto b/proto/ocf/dp/dp-data.messages.proto index 565c14d..fad9b81 100644 --- a/proto/ocf/dp/dp-data.messages.proto +++ b/proto/ocf/dp/dp-data.messages.proto @@ -69,6 +69,12 @@ message GetForecastAsTimeseriesRequest { Forecaster forecaster = 5 [ (buf.validate.field).required = true ]; + /* The time to search backwards from to find forecasts. + * If not specified, the current time will be used. + */ + optional google.protobuf.Timestamp pivot_timestamp_utc = 6 [ + (buf.validate.field).timestamp = { gt: { seconds: 112000000}, lt_now: true } + ]; } message GetForecastAsTimeseriesResponse {