Skip to content

Commit 86e9fb3

Browse files
drewdairees
andauthored
Additional guards and checks for Flex trips (#538)
* guard against creating shapes for flex trips fixes interline-io/calact-network-analysis-tool#258 * add a test * Improve test case * Move test * Force rerun jobs * Additional flex trip guards * Test fixes --------- Co-authored-by: Ian Rees <ian@ianrees.net>
1 parent bddc11c commit 86e9fb3

File tree

23 files changed

+224
-25
lines changed

23 files changed

+224
-25
lines changed

copier/copier.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -894,8 +894,12 @@ func (copier *Copier) copyTripsAndStopTimes() error {
894894
trip.StopTimes = sts
895895

896896
// Set StopPattern
897+
// Empty key means flex trip - assign unique pattern ID without caching
897898
patkey := stopPatternKey(trip.StopTimes)
898-
if pat, ok := stopPatterns[patkey]; ok {
899+
if patkey == "" {
900+
trip.StopPatternID.SetInt(len(stopPatterns))
901+
stopPatterns[trip.TripID.Val] = trip.StopPatternID.Int() // Use trip ID as unique key
902+
} else if pat, ok := stopPatterns[patkey]; ok {
899903
trip.StopPatternID.SetInt(pat)
900904
} else {
901905
trip.StopPatternID.SetInt(len(stopPatterns))
@@ -909,8 +913,7 @@ func (copier *Copier) copyTripsAndStopTimes() error {
909913
trip.ShapeID.Set(shapeid)
910914
} else {
911915
if shapeid, err := copier.createMissingShape(fmt.Sprintf("generated-%d-%d", trip.StopPatternID.Val, time.Now().Unix()), trip.StopTimes); err != nil {
912-
copier.log.Error().Err(err).Str("filename", "trips.txt").Str("source_id", trip.EntityID()).Msg("failed to create shape")
913-
trip.AddWarning(err)
916+
copier.log.Debug().Err(err).Str("filename", "trips.txt").Str("source_id", trip.EntityID()).Msg("skipping shape generation")
914917
} else {
915918
// Set ShapeID
916919
stopPatternShapeIDs[trip.StopPatternID.Int()] = shapeid
@@ -929,8 +932,12 @@ func (copier *Copier) copyTripsAndStopTimes() error {
929932
}
930933

931934
// Set JourneyPattern
935+
// Empty key means flex trip - don't deduplicate
932936
jkey := copier.options.JourneyPatternKey(trip)
933-
if jpat, ok := journeyPatterns[jkey]; ok {
937+
if jkey == "" {
938+
trip.JourneyPatternID.Set(trip.TripID.Val)
939+
trip.JourneyPatternOffset.Set(0)
940+
} else if jpat, ok := journeyPatterns[jkey]; ok {
934941
trip.JourneyPatternID.Set(jpat.key)
935942
trip.JourneyPatternOffset.SetInt(trip.StopTimes[0].ArrivalTime.Int() - jpat.firstArrival)
936943
tripOffsets[trip.TripID.Val] = trip.JourneyPatternOffset.Int() // do not write stop times for this trip
@@ -1023,11 +1030,16 @@ func (copier *Copier) logCount(ent tt.Entity) {
10231030
}
10241031

10251032
func (copier *Copier) createMissingShape(shapeID string, stoptimes []gtfs.StopTime) (string, error) {
1026-
stopids := []string{}
1033+
// Only create shapes when ALL stop_times have stop_id set
1034+
// Fallback shape is undefined for location/location_group based stops
1035+
if !gtfs.CheckFlexStopTimes(stoptimes).CanUseStopBasedGeometry() {
1036+
return "", errors.New("cannot create shape: not all stop_times have stop_id (trip may use flex locations)")
1037+
}
1038+
if len(stoptimes) == 0 {
1039+
return "", errors.New("cannot create shape: no stop_times")
1040+
}
1041+
stopids := make([]string, 0, len(stoptimes))
10271042
for _, st := range stoptimes {
1028-
if !st.StopID.Valid {
1029-
continue
1030-
}
10311043
stopids = append(stopids, st.StopID.Val)
10321044
}
10331045
line, dists, err := copier.geomCache.MakeShape(stopids...)

copier/copier_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77

88
"github.com/interline-io/transitland-lib/adapters/direct"
99
"github.com/interline-io/transitland-lib/gtfs"
10+
"github.com/interline-io/transitland-lib/internal/testpath"
11+
"github.com/interline-io/transitland-lib/tlcsv"
1012
"github.com/interline-io/transitland-lib/tt"
1113
"github.com/stretchr/testify/assert"
1214
)
@@ -296,3 +298,47 @@ func TestResult_CheckErrorThreshold(t *testing.T) {
296298
})
297299
}
298300
}
301+
302+
func TestCopier_CreateMissingShapes_FlexTrips(t *testing.T) {
303+
// Test that CreateMissingShapes only creates shapes when ALL stop_times have stop_id
304+
// Feed contains:
305+
// - trip_regular: all stop_times have stop_id -> should get generated shape
306+
// - trip_flex: one stop_time uses location_id instead of stop_id -> should NOT get shape
307+
// - trip_with_shape: has existing shape -> should keep it
308+
reader, err := tlcsv.NewReader(testpath.RelPath("testdata/gtfs-builders/flex-trip-create-shapes"))
309+
if err != nil {
310+
t.Fatal(err)
311+
}
312+
313+
writer := direct.NewWriter()
314+
cpOpts := Options{
315+
CreateMissingShapes: true,
316+
}
317+
318+
cpResult, err := CopyWithOptions(context.Background(), reader, writer, cpOpts)
319+
if err != nil {
320+
t.Fatal(err)
321+
}
322+
323+
// Verify results
324+
wreader, _ := writer.NewReader()
325+
326+
// Collect trips and their shape IDs
327+
tripShapes := map[string]string{}
328+
for trip := range wreader.Trips() {
329+
tripShapes[trip.TripID.Val] = trip.ShapeID.Val
330+
}
331+
332+
// Trip with all stop_ids should have a generated shape
333+
assert.NotEmpty(t, tripShapes["trip_regular"], "regular trip should have a shape")
334+
assert.Contains(t, tripShapes["trip_regular"], "generated", "regular trip should have a generated shape")
335+
336+
// Flex trip should NOT have a shape (no shape generated because not all stop_times have stop_id)
337+
assert.Empty(t, tripShapes["trip_flex"], "flex trip should not have a shape")
338+
339+
// Trip with existing shape should keep it
340+
assert.Equal(t, "existing_shape", tripShapes["trip_with_shape"], "trip with existing shape should keep it")
341+
342+
// Verify generated shapes count
343+
assert.Equal(t, 1, cpResult.GeneratedCount["shapes.txt"], "should have generated exactly 1 shape")
344+
}

copier/stoppattern.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,22 @@ import (
99
)
1010

1111
func stopPatternKey(stoptimes []gtfs.StopTime) string {
12+
// Skip flex trips - return empty key which won't match other patterns
13+
if !gtfs.CheckFlexStopTimes(stoptimes).AllStopsHaveStopID {
14+
return ""
15+
}
1216
key := make([]string, 0, len(stoptimes))
1317
for i := 0; i < len(stoptimes); i++ {
14-
if stoptimes[i].StopID.Valid {
15-
key = append(key, stoptimes[i].StopID.Val)
16-
}
18+
key = append(key, stoptimes[i].StopID.Val)
1719
}
1820
return strings.Join(key, string(byte(0)))
1921
}
2022

2123
func journeyPatternKey(trip *gtfs.Trip) string {
24+
// Skip flex trips - return empty key to prevent deduplication
25+
if !gtfs.CheckFlexStopTimes(trip.StopTimes).AllStopsHaveStopID {
26+
return ""
27+
}
2228
m := sha1.New()
2329
a := trip.StopTimes[0].ArrivalTime
2430
b := trip.StopTimes[0].DepartureTime

ext/bestpractices/block_overlap.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ func (e *BlockOverlapCheck) Validate(ent tt.Entity) []error {
5252
if !ok || !trip.BlockID.Valid || len(trip.StopTimes) < 2 {
5353
return nil
5454
}
55+
// Skip flex trips - block overlap check requires fixed arrival/departure times
56+
// Flex trips use time windows instead and don't have meaningful overlap semantics
57+
flexInfo := gtfs.CheckFlexStopTimes(trip.StopTimes)
58+
if flexInfo.IsFlexTrip() {
59+
return nil
60+
}
5561
if e.blocks == nil {
5662
e.blocks = map[string][]*tripBlockInfo{}
5763
}

ext/bestpractices/fast_travel.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,13 @@ func (e *StopTimeFastTravelCheck) Validate(ent tt.Entity) []error {
9797
}
9898
// todo: cache for trip pattern?
9999
var errs []error
100-
if len(trip.StopTimes) == 0 || !trip.StopTimes[0].StopID.Valid {
100+
// Skip flex trips that cannot use stop-based geometry
101+
if len(trip.StopTimes) == 0 || !gtfs.CheckFlexStopTimes(trip.StopTimes).CanUseStopBasedGeometry() {
101102
return errs
102103
}
103104
s1 := trip.StopTimes[0].StopID.Val
104105
t := trip.StopTimes[0].DepartureTime
105106
for i := 1; i < len(trip.StopTimes); i++ {
106-
if !trip.StopTimes[i].StopID.Valid {
107-
continue
108-
}
109107
s2 := trip.StopTimes[i].StopID.Val
110108
key := s1 + ":" + s2 // todo: use a real separator...
111109
dx, ok := e.stopDist[key]

ext/builders/route_geometry_builder.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ func (pp *RouteGeometryBuilder) AfterWrite(eid string, ent tt.Entity, emap *tt.E
6868
for i, c := range v.Geometry.Val.Coords() {
6969
pts[i] = tlxy.Point{Lon: c[0], Lat: c[1]}
7070
}
71+
// Skip shapes with no coordinates (invalid shapes)
72+
if len(pts) == 0 {
73+
return nil
74+
}
7175
// If we've already seen this line, re-use shapeInfo to reduce mem usage
7276
for _, si := range pp.shapeInfos {
7377
// Match on generated value too

ext/builders/route_geometry_builder_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package builders
22

33
import (
4+
"context"
45
"testing"
56

7+
"github.com/interline-io/transitland-lib/adapters/direct"
8+
"github.com/interline-io/transitland-lib/copier"
69
"github.com/interline-io/transitland-lib/internal/testpath"
710
"github.com/interline-io/transitland-lib/internal/testreader"
11+
"github.com/interline-io/transitland-lib/tlcsv"
812
"github.com/interline-io/transitland-lib/tlxy"
913
"github.com/stretchr/testify/assert"
1014
"github.com/twpayne/go-geom"
@@ -97,3 +101,22 @@ func TestRouteGeometryBuilder(t *testing.T) {
97101
})
98102
}
99103
}
104+
105+
func TestRouteGeometryBuilder_FlexFeed(t *testing.T) {
106+
reader, err := tlcsv.NewReader(testpath.RelPath("testdata/gtfs-external/ctran-flex.zip"))
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
writer := direct.NewWriter()
111+
cpOpts := copier.Options{
112+
CreateMissingShapes: true,
113+
}
114+
cpOpts.AddExtension(NewRouteGeometryBuilder())
115+
116+
cpResult, err := copier.CopyWithOptions(context.Background(), reader, writer, cpOpts)
117+
if err != nil {
118+
t.Fatal(err)
119+
}
120+
121+
assert.NotNil(t, cpResult)
122+
}

ext/sched/sched.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ func (fi *ScheduleChecker) Validate(ent tt.Entity) []error {
3737
ti := tripInfo{
3838
ServiceID: v.ServiceID.Val,
3939
}
40-
if len(v.StopTimes) > 0 {
40+
// Only track fixed-route trips with arrival/departure times
41+
// Flex trips use time windows instead of fixed times
42+
if len(v.StopTimes) > 0 && !gtfs.CheckFlexStopTimes(v.StopTimes).IsFlexTrip() {
4143
ti.StartTime = v.StopTimes[0].DepartureTime
4244
ti.EndTime = v.StopTimes[len(v.StopTimes)-1].ArrivalTime
4345
}

gtfs/stop_time.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,67 @@ func (ent *StopTime) SetString(key, value string) error {
406406
}
407407
return perr
408408
}
409+
410+
// FlexInfo contains information about flex characteristics of a trip's stop times
411+
type FlexInfo struct {
412+
HasFlexStopTimes bool // Any StopTime uses Location or LocationGroup instead of StopID
413+
HasFlexTimeWindows bool // Any StopTime uses pickup/dropoff time windows
414+
HasMixedStopTypes bool // Mix of regular stops and flex locations
415+
AllStopsHaveStopID bool // All StopTimes have a valid StopID
416+
FlexStopTimeCount int // Number of StopTimes with flex characteristics
417+
RegularStopTimeCount int // Number of StopTimes with regular StopID
418+
}
419+
420+
// IsFlexTrip returns true if any StopTime has flex characteristics
421+
func (f FlexInfo) IsFlexTrip() bool {
422+
return f.HasFlexStopTimes || f.HasFlexTimeWindows
423+
}
424+
425+
// CanUseStopBasedGeometry returns true if the trip can use stop-based geometry calculations
426+
// (i.e., all StopTimes have valid StopIDs)
427+
func (f FlexInfo) CanUseStopBasedGeometry() bool {
428+
return f.AllStopsHaveStopID
429+
}
430+
431+
// CheckFlexStopTimes analyzes a slice of StopTimes and returns information about
432+
// whether they represent fixed-route service or flex service.
433+
func CheckFlexStopTimes(stoptimes []StopTime) FlexInfo {
434+
info := FlexInfo{
435+
AllStopsHaveStopID: true,
436+
}
437+
438+
for i := 0; i < len(stoptimes); i++ {
439+
st := stoptimes[i]
440+
441+
// Check for flex location types
442+
hasLocationID := st.LocationID.Valid && st.LocationID.Val != ""
443+
hasLocationGroupID := st.LocationGroupID.Valid && st.LocationGroupID.Val != ""
444+
hasStopID := st.StopID.Valid && st.StopID.Val != ""
445+
446+
// Check for flex time windows
447+
hasTimeWindow := (st.StartPickupDropOffWindow.Valid && st.StartPickupDropOffWindow.Val > 0) ||
448+
(st.EndPickupDropOffWindow.Valid && st.EndPickupDropOffWindow.Val > 0)
449+
450+
if hasLocationID || hasLocationGroupID {
451+
info.HasFlexStopTimes = true
452+
info.FlexStopTimeCount++
453+
}
454+
455+
if hasTimeWindow {
456+
info.HasFlexTimeWindows = true
457+
}
458+
459+
if hasStopID {
460+
info.RegularStopTimeCount++
461+
} else {
462+
info.AllStopsHaveStopID = false
463+
}
464+
}
465+
466+
// Mixed if we have both regular stops and flex locations
467+
if info.RegularStopTimeCount > 0 && info.FlexStopTimeCount > 0 {
468+
info.HasMixedStopTypes = true
469+
}
470+
471+
return info
472+
}

internal/geomcache/geomcache.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ func (g *GeomCache) InterpolateStopTimes(trip *gtfs.Trip) ([]gtfs.StopTime, erro
111111
return sts, nil
112112
}
113113

114+
// Skip flex trips that cannot use stop-based geometry
115+
if !gtfs.CheckFlexStopTimes(sts).CanUseStopBasedGeometry() {
116+
return sts, nil
117+
}
118+
114119
// Do we have valid ShapeDistTraveled values?
115120
validDists := true
116121
if sts[len(sts)-1].ShapeDistTraveled.Val-sts[0].ShapeDistTraveled.Val <= 0 {
@@ -142,9 +147,6 @@ func (g *GeomCache) setStopTimeDists(shapeId string, patternId int64, sts []gtfs
142147
// Generate the stop-to-stop geometry
143148
stopLine := make([]tlxy.Point, 0, len(sts))
144149
for i := 0; i < len(sts); i++ {
145-
if !sts[i].StopID.Valid {
146-
continue
147-
}
148150
point, ok := g.stops[sts[i].StopID.Val]
149151
if !ok {
150152
return fmt.Errorf("stop '%s' not in cache", sts[i].StopID)

0 commit comments

Comments
 (0)