diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index 6bb04173..dbecc5a2 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -29,12 +29,19 @@ type StopTime struct { EndPickupDropOffWindow tt.Seconds PickupBookingRuleID tt.Key `target:"booking_rules.txt"` DropOffBookingRuleID tt.Key `target:"booking_rules.txt"` - // GTFS-Flex proposed fields (not yet formally adopted, may change) - // See: https://github.com/MobilityData/gtfs-flex/blob/master/spec/reference.md - MeanDurationFactor tt.Float // proposed gtfs-flex - MeanDurationOffset tt.Float // proposed gtfs-flex - SafeDurationFactor tt.Float // proposed gtfs-flex - SafeDurationOffset tt.Float // proposed gtfs-flex + // Deprecated: safe_duration_factor / safe_duration_offset were adopted into GTFS + // on trips.txt (google/transit#598, merged 2026-04), not stop_times.txt. The + // GTFS-Flex proposal originally placed them on stop_times.txt, and some feeds + // still emit them there, so they are retained here for backward compat. See + // gtfs.Trip for the spec-conformant fields. + SafeDurationFactor tt.Float + SafeDurationOffset tt.Float + // Deprecated: mean_duration_factor / mean_duration_offset remain only in the + // (unadopted) GTFS-Flex proposal on stop_times.txt; they were not part of the + // GTFS adoption (google/transit#598) and have no official home. Retained for + // backward compat with feeds that still emit them on stop_times. + MeanDurationFactor tt.Float + MeanDurationOffset tt.Float tt.MinEntity tt.ErrorEntity tt.ExtraEntity diff --git a/gtfs/trip.go b/gtfs/trip.go index 6f87f784..00fc0333 100644 --- a/gtfs/trip.go +++ b/gtfs/trip.go @@ -1,6 +1,9 @@ package gtfs import ( + "fmt" + + "github.com/interline-io/transitland-lib/causes" "github.com/interline-io/transitland-lib/tt" ) @@ -13,9 +16,11 @@ type Trip struct { TripShortName tt.String DirectionID tt.Int `enum:"0,1"` BlockID tt.String - ShapeID tt.Key `target:"shapes.txt"` - WheelchairAccessible tt.Int `enum:"0,1,2"` - BikesAllowed tt.Int `enum:"0,1,2"` + ShapeID tt.Key `target:"shapes.txt"` + WheelchairAccessible tt.Int `enum:"0,1,2"` + BikesAllowed tt.Int `enum:"0,1,2"` + SafeDurationFactor tt.Float + SafeDurationOffset tt.Float JourneyPatternID tt.String `csv:"-"` JourneyPatternOffset tt.Int `csv:"-"` StopPatternID tt.Int `csv:"-"` @@ -42,3 +47,23 @@ func (ent *Trip) Filename() string { func (ent *Trip) TableName() string { return "gtfs_trips" } + +// ConditionalErrors validates GTFS-Flex safe duration fields. +func (ent *Trip) ConditionalErrors() (errs []error) { + // safe_duration_factor and safe_duration_offset must both be present or both absent + if ent.SafeDurationFactor.Valid && !ent.SafeDurationOffset.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_offset")) + } + if ent.SafeDurationOffset.Valid && !ent.SafeDurationFactor.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_factor")) + } + // safe_duration_factor must be positive + if ent.SafeDurationFactor.Valid && ent.SafeDurationFactor.Val <= 0 { + errs = append(errs, causes.NewInvalidFieldError( + "safe_duration_factor", + fmt.Sprintf("%f", ent.SafeDurationFactor.Val), + fmt.Errorf("must be positive"), + )) + } + return errs +} diff --git a/gtfs/trip_test.go b/gtfs/trip_test.go index 287b2f30..2a5c8afc 100644 --- a/gtfs/trip_test.go +++ b/gtfs/trip_test.go @@ -78,6 +78,45 @@ func TestTrip_Errors(t *testing.T) { }), expectedErrors: PE("InvalidFieldError:bikes_allowed"), }, + // GTFS-Flex safe duration fields (google/transit#598) + { + name: "Valid: both safe_duration fields present", + trip: newTrip(func(t *Trip) { + t.SafeDurationFactor = tt.NewFloat(1.75) + t.SafeDurationOffset = tt.NewFloat(900) + }), + expectedErrors: nil, + }, + { + name: "Invalid: only safe_duration_factor present", + trip: newTrip(func(t *Trip) { + t.SafeDurationFactor = tt.NewFloat(1.5) + }), + expectedErrors: PE("ConditionallyRequiredFieldError:safe_duration_offset"), + }, + { + name: "Invalid: only safe_duration_offset present", + trip: newTrip(func(t *Trip) { + t.SafeDurationOffset = tt.NewFloat(300) + }), + expectedErrors: PE("ConditionallyRequiredFieldError:safe_duration_factor"), + }, + { + name: "Invalid: safe_duration_factor is negative", + trip: newTrip(func(t *Trip) { + t.SafeDurationFactor = tt.NewFloat(-1.5) + t.SafeDurationOffset = tt.NewFloat(300) + }), + expectedErrors: PE("InvalidFieldError:safe_duration_factor"), + }, + { + name: "Valid: safe_duration_factor is zero offset", + trip: newTrip(func(t *Trip) { + t.SafeDurationFactor = tt.NewFloat(1.0) + t.SafeDurationOffset = tt.NewFloat(0) + }), + expectedErrors: nil, + }, } for _, tc := range tests { diff --git a/internal/generated/gqlout/generated.go b/internal/generated/gqlout/generated.go index afe00c47..b3b80854 100644 --- a/internal/generated/gqlout/generated.go +++ b/internal/generated/gqlout/generated.go @@ -1218,6 +1218,8 @@ type ComplexityRoot struct { Frequencies func(childComplexity int, limit *int) int ID func(childComplexity int) int Route func(childComplexity int) int + SafeDurationFactor func(childComplexity int) int + SafeDurationOffset func(childComplexity int) int ScheduleRelationship func(childComplexity int) int Shape func(childComplexity int) int StopPatternID func(childComplexity int) int @@ -7189,6 +7191,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Trip.Route(childComplexity), true + case "Trip.safe_duration_factor": + if e.ComplexityRoot.Trip.SafeDurationFactor == nil { + break + } + + return e.ComplexityRoot.Trip.SafeDurationFactor(childComplexity), true + case "Trip.safe_duration_offset": + if e.ComplexityRoot.Trip.SafeDurationOffset == nil { + break + } + + return e.ComplexityRoot.Trip.SafeDurationOffset(childComplexity), true case "Trip.schedule_relationship": if e.ComplexityRoot.Trip.ScheduleRelationship == nil { break @@ -9818,7 +9832,13 @@ type Trip { "GTFS ` + "`" + `trips.bikes_allowed` + "`" + ` [0=no information, 1=vehicle can accommodate at least one bicycle, 2=no bicycles allowed]" bikes_allowed: Int - + + "GTFS ` + "`" + `trips.safe_duration_factor` + "`" + ` (GTFS-Flex, google/transit#598); multiplier applied to driving duration for a safe travel time estimate" + safe_duration_factor: Float + + "GTFS ` + "`" + `trips.safe_duration_offset` + "`" + ` (GTFS-Flex, google/transit#598); fixed offset in seconds added to driving duration for a safe travel time estimate" + safe_duration_offset: Float + "Calculated stop pattern ID; an integer scoped to the feed version" stop_pattern_id: Int! @@ -14123,6 +14143,10 @@ func (ec *executionContext) childFields_Trip(ctx context.Context, field graphql. return ec.fieldContext_Trip_wheelchair_accessible(ctx, field) case "bikes_allowed": return ec.fieldContext_Trip_bikes_allowed(ctx, field) + case "safe_duration_factor": + return ec.fieldContext_Trip_safe_duration_factor(ctx, field) + case "safe_duration_offset": + return ec.fieldContext_Trip_safe_duration_offset(ctx, field) case "stop_pattern_id": return ec.fieldContext_Trip_stop_pattern_id(ctx, field) case "calendar": @@ -38898,6 +38922,52 @@ func (ec *executionContext) fieldContext_Trip_bikes_allowed(_ context.Context, f return graphql.NewScalarFieldContext("Trip", field, false, false, errors.New("field of type Int does not have child fields")) } +func (ec *executionContext) _Trip_safe_duration_factor(ctx context.Context, field graphql.CollectedField, obj *model.Trip) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Trip_safe_duration_factor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.SafeDurationFactor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v tt.Float) graphql.Marshaler { + return ec.marshalOFloat2githubᚗcomᚋinterlineᚑioᚋtransitlandᚑlibᚋttᚐFloat(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_Trip_safe_duration_factor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("Trip", field, false, false, errors.New("field of type Float does not have child fields")) +} + +func (ec *executionContext) _Trip_safe_duration_offset(ctx context.Context, field graphql.CollectedField, obj *model.Trip) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Trip_safe_duration_offset(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.SafeDurationOffset, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v tt.Float) graphql.Marshaler { + return ec.marshalOFloat2githubᚗcomᚋinterlineᚑioᚋtransitlandᚑlibᚋttᚐFloat(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_Trip_safe_duration_offset(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("Trip", field, false, false, errors.New("field of type Float does not have child fields")) +} + func (ec *executionContext) _Trip_stop_pattern_id(ctx context.Context, field graphql.CollectedField, obj *model.Trip) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -56000,6 +56070,10 @@ func (ec *executionContext) _Trip(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._Trip_wheelchair_accessible(ctx, field, obj) case "bikes_allowed": out.Values[i] = ec._Trip_bikes_allowed(ctx, field, obj) + case "safe_duration_factor": + out.Values[i] = ec._Trip_safe_duration_factor(ctx, field, obj) + case "safe_duration_offset": + out.Values[i] = ec._Trip_safe_duration_offset(ctx, field, obj) case "stop_pattern_id": out.Values[i] = ec._Trip_stop_pattern_id(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/schema/graphql/schema.graphqls b/schema/graphql/schema.graphqls index 1851f8f0..c064f3cb 100644 --- a/schema/graphql/schema.graphqls +++ b/schema/graphql/schema.graphqls @@ -1034,7 +1034,13 @@ type Trip { "GTFS `trips.bikes_allowed` [0=no information, 1=vehicle can accommodate at least one bicycle, 2=no bicycles allowed]" bikes_allowed: Int - + + "GTFS `trips.safe_duration_factor` (GTFS-Flex, google/transit#598); multiplier applied to driving duration for a safe travel time estimate" + safe_duration_factor: Float + + "GTFS `trips.safe_duration_offset` (GTFS-Flex, google/transit#598); fixed offset in seconds added to driving duration for a safe travel time estimate" + safe_duration_offset: Float + "Calculated stop pattern ID; an integer scoped to the feed version" stop_pattern_id: Int! diff --git a/schema/postgres/migrations/20260617000001_trip_safe_duration.up.pgsql b/schema/postgres/migrations/20260617000001_trip_safe_duration.up.pgsql new file mode 100644 index 00000000..12d44a86 --- /dev/null +++ b/schema/postgres/migrations/20260617000001_trip_safe_duration.up.pgsql @@ -0,0 +1,9 @@ +BEGIN; + +-- Add GTFS-Flex safe duration fields to trips table +-- See: https://github.com/google/transit/pull/598 +ALTER TABLE public.gtfs_trips + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +COMMIT; \ No newline at end of file diff --git a/schema/sqlite/sqlite.sql b/schema/sqlite/sqlite.sql index 6efe658d..9597d9c6 100644 --- a/schema/sqlite/sqlite.sql +++ b/schema/sqlite/sqlite.sql @@ -265,6 +265,8 @@ CREATE TABLE IF NOT EXISTS "gtfs_trips" ( "feed_version_id" integer NOT NULL, "journey_pattern_id" integer, "journey_pattern_offset" integer, + "safe_duration_factor" real, + "safe_duration_offset" real, "created_at" datetime DEFAULT CURRENT_TIMESTAMP, "updated_at" datetime DEFAULT CURRENT_TIMESTAMP, foreign key(feed_version_id) REFERENCES feed_versions(id), diff --git a/server/finders/dbfinder/trip.go b/server/finders/dbfinder/trip.go index b866da3c..9a332150 100644 --- a/server/finders/dbfinder/trip.go +++ b/server/finders/dbfinder/trip.go @@ -136,6 +136,8 @@ func tripSelect(limit *int, after *model.Cursor, ids []int, active bool, permFil "gtfs_trips.stop_pattern_id", "gtfs_trips.journey_pattern_id", "gtfs_trips.journey_pattern_offset", + "gtfs_trips.safe_duration_factor", + "gtfs_trips.safe_duration_offset", "current_feeds.id AS feed_id", "current_feeds.onestop_id AS feed_onestop_id", "feed_versions.sha1 AS feed_version_sha1", diff --git a/server/gql/trip_resolver_test.go b/server/gql/trip_resolver_test.go index abc62bac..49347a40 100644 --- a/server/gql/trip_resolver_test.go +++ b/server/gql/trip_resolver_test.go @@ -411,3 +411,38 @@ func TestTripResolver_FlexStopTimes(t *testing.T) { c, _ := newTestClient(t) queryTestcases(t, c, testcases) } + +func TestTripResolver_SafeDuration(t *testing.T) { + ctranFlexSha1 := "e8bc76c3c8602cad745f41a49ed5c5627ad6904c" + ctranFlexTripID := "trip_id__ri-<2bc6804f-9e24-4b91-8947-c73a2363e7b6>_from-_to-_si-" + testcases := []testcase{ + { + name: "trip safe duration fields", + query: `query($sha1: String!, $trip_id: String!) { + feed_versions(where:{sha1:$sha1}) { + trips(where:{trip_id:$trip_id}) { + trip_id + safe_duration_factor + safe_duration_offset + } + } + }`, + vars: hw{"sha1": ctranFlexSha1, "trip_id": ctranFlexTripID}, + expect: `{"feed_versions":[{"trips":[{"trip_id":"trip_id__ri-<2bc6804f-9e24-4b91-8947-c73a2363e7b6>_from-_to-_si-","safe_duration_factor":1,"safe_duration_offset":0}]}]}`, + }, + { + name: "safe duration fields null for non-flex trip", + query: `query($trip_id: String!) { + trips(where:{trip_id:$trip_id}) { + trip_id + safe_duration_factor + safe_duration_offset + } + }`, + vars: hw{"trip_id": "3850526WKDY"}, + expect: `{"trips":[{"trip_id":"3850526WKDY","safe_duration_factor":null,"safe_duration_offset":null}]}`, + }, + } + c, _ := newTestClient(t) + queryTestcases(t, c, testcases) +}