From 7d1e8f606c11521bc12530e5e8a7822ed3b756d3 Mon Sep 17 00:00:00 2001 From: Samuel Cedarbaum Date: Sun, 22 Sep 2024 23:33:47 -0400 Subject: [PATCH] Handle informed trip descriptors with empty trip ids and non-empty route --- realtime.go | 83 +++++++- realtime_test.go | 509 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 588 insertions(+), 4 deletions(-) diff --git a/realtime.go b/realtime.go index d0d9626..c6770a4 100644 --- a/realtime.go +++ b/realtime.go @@ -326,6 +326,7 @@ func ParseRealtime(content []byte, opts *ParseRealtimeOptions) (*Realtime, error } result.Trips = append(result.Trips, *trip) } + for vehicleID, vehicle := range vehiclesByID { if tripID, ok := vehicleIDToTripID[vehicleID]; ok { vehicle.Trip = tripsById[tripID] @@ -525,23 +526,88 @@ func parseAlert(ID string, alert *gtfsrt.Alert, opts *ParseRealtimeOptions) (*Al } var informedEntities []AlertInformedEntity var trips []Trip + var informedRoutes = make(map[string]bool) + var informedRoutesFromTripIDs = make(map[string]map[DirectionID]bool) for _, entity := range alert.GetInformedEntity() { tripIDOrNil := parseOptionalTripDescriptor(entity.Trip, opts) - informedEntities = append(informedEntities, AlertInformedEntity{ + + // Handle the case where the trip descriptor's doesn't map to a trip instance but the route ID is not. + // In such cases, we can inform the route in one or both directions. + // Such cases are not handled by the GTFS-realtime spec, but occur in some feeds such + // as the MTA bus alerts feed. See: https://groups.google.com/g/mtadeveloperresources/c/pn_EupBj1nY + if tripIDOrNil != nil && !tripIDUniquelyIdentifiesTrip(tripIDOrNil) && tripIDOrNil.RouteID != "" { + if tripIDOrNil.DirectionID == DirectionID_Unspecified { + informedRoutesFromTripIDs[tripIDOrNil.RouteID] = map[DirectionID]bool{ + DirectionID_False: true, + DirectionID_True: true, + } + } else { + if _, ok := informedRoutesFromTripIDs[tripIDOrNil.RouteID]; !ok { + informedRoutesFromTripIDs[tripIDOrNil.RouteID] = map[DirectionID]bool{} + } + informedRoutesFromTripIDs[tripIDOrNil.RouteID][tripIDOrNil.DirectionID] = true + } + } + if entity.RouteId != nil { + informedRoutes[*entity.RouteId] = true + } + + informedEntity := AlertInformedEntity{ AgencyID: entity.AgencyId, RouteID: entity.RouteId, RouteType: parseRouteType_GTFSRealtime(entity.RouteType), DirectionID: parseDirectionID_GTFSRealtime(entity.DirectionId), TripID: tripIDOrNil, StopID: entity.StopId, - }) - if tripIDOrNil != nil { + } + + // Ensure at least one entity is informed + if !alertInformedEntityInformsAtLeastOneEntity(informedEntity) { + continue + } + + if tripIDUniquelyIdentifiesTrip(tripIDOrNil) { trips = append(trips, Trip{ ID: *tripIDOrNil, IsEntityInMessage: false, }) + } else { + // Clear the trip ID if it doesn't uniquely identify a trip + informedEntity.TripID = nil + } + + informedEntities = append(informedEntities, informedEntity) + } + + for routeID, directions := range informedRoutesFromTripIDs { + if informedRoutes[routeID] { + continue + } + + routeIDCopy := routeID + if directions[DirectionID_False] && directions[DirectionID_True] { + // Inform the route in both directions + informedEntities = append(informedEntities, AlertInformedEntity{ + RouteID: &routeIDCopy, + RouteType: RouteType_Unknown, + }) + } else { + // Inform the route in one direction + var informedDirection DirectionID + if directions[DirectionID_False] { + informedDirection = DirectionID_False + } else { + informedDirection = DirectionID_True + } + + informedEntities = append(informedEntities, AlertInformedEntity{ + RouteID: &routeIDCopy, + RouteType: RouteType_Unknown, + DirectionID: informedDirection, + }) } } + gtfsAlert := &Alert{ ID: ID, ActivePeriods: activePeriods, @@ -573,3 +639,14 @@ func convertOptionalTimestamp(in *uint64, timezone *time.Location) *time.Time { out := time.Unix(int64(*in), 0).In(timezone) return &out } + +func alertInformedEntityInformsAtLeastOneEntity(alertInformedEntity AlertInformedEntity) bool { + return (alertInformedEntity.AgencyID != nil || alertInformedEntity.RouteID != nil || + alertInformedEntity.RouteType != RouteType_Unknown || tripIDUniquelyIdentifiesTrip(alertInformedEntity.TripID) || + alertInformedEntity.StopID != nil) +} + +func tripIDUniquelyIdentifiesTrip(tripID *TripID) bool { + return tripID != nil && + (tripID.ID != "" || (tripID.RouteID != "" && tripID.DirectionID != DirectionID_Unspecified && tripID.HasStartTime && tripID.HasStartDate)) +} diff --git a/realtime_test.go b/realtime_test.go index cf40ec2..89307c0 100644 --- a/realtime_test.go +++ b/realtime_test.go @@ -158,7 +158,7 @@ func TestRealtime(t *testing.T) { Trip: >fsrt.TripDescriptor{ TripId: ptr("TripID"), }, - DirectionId: ptr(uint32(gtfs.DirectionID_True)), + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), }, }, Cause: ptr(gtfsrt.Alert_CONSTRUCTION), @@ -306,6 +306,466 @@ func TestRealtime(t *testing.T) { } }(), }, + { + name: "alert trip descriptor with empty trip id, route id, and unspecified direction id", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + { + Trip: >fsrt.TripDescriptor{ + RouteId: ptr("RouteID"), + }, + }, + } + + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + InformedEntities: []gtfs.AlertInformedEntity{ + { + RouteID: ptr("RouteID"), + RouteType: gtfs.RouteType_Unknown, + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + }, + }, + { + name: "alert trip descriptor with empty trip id, route id, and both direction ids", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + { + Trip: >fsrt.TripDescriptor{ + RouteId: ptr("RouteID"), + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), + }, + }, + { + Trip: >fsrt.TripDescriptor{ + RouteId: ptr("RouteID"), + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_False), + }, + }, + } + + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + InformedEntities: []gtfs.AlertInformedEntity{ + { + RouteID: ptr("RouteID"), + RouteType: gtfs.RouteType_Unknown, + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + }, + }, + { + name: "alert trip descriptor with empty trip id, route id, and single direction id", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + { + Trip: >fsrt.TripDescriptor{ + RouteId: ptr("RouteID"), + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), + }, + }, + } + + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + InformedEntities: []gtfs.AlertInformedEntity{ + { + RouteID: ptr("RouteID"), + RouteType: gtfs.RouteType_Unknown, + DirectionID: gtfs.DirectionID_True, + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + }, + }, + { + name: "alert trip descriptor with empty trip id and route id doesn't overwrite normal informed route", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + { + Trip: >fsrt.TripDescriptor{ + RouteId: ptr("RouteID"), + }, + }, + { + RouteId: ptr("RouteID"), + RouteType: ptr(int32(gtfs.RouteType_Subway)), + }, + } + + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + InformedEntities: []gtfs.AlertInformedEntity{ + { + RouteID: ptr("RouteID"), + RouteType: gtfs.RouteType_Subway, + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + }, + }, + { + name: "trip informed entity with empty trip id but still identifiable", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + { + Trip: >fsrt.TripDescriptor{ + RouteId: ptr("RouteID"), + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), + StartDate: ptr("20240101"), + StartTime: ptr("11:00:00"), + }, + }, + } + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + InformedEntities: []gtfs.AlertInformedEntity{ + { + TripID: >fs.TripID{ + RouteID: "RouteID", + DirectionID: gtfs.DirectionID_True, + HasStartDate: true, + StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + HasStartTime: true, + StartTime: 11 * time.Hour, + }, + RouteType: gtfs.RouteType_Unknown, + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + Trips: []gtfs.Trip{ + { + ID: gtfs.TripID{ + ID: "", + RouteID: "RouteID", + DirectionID: gtfs.DirectionID_True, + HasStartDate: true, + StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + HasStartTime: true, + StartTime: 11 * time.Hour, + }, + }, + }, + }, + }, + { + name: "useless informed entities are ignored", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + // Empty + {}, + { + // No route ID + Trip: >fsrt.TripDescriptor{ + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), + StartDate: ptr("20240101"), + StartTime: ptr("11:00:00"), + }, + }, + { + // Direction ID without a route + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), + }, + } + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + }, + }, + { + name: "trip id is removed from informed entity when not uniquely identifiable", + in: []*gtfsrt.FeedEntity{ + { + Id: ptr("AlertID"), + Alert: func() *gtfsrt.Alert { + alert := buildBaseRtAlert() + alert.InformedEntity = []*gtfsrt.EntitySelector{ + { + RouteId: ptr("RouteID"), + // No route ID + Trip: >fsrt.TripDescriptor{ + DirectionId: parseDirectionID_GTFSRealtimeRaw(gtfs.DirectionID_True), + StartDate: ptr("20240101"), + StartTime: ptr("11:00:00"), + }, + }, + } + return alert + }(), + }, + }, + want: >fs.Realtime{ + CreatedAt: createTime, + Alerts: []gtfs.Alert{ + { + ID: "AlertID", + ActivePeriods: []gtfs.AlertActivePeriod{ + { + StartsAt: ptr(time1), + EndsAt: ptr(time2), + }, + }, + InformedEntities: []gtfs.AlertInformedEntity{ + { + RouteID: ptr("RouteID"), + RouteType: gtfs.RouteType_Unknown, + }, + }, + Cause: gtfsrt.Alert_CONSTRUCTION, + Effect: gtfsrt.Alert_SIGNIFICANT_DELAYS, + URL: []gtfs.AlertText{ + { + Text: "UrlText", + Language: "UrlLanguage", + }, + }, + Header: []gtfs.AlertText{ + { + Text: "HeaderText", + Language: "HeaderLanguage", + }, + }, + Description: []gtfs.AlertText{ + { + Text: "DescriptionText", + Language: "DescriptionLanguage", + }, + }, + }, + }, + }, + }, } { t.Run(tc.name, func(t *testing.T) { header := >fsrt.FeedHeader{ @@ -321,6 +781,53 @@ func TestRealtime(t *testing.T) { } } +func buildBaseRtAlert() *gtfsrt.Alert { + return >fsrt.Alert{ + ActivePeriod: []*gtfsrt.TimeRange{ + { + Start: ptr(uint64(time1.Unix())), + End: ptr(uint64(time2.Unix())), + }, + }, + Cause: ptr(gtfsrt.Alert_CONSTRUCTION), + Effect: ptr(gtfsrt.Alert_SIGNIFICANT_DELAYS), + Url: >fsrt.TranslatedString{ + Translation: []*gtfsrt.TranslatedString_Translation{ + { + Text: ptr("UrlText"), + Language: ptr("UrlLanguage"), + }, + }, + }, + HeaderText: >fsrt.TranslatedString{ + Translation: []*gtfsrt.TranslatedString_Translation{ + { + Text: ptr("HeaderText"), + Language: ptr("HeaderLanguage"), + }, + }, + }, + DescriptionText: >fsrt.TranslatedString{ + Translation: []*gtfsrt.TranslatedString_Translation{ + { + Text: ptr("DescriptionText"), + Language: ptr("DescriptionLanguage"), + }, + }, + }, + } +} + +func parseDirectionID_GTFSRealtimeRaw(d gtfs.DirectionID) *uint32 { + if d == gtfs.DirectionID_Unspecified { + return nil + } + if d == gtfs.DirectionID_False { + return ptr(uint32(0)) + } + return ptr(uint32(1)) +} + func ptr[T any](t T) *T { return &t }