Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions gtfs/stop_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions gtfs/trip.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package gtfs

import (
"fmt"

"github.com/interline-io/transitland-lib/causes"
"github.com/interline-io/transitland-lib/tt"
)

Expand All @@ -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:"-"`
Expand All @@ -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
}
39 changes: 39 additions & 0 deletions gtfs/trip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 75 additions & 1 deletion internal/generated/gqlout/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion schema/graphql/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
drewda marked this conversation as resolved.

"Calculated stop pattern ID; an integer scoped to the feed version"
stop_pattern_id: Int!

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
drewda marked this conversation as resolved.

COMMIT;
2 changes: 2 additions & 0 deletions schema/sqlite/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
drewda marked this conversation as resolved.
"created_at" datetime DEFAULT CURRENT_TIMESTAMP,
"updated_at" datetime DEFAULT CURRENT_TIMESTAMP,
foreign key(feed_version_id) REFERENCES feed_versions(id),
Expand Down
2 changes: 2 additions & 0 deletions server/finders/dbfinder/trip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions server/gql/trip_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<db7489d3-7478-4d3b-a47f-60c58e3fed6e>_to-<db7489d3-7478-4d3b-a47f-60c58e3fed6e>_si-<MTWTFxx_20220107_20320522__053000_190000__053000_190000__m_b3a73dc523608998d850c431bf49b740093fd69415233fb3e74709073b335b6a>"
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-<db7489d3-7478-4d3b-a47f-60c58e3fed6e>_to-<db7489d3-7478-4d3b-a47f-60c58e3fed6e>_si-<MTWTFxx_20220107_20320522__053000_190000__053000_190000__m_b3a73dc523608998d850c431bf49b740093fd69415233fb3e74709073b335b6a>","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)
}