diff --git a/internal/server/postgres/dataserverimpl.go b/internal/server/postgres/dataserverimpl.go index c647c0c..32b6dcf 100644 --- a/internal/server/postgres/dataserverimpl.go +++ b/internal/server/postgres/dataserverimpl.go @@ -566,12 +566,20 @@ func (s *DataPlatformDataServiceServerImpl) GetObservationsAsTimeseries( start, end := timeWindowToPgWindow(req.TimeWindow) + var pivotTime pgtype.Timestamp + if req.PivotTimestampUtc != nil { + pivotTime = pgtype.Timestamp{Time: req.PivotTimestampUtc.AsTime(), Valid: true} + } else { + pivotTime = pgtype.Timestamp{Time: time.Now().UTC(), Valid: true} + } + goprms := db.GetObservationsBetweenParams{ GeometryUuid: locationUuid, SourceTypeID: int16(req.EnergySource), ObserverUuid: observerResp.ObserverUuid, StartTimeUtc: start, EndTimeUtc: end, + PivotTimeUtc: pivotTime, } dbObs, err := querier.GetObservationsBetween(ctx, goprms) @@ -631,6 +639,16 @@ func (s *DataPlatformDataServiceServerImpl) CreateObservations( } // Insert the observations + var createdTime pgtype.Timestamp + if req.CreatedTimestampUtc != nil { + createdTime = pgtype.Timestamp{Time: req.CreatedTimestampUtc.AsTime(), Valid: true} + } else { + createdTime = pgtype.Timestamp{ + Time: time.Now().UTC().Truncate(time.Second), + Valid: true, + } + } + coprms := make([]db.CreateObservationsBatchParams, len(req.Values)) for i, v := range req.Values { coprms[i] = db.CreateObservationsBatchParams{ @@ -640,8 +658,9 @@ func (s *DataPlatformDataServiceServerImpl) CreateObservations( Time: v.TimestampUtc.AsTime(), Valid: true, }, - SourceTypeID: dbSource.SourceTypeID, - ValueWatts: int64(v.ValueWatts), + SourceTypeID: dbSource.SourceTypeID, + ValueWatts: int64(v.ValueWatts), + CreatedTimestampUtc: createdTime, } } diff --git a/internal/server/postgres/dataserverimpl_test.go b/internal/server/postgres/dataserverimpl_test.go index 4c179c5..6cfcc99 100644 --- a/internal/server/postgres/dataserverimpl_test.go +++ b/internal/server/postgres/dataserverimpl_test.go @@ -1043,6 +1043,8 @@ func TestGetObservationsAtTimestamp(t *testing.T) { }) require.NoError(t, err) + expectedCreatedTime := time.Date(2025, 2, 26, 12, 5, 0, 0, time.UTC) + siteUuids := make([]string, 3) for i := range siteUuids { capacity := uint64(1000000 + i*100000) @@ -1058,9 +1060,10 @@ func TestGetObservationsAtTimestamp(t *testing.T) { siteUuids[i] = siteResp.LocationUuid req := &pb.CreateObservationsRequest{ - LocationUuid: siteResp.LocationUuid, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - ObserverName: observerResp.ObserverName, + LocationUuid: siteResp.LocationUuid, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + ObserverName: observerResp.ObserverName, + CreatedTimestampUtc: timestamppb.New(expectedCreatedTime), Values: []*pb.CreateObservationsRequest_Value{ { ValueWatts: uint64(capacity / 10), @@ -1144,6 +1147,11 @@ func TestGetObservationsAtTimestamp(t *testing.T) { for i, obs := range resp.Values { require.Equal(t, tc.expectedFractions[i], obs.ValueFraction) + + require.NotNil(t, obs.CreatedTimestampUtc) + created := obs.CreatedTimestampUtc.AsTime() + require.True(t, created.Equal(expectedCreatedTime), + "created time %s should be equal to %s", created, expectedCreatedTime) } } }) @@ -1385,7 +1393,7 @@ func TestGetForecastAsTimeseries(t *testing.T) { "test_get_forecast_as_timeseries_site", "POINT(-60.25 57.5)", 1000000, - pivotTime.Add(-time.Hour*49), + pivotTime.Add(-time.Hour*24*32), metadata, "GB", ) @@ -1538,6 +1546,42 @@ func TestGetForecastAsTimeseries(t *testing.T) { }, shouldErr: false, }, + { + name: "Should allow a time window of exactly 31 days", + req: &pb.GetForecastAsTimeseriesRequest{ + LocationUuid: siteResp.LocationUuid, + Forecaster: fc, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + HorizonMins: 0, + TimeWindow: &pb.TimeWindow{ + StartTimestampUtc: timestamppb.New( + pivotTime.Add(time.Hour * 36).Add(-time.Hour * 24 * 31), + ), + EndTimestampUtc: timestamppb.New(pivotTime.Add(time.Hour * 36)), + }, + InitializationTimestampUtc: timestamppb.New(pivotTime.Add(-30 * time.Minute)), + }, + expectedValues: []float32{ + 0.5, 0.501, 0.502, 0.503, 0.504, 0.505, 0.506, 0.507, 0.508, 0.509, 0.510, 0.511, + }, + shouldErr: false, + }, + { + name: "Should reject a time window longer than 31 days", + req: &pb.GetForecastAsTimeseriesRequest{ + LocationUuid: siteResp.LocationUuid, + Forecaster: fc, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + HorizonMins: 0, + TimeWindow: &pb.TimeWindow{ + StartTimestampUtc: timestamppb.New( + pivotTime.Add(time.Hour * 36).Add(-time.Hour*24*31 - time.Minute), + ), + EndTimestampUtc: timestamppb.New(pivotTime.Add(time.Hour * 36)), + }, + }, + shouldErr: true, + }, } for _, tc := range testcases { @@ -1838,28 +1882,52 @@ func TestGetObservationsAsTimeseries(t *testing.T) { } } + expectedCreatedTime := time.Date(2025, 2, 26, 12, 5, 0, 0, time.UTC) + _, err = dc.CreateObservations(t.Context(), &pb.CreateObservationsRequest{ - LocationUuid: siteResp.LocationUuid, - EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, - ObserverName: obsResp.ObserverName, - Values: values, + LocationUuid: siteResp.LocationUuid, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + ObserverName: obsResp.ObserverName, + CreatedTimestampUtc: timestamppb.New(expectedCreatedTime), + Values: values, }) require.NoError(t, err) testcases := []struct { - startTime time.Time - endTime time.Time - expectedSize int + name string + startTime time.Time + endTime time.Time + expectedSize int + expectedErrCode codes.Code }{ { + name: "Should return all values in a day-long window", startTime: pivotTime.Add(-time.Hour * 24), endTime: pivotTime, expectedSize: len(values), }, + { + name: "Should allow windows longer than a week", + startTime: pivotTime.Add(-time.Hour * 24 * 8), + endTime: pivotTime, + expectedSize: len(values), + }, + { + name: "Should allow windows of exactly 31 days", + startTime: pivotTime.Add(-time.Hour * 24 * 31), + endTime: pivotTime, + expectedSize: len(values), + }, + { + name: "Should reject windows longer than 31 days", + startTime: pivotTime.Add(-time.Hour*24*31 - time.Minute), + endTime: pivotTime, + expectedErrCode: codes.InvalidArgument, + }, } for _, tc := range testcases { - t.Run(fmt.Sprintf("Size %d", tc.expectedSize), func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { resp, err := dc.GetObservationsAsTimeseries( t.Context(), &pb.GetObservationsAsTimeseriesRequest{ @@ -1872,8 +1940,21 @@ func TestGetObservationsAsTimeseries(t *testing.T) { ObserverName: obsResp.ObserverName, }, ) + if tc.expectedErrCode != codes.OK { + require.Error(t, err) + require.Equal(t, tc.expectedErrCode, status.Code(err)) + return + } + require.NoError(t, err) require.Equal(t, tc.expectedSize, len(resp.Values)) + + for _, v := range resp.Values { + require.NotNil(t, v.CreatedTimestampUtc) + created := v.CreatedTimestampUtc.AsTime() + require.True(t, created.Equal(expectedCreatedTime), + "created time %s should be equal to %s", created, expectedCreatedTime) + } }) } } @@ -1899,21 +1980,33 @@ func TestGetLatestObservations(t *testing.T) { require.NoError(t, err) // Seed some observations for the site. - values := []*pb.CreateObservationsRequest_Value{ - { - TimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 2)), - ValueWatts: uint64(0.3 * float64(siteResp.EffectiveCapacityWatts)), - }, - { - TimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), - ValueWatts: uint64(0.5 * float64(siteResp.EffectiveCapacityWatts)), + // Create the older observation + _, err = dc.CreateObservations(t.Context(), &pb.CreateObservationsRequest{ + LocationUuid: siteResp.LocationUuid, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + ObserverName: obsResp.ObserverName, + CreatedTimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 2)), + Values: []*pb.CreateObservationsRequest_Value{ + { + TimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 2)), + ValueWatts: uint64(0.3 * float64(siteResp.EffectiveCapacityWatts)), + }, }, - } + }) + require.NoError(t, err) + + // Create the newer observation _, err = dc.CreateObservations(t.Context(), &pb.CreateObservationsRequest{ LocationUuid: siteResp.LocationUuid, EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, ObserverName: obsResp.ObserverName, - Values: values, + CreatedTimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), + Values: []*pb.CreateObservationsRequest_Value{ + { + TimestampUtc: timestamppb.New(pivotTime.Add(-time.Hour * 1)), + ValueWatts: uint64(0.5 * float64(siteResp.EffectiveCapacityWatts)), + }, + }, }) require.NoError(t, err) @@ -2127,6 +2220,16 @@ func TestCreateObservations(t *testing.T) { Values: validObservations, }, }, + { + name: "Should create valid observations with explicitly provided created timestamp", + req: &pb.CreateObservationsRequest{ + LocationUuid: siteResp.LocationUuid, + EnergySource: pb.EnergySource_ENERGY_SOURCE_SOLAR, + ObserverName: obsResp.ObserverName, + CreatedTimestampUtc: timestamppb.New(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), + Values: validObservations, + }, + }, { name: "Shouldn't create invalid observations", req: &pb.CreateObservationsRequest{ diff --git a/internal/server/postgres/mappers.go b/internal/server/postgres/mappers.go index 62f4611..ac42de4 100644 --- a/internal/server/postgres/mappers.go +++ b/internal/server/postgres/mappers.go @@ -277,6 +277,7 @@ func mapObservationAsTimeseries( ValueFraction: sipToFraction(obs.ValueSip), TimestampUtc: timestamppb.New(obs.ObservationTimestampUtc.Time), EffectiveCapacityWatts: uint64(obs.CapacityWatts), + CreatedTimestampUtc: timestamppb.New(obs.CreatedTimestampUtc.Time), } } @@ -335,6 +336,7 @@ func mapObservationAtTimestamp( Latitude: obs.Latitude, Longitude: obs.Longitude, }, + CreatedTimestampUtc: timestamppb.New(obs.CreatedTimestampUtc.Time), } } diff --git a/internal/server/postgres/mappers_test.go b/internal/server/postgres/mappers_test.go index bb0b11b..612ea9f 100644 --- a/internal/server/postgres/mappers_test.go +++ b/internal/server/postgres/mappers_test.go @@ -409,3 +409,41 @@ func Test_mapForecastAsTimeseriesFromLocationValue(t *testing.T) { }) } } + +func Test_mapObservationAsTimeseries(t *testing.T) { + obsTime := time.Date(2025, 2, 26, 12, 0, 0, 0, time.UTC) + createdTime := time.Date(2025, 2, 26, 12, 5, 0, 0, time.UTC) + + res := mapObservationAsTimeseries(db.GetObservationsBetweenRow{ + ObservationTimestampUtc: pgtype.Timestamp{Time: obsTime, Valid: true}, + CreatedTimestampUtc: pgtype.Timestamp{Time: createdTime, Valid: true}, + ValueSip: 15000, + CapacityWatts: 2000, + }) + + require.Equal(t, obsTime, res.TimestampUtc.AsTime()) + require.Equal(t, createdTime, res.CreatedTimestampUtc.AsTime()) + require.Equal(t, float32(0.5), res.ValueFraction) + require.Equal(t, uint64(2000), res.EffectiveCapacityWatts) +} + +func Test_mapObservationAtTimestamp(t *testing.T) { + id := uuid.New() + createdTime := time.Date(2025, 2, 26, 12, 5, 0, 0, time.UTC) + + res := mapObservationAtTimestamp(db.ListObservationsAtTimeForLocationsRow{ + GeometryUuid: id, + CreatedTimestampUtc: pgtype.Timestamp{Time: createdTime, Valid: true}, + ValueSip: 30000, + CapacityWatts: 1000, + Latitude: 51.5, + Longitude: -0.1, + }) + + require.Equal(t, id.String(), res.LocationUuid) + require.Equal(t, createdTime, res.CreatedTimestampUtc.AsTime()) + require.Equal(t, float32(1), res.ValueFraction) + require.Equal(t, uint64(1000), res.EffectiveCapacityWatts) + require.Equal(t, float32(51.5), res.Latlng.Latitude) + require.Equal(t, float32(-0.1), res.Latlng.Longitude) +} diff --git a/internal/server/postgres/sql/migrations/00015_consolidated_schema.sql b/internal/server/postgres/sql/migrations/00016_consolidated_schema.sql similarity index 99% rename from internal/server/postgres/sql/migrations/00015_consolidated_schema.sql rename to internal/server/postgres/sql/migrations/00016_consolidated_schema.sql index 1598918..47af3ee 100644 --- a/internal/server/postgres/sql/migrations/00015_consolidated_schema.sql +++ b/internal/server/postgres/sql/migrations/00016_consolidated_schema.sql @@ -138,9 +138,9 @@ CREATE TABLE loc.geometries ( REFERENCES loc.entities(entity_id) ON UPDATE CASCADE ON DELETE SET NULL, - country_code CHAR(3), + country_code CHAR(2), CONSTRAINT country_code_format_check CHECK ( - country_code IS NULL OR country_code ~ '^[A-Z]{3}$' + country_code IS NULL OR country_code ~ '^[A-Z]{2}$' ), PRIMARY KEY (geometry_uuid), UNIQUE (geometry_name, geom_hash) diff --git a/internal/server/postgres/sql/migrations/00016_country_code_alpha2.sql b/internal/server/postgres/sql/migrations/00016_country_code_alpha2.sql deleted file mode 100644 index 81215fa..0000000 --- a/internal/server/postgres/sql/migrations/00016_country_code_alpha2.sql +++ /dev/null @@ -1,41 +0,0 @@ --- +goose Up - -DROP MATERIALIZED VIEW loc.sources_mv; - -ALTER TABLE loc.geometries DROP CONSTRAINT country_code_format_check; - -UPDATE loc.geometries SET country_code = SUBSTRING(country_code, 1, 2) -WHERE country_code IS NOT NULL; - -ALTER TABLE loc.geometries ALTER COLUMN country_code TYPE CHAR(2); - -ALTER TABLE loc.geometries ADD CONSTRAINT country_code_format_check CHECK (country_code IS NULL OR country_code ~ '^[A-Z]{2}$'); - -CREATE MATERIALIZED VIEW loc.sources_mv AS -SELECT - sh.geometry_uuid, - sh.source_type_id, - sh.capacity_watts, - sh.capacity_limit_sip, - sh.metadata, - COALESCE(sh.metadata || g.metadata, sh.metadata, g.metadata)::JSONB AS metadata_jsonb, - g.geometry_name, - g.geometry_type_id, - g.country_code, - g.owning_entity_id, - ST_X(g.associated_point)::REAL AS longitude, - ST_Y(g.associated_point)::REAL AS latitude, - TSRANGE( - sh.valid_from_utc, - LEAD(sh.valid_from_utc, 1) OVER ( - PARTITION BY sh.geometry_uuid, sh.source_type_id - ORDER BY sh.valid_from_utc - ) - ) AS sys_period -FROM loc.sources_history AS sh -INNER JOIN loc.geometries AS g USING (geometry_uuid); - -CREATE UNIQUE INDEX ON loc.sources_mv (geometry_uuid, source_type_id, sys_period); -CREATE INDEX idx_sources_mv_owning_entity_id ON loc.sources_mv (owning_entity_id); -CREATE INDEX idx_sources_mv_composite_lookup ON loc.sources_mv USING gist (geometry_uuid, source_type_id, sys_period); -CREATE INDEX idx_sources_mv_country_type ON loc.sources_mv (country_code, geometry_type_id); diff --git a/internal/server/postgres/sql/migrations/00017_observation_created_timestamp.sql b/internal/server/postgres/sql/migrations/00017_observation_created_timestamp.sql new file mode 100644 index 0000000..bd1974b --- /dev/null +++ b/internal/server/postgres/sql/migrations/00017_observation_created_timestamp.sql @@ -0,0 +1,10 @@ +-- +goose Up + +ALTER TABLE obs.observed_generation_values ADD COLUMN created_timestamp_utc TIMESTAMP; + +-- Existing observations are backfilled with the time of the observation itself. +UPDATE obs.observed_generation_values SET created_timestamp_utc = observation_timestamp_utc; + +ALTER TABLE obs.observed_generation_values +ALTER COLUMN created_timestamp_utc SET DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN created_timestamp_utc SET NOT NULL; diff --git a/internal/server/postgres/sql/queries/observations.sql b/internal/server/postgres/sql/queries/observations.sql index 8f05021..f202420 100644 --- a/internal/server/postgres/sql/queries/observations.sql +++ b/internal/server/postgres/sql/queries/observations.sql @@ -27,9 +27,9 @@ WHERE o.observer_name = $1; * and 30000 representing 100% of capacity. */ INSERT INTO obs.observed_generation_values ( - geometry_uuid, source_type_id, observer_uuid, observation_timestamp_utc, value_sip + geometry_uuid, source_type_id, observer_uuid, observation_timestamp_utc, value_sip, created_timestamp_utc ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4, $5, $6 ); -- name: CreateObservationsBatch :batchone @@ -42,14 +42,16 @@ INSERT INTO obs.observed_generation_values ( source_type_id, observer_uuid, observation_timestamp_utc, - value_sip + value_sip, + created_timestamp_utc ) SELECT mv.geometry_uuid, mv.source_type_id, sqlc.arg(observer_uuid)::UUID, sqlc.arg(observation_timestamp_utc)::TIMESTAMP, - ((sqlc.arg(value_watts)::BIGINT::DOUBLE PRECISION / mv.capacity_watts) * 30000.0)::SMALLINT AS calculated_value_sip + ((sqlc.arg(value_watts)::BIGINT::DOUBLE PRECISION / mv.capacity_watts) * 30000.0)::SMALLINT AS calculated_value_sip, + sqlc.arg(created_timestamp_utc)::TIMESTAMP FROM loc.sources_mv AS mv WHERE mv.geometry_uuid = sqlc.arg(geometry_uuid)::UUID AND mv.source_type_id = sqlc.arg(source_type_id)::SMALLINT @@ -68,6 +70,7 @@ SELECT og.source_type_id, og.observation_timestamp_utc, og.value_sip, + og.created_timestamp_utc, sh.capacity_watts FROM obs.observed_generation_values AS og INNER JOIN loc.sources_mv AS sh USING (geometry_uuid, source_type_id) @@ -76,6 +79,7 @@ WHERE AND og.source_type_id = $2 AND og.observer_uuid = $3 AND og.observation_timestamp_utc BETWEEN sqlc.arg(start_time_utc)::TIMESTAMP AND sqlc.arg(end_time_utc)::TIMESTAMP + AND og.created_timestamp_utc <= sqlc.arg(pivot_time_utc)::TIMESTAMP AND sh.sys_period @> og.observation_timestamp_utc; -- name: GetLatestObservations :many @@ -111,7 +115,7 @@ FROM target_locations AS tl WHERE og.geometry_uuid = tl.geometry_uuid AND og.source_type_id = sqlc.arg(source_type_id)::SMALLINT AND og.observer_uuid = tobs.observer_uuid - AND og.observation_timestamp_utc <= sqlc.arg(pivot_time_utc)::TIMESTAMP + AND og.created_timestamp_utc <= sqlc.arg(pivot_time_utc)::TIMESTAMP ORDER BY og.observation_timestamp_utc DESC LIMIT 1 ) AS latest_obs @@ -131,6 +135,7 @@ SELECT og.source_type_id, og.observation_timestamp_utc, og.value_sip, + og.created_timestamp_utc, sh.capacity_watts, sh.latitude, sh.longitude, diff --git a/proto/ocf/dp/dp-data.messages.proto b/proto/ocf/dp/dp-data.messages.proto index 483c2e1..951c97d 100644 --- a/proto/ocf/dp/dp-data.messages.proto +++ b/proto/ocf/dp/dp-data.messages.proto @@ -25,14 +25,14 @@ message Forecaster { message TimeWindow { /* The start of the time window, inclusive. - * Cannot be more than 7 days before end_timestamp_utc, nor more than 1 month in the future. + * Cannot be more than 1 month in the future. The maximum window size depends on the request. */ google.protobuf.Timestamp start_timestamp_utc = 1 [ (buf.validate.field).required = true, (buf.validate.field).timestamp = { gt: { seconds: 112000000} } ]; /* The end of the time window, inclusive. - * Cannot be more than 7 days after start_timestamp_utc. + * Must not be before start_timestamp_utc. The maximum window size depends on the request. */ google.protobuf.Timestamp end_timestamp_utc = 2 [ (buf.validate.field).required = true, @@ -67,8 +67,8 @@ message GetForecastAsTimeseriesRequest { (buf.validate.field).required = true, (buf.validate.field).cel = { id: "window_size_limit" - message: "window size must not exceed 7 days" - expression: "this.end_timestamp_utc - this.start_timestamp_utc <= duration('168h')" + message: "window size must not exceed 31 days" + expression: "this.end_timestamp_utc - this.start_timestamp_utc <= duration('744h')" }, (buf.validate.field).cel = { id: "start_time_near_future_limit" @@ -136,8 +136,8 @@ message GetObservationsAsTimeseriesRequest { (buf.validate.field).required = true, (buf.validate.field).cel = { id: "window_size_limit" - message: "window size must not exceed 7 days" - expression: "this.end_timestamp_utc - this.start_timestamp_utc <= duration('168h')" + message: "window size must not exceed 31 days" + expression: "this.end_timestamp_utc - this.start_timestamp_utc <= duration('744h')" }, (buf.validate.field).cel = { id: "start_time_near_future_limit" @@ -145,6 +145,13 @@ message GetObservationsAsTimeseriesRequest { expression: "this.start_timestamp_utc <= now + duration('720h')" } ]; + /* The time to search backwards from to find observations. + * If not specified, the current time will be used. + * Observations created after this time are not included. + */ + optional google.protobuf.Timestamp pivot_timestamp_utc = 5 [ + (buf.validate.field).timestamp = { gt: { seconds: 112000000}, lt_now: true } + ]; } message GetObservationsAsTimeseriesResponse { @@ -152,6 +159,7 @@ message GetObservationsAsTimeseriesResponse { google.protobuf.Timestamp timestamp_utc = 1; float value_fraction = 2; uint64 effective_capacity_watts = 3; + google.protobuf.Timestamp created_timestamp_utc = 4; } string location_uuid = 1; @@ -236,6 +244,7 @@ message GetObservationsAtTimestampResponse { uint64 effective_capacity_watts = 3; LatLng latlng = 4; google.protobuf.Struct metadata = 5; + google.protobuf.Timestamp created_timestamp_utc = 6; } google.protobuf.Timestamp timestamp_utc = 1; @@ -695,6 +704,10 @@ message CreateObservationsRequest { (buf.validate.field).required = true, (buf.validate.field).string.(valid_observer_name) = true ]; + optional google.protobuf.Timestamp created_timestamp_utc = 5 [ + (buf.validate.field).required = false, + (buf.validate.field).timestamp = { gt: { seconds: 112000000}, lt_now: true } + ]; repeated CreateObservationsRequest.Value values = 4 [ (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 10000