diff --git a/README.md b/README.md index a57d723..c3667ee 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,14 @@ Forecaster represents a generative source of predicted values. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| location_uuid | [string](#string) | | || location_name | [string](#string) | | || value_fraction | [float](#float) | | || effective_capacity_watts | [uint64](#uint64) | | || latlng | [LatLng](#ocf-dp-LatLng) | | || metadata | [google.protobuf.Struct](#google-protobuf-Struct) | | || initialization_timestamp_utc | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | || created_timestamp_utc | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| location_uuid | [string](#string) | | || location_name | [string](#string) | | || value_fraction | [float](#float) | | || effective_capacity_watts | [uint64](#uint64) | | || latlng | [LatLng](#ocf-dp-LatLng) | | || metadata | [google.protobuf.Struct](#google-protobuf-Struct) | | || initialization_timestamp_utc | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | || created_timestamp_utc | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | || other_statistics_fractions | [GetForecastAtTimestampResponse.Value.OtherStatisticsFractionsEntry](#ocf-dp-GetForecastAtTimestampResponse-Value-OtherStatisticsFractionsEntry) | repeated | | + +
GetForecastAtTimestampResponse.Value.OtherStatisticsFractionsEntry + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | || value | [float](#float) | | |
GetLatestForecastsRequest diff --git a/internal/server/postgres/dataserverimpl.go b/internal/server/postgres/dataserverimpl.go index 4664d50..1a2f52d 100644 --- a/internal/server/postgres/dataserverimpl.go +++ b/internal/server/postgres/dataserverimpl.go @@ -185,9 +185,13 @@ func (s *DataPlatformDataServiceServerImpl) CreateForecast( for i, value := range req.Values { paramsList[i] = db.CreatePredictedValuesParams{ HorizonMins: int16(value.HorizonMins), + P02Sip: extractSIPStatPtrFromMap(value.OtherStatisticsFractions, "p02"), P10Sip: extractSIPStatPtrFromMap(value.OtherStatisticsFractions, "p10"), + P25Sip: extractSIPStatPtrFromMap(value.OtherStatisticsFractions, "p25"), P50Sip: int16(value.P50Fraction * 30000.0), + P75Sip: extractSIPStatPtrFromMap(value.OtherStatisticsFractions, "p75"), P90Sip: extractSIPStatPtrFromMap(value.OtherStatisticsFractions, "p90"), + P98Sip: extractSIPStatPtrFromMap(value.OtherStatisticsFractions, "p98"), ForecastUuid: dbForecast.ForecastUuid, } } @@ -523,9 +527,13 @@ func (s *DataPlatformDataServiceServerImpl) StreamForecastData( &row.ForecasterVersion, &row.CreatedAtUtc, &row.HorizonMins, + &row.P02Sip, &row.P10Sip, + &row.P25Sip, &row.P50Sip, + &row.P75Sip, &row.P90Sip, + &row.P98Sip, &row.CapacityWatts, &row.Metadata, &row.InitTimeUtc, @@ -537,14 +545,30 @@ func (s *DataPlatformDataServiceServerImpl) StreamForecastData( } otherStatistics := make(map[string]float32) + if row.P02Sip != nil { + otherStatistics["p02"] = float32(*row.P02Sip) / 30000.0 + } + if row.P10Sip != nil { otherStatistics["p10"] = float32(*row.P10Sip) / 30000.0 } + if row.P25Sip != nil { + otherStatistics["p25"] = float32(*row.P25Sip) / 30000.0 + } + + if row.P75Sip != nil { + otherStatistics["p75"] = float32(*row.P75Sip) / 30000.0 + } + if row.P90Sip != nil { otherStatistics["p90"] = float32(*row.P90Sip) / 30000.0 } + if row.P98Sip != nil { + otherStatistics["p98"] = float32(*row.P98Sip) / 30000.0 + } + metadata := make(map[string]string) if req.IncludeMetadata && row.Metadata != nil { for k, v := range row.Metadata.AsMap() { @@ -1057,6 +1081,31 @@ func (s *DataPlatformDataServiceServerImpl) GetForecastAtTimestamp( values := make([]*pb.GetForecastAtTimestampResponse_Value, len(dbPredictions)) for i, value := range dbPredictions { + otherStats := make(map[string]float32) + if value.P02Sip != nil { + otherStats["p02"] = float32(*value.P02Sip) / 30000.0 + } + + if value.P10Sip != nil { + otherStats["p10"] = float32(*value.P10Sip) / 30000.0 + } + + if value.P25Sip != nil { + otherStats["p25"] = float32(*value.P25Sip) / 30000.0 + } + + if value.P75Sip != nil { + otherStats["p75"] = float32(*value.P75Sip) / 30000.0 + } + + if value.P90Sip != nil { + otherStats["p90"] = float32(*value.P90Sip) / 30000.0 + } + + if value.P98Sip != nil { + otherStats["p98"] = float32(*value.P98Sip) / 30000.0 + } + values[i] = &pb.GetForecastAtTimestampResponse_Value{ ValueFraction: float32(value.P50Sip) / 30000.0, EffectiveCapacityWatts: uint64(value.CapacityWatts), @@ -1069,6 +1118,7 @@ func (s *DataPlatformDataServiceServerImpl) GetForecastAtTimestamp( Metadata: value.Metadata, InitializationTimestampUtc: timestamppb.New(value.InitTimeUtc.Time), CreatedTimestampUtc: timestamppb.New(value.CreatedAtUtc.Time), + OtherStatisticsFractions: otherStats, } } @@ -1378,7 +1428,7 @@ func (s *DataPlatformDataServiceServerImpl) UpdateLocation( lsprms := db.GetSourceAtTimestampParams{ GeometryUuid: uuid.MustParse(req.LocationUuid), SourceTypeID: int16(req.EnergySource.Number()), - AtTimestampUtc: pgtype.Timestamp{Time: req.ValidFromUtc.AsTime(), Valid: true}, + AtTimestampUtc: pgtype.Timestamp{Time: validFrom, Valid: true}, } dbSource, err := querier.GetSourceAtTimestamp(ctx, lsprms) @@ -1618,14 +1668,30 @@ func (s *DataPlatformDataServiceServerImpl) GetForecastAsTimeseries( out := make([]*pb.GetForecastAsTimeseriesResponse_Value, len(dbPreds)) for i, pred := range dbPreds { otherStats := make(map[string]float32) + if pred.P02Sip != nil { + otherStats["p02"] = float32(*pred.P02Sip) / 30000.0 + } + if pred.P10Sip != nil { otherStats["p10"] = float32(*pred.P10Sip) / 30000.0 } + if pred.P25Sip != nil { + otherStats["p25"] = float32(*pred.P25Sip) / 30000.0 + } + + if pred.P75Sip != nil { + otherStats["p75"] = float32(*pred.P75Sip) / 30000.0 + } + if pred.P90Sip != nil { otherStats["p90"] = float32(*pred.P90Sip) / 30000.0 } + if pred.P98Sip != nil { + otherStats["p98"] = float32(*pred.P98Sip) / 30000.0 + } + out[i] = &pb.GetForecastAsTimeseriesResponse_Value{ TargetTimestampUtc: timestamppb.New( pred.InitTimeUtc.Time.Add(time.Duration(pred.HorizonMins) * time.Minute), @@ -1718,14 +1784,30 @@ func (s *DataPlatformDataServiceServerImpl) GetForecastAsTimeseries( values := make([]*pb.GetForecastAsTimeseriesResponse_Value, len(dbValues)) for i, value := range dbValues { otherStats := make(map[string]float32) + if value.P02Sip != nil { + otherStats["p02"] = float32(*value.P02Sip) / 30000.0 + } + if value.P10Sip != nil { otherStats["p10"] = float32(*value.P10Sip) / 30000.0 } + if value.P25Sip != nil { + otherStats["p25"] = float32(*value.P25Sip) / 30000.0 + } + + if value.P75Sip != nil { + otherStats["p75"] = float32(*value.P75Sip) / 30000.0 + } + if value.P90Sip != nil { otherStats["p90"] = float32(*value.P90Sip) / 30000.0 } + if value.P98Sip != nil { + otherStats["p98"] = float32(*value.P98Sip) / 30000.0 + } + values[i] = &pb.GetForecastAsTimeseriesResponse_Value{ TargetTimestampUtc: timestamppb.New(value.TargetTimeUtc.Time), P50ValueFraction: float32(value.P50Sip) / 30000.0, diff --git a/internal/server/postgres/dataserverimpl_test.go b/internal/server/postgres/dataserverimpl_test.go index 3b5108c..3a9780e 100644 --- a/internal/server/postgres/dataserverimpl_test.go +++ b/internal/server/postgres/dataserverimpl_test.go @@ -18,14 +18,104 @@ import ( pb "github.com/openclimatefix/data-platform/internal/gen/ocf/dp" ) -func TestCreateLocation(t *testing.T) { - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) +func ptr[T any](v T) *T { + return &v +} + +func createTestMetadata(t *testing.T, m map[string]any) *structpb.Struct { + t.Helper() + md, err := structpb.NewStruct(m) + require.NoError(t, err) + return md +} + +func createTestLocation( + t *testing.T, + name, wkt string, + capacity uint64, + ts time.Time, + metadata *structpb.Struct, +) *pb.CreateLocationResponse { + t.Helper() + resp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ + LocationName: name, + GeometryWkt: wkt, + EffectiveCapacityWatts: capacity, + Metadata: metadata, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + LocationType: pb.LocationType_LOCATION_TYPE_SITE, + ValidFromUtc: timestamppb.New(ts), + }) + require.NoError(t, err) + + return resp +} + +func createTestForecaster(t *testing.T, name, version string) *pb.Forecaster { + t.Helper() + resp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ + Name: name, + Version: version, + }) require.NoError(t, err) + return resp.Forecaster +} + +// generateTestForecastValues creates a slice of ForecastValues with predictable plevels. +func generateTestForecastValues( + numHorizons int, + intervalMins uint32, +) []*pb.CreateForecastRequest_ForecastValue { + values := make([]*pb.CreateForecastRequest_ForecastValue, numHorizons) + for i := range values { + values[i] = &pb.CreateForecastRequest_ForecastValue{ + HorizonMins: uint32(i) * intervalMins, + P50Fraction: float32(0.5 + float64(i)*0.001), + OtherStatisticsFractions: map[string]float32{ + "p98": float32(0.98 + float32(i)*0.001), + "p90": float32(0.9 + float32(i)*0.001), + "p75": float32(0.75 + float32(i)*0.001), + "p25": float32(0.25 + float32(i)*0.001), + "p10": float32(0.1 + float32(i)*0.001), + "p02": float32(0.02 + float32(i)*0.001), + }, + } + } + + return values +} + +func createTestForecast( + t *testing.T, + locationUuid string, + forecaster *pb.Forecaster, + initTime time.Time, + values []*pb.CreateForecastRequest_ForecastValue, + createdTime time.Time, +) { + t.Helper() + req := &pb.CreateForecastRequest{ + LocationUuid: locationUuid, + Forecaster: forecaster, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + InitTimeUtc: timestamppb.New(initTime), + Values: values, + Metadata: createTestMetadata(t, map[string]any{"source": "test"}), + CreatedTimestampUtc: timestamppb.New(createdTime), + } + _, err := dc.CreateForecast(t.Context(), req) + require.NoError(t, err) +} + +func TestCreateLocation(t *testing.T) { + metadata := createTestMetadata(t, map[string]any{"source": "test"}) + testcases := []struct { name string req *pb.CreateLocationRequest expectedLatLng *pb.LatLng + shouldErr bool }{ { name: "Should create solar location", @@ -41,6 +131,7 @@ func TestCreateLocation(t *testing.T) { Latitude: 51.5, Longitude: 0.0, }, + shouldErr: false, }, { name: "Shouldn't create unknown energy source", @@ -52,6 +143,7 @@ func TestCreateLocation(t *testing.T) { LocationType: pb.LocationType_LOCATION_TYPE_SITE, Metadata: metadata, }, + shouldErr: true, }, { name: "Should create wind location", @@ -67,6 +159,7 @@ func TestCreateLocation(t *testing.T) { Latitude: 51.5, Longitude: 0.0, }, + shouldErr: false, }, { name: "Shouldn't create unknown location type", @@ -78,6 +171,7 @@ func TestCreateLocation(t *testing.T) { LocationType: pb.LocationType_LOCATION_TYPE_UNSPECIFIED, Metadata: metadata, }, + shouldErr: true, }, { name: "Shouldn't create location with empty name", @@ -89,6 +183,7 @@ func TestCreateLocation(t *testing.T) { LocationType: pb.LocationType_LOCATION_TYPE_SITE, Metadata: metadata, }, + shouldErr: true, }, { name: "Should create location with pipe in the name", @@ -104,6 +199,7 @@ func TestCreateLocation(t *testing.T) { Latitude: 51.5, Longitude: 0.0, }, + shouldErr: false, }, { name: "Should create location with large capacity", @@ -119,6 +215,7 @@ func TestCreateLocation(t *testing.T) { Latitude: 51.75, Longitude: 0.5, }, + shouldErr: false, }, { name: "Shouldn't create location with non-closed POLYGON geometry", @@ -130,6 +227,7 @@ func TestCreateLocation(t *testing.T) { LocationType: pb.LocationType_LOCATION_TYPE_DNO, Metadata: metadata, }, + shouldErr: true, }, { name: "Should create location with closed MULTIPOLYGON geometry", @@ -145,6 +243,7 @@ func TestCreateLocation(t *testing.T) { Latitude: 51.75, Longitude: 1.5, }, + shouldErr: false, }, { name: "Shouldn't create location with non-closed MULTIPOLYGON geometry", @@ -156,6 +255,7 @@ func TestCreateLocation(t *testing.T) { LocationType: pb.LocationType_LOCATION_TYPE_DNO, Metadata: metadata, }, + shouldErr: true, }, { name: "Shouldn't create location with non WSG84 geometry", @@ -167,6 +267,7 @@ func TestCreateLocation(t *testing.T) { LocationType: pb.LocationType_LOCATION_TYPE_SITE, Metadata: metadata, }, + shouldErr: true, }, { name: "Should create location with associated lat long", @@ -186,6 +287,7 @@ func TestCreateLocation(t *testing.T) { Latitude: 51.5074, Longitude: -0.1278, }, + shouldErr: false, }, } @@ -193,7 +295,7 @@ func TestCreateLocation(t *testing.T) { t.Run(tc.name, func(t *testing.T) { resp, err := dc.CreateLocation(t.Context(), tc.req) - if strings.Split(tc.name, " ")[0] == "Shouldn't" { + if tc.shouldErr { require.Error(t, err, "Expected not to be able to create the location") } else { // Try fetching the created location and check it's the same @@ -227,25 +329,20 @@ func TestCreateLocation(t *testing.T) { } func TestUpdateLocation(t *testing.T) { - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) pivotTime := time.Date(2019, 5, 6, 6, 0, 0, 0, time.UTC) - createResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_update_location_site", - GeometryWkt: "POINT(-0.1 51.5)", - EffectiveCapacityWatts: 1234e6, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-10 * time.Hour)), - }) - - require.NoError(t, err) + createResp := createTestLocation( + t, + "test_update_location_site", + "POINT(-0.1 51.5)", + 1234e6, + pivotTime.Add(-10*time.Hour), + metadata, + ) - newMetadata, err := structpb.NewStruct(map[string]any{"source": "test", "updated": true}) - require.NoError(t, err) + newMetadata := createTestMetadata(t, map[string]any{"source": "test", "updated": true}) testcases := []struct { name string @@ -253,30 +350,33 @@ func TestUpdateLocation(t *testing.T) { expectedName string expectedCapacityWatts uint64 expectedMetadata map[string]any + shouldErr bool }{ { name: "Should return the same when the update doesn't change anything", req: &pb.UpdateLocationRequest{ LocationUuid: createResp.LocationUuid, - NewEffectiveCapacityWatts: func() *uint64 { v := uint64(1234e6); return &v }(), + NewEffectiveCapacityWatts: ptr(uint64(1234e6)), EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, ValidFromUtc: timestamppb.New(pivotTime.Add(-4 * time.Hour)), }, expectedName: "test_update_location_site", expectedCapacityWatts: 1234e6, expectedMetadata: map[string]any{"source": "test"}, + shouldErr: false, }, { name: "Should update capacity to higher value", req: &pb.UpdateLocationRequest{ LocationUuid: createResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - NewEffectiveCapacityWatts: func() *uint64 { v := uint64(1235e6); return &v }(), + NewEffectiveCapacityWatts: ptr(uint64(1235e6)), ValidFromUtc: timestamppb.New(pivotTime.Add(-5 * time.Hour)), }, expectedName: "test_update_location_site", expectedCapacityWatts: 1235e6, expectedMetadata: map[string]any{"source": "test"}, + shouldErr: false, }, { name: "Shouldn't update anything when nothing is set", @@ -285,19 +385,21 @@ func TestUpdateLocation(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, ValidFromUtc: timestamppb.New(pivotTime.Add(-4 * time.Hour)), }, + shouldErr: true, }, { name: "Should update name and metadata", req: &pb.UpdateLocationRequest{ LocationUuid: createResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - NewLocationName: func() *string { s := "test_updated_location_site"; return &s }(), + NewLocationName: ptr("test_updated_location_site"), NewMetadata: newMetadata, ValidFromUtc: timestamppb.New(pivotTime.Add(-3 * time.Hour)), }, expectedName: "test_updated_location_site", expectedCapacityWatts: 1235e6, expectedMetadata: map[string]any{"source": "test", "updated": true}, + shouldErr: false, }, } @@ -305,7 +407,7 @@ func TestUpdateLocation(t *testing.T) { t.Run(tc.name, func(t *testing.T) { resp, err := dc.UpdateLocation(t.Context(), tc.req) - if strings.Split(tc.name, " ")[0] == "Shouldn't" { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -332,54 +434,49 @@ func TestUpdateLocation(t *testing.T) { } func TestUpdateLocationOwner(t *testing.T) { - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) - createResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_update_location_owner_site", - GeometryWkt: "POINT(-0.1 51.5)", - EffectiveCapacityWatts: 1234e6, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(time.Date(2019, 5, 6, 6, 0, 0, 0, time.UTC)), - }) - require.NoError(t, err) - - createResp, err = dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_update_location_owner_site_2", - GeometryWkt: "POINT(-0.2 51.6)", - EffectiveCapacityWatts: 1000e6, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(time.Date(2019, 5, 6, 6, 0, 0, 0, time.UTC)), - }) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) + createResp := createTestLocation( + t, + "test_update_location_owner_site", + "POINT(-0.1 51.5)", + 1234e6, + time.Date(2019, 5, 6, 6, 0, 0, 0, time.UTC), + metadata, + ) + + createResp = createTestLocation( + t, + "test_update_location_owner_site_2", + "POINT(-0.2 51.6)", + 1000e6, + time.Date(2019, 5, 6, 6, 0, 0, 0, time.UTC), + metadata, + ) testcases := []struct { - name string - newOwner string - shouldError bool + name string + newOwner string + shouldErr bool }{ { - name: "Should update owner to a new value", - newOwner: "first_owner", - shouldError: false, + name: "Should update owner to a new value", + newOwner: "first_owner", + shouldErr: false, }, { - name: "Should update owner to the same value", - newOwner: "first_owner", - shouldError: false, + name: "Should update owner to the same value", + newOwner: "first_owner", + shouldErr: false, }, { - name: "Should update owner to a different value", - newOwner: "second_owner", - shouldError: false, + name: "Should update owner to a different value", + newOwner: "second_owner", + shouldErr: false, }, { - name: "Should remove owner with empty string", - newOwner: "", - shouldError: false, + name: "Should remove owner with empty string", + newOwner: "", + shouldErr: false, }, } @@ -389,7 +486,7 @@ func TestUpdateLocationOwner(t *testing.T) { LocationUuid: createResp.LocationUuid, NewOrganisationId: tc.newOwner, }) - if tc.shouldError { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -414,6 +511,7 @@ func TestCreateUpdateForecaster(t *testing.T) { name string createReq *pb.CreateForecasterRequest updateReq *pb.UpdateForecasterRequest + shouldErr bool }{ { name: "Should create forecaster", @@ -421,6 +519,7 @@ func TestCreateUpdateForecaster(t *testing.T) { Name: "test_forecaster_1", Version: "v1", }, + shouldErr: false, }, { name: "Should update existing forecaster", @@ -428,6 +527,7 @@ func TestCreateUpdateForecaster(t *testing.T) { Name: "test_forecaster_1", NewVersion: "v2", }, + shouldErr: false, }, { name: "Shouldn't update with non-unique version", @@ -435,6 +535,7 @@ func TestCreateUpdateForecaster(t *testing.T) { Name: "test_forecaster_1", NewVersion: "v2", }, + shouldErr: true, }, { name: "Shouldn't update non-existent forecaster", @@ -442,6 +543,7 @@ func TestCreateUpdateForecaster(t *testing.T) { Name: "non_existent_forecaster", NewVersion: "v1", }, + shouldErr: true, }, { name: "Shouldn't create existing forecaster", @@ -449,6 +551,7 @@ func TestCreateUpdateForecaster(t *testing.T) { Name: "test_forecaster_1", Version: "v2", }, + shouldErr: true, }, { name: "Shouldn't create forecaster with invalid name", @@ -456,6 +559,7 @@ func TestCreateUpdateForecaster(t *testing.T) { Name: "", Version: "v1", }, + shouldErr: true, }, } @@ -469,7 +573,7 @@ func TestCreateUpdateForecaster(t *testing.T) { _, err = dc.UpdateForecaster(t.Context(), tc.updateReq) } - if strings.Split(tc.name, " ")[0] == "Shouldn't" { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -482,11 +586,7 @@ func TestListForecasters(t *testing.T) { "test_list_forecaster_1", "test_list_forecaster_2", } { - _, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: name, - Version: "v0", - }) - require.NoError(t, err) + _ = createTestForecaster(t, name, "v0") for i := range 4 { _, err := dc.UpdateForecaster(t.Context(), &pb.UpdateForecasterRequest{ @@ -501,6 +601,7 @@ func TestListForecasters(t *testing.T) { name string req *pb.ListForecastersRequest expectedCount int + shouldErr bool }{ { name: "Should return all forecasters", @@ -511,6 +612,7 @@ func TestListForecasters(t *testing.T) { }, }, expectedCount: 2 * 5, + shouldErr: false, }, { name: "Should list only forecasters with filtered names", @@ -518,6 +620,7 @@ func TestListForecasters(t *testing.T) { ForecasterNamesFilter: []string{"test_list_forecaster_1"}, }, expectedCount: 5, + shouldErr: false, }, { name: "Should list only the latest versions when asked", @@ -529,13 +632,14 @@ func TestListForecasters(t *testing.T) { LatestVersionsOnly: true, }, expectedCount: 2, + shouldErr: false, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { resp, err := dc.ListForecasters(t.Context(), tc.req) - if strings.Contains(tc.name, "Shouldn't") { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -549,94 +653,129 @@ func TestGetForecastAtTimestamp(t *testing.T) { pivotTime := time.Date(2025, 4, 5, 12, 0, 0, 0, time.UTC) // --- Create a forecast --- // // Create two sites to attach the forecasts to - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_forecast_at_timestamp_site", - GeometryWkt: "POINT(-0.6 51.8)", - EffectiveCapacityWatts: 1000000, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), - }) - require.NoError(t, err) - siteResp2, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_forecast_at_timestamp_site_2", - GeometryWkt: "POINT(-0.5 58.6)", - EffectiveCapacityWatts: 2000000, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), - }) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) + siteResp := createTestLocation( + t, + "test_get_forecast_at_timestamp_site", + "POINT(-0.6 51.8)", + 1000000, + pivotTime.Add(-time.Hour*1), + metadata, + ) + siteResp2 := createTestLocation( + t, + "test_get_forecast_at_timestamp_site_2", + "POINT(-0.5 58.6)", + 2000000, + pivotTime.Add(-time.Hour*1), + metadata, + ) // Create a forecaster - forecasterResp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "test_get_forecast_at_timestamp_forecaster", - Version: "v1", - }) - require.NoError(t, err) + fc := createTestForecaster(t, "test_get_forecast_at_timestamp_forecaster", "v1") - yields := make([]*pb.CreateForecastRequest_ForecastValue, 10) - for i := range yields { - yields[i] = &pb.CreateForecastRequest_ForecastValue{ - HorizonMins: uint32(i * 30), - P50Fraction: float32(0.5 + float64(i)*0.05), - OtherStatisticsFractions: map[string]float32{ - "p90": float32(0.6 + float32(i)*0.05), - "p10": float32(0.4 + float32(i)*0.05), - }, - } - } - - for _, locationUuid := range [2]string{siteResp.LocationUuid, siteResp2.LocationUuid} { - req := &pb.CreateForecastRequest{ - LocationUuid: locationUuid, - Forecaster: forecasterResp.Forecaster, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - InitTimeUtc: timestamppb.New(pivotTime), - Values: yields, - } - _, err = dc.CreateForecast(t.Context(), req) - require.NoError(t, err) - } + yields := generateTestForecastValues(10, 30) + createTestForecast(t, siteResp.LocationUuid, fc, pivotTime, yields, pivotTime) + createTestForecast(t, siteResp2.LocationUuid, fc, pivotTime, yields, pivotTime) testcases := []struct { - name string - timestamp time.Time - expectedp50s []float32 + name string + timestamp time.Time + expectedStats []map[string]float32 + shouldErr bool }{ { - name: "Should get forecast at init time", - timestamp: pivotTime, - expectedp50s: []float32{0.5, 0.5}, + name: "Should get forecast at init time", + timestamp: pivotTime, + expectedStats: []map[string]float32{ + { + "p50": 0.5, + "p98": 0.98, + "p90": 0.9, + "p75": 0.75, + "p25": 0.25, + "p10": 0.1, + "p02": 0.02, + }, + { + "p50": 0.5, + "p98": 0.98, + "p90": 0.9, + "p75": 0.75, + "p25": 0.25, + "p10": 0.1, + "p02": 0.02, + }, + }, + shouldErr: false, }, { - name: "Should get forecast at first horizon", - timestamp: pivotTime.Add(30 * time.Minute), - expectedp50s: []float32{0.55, 0.55}, + name: "Should get forecast at first horizon", + timestamp: pivotTime.Add(30 * time.Minute), + expectedStats: []map[string]float32{ + { + "p50": 0.501, + "p98": 0.981, + "p90": 0.901, + "p75": 0.751, + "p25": 0.251, + "p10": 0.101, + "p02": 0.021, + }, + { + "p50": 0.501, + "p98": 0.981, + "p90": 0.901, + "p75": 0.751, + "p25": 0.251, + "p10": 0.101, + "p02": 0.021, + }, + }, + shouldErr: false, }, { - name: "Should return no values where no predicted values exist", - timestamp: pivotTime.Add(45 * time.Minute), - expectedp50s: []float32{}, + name: "Should return no values where no predicted values exist", + timestamp: pivotTime.Add(45 * time.Minute), + expectedStats: []map[string]float32{}, + shouldErr: false, }, { - name: "Should get forecast at last horizon", - timestamp: pivotTime.Add(270 * time.Minute), - expectedp50s: []float32{0.95, 0.95}, + name: "Should get forecast at last horizon", + timestamp: pivotTime.Add(270 * time.Minute), + expectedStats: []map[string]float32{ + { + "p50": 0.509, + "p98": 0.989, + "p90": 0.909, + "p75": 0.759, + "p25": 0.259, + "p10": 0.109, + "p02": 0.029, + }, + { + "p50": 0.509, + "p98": 0.989, + "p90": 0.909, + "p75": 0.759, + "p25": 0.259, + "p10": 0.109, + "p02": 0.029, + }, + }, + shouldErr: false, }, { - name: "Should return no values before init time", - timestamp: pivotTime.Add(-15 * time.Minute), - expectedp50s: []float32{}, + name: "Should return no values before init time", + timestamp: pivotTime.Add(-15 * time.Minute), + expectedStats: []map[string]float32{}, + shouldErr: false, }, { - name: "Should return no values after last horizon", - timestamp: pivotTime.Add(300 * time.Minute), - expectedp50s: []float32{}, + name: "Should return no values after last horizon", + timestamp: pivotTime.Add(300 * time.Minute), + expectedStats: []map[string]float32{}, + shouldErr: false, }, } @@ -647,19 +786,31 @@ func TestGetForecastAtTimestamp(t *testing.T) { siteResp.LocationUuid, siteResp2.LocationUuid, }, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, TimestampUtc: timestamppb.New(tc.timestamp), }) - if strings.Contains(tc.name, "Shouldn't") { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, resp) - require.Len(t, resp.Values, len(tc.expectedp50s)) + require.Len(t, resp.Values, len(tc.expectedStats)) for i, forecast := range resp.Values { - require.Equal(t, tc.expectedp50s[i], forecast.ValueFraction) + for stat, expected := range tc.expectedStats[i] { + if stat == "p50" { + require.InDelta(t, expected, forecast.ValueFraction, 0.0001) + } else { + require.InDelta( + t, + expected, + forecast.OtherStatisticsFractions[stat], + 0.0001, + ) + } + } + require.NotNil(t, forecast.InitializationTimestampUtc) require.NotNil(t, forecast.CreatedTimestampUtc) } @@ -670,32 +821,23 @@ func TestGetForecastAtTimestamp(t *testing.T) { func TestGetObservationsAtTimestamp(t *testing.T) { pivotTime := time.Date(2025, 2, 26, 12, 0, 0, 0, time.UTC) - // Create an observer + metadata := createTestMetadata(t, map[string]any{"source": "test"}) observerResp, err := dc.CreateObserver(t.Context(), &pb.CreateObserverRequest{ Name: "test_get_observations_at_timestamp_observer", }) require.NoError(t, err) - // Create a few sites to attach the observations to - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) siteUuids := make([]string, 3) for i := range siteUuids { capacity := uint64(1000000 + i*100000) - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: fmt.Sprintf("test_get_observations_at_timestamp_site_%d", i), - GeometryWkt: fmt.Sprintf( - "POINT(%f %f)", - -0.1+float32(i)*0.01, - 51.5+float32(i)*0.01, - ), - EffectiveCapacityWatts: capacity, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + fmt.Sprintf("test_get_observations_at_timestamp_site_%d", i), + fmt.Sprintf("POINT(%f %f)", -0.1+float32(i)*0.01, 51.5+float32(i)*0.01), + capacity, + pivotTime.Add(-time.Hour*1), + metadata, + ) siteUuids[i] = siteResp.LocationUuid req := &pb.CreateObservationsRequest{ @@ -717,6 +859,7 @@ func TestGetObservationsAtTimestamp(t *testing.T) { name string req *pb.GetObservationsAtTimestampRequest expectedFractions []float32 + shouldErr bool }{ { name: "Should get observation at exact timestamp for single location", @@ -727,6 +870,7 @@ func TestGetObservationsAtTimestamp(t *testing.T) { TimestampUtc: timestamppb.New(pivotTime), }, expectedFractions: []float32{0.1}, + shouldErr: false, }, { name: "Should get observation at exact timestamp for multiple locations", @@ -737,6 +881,7 @@ func TestGetObservationsAtTimestamp(t *testing.T) { TimestampUtc: timestamppb.New(pivotTime), }, expectedFractions: []float32{0.1, 0.1, 0.1}, + shouldErr: false, }, { name: "Should return no observations where no values exist at timestamp", @@ -746,6 +891,7 @@ func TestGetObservationsAtTimestamp(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, TimestampUtc: timestamppb.New(pivotTime.Add(5 * time.Minute)), }, + shouldErr: false, }, { name: "Should return no observations for non-existent location", @@ -755,6 +901,7 @@ func TestGetObservationsAtTimestamp(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, TimestampUtc: timestamppb.New(pivotTime), }, + shouldErr: false, }, { name: "Shouldn't return observations for non-existent observer", @@ -764,13 +911,14 @@ func TestGetObservationsAtTimestamp(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, TimestampUtc: timestamppb.New(pivotTime), }, + shouldErr: true, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { resp, err := dc.GetObservationsAtTimestamp(t.Context(), tc.req) - if strings.Contains(tc.name, "Shouldn't") { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -786,23 +934,20 @@ func TestGetObservationsAtTimestamp(t *testing.T) { } func TestGetLocation(t *testing.T) { - // Create a location to fetch - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) - createResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_location_site", - GeometryWkt: "POLYGON((0 0,0 1,1 1,1 0,0 0))", - EffectiveCapacityWatts: 12e6, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_GSP, - ValidFromUtc: timestamppb.New(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)), - }) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) + createResp := createTestLocation( + t, + "test_get_location_site", + "POLYGON((0 0,0 1,1 1,1 0,0 0))", + 12e6, + time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + metadata, + ) testCases := []struct { - name string - req *pb.GetLocationRequest + name string + req *pb.GetLocationRequest + shouldErr bool }{ { name: "Should get location without geometry", @@ -811,6 +956,7 @@ func TestGetLocation(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, IncludeGeometry: false, }, + shouldErr: false, }, { name: "Should get location with geometry", @@ -819,23 +965,28 @@ func TestGetLocation(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, IncludeGeometry: true, }, + shouldErr: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { resp, err := dc.GetLocation(t.Context(), tc.req) - require.NoError(t, err) - require.Equal(t, createResp.LocationUuid, resp.LocationUuid) - require.Equal(t, "test_get_location_site", resp.LocationName) - require.Equal(t, uint64(12e6), resp.EffectiveCapacityWatts) - - if tc.req.IncludeGeometry { - expected, err := hex.DecodeString( - "01030000000100000005000000000000000000000000000000000000000000000000000000000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000000000000000000000000000000000000000", - ) + if tc.shouldErr { + require.Error(t, err) + } else { require.NoError(t, err) - require.Equal(t, expected, resp.GeometryWkb) + require.Equal(t, createResp.LocationUuid, resp.LocationUuid) + require.Equal(t, "test_get_location_site", resp.LocationName) + require.Equal(t, uint64(12e6), resp.EffectiveCapacityWatts) + + if tc.req.IncludeGeometry { + expected, err := hex.DecodeString( + "01030000000100000005000000000000000000000000000000000000000000000000000000000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000000000000000000000000000000000000000", + ) + require.NoError(t, err) + require.Equal(t, expected, resp.GeometryWkb) + } } }) } @@ -844,40 +995,35 @@ func TestGetLocation(t *testing.T) { func TestGetLocationAsTimeseries(t *testing.T) { pivotTime := time.Date(2026, 1, 4, 12, 0, 0, 0, time.UTC) - metadata, err := structpb.NewStruct(map[string]any{"source": "test_initial"}) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test_initial"}) // Site is valid from 48 hours before pivot - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_location_as_timeseries_site", - GeometryWkt: "POINT(-60.25 57.5)", - EffectiveCapacityWatts: 1000000, // 1 MW - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 48)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_get_location_as_timeseries_site", + "POINT(-60.25 57.5)", + 1000000, + pivotTime.Add(-time.Hour*48), + metadata, + ) // Update the metadata and capacity at 24 hours before pivot - updatedMetadata, err := structpb.NewStruct(map[string]any{"source": "test_update_1"}) - require.NoError(t, err) - _, err = dc.UpdateLocation(t.Context(), &pb.UpdateLocationRequest{ + updatedMetadata := createTestMetadata(t, map[string]any{"source": "test_update_1"}) + _, err := dc.UpdateLocation(t.Context(), &pb.UpdateLocationRequest{ LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - NewEffectiveCapacityWatts: func() *uint64 { v := uint64(1500000); return &v }(), // 1.5 MW + NewEffectiveCapacityWatts: ptr(uint64(1500000)), NewMetadata: updatedMetadata, ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 24)), }) require.NoError(t, err) // Update the metadata and capacity again at pivot - finalMetadata, err := structpb.NewStruct(map[string]any{"source": "test_update_2"}) - require.NoError(t, err) + finalMetadata := createTestMetadata(t, map[string]any{"source": "test_update_2"}) _, err = dc.UpdateLocation(t.Context(), &pb.UpdateLocationRequest{ LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - NewEffectiveCapacityWatts: func() *uint64 { v := uint64(2000000); return &v }(), // 2.0 MW + NewEffectiveCapacityWatts: ptr(uint64(2000000)), NewMetadata: finalMetadata, ValidFromUtc: timestamppb.New(pivotTime), }) @@ -893,7 +1039,7 @@ func TestGetLocationAsTimeseries(t *testing.T) { req *pb.GetLocationAsTimeseriesRequest expectedCapacities []uint64 expectedSources []string - expectErr bool + shouldErr bool }{ { name: "Should return full history of capacity changes when window covers everything", @@ -904,7 +1050,7 @@ func TestGetLocationAsTimeseries(t *testing.T) { }, expectedCapacities: []uint64{1000000, 1500000, 2000000}, expectedSources: []string{"test_initial", "test_update_1", "test_update_2"}, - expectErr: false, + shouldErr: false, }, { name: "Should return only changes covered by time window and exclude others", @@ -918,7 +1064,7 @@ func TestGetLocationAsTimeseries(t *testing.T) { }, expectedCapacities: []uint64{1500000, 2000000}, expectedSources: []string{"test_update_1", "test_update_2"}, - expectErr: false, + shouldErr: false, }, { name: "Should return no values if time window is before the location existed", @@ -932,7 +1078,7 @@ func TestGetLocationAsTimeseries(t *testing.T) { }, expectedCapacities: []uint64{}, expectedSources: []string{}, - expectErr: false, + shouldErr: false, }, { name: "Shouldn't work with an invalid UUID", @@ -941,7 +1087,7 @@ func TestGetLocationAsTimeseries(t *testing.T) { EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, TimeWindow: defaultTimeWindow, }, - expectErr: true, + shouldErr: true, }, } @@ -949,7 +1095,7 @@ func TestGetLocationAsTimeseries(t *testing.T) { t.Run(tc.name, func(t *testing.T) { resp, err := dc.GetLocationAsTimeseries(t.Context(), tc.req) - if tc.expectErr { + if tc.shouldErr { require.Error(t, err) return } @@ -983,20 +1129,14 @@ func TestGetLocationsAsGeoJSON(t *testing.T) { // Create some locations siteUuids := make([]string, 3) for i := range siteUuids { - resp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: fmt.Sprintf("testsite%02d", i), - GeometryWkt: fmt.Sprintf( - "POINT(%f %f)", - -0.1+float32(i)*0.01, - 51.5+float32(i)*0.01, - ), - EffectiveCapacityWatts: uint64(1000000 + i*100), - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Metadata: &structpb.Struct{}, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - }) - require.NoError(t, err) - + resp := createTestLocation( + t, + fmt.Sprintf("testsite%02d", i), + fmt.Sprintf("POINT(%f %f)", -0.1+float32(i)*0.01, 51.5+float32(i)*0.01), + uint64(1000000+i*100), + time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + &structpb.Struct{}, + ) siteUuids[i] = resp.LocationUuid } @@ -1018,60 +1158,41 @@ func TestGetForecastAsTimeseries(t *testing.T) { pivotTime := time.Date(2025, 2, 5, 12, 0, 0, 0, time.UTC) // Create a site to attach the forecasts to - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_forecast_as_timeseries_site", - GeometryWkt: "POINT(-60.25 57.5)", - EffectiveCapacityWatts: 1000000, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 49)), - }) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) + siteResp := createTestLocation( + t, + "test_get_forecast_as_timeseries_site", + "POINT(-60.25 57.5)", + 1000000, + pivotTime.Add(-time.Hour*49), + metadata, + ) // Update the capacity of the site to check it is reflected in the values - _, err = dc.UpdateLocation(t.Context(), &pb.UpdateLocationRequest{ + _, err := dc.UpdateLocation(t.Context(), &pb.UpdateLocationRequest{ LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - NewEffectiveCapacityWatts: func() *uint64 { v := uint64(1500000); return &v }(), + NewEffectiveCapacityWatts: ptr(uint64(1500000)), ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), }) require.NoError(t, err) // Create a forecaster to make the forecasts - forecasterResp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "test_get_forecast_as_timeseries_forecaster", - Version: "v1", - }) - require.NoError(t, err) + fc := createTestForecaster(t, "test_get_forecast_as_timeseries_forecaster", "v1") // Create 4, hour-long forecasts, each 30 minutes apart, with a resolution of 5 minutes. - // The forecast values increase linearly from 0% to 100% of capacity over each forecast. // The last forecast begins at the pivot time. - yields := make([]*pb.CreateForecastRequest_ForecastValue, 60/5) - for i := range yields { - yields[i] = &pb.CreateForecastRequest_ForecastValue{ - HorizonMins: uint32(i * 5), - P50Fraction: float32(i) * float32(100/len(yields)) / 100.0, - OtherStatisticsFractions: map[string]float32{ - "p10": float32(max(float32(i-1)*float32(100/len(yields))/100.0, 0)), - "p90": float32(min(float32(i+1)*float32(100/len(yields))/100.0, 1.1)), - }, - } - } + yields := generateTestForecastValues(12, 5) for i := 3; i >= 0; i-- { - req := &pb.CreateForecastRequest{ - LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - InitTimeUtc: timestamppb.New(pivotTime.Add(time.Duration(-i*30) * time.Minute)), - Values: yields, - } - _, err = dc.CreateForecast(t.Context(), req) - require.NoError(t, err) + createTestForecast( + t, + siteResp.LocationUuid, + fc, + pivotTime.Add(time.Duration(-i*30)*time.Minute), + yields, + pivotTime, + ) } defaultTimeWindow := &pb.TimeWindow{ @@ -1083,13 +1204,13 @@ func TestGetForecastAsTimeseries(t *testing.T) { name string req *pb.GetForecastAsTimeseriesRequest expectedValues []float32 - expectErr bool + shouldErr bool }{ { name: "Should return expected values for horizon 0 mins", req: &pb.GetForecastAsTimeseriesRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, HorizonMins: 0, TimeWindow: defaultTimeWindow, @@ -1099,25 +1220,27 @@ func TestGetForecastAsTimeseries(t *testing.T) { // for each target time. // Since the predicted values are every 5 minutes, and the forecasts are every 30, // we should get 6 values from each forecast, until the latest where we get all 12. - // The forecast values are seeded increasing from 0% to 100% in regular intervals, - // and there are 12 values per forecast - 100 // 12 = 8, so - // this means the values we are fetching should be - // 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) + // The forecast values are equal to the plevel fraction + (0.001*step), + // where step is the index of the value in the forecast. + // This means the values we are fetching should be: + // - horizons 0 to 25 minutes from forecast 3 + // - horizons 0 to 25 minutes from forecast 2 + // - horizons 0 to 25 minutes from forecast 1 + // - all horizons for forecast 0 + // - horizons 0 to 55 minutes from forecast 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, - 0.00, 0.08, 0.16, 0.24, 0.32, 0.40, - 0.00, 0.08, 0.16, 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, + 0.5, 0.501, 0.502, 0.503, 0.504, 0.505, + 0.5, 0.501, 0.502, 0.503, 0.504, 0.505, + 0.5, 0.501, 0.502, 0.503, 0.504, 0.505, + 0.5, 0.501, 0.502, 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, }, - expectErr: false, + shouldErr: false, }, { name: "Should return expected values for horizon 14 mins", req: &pb.GetForecastAsTimeseriesRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, HorizonMins: 14, TimeWindow: defaultTimeWindow, @@ -1125,47 +1248,49 @@ 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. 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.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, + 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, + 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, + 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, + 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, }, - expectErr: false, + shouldErr: false, }, { name: "Should return expected values for horizon 30 mins", req: &pb.GetForecastAsTimeseriesRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, HorizonMins: 30, TimeWindow: defaultTimeWindow, }, + // For horizon of 30 minutes, the values for 0, 5, 10, 15, 20, + // and 25 minutes should not be included. expectedValues: []float32{ - 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, - 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, - 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, - 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, + 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, + 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, + 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, + 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, }, - expectErr: false, + shouldErr: false, }, { name: "Should return no values for horizon 60 mins", req: &pb.GetForecastAsTimeseriesRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, HorizonMins: 60, TimeWindow: defaultTimeWindow, }, expectedValues: []float32{}, - expectErr: false, + shouldErr: false, }, { name: "Should return no values for a time window outside of the forecasted values", req: &pb.GetForecastAsTimeseriesRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, HorizonMins: 0, TimeWindow: &pb.TimeWindow{ @@ -1174,22 +1299,22 @@ func TestGetForecastAsTimeseries(t *testing.T) { }, }, expectedValues: []float32{}, - expectErr: false, + shouldErr: false, }, { name: "Should return all predictions for a specific initialization time", req: &pb.GetForecastAsTimeseriesRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, HorizonMins: 0, TimeWindow: defaultTimeWindow, InitializationTimestampUtc: timestamppb.New(pivotTime.Add(-30 * time.Minute)), }, expectedValues: []float32{ - 0.00, 0.08, 0.16, 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72, 0.80, 0.88, + 0.5, 0.501, 0.502, 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, }, - expectErr: false, + shouldErr: false, }, } @@ -1197,7 +1322,7 @@ func TestGetForecastAsTimeseries(t *testing.T) { t.Run(tc.name, func(t *testing.T) { resp, err := dc.GetForecastAsTimeseries(t.Context(), tc.req) - if tc.expectErr { + if tc.shouldErr { require.Error(t, err) return } @@ -1223,15 +1348,15 @@ func TestGetForecastAsTimeseries(t *testing.T) { } require.IsIncreasing(t, targetTimes) - require.Equal(t, tc.expectedValues, actualValues) + require.Equal(t, len(tc.expectedValues), len(actualValues)) + require.InDeltaSlice(t, tc.expectedValues, actualValues, 0.0001) }) } } func TestListLocationsLocationFilters(t *testing.T) { pivotTime := time.Now().Truncate(time.Minute) - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) // Create a bunch of locations var locationUuids []string @@ -1347,7 +1472,7 @@ func TestListLocationsLocationFilters(t *testing.T) { name: "Should return nothing for non-matching filters", req: &pb.ListLocationsRequest{ EnergySourceFilter: sourceFilter, - LocationTypeFilter: func() *pb.LocationType { t := pb.LocationType_LOCATION_TYPE_DNO; return &t }(), + LocationTypeFilter: ptr(pb.LocationType_LOCATION_TYPE_DNO), LocationUuidsFilter: locationUuids, }, }, @@ -1406,7 +1531,7 @@ func TestListLocationsLocationFilters(t *testing.T) { { name: "Should return nothing for enclosing geometry containing nothing", req: &pb.ListLocationsRequest{ - EnclosingLocationUuidFilter: func() *string { s := uuid.New().String(); return &s }(), + EnclosingLocationUuidFilter: ptr(uuid.New().String()), LocationUuidsFilter: locationUuids, }, expectedCount: 0, @@ -1434,16 +1559,14 @@ func TestGetObservationsAsTimeseries(t *testing.T) { pivotTime := time.Now().Truncate(time.Minute) // Create a site to attach the observations to - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_observations_as_timeseries_site", - GeometryWkt: "POINT(-20.25 57.5)", - EffectiveCapacityWatts: 1000000, - Metadata: &structpb.Struct{}, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 64)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_get_observations_as_timeseries_site", + "POINT(-20.25 57.5)", + 1000000, + pivotTime.Add(-time.Hour*64), + &structpb.Struct{}, + ) // Create an observer to make the observations obsResp, err := dc.CreateObserver(t.Context(), &pb.CreateObserverRequest{ @@ -1508,16 +1631,14 @@ func TestGetLatestObservations(t *testing.T) { pivotTime := time.Now().Truncate(time.Minute) // Create a site to attach the observations to - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_latest_observations_site_1", - GeometryWkt: "POINT(-20.25 57.5)", - EffectiveCapacityWatts: 1000000, - Metadata: &structpb.Struct{}, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 4)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_get_latest_observations_site", + "POINT(-20.25 57.5)", + 1000000, + pivotTime.Add(-time.Hour*4), + &structpb.Struct{}, + ) // Create an observer to make the observations obsResp, err := dc.CreateObserver(t.Context(), &pb.CreateObserverRequest{ @@ -1690,22 +1811,20 @@ func TestCreateListObservers(t *testing.T) { func TestCreateObservations(t *testing.T) { pivotTime := time.Date(2020, 7, 7, 12, 0, 0, 0, time.UTC) // Create a site to attach the observations to - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_create_observations_site", - GeometryWkt: "POINT(-0.1 51.5)", - EffectiveCapacityWatts: 1000000, - Metadata: &structpb.Struct{}, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 4)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_create_observations_site", + "POINT(-0.1 51.5)", + 1000000, + pivotTime.Add(-time.Hour*4), + &structpb.Struct{}, + ) // Update the capacity updateResp, err := dc.UpdateLocation(t.Context(), &pb.UpdateLocationRequest{ LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - NewEffectiveCapacityWatts: func() *uint64 { v := uint64(2000000); return &v }(), + NewEffectiveCapacityWatts: ptr(uint64(2000000)), ValidFromUtc: timestamppb.New(pivotTime.Add(time.Hour * 1)), }) require.NoError(t, err) @@ -1799,17 +1918,14 @@ func TestCreateObservations(t *testing.T) { func TestGetWeekAverageDeltas(t *testing.T) { pivotTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - // Create a site to attach the observations to - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_week_average_deltas_site", - GeometryWkt: "POINT(-20.25 59.5)", - EffectiveCapacityWatts: 1000000, - Metadata: &structpb.Struct{}, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 12 * 24)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_get_week_average_deltas_site", + "POINT(-20.25 59.5)", + 1000000, + pivotTime.Add(-time.Hour*12*24), + &structpb.Struct{}, + ) // Create an observer to make the observations obsResp, err := dc.CreateObserver(t.Context(), &pb.CreateObserverRequest{ @@ -1839,43 +1955,28 @@ func TestGetWeekAverageDeltas(t *testing.T) { require.NoError(t, err) // Create a forecaster to make the forecasts - forecasterResp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "test_get_week_average_deltas_forecaster", - Version: "v1", - }) - require.NoError(t, err) + fc := createTestForecaster(t, "test_get_week_average_deltas_forecaster", "v1") // Create 8, 8 hour-long forecasts, each one day apart, with a resolution of 30 minutes. // The forecast values increase linearly from 0% to 100% of capacity over each forecast. // The last forecast begins at the pivot time. - yields := make([]*pb.CreateForecastRequest_ForecastValue, 8*60/30) - for i := range yields { - yields[i] = &pb.CreateForecastRequest_ForecastValue{ - HorizonMins: uint32(i * 5), - P50Fraction: float32(i) * float32(100/len(yields)) / 100.0, - OtherStatisticsFractions: map[string]float32{ - "p10": float32(max(float32(i-1)*float32(100/len(yields))/100.0, 0)), - "p90": float32(min(float32(i+1)*float32(100/len(yields))/100.0, 1.1)), - }, - } - } + yields := generateTestForecastValues(16, 30) for i := 7; i >= 0; i-- { - req := &pb.CreateForecastRequest{ - LocationUuid: siteResp.LocationUuid, - Forecaster: forecasterResp.Forecaster, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - InitTimeUtc: timestamppb.New(pivotTime.Add(time.Duration(-i*24) * time.Hour)), - Values: yields, - } - _, err = dc.CreateForecast(t.Context(), req) - require.NoError(t, err) + createTestForecast( + t, + siteResp.LocationUuid, + fc, + pivotTime.Add(time.Duration(-i*24)*time.Hour), + yields, + pivotTime, + ) } deltaResp, err := dc.GetWeekAverageDeltas(t.Context(), &pb.GetWeekAverageDeltasRequest{ LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Forecaster: forecasterResp.Forecaster, + Forecaster: fc, ObserverName: obsResp.ObserverName, PivotTimestampUtc: timestamppb.New(pivotTime), }) @@ -1886,41 +1987,23 @@ func TestGetWeekAverageDeltas(t *testing.T) { func TestCreateForecast(t *testing.T) { pivotTime := time.Date(2024, 5, 5, 0, 30, 0, 0, time.UTC) - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) - metadata2, err := structpb.NewStruct(map[string]any{"source": "test", "extra": "value"}) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) + metadata2 := createTestMetadata(t, map[string]any{"source": "test", "extra": "value"}) // Create a site to attach the forecast to - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_create_forecast_site", - GeometryWkt: "POINT(-0.1 51.5)", - EffectiveCapacityWatts: 1000000, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 24)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_create_forecast_site", + "POINT(-0.1 51.5)", + 1000000, + pivotTime.Add(-time.Hour*24), + metadata, + ) // Create a forecaster - fcResp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "test_create_forecast_forecaster", - Version: "v1", - }) - require.NoError(t, err) + fc := createTestForecaster(t, "test_create_forecast_forecaster", "v1") - yieldsPopulated := make([]*pb.CreateForecastRequest_ForecastValue, 10) - for i := range yieldsPopulated { - yieldsPopulated[i] = &pb.CreateForecastRequest_ForecastValue{ - HorizonMins: uint32(i * 30), - P50Fraction: 0.5 + float32(i)*0.05, - OtherStatisticsFractions: map[string]float32{ - "p10": 0.4 + float32(i)*0.05, - "p90": 0.6 + float32(i)*0.05, - }, - } - } + yields := generateTestForecastValues(10, 30) yieldsZeros := make([]*pb.CreateForecastRequest_ForecastValue, 10) for i := range yieldsZeros { @@ -1937,55 +2020,64 @@ func TestCreateForecast(t *testing.T) { HorizonMins: uint32(i * 30), P50Fraction: 1.2, OtherStatisticsFractions: map[string]float32{ + "p02": 1.5, "p10": 1.3, + "p25": 1.1, + "p75": 0.9, "p90": -0.2, + "p98": -0.1, }, } } testcases := []struct { - name string - req *pb.CreateForecastRequest + name string + req *pb.CreateForecastRequest + shouldErr bool }{ { name: "Should create forecast with populated values", req: &pb.CreateForecastRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: fcResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), - Values: yieldsPopulated, + Values: yields, }, + shouldErr: false, }, { name: "Should create forecast with zeroed values and metadata", req: &pb.CreateForecastRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: fcResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), Values: yieldsZeros, }, + shouldErr: false, }, { name: "Shouldn't create forecast with no values", req: &pb.CreateForecastRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: fcResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), Values: []*pb.CreateForecastRequest_ForecastValue{}, }, + shouldErr: true, }, { name: "Shouldn't create forecast for non-existent location source", req: &pb.CreateForecastRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: fcResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_WIND, InitTimeUtc: timestamppb.New(pivotTime), - Values: yieldsPopulated, + Values: yields, }, + shouldErr: true, }, { name: "Shouldn't create forecast for non-existent forecaster", @@ -1997,8 +2089,9 @@ func TestCreateForecast(t *testing.T) { }, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), - Values: yieldsPopulated, + Values: yields, }, + shouldErr: true, }, { name: "Shouldn't create forecast for non-existent forecaster version", @@ -2010,36 +2103,39 @@ func TestCreateForecast(t *testing.T) { }, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), - Values: yieldsPopulated, + Values: yields, }, + shouldErr: true, }, { name: "Shouldn't create forecast with invalid value fractions", req: &pb.CreateForecastRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: fcResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), Values: yieldsInvalid, }, + shouldErr: true, }, { name: "Should create forecast with extra metadata", req: &pb.CreateForecastRequest{ LocationUuid: siteResp.LocationUuid, - Forecaster: fcResp.Forecaster, + Forecaster: fc, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: timestamppb.New(pivotTime), - Values: yieldsPopulated, + Values: yields, Metadata: metadata2, }, + shouldErr: false, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { _, err := dc.CreateForecast(t.Context(), tc.req) - if strings.Contains(tc.name, "Shouldn't") { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -2056,13 +2152,13 @@ func TestCreateForecast(t *testing.T) { tc.req.InitTimeUtc.AsTime().Add(5 * time.Hour), ), }, - Forecaster: fcResp.Forecaster, + Forecaster: fc, }, ) require.NoError(t, err) require.Equal(t, len(tc.req.Values), len(fResp.Values)) _, err = dc.DeleteForecast(t.Context(), &pb.DeleteForecastRequest{ - Forecaster: fcResp.Forecaster, + Forecaster: fc, LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, InitTimeUtc: tc.req.InitTimeUtc, @@ -2079,71 +2175,47 @@ func TestCreateForecast(t *testing.T) { func TestGetLatestForecasts(t *testing.T) { pivotTime := time.Date(2022, 1, 5, 12, 0, 0, 0, time.UTC) - metadata, err := structpb.NewStruct(map[string]any{"source": "test"}) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"source": "test"}) // Create a site to attach the forecasts to - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_get_latest_forecasts_site", - GeometryWkt: "POINT(-0.6 51.8)", - EffectiveCapacityWatts: 1000000, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 24)), - }) - require.NoError(t, err) + siteResp := createTestLocation( + t, + "test_get_latest_forecasts_site", + "POINT(-0.6 51.8)", + 1000000, + pivotTime.Add(-time.Hour*24), + metadata, + ) + fc := createTestForecaster(t, "test_get_latest_forecasts_forecaster", "v1") - // Create forecaster - forecasterResp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "test_get_latest_forecasts_forecaster", - Version: "v1", - }) - require.NoError(t, err) // Update the forecaster a couple of times for i := range 2 { - _, err = dc.UpdateForecaster(t.Context(), &pb.UpdateForecasterRequest{ - Name: forecasterResp.Forecaster.ForecasterName, + _, err := dc.UpdateForecaster(t.Context(), &pb.UpdateForecasterRequest{ + Name: fc.ForecasterName, NewVersion: fmt.Sprintf("v%d", i+2), }) require.NoError(t, err) } - yields := make([]*pb.CreateForecastRequest_ForecastValue, 10) - for i := range yields { - yields[i] = &pb.CreateForecastRequest_ForecastValue{ - HorizonMins: uint32(i * 30), - P50Fraction: 0.5 + float32(i)*0.05, - OtherStatisticsFractions: map[string]float32{ - "p10": 0.4 + float32(i)*0.05, - "p90": 0.6 + float32(i)*0.05, - }, - } - } + yields := generateTestForecastValues(10, 30) - // Make a forecast at differring times for each forecaster + // Make a forecast at diferring times for each forecaster for i := range 3 { - req := &pb.CreateForecastRequest{ - LocationUuid: siteResp.LocationUuid, - Forecaster: &pb.Forecaster{ - ForecasterName: forecasterResp.Forecaster.ForecasterName, - ForecasterVersion: fmt.Sprintf("v%d", i+1), - }, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - InitTimeUtc: timestamppb.New(pivotTime.Add(time.Duration(-i) * time.Hour)), - Values: yields, - CreatedTimestampUtc: timestamppb.New( - pivotTime.Add(time.Duration(-i)*time.Hour + time.Minute), - ), - } - _, err = dc.CreateForecast(t.Context(), req) - require.NoError(t, err) + createTestForecast( + t, + siteResp.LocationUuid, + fc, + pivotTime.Add(time.Duration(-i)*time.Hour), + yields, + pivotTime.Add(time.Duration(-i)*time.Hour+time.Minute), + ) } testcases := []struct { name string req *pb.GetLatestForecastsRequest expectedInitTimes []time.Time + shouldErr bool }{ { name: "Should return latest forecasts for each forecaster name", @@ -2155,6 +2227,7 @@ func TestGetLatestForecasts(t *testing.T) { expectedInitTimes: []time.Time{ pivotTime, }, + shouldErr: false, }, { name: "Should return older forecasts for earlier pivot time", @@ -2166,13 +2239,14 @@ func TestGetLatestForecasts(t *testing.T) { expectedInitTimes: []time.Time{ pivotTime.Add(-time.Hour * 1), }, + shouldErr: false, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { resp, err := dc.GetLatestForecasts(t.Context(), tc.req) - if strings.Contains(tc.name, "Shouldn't") { + if tc.shouldErr { require.Error(t, err) } else { require.NoError(t, err) @@ -2199,67 +2273,42 @@ func TestStreamForecastData(t *testing.T) { pivotTime := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) // Create a site to attach the forecasts to - metadata, err := structpb.NewStruct(map[string]any{"stream_test": "true"}) - require.NoError(t, err) - siteResp, err := dc.CreateLocation(t.Context(), &pb.CreateLocationRequest{ - LocationName: "test_stream_forecast_data_site", - GeometryWkt: "POINT(-60.25 57.5)", - EffectiveCapacityWatts: 1000000, - Metadata: metadata, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - LocationType: pb.LocationType_LOCATION_TYPE_SITE, - ValidFromUtc: timestamppb.New(pivotTime.Add(-time.Hour * 48)), - }) - require.NoError(t, err) + metadata := createTestMetadata(t, map[string]any{"stream_test": "true"}) + siteResp := createTestLocation( + t, + "test_stream_forecast_data_site", + "POINT(-60.25 57.5)", + 1000000, + pivotTime.Add(-time.Hour*48), + metadata, + ) // Create two forecasters - fc1Resp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "stream_forecaster_alpha", - Version: "v1", - }) - require.NoError(t, err) - - fc2Resp, err := dc.CreateForecaster(t.Context(), &pb.CreateForecasterRequest{ - Name: "stream_forecaster_beta", - Version: "v2", - }) - require.NoError(t, err) + fc1 := createTestForecaster(t, "stream_forecaster_alpha", "v1") + fc2 := createTestForecaster(t, "stream_forecaster_beta", "v2") // Generate 3 forecasts for each forecaster, each with 4 horizon values (0, 5, 10, 15) - yields := make([]*pb.CreateForecastRequest_ForecastValue, 4) - for i := range yields { - yields[i] = &pb.CreateForecastRequest_ForecastValue{ - HorizonMins: uint32(i * 5), - P50Fraction: float32(i) * 0.25, - OtherStatisticsFractions: map[string]float32{ - "p90": float32(i)*0.25 + 0.1, - }, - } - } + yields := generateTestForecastValues(4, 5) // Seed forecasts for both forecasters spanning backwards from pivot time for i := 2; i >= 0; i-- { initTime := timestamppb.New(pivotTime.Add(time.Duration(-i*60) * time.Minute)) - - _, err = dc.CreateForecast(t.Context(), &pb.CreateForecastRequest{ - LocationUuid: siteResp.LocationUuid, - Forecaster: fc1Resp.Forecaster, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - InitTimeUtc: initTime, - Values: yields, - Metadata: metadata, - }) - require.NoError(t, err) - - _, err = dc.CreateForecast(t.Context(), &pb.CreateForecastRequest{ - LocationUuid: siteResp.LocationUuid, - Forecaster: fc2Resp.Forecaster, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - InitTimeUtc: initTime, - Values: yields, - Metadata: metadata, - }) - require.NoError(t, err) + createTestForecast( + t, + siteResp.LocationUuid, + fc1, + initTime.AsTime(), + yields, + initTime.AsTime(), + ) + createTestForecast( + t, + siteResp.LocationUuid, + fc2, + initTime.AsTime(), + yields, + initTime.AsTime(), + ) } defaultTimeWindow := &pb.TimeWindow{ @@ -2271,7 +2320,7 @@ func TestStreamForecastData(t *testing.T) { name string req *pb.StreamForecastDataRequest expectedRowsCount int - expectErr bool + shouldErr bool checkMetadata bool }{ { @@ -2279,33 +2328,33 @@ func TestStreamForecastData(t *testing.T) { req: &pb.StreamForecastDataRequest{ LocationUuids: []string{siteResp.LocationUuid}, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Forecasters: []*pb.Forecaster{fc1Resp.Forecaster}, + Forecasters: []*pb.Forecaster{fc1}, TimeWindow: defaultTimeWindow, IncludeMetadata: false, }, // 3 forecasts * 4 values = 12 rows expectedRowsCount: 12, - expectErr: false, + shouldErr: false, }, { name: "Should successfully stream forecasts for multiple forecasters", req: &pb.StreamForecastDataRequest{ LocationUuids: []string{siteResp.LocationUuid}, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Forecasters: []*pb.Forecaster{fc1Resp.Forecaster, fc2Resp.Forecaster}, + Forecasters: []*pb.Forecaster{fc1, fc2}, TimeWindow: defaultTimeWindow, IncludeMetadata: false, }, // (3 forecasts * 4 values) * 2 forecasters = 24 rows expectedRowsCount: 24, - expectErr: false, + shouldErr: false, }, { name: "Should only return forecasts whos init times respect the time window boundaries", req: &pb.StreamForecastDataRequest{ LocationUuids: []string{siteResp.LocationUuid}, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Forecasters: []*pb.Forecaster{fc1Resp.Forecaster}, + Forecasters: []*pb.Forecaster{fc1}, TimeWindow: &pb.TimeWindow{ // Constrain the window to only capture the most recent forecast StartTimestampUtc: timestamppb.New(pivotTime.Add(-time.Minute * 10)), @@ -2314,19 +2363,19 @@ func TestStreamForecastData(t *testing.T) { IncludeMetadata: false, }, expectedRowsCount: 4, - expectErr: false, + shouldErr: false, }, { name: "Should include metadata when asked", req: &pb.StreamForecastDataRequest{ LocationUuids: []string{siteResp.LocationUuid}, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Forecasters: []*pb.Forecaster{fc1Resp.Forecaster}, + Forecasters: []*pb.Forecaster{fc1}, TimeWindow: defaultTimeWindow, IncludeMetadata: true, }, expectedRowsCount: 12, - expectErr: false, + shouldErr: false, checkMetadata: true, }, { @@ -2334,10 +2383,10 @@ func TestStreamForecastData(t *testing.T) { req: &pb.StreamForecastDataRequest{ LocationUuids: []string{"not-a-valid-uuid"}, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - Forecasters: []*pb.Forecaster{fc1Resp.Forecaster}, + Forecasters: []*pb.Forecaster{fc1}, TimeWindow: defaultTimeWindow, }, - expectErr: true, + shouldErr: true, }, } @@ -2345,7 +2394,7 @@ func TestStreamForecastData(t *testing.T) { t.Run(tc.name, func(t *testing.T) { stream, err := dc.StreamForecastData(t.Context(), tc.req) if err != nil { - if tc.expectErr { + if tc.shouldErr { return } @@ -2359,7 +2408,7 @@ func TestStreamForecastData(t *testing.T) { break } - if tc.expectErr { + if tc.shouldErr { require.Error(t, err) return } @@ -2372,18 +2421,19 @@ func TestStreamForecastData(t *testing.T) { if tc.checkMetadata { require.NotNil(t, pt.Metadata) - require.Equal(t, "true", pt.Metadata["stream_test"]) + require.Equal(t, "test", pt.Metadata["source"]) } else { require.Empty(t, pt.Metadata) } require.NotZero(t, pt.EffectiveCapacityWatts) require.NotNil(t, pt.OtherStatisticsFractions) - require.Contains(t, pt.OtherStatisticsFractions, "p90") + require.Contains(t, pt.OtherStatisticsFractions, "p90", "p02") + require.NotContains(t, pt.OtherStatisticsFractions, "p50", "p25", "p75", "p98") } } - if !tc.expectErr { + if !tc.shouldErr { require.Equal(t, tc.expectedRowsCount, actualRowsCount) } }) diff --git a/internal/server/postgres/sql/migrations/00010_7_plevels.sql b/internal/server/postgres/sql/migrations/00010_7_plevels.sql new file mode 100644 index 0000000..3e22968 --- /dev/null +++ b/internal/server/postgres/sql/migrations/00010_7_plevels.sql @@ -0,0 +1,22 @@ +-- +goose Up + +/* + * Adds 4 new plevels to the predicted_generation_values table. + */ + +ALTER TABLE pred.predicted_generation_values + ADD COLUMN p02_sip SMALLINT, + ADD CONSTRAINT p02_sip_nonnegative_check CHECK (p02_sip >= 0), + ADD COLUMN p98_sip SMALLINT, + ADD CONSTRAINT p98_sip_nonnegative_check CHECK (p98_sip >= 0), + ADD COLUMN p25_sip SMALLINT, + ADD CONSTRAINT p25_sip_nonnegative_check CHECK (p25_sip >= 0), + ADD COLUMN p75_sip SMALLINT, + ADD CONSTRAINT p75_sip_nonnegative_check CHECK (p75_sip >= 0); + +-- +goose Down +ALTER TABLE pred.predicted_generation_values + DROP COLUMN p02_sip, + DROP COLUMN p25_sip, + DROP COLUMN p75_sip; + DROP COLUMN p98_sip, diff --git a/internal/server/postgres/sql/queries/predictions.sql b/internal/server/postgres/sql/queries/predictions.sql index 51b1aca..18bbddc 100644 --- a/internal/server/postgres/sql/queries/predictions.sql +++ b/internal/server/postgres/sql/queries/predictions.sql @@ -123,9 +123,9 @@ WHERE forecast_uuid IN (SELECT forecast_uuid FROM forecasts_to_delete); * with 0 representing 0% and 30000 representing 100% of capacity. */ INSERT INTO pred.predicted_generation_values ( - horizon_mins, p50_sip, p10_sip, p90_sip, forecast_uuid + horizon_mins, p50_sip, p10_sip, p90_sip, forecast_uuid, p02_sip, p98_sip, p25_sip, p75_sip ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ); -- name: ListPredictionsForForecasts :many @@ -153,9 +153,13 @@ SELECT mf.forecaster_version, f.created_at_utc, pg.horizon_mins, + pg.p02_sip, pg.p10_sip, + pg.p25_sip, pg.p50_sip, + pg.p75_sip, pg.p90_sip, + pg.p98_sip, sv.capacity_watts, f.metadata, UUIDV7_EXTRACT_TIMESTAMP(f.forecast_uuid)::TIMESTAMP AS init_time_utc, @@ -270,9 +274,13 @@ winning_predictions AS ( fow.geometry_uuid, fow.source_type_id, pg.horizon_mins, + pg.p02_sip, + pg.p25_sip, pg.p10_sip, pg.p50_sip, + pg.p75_sip, pg.p90_sip, + pg.p98_sip, fow.metadata, ( UUIDV7_EXTRACT_TIMESTAMP(pg.forecast_uuid)::TIMESTAMP + MAKE_INTERVAL(mins => pg.horizon_mins::INTEGER) @@ -290,9 +298,13 @@ winning_predictions AS ( ) SELECT wp.horizon_mins, + wp.p02_sip, + wp.p25_sip, wp.p10_sip, wp.p50_sip, + wp.p75_sip, wp.p90_sip, + wp.p98_sip, wp.target_time_utc, wp.metadata, wp.init_time_utc, @@ -358,9 +370,13 @@ SELECT laf.geometry_uuid, laf.source_type_id, pg.horizon_mins, + pg.p02_sip, pg.p10_sip, + pg.p25_sip, pg.p50_sip, + pg.p75_sip, pg.p90_sip, + pg.p98_sip, laf.created_at_utc, laf.init_time_utc, sv.capacity_watts, diff --git a/proto/ocf/dp/dp-data.messages.proto b/proto/ocf/dp/dp-data.messages.proto index b833e7d..7be5ca0 100644 --- a/proto/ocf/dp/dp-data.messages.proto +++ b/proto/ocf/dp/dp-data.messages.proto @@ -191,6 +191,7 @@ message GetForecastAtTimestampResponse { google.protobuf.Struct metadata = 6; google.protobuf.Timestamp initialization_timestamp_utc = 7; google.protobuf.Timestamp created_timestamp_utc = 8; + map other_statistics_fractions = 9; } google.protobuf.Timestamp timestamp_utc = 1; @@ -330,7 +331,7 @@ message CreateForecastRequest { (buf.validate.field).map.min_pairs = 0, (buf.validate.field).map.max_pairs = 20, (buf.validate.field).map.keys = { - string: {in: ["p10", "p90"]} + string: {in: ["p02", "p10", "p25", "p75", "p90", "p98"]} }, (buf.validate.field).map.values = { float: {