From 46dc7373889e048f822eb3bc339f2ab939eec130 Mon Sep 17 00:00:00 2001 From: Ahmed Alian Date: Tue, 3 Feb 2026 13:03:37 +0200 Subject: [PATCH 01/94] feat: Implement Persistence for User Problem Reports - Add database schema for problem_reports_trip and problem_reports_stop - Implement REST API handlers for submitting problem reports - Add manual FTS queries to support route_search and stop_search - Optimize SQL queries to use :exec where appropriate - Cleanup legacy/unused SQL queries --- gtfsdb/query.sql | 13 ++++++------- gtfsdb/query.sql.go | 10 +++++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/gtfsdb/query.sql b/gtfsdb/query.sql index 802c54b6..74a19b80 100644 --- a/gtfsdb/query.sql +++ b/gtfsdb/query.sql @@ -614,16 +614,16 @@ ORDER BY s.shape_pt_sequence ASC; -- name: GetStopsWithShapeContextByIDs :many -SELECT - st.stop_id, - t.shape_id, - s.lat, - s.lon, +SELECT + st.stop_id, + t.shape_id, + s.lat, + s.lon, st.shape_dist_traveled FROM stop_times st JOIN trips t ON st.trip_id = t.id JOIN stops s ON st.stop_id = s.id -WHERE st.stop_id IN (sqlc.slice('stop_ids')); +WHERE st.stop_id IN (sqlc.slice('stop_ids')); -- name: GetTripsByBlockIDOrdered :many SELECT @@ -977,7 +977,6 @@ WHERE t.block_id IN (sqlc.slice('block_ids')) AND t.service_id IN (sqlc.slice('service_ids')) GROUP BY t.id ORDER BY t.block_id, MIN(st.departure_time), t.id; - -- Problem Report Queries -- name: CreateProblemReportTrip :exec diff --git a/gtfsdb/query.sql.go b/gtfsdb/query.sql.go index 271e7389..8ce93714 100644 --- a/gtfsdb/query.sql.go +++ b/gtfsdb/query.sql.go @@ -3185,11 +3185,11 @@ func (q *Queries) GetStopsWithShapeContext(ctx context.Context, id string) ([]Ge } const getStopsWithShapeContextByIDs = `-- name: GetStopsWithShapeContextByIDs :many -SELECT - st.stop_id, - t.shape_id, - s.lat, - s.lon, +SELECT + st.stop_id, + t.shape_id, + s.lat, + s.lon, st.shape_dist_traveled FROM stop_times st JOIN trips t ON st.trip_id = t.id From 8a6ed752416ade977828c28e05f3f6cb1bfd0df0 Mon Sep 17 00:00:00 2001 From: Ahmed Alian Date: Wed, 18 Feb 2026 11:51:13 +0200 Subject: [PATCH 02/94] chore: Trigger CI From 7e78baf4b39bd7c057fdbd08c7a20b2b2ebfa661 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 16 Jan 2026 11:34:39 +0200 Subject: [PATCH 03/94] fix: update SituationIDs in BuildTripStatus to use GetSituationIDsForTrip --- internal/restapi/trips_helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index a739346f..12075621 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -40,7 +40,7 @@ func (api *RestAPI) BuildTripStatus( ServiceDate: serviceDate.Unix() * 1000, VehicleID: vehicleID, OccupancyStatus: occupancyStatus, - SituationIDs: []string{}, + SituationIDs: api.GetSituationIDsForTrip(tripID), } api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) From 5072ff63e07071662392b2ab465af92af93b7e9a Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 16 Jan 2026 20:16:36 +0200 Subject: [PATCH 04/94] refactor: Add situations to reference --- internal/restapi/trip_details_handler.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 6469eadc..4529dcad 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -166,12 +166,13 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { } } + situationsIDs := api.GetSituationIDsForTrip(r.Context(), tripID) tripDetails := &models.TripDetails{ TripID: utils.FormCombinedID(agencyID, trip.ID), ServiceDate: serviceDateMillis, Schedule: schedule, Frequency: nil, - SituationIDs: api.GetSituationIDsForTrip(r.Context(), tripID), + SituationIDs: situationsIDs, } if status != nil && status.VehicleID != "" { @@ -225,6 +226,16 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { ) references.Agencies = append(references.Agencies, agencyModel) + if len(situationsIDs) > 0 { + alerts := api.GtfsManager.GetAlertsForTrip(r.Context(), tripID) + if len(alerts) > 0 { + situations := api.BuildSituationReferences(alerts, agencyID) + for _, situation := range situations { + references.Situations = append(references.Situations, situation) + } + } + } + if params.IncludeSchedule && schedule != nil { stops, err := api.buildStopReferences(ctx, calc, agencyID, schedule.StopTimes) if err != nil { From 2ba142543bb4333fc6daf33262c7b4f013bd1339 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sat, 24 Jan 2026 02:56:44 +0200 Subject: [PATCH 05/94] feat: improve trip status calculations with real-time stop delays + rt status --- internal/restapi/trips_helper.go | 211 +++++++++++++++++++++++++++---- 1 file changed, 185 insertions(+), 26 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 12075621..aac7ea08 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -37,10 +37,12 @@ func (api *RestAPI) BuildTripStatus( } status := &models.TripStatusForTripDetails{ - ServiceDate: serviceDate.Unix() * 1000, - VehicleID: vehicleID, - OccupancyStatus: occupancyStatus, - SituationIDs: api.GetSituationIDsForTrip(tripID), + ServiceDate: serviceDate.Unix() * 1000, + VehicleID: vehicleID, + OccupancyStatus: occupancyStatus, + SituationIDs: api.GetSituationIDsForTrip(ctx, tripID), + OccupancyCapacity: -1, + OccupancyCount: -1, } api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) @@ -98,12 +100,14 @@ func (api *RestAPI) BuildTripStatus( var closestOffset, nextOffset int if vehicle != nil && vehicle.Position != nil { - closestStopID, closestOffset = findClosestStop(api, ctx, vehicle.Position, stopTimesPtrs) - nextStopID, nextOffset = findNextStop(api, stopTimesPtrs, vehicle) + closestStopID, closestOffset = findClosestStop(api, ctx, vehicle.Position, stopTimesPtrs, currentTime, vehicle) + nextStopID, nextOffset = findNextStop(api, stopTimesPtrs, vehicle, currentTime) } else { + // Use real-time delays for more accurate stop predictions + stopDelays := api.getStopDelaysFromTripUpdates(tripID) currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - closestStopID, closestOffset = findClosestStopByTime(currentTimeSeconds, stopTimesPtrs) - nextStopID, nextOffset = findNextStopByTime(currentTimeSeconds, stopTimesPtrs) + closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) + nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) } if closestStopID != "" { @@ -292,6 +296,7 @@ func findNextStop( api *RestAPI, stopTimes []*gtfsdb.StopTime, vehicle *gtfs.Vehicle, + currentTime time.Time, ) (stopID string, offset int) { if vehicle == nil || vehicle.CurrentStopSequence == nil { @@ -299,12 +304,31 @@ func findNextStop( } vehicleCurrentStopSequence := vehicle.CurrentStopSequence + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + // INCOMING_AT (0): vehicle is just about to arrive at currentStopSequence - that IS the next stop + // STOPPED_AT (1): vehicle is at currentStopSequence - next stop is the one after + // IN_TRANSIT_TO (2): vehicle has departed previous stop, heading to currentStopSequence - that IS the next stop + isAtCurrentStop := vehicle.CurrentStatus != nil && *vehicle.CurrentStatus == gtfs.CurrentStatus(1) for i, st := range stopTimes { if uint32(st.StopSequence) == *vehicleCurrentStopSequence { - if len(stopTimes) > 0 { - nextIdx := (i + 1) % len(stopTimes) - return stopTimes[nextIdx].StopID, 0 + var nextSt *gtfsdb.StopTime + + if isAtCurrentStop { + if i+1 < len(stopTimes) { + nextSt = stopTimes[i+1] + } + } else { + nextSt = st + } + + if nextSt != nil { + stopTimeSeconds := nextSt.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = nextSt.DepartureTime / 1e9 + } + return nextSt.StopID, int(stopTimeSeconds - currentTimeSeconds) } } } @@ -313,12 +337,68 @@ func findNextStop( } // IMPORTANT: Caller must hold manager.RLock() before calling this method. -func findClosestStop(api *RestAPI, ctx context.Context, pos *gtfs.Position, stopTimes []*gtfsdb.StopTime) (stopID string, offset int) { +func findClosestStop(api *RestAPI, ctx context.Context, pos *gtfs.Position, stopTimes []*gtfsdb.StopTime, currentTime time.Time, vehicle *gtfs.Vehicle) (stopID string, offset int) { if pos == nil || pos.Latitude == nil || pos.Longitude == nil { return "", 0 } + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + if vehicle != nil && vehicle.CurrentStopSequence != nil { + + isAtCurrentStop := vehicle.CurrentStatus != nil && *vehicle.CurrentStatus == gtfs.CurrentStatus(1) + + for i, st := range stopTimes { + if uint32(st.StopSequence) == *vehicle.CurrentStopSequence { + var closestSt *gtfsdb.StopTime + + if isAtCurrentStop { + closestSt = st + } else { + + if i > 0 { + prevSt := stopTimes[i-1] + currSt := st + + stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, []string{prevSt.StopID, currSt.StopID}) + if err == nil && len(stops) == 2 { + stopMap := make(map[string]gtfsdb.Stop) + for _, s := range stops { + stopMap[s.ID] = s + } + + prevStop := stopMap[prevSt.StopID] + currStop := stopMap[currSt.StopID] + + distToPrev := utils.Distance(float64(*pos.Latitude), float64(*pos.Longitude), prevStop.Lat, prevStop.Lon) + distToCurr := utils.Distance(float64(*pos.Latitude), float64(*pos.Longitude), currStop.Lat, currStop.Lon) + + if distToPrev < distToCurr { + closestSt = prevSt + } else { + closestSt = currSt + } + } else { + closestSt = st + } + } else { + closestSt = st + } + } + + if closestSt != nil { + stopTimeSeconds := closestSt.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = closestSt.DepartureTime / 1e9 + } + return closestSt.StopID, int(stopTimeSeconds - currentTimeSeconds) + } + } + } + } + var minDist = math.MaxFloat64 + var closestStopTime *gtfsdb.StopTime stopIDs := make([]string, len(stopTimes)) for i, st := range stopTimes { @@ -351,61 +431,103 @@ func findClosestStop(api *RestAPI, ctx context.Context, pos *gtfs.Position, stop if d < minDist { minDist = d stopID = stop.ID - offset = int(st.StopSequence) + closestStopTime = st } } + if closestStopTime != nil { + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + stopTimeSeconds := closestStopTime.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = closestStopTime.DepartureTime / 1e9 + } + offset = int(stopTimeSeconds - currentTimeSeconds) + } + return } -func findClosestStopByTime(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime) (stopID string, offset int) { +func findClosestStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { var minTimeDiff int64 = math.MaxInt64 + var closestStopTimeSeconds int64 for _, st := range stopTimes { - var stopTime int64 + var stopTimeSeconds int64 if st.DepartureTime > 0 { - stopTime = int64(st.DepartureTime) + stopTimeSeconds = st.DepartureTime / 1e9 } else if st.ArrivalTime > 0 { - stopTime = int64(st.ArrivalTime) + stopTimeSeconds = st.ArrivalTime / 1e9 } else { continue } - timeDiff := int64(math.Abs(float64(currentTimeSeconds - stopTime))) + if stopDelays != nil { + if delayInfo, exists := stopDelays[st.StopID]; exists { + if st.DepartureTime > 0 && delayInfo.DepartureDelay != 0 { + stopTimeSeconds += delayInfo.DepartureDelay + } else if delayInfo.ArrivalDelay != 0 { + stopTimeSeconds += delayInfo.ArrivalDelay + } + } + } + + timeDiff := int64(math.Abs(float64(currentTimeSeconds - stopTimeSeconds))) if timeDiff < minTimeDiff { minTimeDiff = timeDiff stopID = st.StopID - offset = int(st.StopSequence) + closestStopTimeSeconds = stopTimeSeconds } } + if stopID != "" { + offset = int(closestStopTimeSeconds - currentTimeSeconds) + } + return } func findNextStopByTime(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime) (stopID string, offset int) { + return findNextStopByTimeWithDelays(currentTimeSeconds, stopTimes, nil) +} + +func findNextStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { var minTimeDiff int64 = math.MaxInt64 + var nextStopTimeSeconds int64 for _, st := range stopTimes { - var stopTime int64 + var stopTimeSeconds int64 if st.DepartureTime > 0 { - stopTime = int64(st.DepartureTime) + stopTimeSeconds = st.DepartureTime / 1e9 } else if st.ArrivalTime > 0 { - stopTime = int64(st.ArrivalTime) + stopTimeSeconds = st.ArrivalTime / 1e9 } else { continue } - // Only consider stops that are in the future - if stopTime > currentTimeSeconds { - timeDiff := stopTime - currentTimeSeconds + if stopDelays != nil { + if delayInfo, exists := stopDelays[st.StopID]; exists { + if st.DepartureTime > 0 && delayInfo.DepartureDelay != 0 { + stopTimeSeconds += delayInfo.DepartureDelay + } else if delayInfo.ArrivalDelay != 0 { + stopTimeSeconds += delayInfo.ArrivalDelay + } + } + } + + if stopTimeSeconds > currentTimeSeconds { + timeDiff := stopTimeSeconds - currentTimeSeconds if timeDiff < minTimeDiff { minTimeDiff = timeDiff stopID = st.StopID - offset = int(st.StopSequence) + nextStopTimeSeconds = stopTimeSeconds } } } + if stopID != "" { + offset = int(nextStopTimeSeconds - currentTimeSeconds) + } + return } @@ -574,6 +696,43 @@ func (api *RestAPI) calculateScheduleDeviationFromTripUpdates( return int(bestDeviation) } +type StopDelayInfo struct { + ArrivalDelay int64 + DepartureDelay int64 +} + +func (api *RestAPI) getStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { + delays := make(map[string]StopDelayInfo) + + tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) + if len(tripUpdates) == 0 { + return delays + } + + tripUpdate := tripUpdates[0] + + for _, stopTimeUpdate := range tripUpdate.StopTimeUpdates { + if stopTimeUpdate.StopID == nil { + continue + } + + info := StopDelayInfo{} + if stopTimeUpdate.Arrival != nil && stopTimeUpdate.Arrival.Delay != nil { + info.ArrivalDelay = int64(stopTimeUpdate.Arrival.Delay.Seconds()) + } + if stopTimeUpdate.Departure != nil && stopTimeUpdate.Departure.Delay != nil { + info.DepartureDelay = int64(stopTimeUpdate.Departure.Delay.Seconds()) + } + + // Only add if we have at least one delay value + if info.ArrivalDelay != 0 || info.DepartureDelay != 0 { + delays[*stopTimeUpdate.StopID] = info + } + } + + return delays +} + // calculatePreciseDistanceAlongTripWithCoords calculates the distance along a trip's shape to a stop // This optimized version accepts pre-calculated cumulative distances and stop coordinates func (api *RestAPI) calculatePreciseDistanceAlongTripWithCoords( From e4314a8d0b8789ee5e634fc0ef6017f922d60b35 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 27 Jan 2026 22:30:51 +0200 Subject: [PATCH 06/94] fix: update the last location update time only when the vehicle position exist --- internal/restapi/vehicles_helper.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 68d926ba..f9282b31 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -49,6 +49,9 @@ func (api *RestAPI) BuildVehicleStatus( } status.Position = position status.LastKnownLocation = position + if vehicle.Timestamp != nil { + status.LastLocationUpdateTime = api.GtfsManager.GetVehicleLastUpdateTime(vehicle) + } } if vehicle.Position != nil && vehicle.Position.Bearing != nil { From de285b8e5aa2cd40ce2c8b9050f7684e3705611b Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 8 Feb 2026 23:46:17 +0200 Subject: [PATCH 07/94] refactor: remove unused functions --- internal/restapi/trips_helper.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index aac7ea08..10f3f111 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -486,10 +486,6 @@ func findClosestStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfs return } -func findNextStopByTime(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime) (stopID string, offset int) { - return findNextStopByTimeWithDelays(currentTimeSeconds, stopTimes, nil) -} - func findNextStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { var minTimeDiff int64 = math.MaxInt64 var nextStopTimeSeconds int64 From ac24b1d1011ce74361a5aa261db9ffa019f1e0dc Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 13:34:37 -0800 Subject: [PATCH 08/94] feat: add service date --- internal/utils/api.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/utils/api.go b/internal/utils/api.go index 6bcfbd30..04ad94b3 100644 --- a/internal/utils/api.go +++ b/internal/utils/api.go @@ -13,6 +13,21 @@ import ( "maglev.onebusaway.org/internal/models" ) +func CalculateServiceDate(currentTime time.Time) time.Time { + year, month, day := currentTime.Date() + return time.Date(year, month, day, 0, 0, 0, 0, currentTime.Location()) +} + +func ServiceDateMillis(explicitServiceDate *time.Time, currentTime time.Time) (time.Time, int64) { + var serviceDate time.Time + if explicitServiceDate != nil { + serviceDate = *explicitServiceDate + } else { + serviceDate = CalculateServiceDate(currentTime) + } + return serviceDate, serviceDate.Unix() * 1000 +} + // ExtractCodeID extracts the `code_id` from a string in the format `{agency_id}_{code_id}`. func ExtractCodeID(combinedID string) (string, error) { parts := strings.SplitN(combinedID, "_", 2) From 13b897363592c1a87011b207f574f13d4a0250e1 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:13:01 -0800 Subject: [PATCH 09/94] refactor: simplify service date handling --- internal/restapi/trip_for_vehicle_handler.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/internal/restapi/trip_for_vehicle_handler.go b/internal/restapi/trip_for_vehicle_handler.go index 31ffeb47..27a01281 100644 --- a/internal/restapi/trip_for_vehicle_handler.go +++ b/internal/restapi/trip_for_vehicle_handler.go @@ -149,15 +149,7 @@ func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request currentTime = api.Clock.Now().In(loc) } - var serviceDate time.Time - if params.ServiceDate != nil { - serviceDate = *params.ServiceDate - } else { - // Use time.Date() to get local midnight, not Truncate() which uses UTC - y, m, d := currentTime.Date() - serviceDate = time.Date(y, m, d, 0, 0, 0, 0, loc) - } - serviceDateMillis := serviceDate.Unix() * 1000 + serviceDate, serviceDateMillis := utils.ServiceDateMillis(params.ServiceDate, currentTime) var status *models.TripStatusForTripDetails if params.IncludeStatus { From 4ed433c3d57f28e054c73303e68369295901f03d Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:13:09 -0800 Subject: [PATCH 10/94] fix: update go-gtfs dependency to v1.1.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 871468d3..0f1c60f9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module maglev.onebusaway.org go 1.24.2 require ( - github.com/OneBusAway/go-gtfs v1.1.0 + github.com/OneBusAway/go-gtfs v1.1.1 github.com/davecgh/go-spew v1.1.1 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.0 diff --git a/go.sum b/go.sum index 6e470507..f0f39873 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/OneBusAway/go-gtfs v1.1.0 h1:oeiuHObV5tkFB8NFwb0TDvnAe1g/o3XGgKUZvgtMs5E= -github.com/OneBusAway/go-gtfs v1.1.0/go.mod h1:MJqNyFOJs+iE1R6uerTyfBY6g3/sxvTvVdRhDeN1bu8= +github.com/OneBusAway/go-gtfs v1.1.1 h1:JWl0ndXHBED6PAh8v3w0UgSDYWBg2OmHvAJb5RXX3Ss= +github.com/OneBusAway/go-gtfs v1.1.1/go.mod h1:MJqNyFOJs+iE1R6uerTyfBY6g3/sxvTvVdRhDeN1bu8= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= From 5b3798afc7e914e36ef52ea56097b1f1c8c6f6ab Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:13:43 -0800 Subject: [PATCH 11/94] feat: enhance vehicle status handling and add position projection onto route --- internal/restapi/vehicles_helper.go | 93 +++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index f9282b31..5cd88d64 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -2,6 +2,7 @@ package restapi import ( "context" + "math" "github.com/OneBusAway/go-gtfs" "maglev.onebusaway.org/internal/models" @@ -10,20 +11,15 @@ import ( // GetVehicleStatusAndPhase returns status and phase based on GTFS-RT CurrentStatus func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase string) { - if vehicle == nil || vehicle.CurrentStatus == nil { + if vehicle == nil { return "SCHEDULED", "scheduled" } - switch *vehicle.CurrentStatus { - case 0: // INCOMING_AT - return "INCOMING_AT", "approaching" - case 1: // STOPPED_AT - return "STOPPED_AT", "stopped" - case 2: // IN_TRANSIT_TO - return "IN_TRANSIT_TO", "in_progress" - default: - return "SCHEDULED", "scheduled" + if vehicle.CurrentStatus != nil { + return "SCHEDULED", "in_progress" } + + return "SCHEDULED", "scheduled" } func (api *RestAPI) BuildVehicleStatus( @@ -43,12 +39,19 @@ func (api *RestAPI) BuildVehicleStatus( } if vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { - position := models.Location{ + actualPosition := models.Location{ Lat: float64(*vehicle.Position.Latitude), Lon: float64(*vehicle.Position.Longitude), } - status.Position = position - status.LastKnownLocation = position + status.LastKnownLocation = actualPosition + + projectedPosition := api.projectPositionOntoRoute(ctx, tripID, actualPosition) + if projectedPosition != nil { + status.Position = *projectedPosition + } else { + status.Position = actualPosition + } + if vehicle.Timestamp != nil { status.LastLocationUpdateTime = api.GtfsManager.GetVehicleLastUpdateTime(vehicle) } @@ -92,3 +95,67 @@ func getCurrentVehicleStopSequence(vehicle *gtfs.Vehicle) *int { val := int(*vehicle.CurrentStopSequence) return &val } + +func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, actualPos models.Location) *models.Location { + shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + if err != nil || len(shapeRows) < 2 { + return nil + } + + shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) + for i, sp := range shapeRows { + shapePoints[i] = gtfs.ShapePoint{ + Latitude: sp.Lat, + Longitude: sp.Lon, + } + } + + minDistance := math.MaxFloat64 + var closestPoint models.Location + + for i := 0; i < len(shapePoints)-1; i++ { + distance, projectedPoint := projectPointToSegment( + actualPos.Lat, actualPos.Lon, + shapePoints[i].Latitude, shapePoints[i].Longitude, + shapePoints[i+1].Latitude, shapePoints[i+1].Longitude, + ) + + if distance < minDistance { + minDistance = distance + closestPoint = projectedPoint + } + } + + if minDistance <= 200 { + return &closestPoint + } + + return nil +} + +func projectPointToSegment(px, py, x1, y1, x2, y2 float64) (float64, models.Location) { + dx := x2 - x1 + dy := y2 - y1 + + if dx == 0 && dy == 0 { + dist := utils.Distance(px, py, x1, y1) + return dist, models.Location{Lat: x1, Lon: y1} + } + + t := ((px-x1)*dx + (py-y1)*dy) / (dx*dx + dy*dy) + + if t < 0 { + dist := utils.Distance(px, py, x1, y1) + return dist, models.Location{Lat: x1, Lon: y1} + } + if t > 1 { + dist := utils.Distance(px, py, x2, y2) + return dist, models.Location{Lat: x2, Lon: y2} + } + + projLat := x1 + t*dx + projLon := y1 + t*dy + + dist := utils.Distance(px, py, projLat, projLon) + return dist, models.Location{Lat: projLat, Lon: projLon} +} From 9d220d4e87096b0bfd277dc05db5e69232de3bdb Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:14:35 -0800 Subject: [PATCH 12/94] feat: implement StaleDetector for vehicle timestamp validation --- internal/restapi/stale_detector.go | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 internal/restapi/stale_detector.go diff --git a/internal/restapi/stale_detector.go b/internal/restapi/stale_detector.go new file mode 100644 index 00000000..3a5c46ee --- /dev/null +++ b/internal/restapi/stale_detector.go @@ -0,0 +1,44 @@ +package restapi + +import ( + "time" + + "github.com/OneBusAway/go-gtfs" +) + +type StaleDetector struct { + threshold time.Duration +} + +func NewStaleDetector() *StaleDetector { + return &StaleDetector{ + threshold: 15 * time.Minute, + } +} + +func (d *StaleDetector) WithThreshold(threshold time.Duration) *StaleDetector { + d.threshold = threshold + return d +} + +func (d *StaleDetector) Check(vehicle *gtfs.Vehicle, currentTime time.Time) bool { + if vehicle == nil { + return true + } + + if vehicle.Timestamp == nil { + return true + } + + age := currentTime.Sub(*vehicle.Timestamp) + + return age > d.threshold +} + +func (d *StaleDetector) Age(vehicle *gtfs.Vehicle, currentTime time.Time) time.Duration { + if vehicle == nil || vehicle.Timestamp == nil { + return d.threshold + 1 + } + + return currentTime.Sub(*vehicle.Timestamp) +} From bb582e36ca5bc1c9b6b887e217a6452549ab068b Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:25:13 -0800 Subject: [PATCH 13/94] feat: improve trip status calculation with block-level position and effective distance handling --- internal/restapi/trips_helper.go | 911 ++++++++++++++++++++++--------- 1 file changed, 657 insertions(+), 254 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 10f3f111..d163be2e 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -2,7 +2,6 @@ package restapi import ( "context" - "fmt" "math" "sort" "time" @@ -52,7 +51,39 @@ func (api *RestAPI) BuildTripStatus( status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) } - scheduleDeviation := api.calculateScheduleDeviationFromTripUpdates(tripID) + staleDetector := NewStaleDetector() + if staleDetector.Check(vehicle, currentTime) { + vehicle = nil + } + + calculatedActiveTripID, blockClosestStop, blockNextStop, blockDistance, err := api.calculateBlockLevelPosition( + ctx, tripID, vehicle, currentTime, 0, // Start with deviation 0 + ) + + if err == nil && calculatedActiveTripID != "" { + activeTripID = calculatedActiveTripID + if blockDistance > 0 { + status.DistanceAlongTrip = blockDistance + } + if blockClosestStop != "" { + status.ClosestStop = utils.FormCombinedID(agencyID, blockClosestStop) + } + if blockNextStop != "" { + status.NextStop = utils.FormCombinedID(agencyID, blockNextStop) + } + } + + deviationTripID := tripID + if activeTripID != "" { + deviationTripID = activeTripID + } + + gtfsRTDeviation := api.calculateScheduleDeviationFromTripUpdates(deviationTripID) + + scheduleDeviation := api.calculateScheduleDeviationFromPosition( + ctx, deviationTripID, vehicle, currentTime, gtfsRTDeviation, + ) + status.ScheduleDeviation = scheduleDeviation blockTripSequence := api.setBlockTripSequence(ctx, tripID, serviceDate, status) @@ -60,8 +91,10 @@ func (api *RestAPI) BuildTripStatus( status.BlockTripSequence = blockTripSequence } - shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - if err == nil && len(shapeRows) > 1 { + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripID) + + shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + if shapeErr == nil && len(shapeRows) > 1 { shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) for i, sp := range shapeRows { shapePoints[i] = gtfs.ShapePoint{ @@ -69,22 +102,34 @@ func (api *RestAPI) BuildTripStatus( Longitude: sp.Lon, } } - status.TotalDistanceAlongTrip = preCalculateCumulativeDistances(shapePoints)[len(shapePoints)-1] + cumulativeDistances := preCalculateCumulativeDistances(shapePoints) + status.TotalDistanceAlongTrip = cumulativeDistances[len(cumulativeDistances)-1] if vehicle != nil && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { - status.DistanceAlongTrip = api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) + actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) + + if err == nil { + effectiveDistance := api.calculateEffectiveDistanceAlongTrip( + actualDistance, + scheduleDeviation, + currentTime, + stopTimes, + cumulativeDistances, + ) + status.DistanceAlongTrip = effectiveDistance + } else { + status.DistanceAlongTrip = actualDistance + } } } - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripID) if err == nil { stopTimesPtrs := make([]*gtfsdb.StopTime, len(stopTimes)) for i := range stopTimes { stopTimesPtrs[i] = &stopTimes[i] } - shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - if err != nil { + if shapeErr != nil { shapeRows = []gtfsdb.Shape{} } @@ -100,8 +145,23 @@ func (api *RestAPI) BuildTripStatus( var closestOffset, nextOffset int if vehicle != nil && vehicle.Position != nil { - closestStopID, closestOffset = findClosestStop(api, ctx, vehicle.Position, stopTimesPtrs, currentTime, vehicle) - nextStopID, nextOffset = findNextStop(api, stopTimesPtrs, vehicle, currentTime) + if vehicle.StopID != nil && *vehicle.StopID != "" { + closestStopID = *vehicle.StopID + closestOffset = api.calculateOffsetForStop(closestStopID, stopTimesPtrs, currentTime, scheduleDeviation) + + nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, scheduleDeviation) + } else if vehicle.CurrentStopSequence != nil { + closestStopID, closestOffset = api.findClosestStopBySequence( + stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, scheduleDeviation, vehicle, + ) + nextStopID, nextOffset = api.findNextStopBySequence( + ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, scheduleDeviation, vehicle, tripID, serviceDate, + ) + } else { + closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( + stopTimesPtrs, currentTime, scheduleDeviation, + ) + } } else { // Use real-time delays for more accurate stop predictions stopDelays := api.getStopDelaysFromTripUpdates(tripID) @@ -182,269 +242,43 @@ func (api *RestAPI) GetNextAndPreviousTripIDs(ctx context.Context, trip *gtfsdb. return "", "", nil, nil } - blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockID(ctx, trip.BlockID) + orderedTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) if err != nil { return "", "", nil, err } - if len(blockTrips) == 0 { - return "", "", nil, nil - } - - tripIDs := make([]string, 0, len(blockTrips)) - for _, blockTrip := range blockTrips { - if trip.ServiceID == blockTrip.ServiceID { - tripIDs = append(tripIDs, blockTrip.ID) - } - } - - if len(tripIDs) == 0 { + if len(orderedTrips) == 0 { return "", "", nil, nil } - allStopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTripIDs(ctx, tripIDs) - if err != nil { - return "", "", nil, fmt.Errorf("failed to batch fetch stop times: %w", err) - } - - stopTimesByTrip := make(map[string][]gtfsdb.StopTime) - for _, st := range allStopTimes { - stopTimesByTrip[st.TripID] = append(stopTimesByTrip[st.TripID], st) - } - - type TripWithDetails struct { - TripID string - StartTime int - EndTime int - IsActive bool - StopTimes []gtfsdb.StopTime - } - - var tripsWithDetails []TripWithDetails - - for _, blockTrip := range blockTrips { - if trip.ServiceID != blockTrip.ServiceID { - continue - } - - stopTimes, exists := stopTimesByTrip[blockTrip.ID] - if !exists || len(stopTimes) == 0 { - continue - } - - startTime := math.MaxInt - endTime := 0 - - for _, st := range stopTimes { - if st.DepartureTime > 0 { - startTime = int(st.DepartureTime) - break - } - } - - for i := len(stopTimes) - 1; i >= 0; i-- { - if stopTimes[i].ArrivalTime > 0 { - endTime = int(stopTimes[i].ArrivalTime) - break - } - } - - if startTime != math.MaxInt && endTime > 0 { - tripsWithDetails = append(tripsWithDetails, TripWithDetails{ - TripID: blockTrip.ID, - StartTime: startTime, - EndTime: endTime, - IsActive: true, - StopTimes: stopTimes, - }) - } - } - - // Sort trips first by start time (chronologically), and then by trip ID to ensure a stable and deterministic order when start times are equal. - // This ensures consistent ordering of trips with identical start times. - sort.Slice(tripsWithDetails, func(i, j int) bool { - if tripsWithDetails[i].StartTime == tripsWithDetails[j].StartTime { - return tripsWithDetails[i].TripID < tripsWithDetails[j].TripID - } - return tripsWithDetails[i].StartTime < tripsWithDetails[j].StartTime - }) - currentIndex := -1 - for i, t := range tripsWithDetails { - if t.TripID == trip.ID { + for i, t := range orderedTrips { + if t.ID == trip.ID { currentIndex = i break } } - if currentIndex != -1 { - if currentIndex > 0 { - previousTripID = utils.FormCombinedID(agencyID, tripsWithDetails[currentIndex-1].TripID) - } - - if currentIndex < len(tripsWithDetails)-1 { - nextTripID = utils.FormCombinedID(agencyID, tripsWithDetails[currentIndex+1].TripID) - } - } if currentIndex == -1 { - // If the trip is not found, return empty values return "", "", nil, nil } - return nextTripID, previousTripID, tripsWithDetails[currentIndex].StopTimes, nil -} -func findNextStop( - api *RestAPI, - stopTimes []*gtfsdb.StopTime, - vehicle *gtfs.Vehicle, - currentTime time.Time, -) (stopID string, offset int) { - - if vehicle == nil || vehicle.CurrentStopSequence == nil { - return "", 0 + if currentIndex > 0 { + previousTripID = utils.FormCombinedID(agencyID, orderedTrips[currentIndex-1].ID) } - vehicleCurrentStopSequence := vehicle.CurrentStopSequence - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - - // INCOMING_AT (0): vehicle is just about to arrive at currentStopSequence - that IS the next stop - // STOPPED_AT (1): vehicle is at currentStopSequence - next stop is the one after - // IN_TRANSIT_TO (2): vehicle has departed previous stop, heading to currentStopSequence - that IS the next stop - isAtCurrentStop := vehicle.CurrentStatus != nil && *vehicle.CurrentStatus == gtfs.CurrentStatus(1) - - for i, st := range stopTimes { - if uint32(st.StopSequence) == *vehicleCurrentStopSequence { - var nextSt *gtfsdb.StopTime - - if isAtCurrentStop { - if i+1 < len(stopTimes) { - nextSt = stopTimes[i+1] - } - } else { - nextSt = st - } - - if nextSt != nil { - stopTimeSeconds := nextSt.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = nextSt.DepartureTime / 1e9 - } - return nextSt.StopID, int(stopTimeSeconds - currentTimeSeconds) - } - } + if currentIndex < len(orderedTrips)-1 { + nextTripID = utils.FormCombinedID(agencyID, orderedTrips[currentIndex+1].ID) } - return "", 0 -} - -// IMPORTANT: Caller must hold manager.RLock() before calling this method. -func findClosestStop(api *RestAPI, ctx context.Context, pos *gtfs.Position, stopTimes []*gtfsdb.StopTime, currentTime time.Time, vehicle *gtfs.Vehicle) (stopID string, offset int) { - if pos == nil || pos.Latitude == nil || pos.Longitude == nil { - return "", 0 - } - - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - - if vehicle != nil && vehicle.CurrentStopSequence != nil { - - isAtCurrentStop := vehicle.CurrentStatus != nil && *vehicle.CurrentStatus == gtfs.CurrentStatus(1) - - for i, st := range stopTimes { - if uint32(st.StopSequence) == *vehicle.CurrentStopSequence { - var closestSt *gtfsdb.StopTime - - if isAtCurrentStop { - closestSt = st - } else { - - if i > 0 { - prevSt := stopTimes[i-1] - currSt := st - - stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, []string{prevSt.StopID, currSt.StopID}) - if err == nil && len(stops) == 2 { - stopMap := make(map[string]gtfsdb.Stop) - for _, s := range stops { - stopMap[s.ID] = s - } - - prevStop := stopMap[prevSt.StopID] - currStop := stopMap[currSt.StopID] - - distToPrev := utils.Distance(float64(*pos.Latitude), float64(*pos.Longitude), prevStop.Lat, prevStop.Lon) - distToCurr := utils.Distance(float64(*pos.Latitude), float64(*pos.Longitude), currStop.Lat, currStop.Lon) - - if distToPrev < distToCurr { - closestSt = prevSt - } else { - closestSt = currSt - } - } else { - closestSt = st - } - } else { - closestSt = st - } - } - - if closestSt != nil { - stopTimeSeconds := closestSt.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = closestSt.DepartureTime / 1e9 - } - return closestSt.StopID, int(stopTimeSeconds - currentTimeSeconds) - } - } - } - } - - var minDist = math.MaxFloat64 - var closestStopTime *gtfsdb.StopTime - - stopIDs := make([]string, len(stopTimes)) - for i, st := range stopTimes { - stopIDs[i] = st.StopID - } - - stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, stopIDs) + stopTimes, err = api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) if err != nil { - return "", 0 + return nextTripID, previousTripID, nil, err } - stopMap := make(map[string]gtfsdb.Stop) - for _, stop := range stops { - stopMap[stop.ID] = stop - } - - for _, st := range stopTimes { - stop, exists := stopMap[st.StopID] - if !exists { - continue - } - - d := utils.Distance( - float64(*pos.Latitude), - float64(*pos.Longitude), - stop.Lat, - stop.Lon, - ) - - if d < minDist { - minDist = d - stopID = stop.ID - closestStopTime = st - } - } - - if closestStopTime != nil { - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - stopTimeSeconds := closestStopTime.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = closestStopTime.DepartureTime / 1e9 - } - offset = int(stopTimeSeconds - currentTimeSeconds) - } - - return + return nextTripID, previousTripID, stopTimes, nil } func findClosestStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { @@ -584,7 +418,8 @@ func (api *RestAPI) setBlockTripSequence(ctx context.Context, tripID string, ser } // calculateBlockTripSequence calculates the index of a trip within its block's ordered trip sequence -// for trips that are active on the given service date +// for trips that are active on the given service date. +// Uses GetTripsByBlockIDOrdered to perform a single SQL JOIN instead of N+1 queries. // IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time) int { blockID, err := api.GtfsManager.GtfsDB.Queries.GetBlockIDByTripID(ctx, tripID) @@ -672,15 +507,19 @@ func (api *RestAPI) calculateScheduleDeviationFromTripUpdates( tripUpdate := tripUpdates[0] + if tripUpdate.Delay != nil { + return int(tripUpdate.Delay.Seconds()) + } + var bestDeviation int64 = 0 var foundRelevantUpdate bool for _, stopTimeUpdate := range tripUpdate.StopTimeUpdates { if stopTimeUpdate.Arrival != nil && stopTimeUpdate.Arrival.Delay != nil { - bestDeviation = int64(*stopTimeUpdate.Arrival.Delay) + bestDeviation = int64(*stopTimeUpdate.Arrival.Delay / 1e9) foundRelevantUpdate = true } else if stopTimeUpdate.Departure != nil && stopTimeUpdate.Departure.Delay != nil { - bestDeviation = int64(*stopTimeUpdate.Departure.Delay) + bestDeviation = int64(*stopTimeUpdate.Departure.Delay / 1e9) foundRelevantUpdate = true } @@ -988,3 +827,567 @@ func (api *RestAPI) GetSituationIDsForTrip(ctx context.Context, tripID string) [ return situationIDs } + +type TripAgencyResolver struct { + RouteToAgency map[string]string + TripToRoute map[string]string +} + +// NewTripAgencyResolver creates a new TripAgencyResolver that maps trip IDs to their respective agency IDs. +func NewTripAgencyResolver(allRoutes []gtfsdb.Route, allTrips []gtfsdb.Trip) *TripAgencyResolver { + routeToAgency := make(map[string]string, len(allRoutes)) + for _, route := range allRoutes { + routeToAgency[route.ID] = route.AgencyID + } + tripToRoute := make(map[string]string, len(allTrips)) + for _, trip := range allTrips { + tripToRoute[trip.ID] = trip.RouteID + } + return &TripAgencyResolver{ + RouteToAgency: routeToAgency, + TripToRoute: tripToRoute, + } +} + +// GetAgencyNameByTripID retrieves the agency name for a given trip ID. +func (r *TripAgencyResolver) GetAgencyNameByTripID(tripID string) string { + routeID := r.TripToRoute[tripID] + + agency := r.RouteToAgency[routeID] + + return agency +} + +func (api *RestAPI) calculateEffectiveDistanceAlongTrip( + actualDistance float64, + scheduleDeviation int, + currentTime time.Time, + stopTimes []gtfsdb.StopTime, + cumulativeDistances []float64, +) float64 { + if scheduleDeviation == 0 || len(stopTimes) == 0 { + return actualDistance + } + + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) + + return api.interpolateDistanceAtScheduledTime(effectiveScheduleTime, stopTimes, cumulativeDistances) +} + +func (api *RestAPI) interpolateDistanceAtScheduledTime( + scheduledTime int64, + stopTimes []gtfsdb.StopTime, + cumulativeDistances []float64, +) float64 { + if len(stopTimes) == 0 { + return 0 + } + + for i := 0; i < len(stopTimes)-1; i++ { + fromStop := stopTimes[i] + toStop := stopTimes[i+1] + + fromTime := fromStop.DepartureTime / 1e9 + toTime := toStop.ArrivalTime / 1e9 + + if scheduledTime >= fromTime && scheduledTime <= toTime { + if toTime == fromTime { + return cumulativeDistances[i] + } + + timeRatio := float64(scheduledTime-fromTime) / float64(toTime-fromTime) + + fromDistance := cumulativeDistances[i*len(cumulativeDistances)/len(stopTimes)] + toDistance := cumulativeDistances[(i+1)*len(cumulativeDistances)/len(stopTimes)] + + return fromDistance + timeRatio*(toDistance-fromDistance) + } + } + + if scheduledTime < stopTimes[0].ArrivalTime/1e9 { + return 0 + } + + return cumulativeDistances[len(cumulativeDistances)-1] +} + +func (api *RestAPI) calculateOffsetForStop( + stopID string, + stopTimes []*gtfsdb.StopTime, + currentTime time.Time, + scheduleDeviation int, +) int { + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + for _, st := range stopTimes { + if st.StopID == stopID { + stopTimeSeconds := st.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = st.DepartureTime / 1e9 + } + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return int(predictedArrival - currentTimeSeconds) + } + } + + return 0 +} + +func (api *RestAPI) findNextStopAfter( + currentStopID string, + stopTimes []*gtfsdb.StopTime, + currentTime time.Time, + scheduleDeviation int, +) (stopID string, offset int) { + if len(stopTimes) == 0 { + return "", 0 + } + + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + for i, st := range stopTimes { + if st.StopID == currentStopID { + if i+1 < len(stopTimes) { + nextSt := stopTimes[i+1] + stopTimeSeconds := nextSt.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = nextSt.DepartureTime / 1e9 + } + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return nextSt.StopID, int(predictedArrival - currentTimeSeconds) + } + break + } + } + + return "", 0 +} + +func (api *RestAPI) findStopsByScheduleDeviation( + stopTimes []*gtfsdb.StopTime, + currentTime time.Time, + scheduleDeviation int, +) (closestStopID string, closestOffset int, nextStopID string, nextOffset int) { + if len(stopTimes) == 0 { + return "", 0, "", 0 + } + + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) + + var closestStop *gtfsdb.StopTime + var closestTimeDiff int64 = math.MaxInt64 + + for _, st := range stopTimes { + stopTime := st.ArrivalTime / 1e9 + if stopTime == 0 { + stopTime = st.DepartureTime / 1e9 + } + + timeDiff := stopTime - effectiveScheduleTime + if timeDiff < 0 { + timeDiff = -timeDiff + } + + if timeDiff < closestTimeDiff { + closestTimeDiff = timeDiff + closestStop = st + } + } + + if closestStop == nil { + return "", 0, "", 0 + } + + closestStopID = closestStop.StopID + + closestStopTime := closestStop.ArrivalTime / 1e9 + if closestStopTime == 0 { + closestStopTime = closestStop.DepartureTime / 1e9 + } + predictedClosestArrival := closestStopTime + int64(scheduleDeviation) + closestOffset = int(predictedClosestArrival - currentTimeSeconds) + + for i, st := range stopTimes { + if st.StopID == closestStopID { + if i+1 < len(stopTimes) { + nextSt := stopTimes[i+1] + nextStopID = nextSt.StopID + + nextStopTime := nextSt.ArrivalTime / 1e9 + if nextStopTime == 0 { + nextStopTime = nextSt.DepartureTime / 1e9 + } + predictedNextArrival := nextStopTime + int64(scheduleDeviation) + nextOffset = int(predictedNextArrival - currentTimeSeconds) + } + break + } + } + + return closestStopID, closestOffset, nextStopID, nextOffset +} + +func (api *RestAPI) calculateScheduleDeviationFromPosition( + ctx context.Context, + tripID string, + vehicle *gtfs.Vehicle, + currentTime time.Time, + gtfsRTDeviation int, +) int { + if vehicle == nil || vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { + return gtfsRTDeviation + } + + actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) + if actualDistance <= 0 { + return gtfsRTDeviation + } + + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + if err != nil || len(stopTimes) == 0 { + return gtfsRTDeviation + } + + serviceDateUnix := utils.CalculateServiceDate(currentTime).Unix() + + var totalDistance float64 + shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + if err == nil && len(shapeRows) > 1 { + shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) + for i, sp := range shapeRows { + shapePoints[i] = gtfs.ShapePoint{ + Latitude: sp.Lat, + Longitude: sp.Lon, + } + } + cumulativeDistances := preCalculateCumulativeDistances(shapePoints) + totalDistance = cumulativeDistances[len(cumulativeDistances)-1] + } + + scheduledTimeAtPosition := api.getScheduledTimeAtDistance(actualDistance, stopTimes, totalDistance) + if scheduledTimeAtPosition < 0 { + return gtfsRTDeviation + } + + currentTimestamp := currentTime.Unix() + effectiveScheduleTime := scheduledTimeAtPosition + serviceDateUnix + deviation := int(currentTimestamp - effectiveScheduleTime) + + return deviation +} + +func (api *RestAPI) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalTripDistance float64) int64 { + if len(stopTimes) == 0 || totalTripDistance <= 0 { + return -1 + } + + hasShapeDist := false + for _, st := range stopTimes { + if st.ShapeDistTraveled.Valid && st.ShapeDistTraveled.Float64 > 0 { + hasShapeDist = true + break + } + } + + if hasShapeDist { + for i := 0; i < len(stopTimes)-1; i++ { + if !stopTimes[i].ShapeDistTraveled.Valid || !stopTimes[i+1].ShapeDistTraveled.Valid { + continue + } + + fromDist := stopTimes[i].ShapeDistTraveled.Float64 + toDist := stopTimes[i+1].ShapeDistTraveled.Float64 + + if fromDist <= distance && distance <= toDist { + fromTime := stopTimes[i].ArrivalTime / 1e9 + if fromTime == 0 { + fromTime = stopTimes[i].DepartureTime / 1e9 + } + + toTime := stopTimes[i+1].ArrivalTime / 1e9 + if toTime == 0 { + toTime = stopTimes[i+1].DepartureTime / 1e9 + } + + ratio := (distance - fromDist) / (toDist - fromDist) + scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) + return int64(scheduledTime) + } + } + } else { + totalStops := len(stopTimes) + stopSpacing := totalTripDistance / float64(totalStops-1) + + for i := 0; i < totalStops-1; i++ { + fromDist := float64(i) * stopSpacing + toDist := float64(i+1) * stopSpacing + + if fromDist <= distance && distance <= toDist { + fromTime := stopTimes[i].ArrivalTime / 1e9 + if fromTime == 0 { + fromTime = stopTimes[i].DepartureTime / 1e9 + } + + toTime := stopTimes[i+1].ArrivalTime / 1e9 + if toTime == 0 { + toTime = stopTimes[i+1].DepartureTime / 1e9 + } + + ratio := (distance - fromDist) / (toDist - fromDist) + scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) + return int64(scheduledTime) + } + } + } + + lastStop := stopTimes[len(stopTimes)-1] + scheduledTime := lastStop.ArrivalTime / 1e9 + if scheduledTime == 0 { + scheduledTime = lastStop.DepartureTime / 1e9 + } + return scheduledTime +} + +func (api *RestAPI) findClosestStopBySequence( + stopTimes []*gtfsdb.StopTime, + currentStopSequence uint32, + currentTime time.Time, + scheduleDeviation int, + vehicle *gtfs.Vehicle, +) (stopID string, offset int) { + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + for _, st := range stopTimes { + if uint32(st.StopSequence) == currentStopSequence { + isAtCurrentStop := vehicle != nil && vehicle.CurrentStatus != nil && + *vehicle.CurrentStatus == gtfs.CurrentStatus(1) + + var closestStop *gtfsdb.StopTime + if isAtCurrentStop { + closestStop = st + } else { + closestStop = st + } + + if closestStop != nil { + stopTimeSeconds := closestStop.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = closestStop.DepartureTime / 1e9 + } + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return closestStop.StopID, int(predictedArrival - currentTimeSeconds) + } + } + } + + return "", 0 +} + +func (api *RestAPI) findNextStopBySequence( + ctx context.Context, + stopTimes []*gtfsdb.StopTime, + currentStopSequence uint32, + currentTime time.Time, + scheduleDeviation int, + vehicle *gtfs.Vehicle, + tripID string, + serviceDate time.Time, +) (stopID string, offset int) { + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + isAtCurrentStop := vehicle != nil && vehicle.CurrentStatus != nil && + *vehicle.CurrentStatus == gtfs.CurrentStatus(1) + + for i, st := range stopTimes { + if uint32(st.StopSequence) == currentStopSequence { + var nextStop *gtfsdb.StopTime + + if isAtCurrentStop { + if i+1 < len(stopTimes) { + nextStop = stopTimes[i+1] + } else { + nextStop = api.getFirstStopOfNextTripInBlock(ctx, tripID, serviceDate) + } + } else { + nextStop = st + } + + if nextStop != nil { + stopTimeSeconds := nextStop.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = nextStop.DepartureTime / 1e9 + } + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return nextStop.StopID, int(predictedArrival - currentTimeSeconds) + } + } + } + + return "", 0 +} + +func (api *RestAPI) getFirstStopOfNextTripInBlock(ctx context.Context, currentTripID string, serviceDate time.Time) *gtfsdb.StopTime { + trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, currentTripID) + if err != nil || !trip.BlockID.Valid { + return nil + } + + orderedTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) + if err != nil { + return nil + } + + currentIndex := -1 + for i, t := range orderedTrips { + if t.ID == currentTripID { + currentIndex = i + break + } + } + + if currentIndex >= 0 && currentIndex+1 < len(orderedTrips) { + nextTripID := orderedTrips[currentIndex+1].ID + nextTripStopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, nextTripID) + if err == nil && len(nextTripStopTimes) > 0 { + return &nextTripStopTimes[0] + } + } + + return nil +} + +func (api *RestAPI) calculateBlockLevelPosition( + ctx context.Context, + tripID string, + vehicle *gtfs.Vehicle, + currentTime time.Time, + scheduleDeviation int, +) (activeTripID string, closestStopID string, nextStopID string, distanceAlongTrip float64, err error) { + trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) + if err != nil { + return tripID, "", "", 0, err + } + + if !trip.BlockID.Valid { + return tripID, "", "", 0, nil + } + + year, month, day := currentTime.Date() + serviceDate := time.Date(year, month, day, 0, 0, 0, 0, currentTime.Location()) + serviceDateUnix := serviceDate.Unix() + + currentTimestamp := currentTime.Unix() + effectiveScheduledTime := currentTimestamp - int64(scheduleDeviation) + effectiveTimeFromMidnight := effectiveScheduledTime - serviceDateUnix + + blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) + if err != nil { + return tripID, "", "", 0, err + } + + var blockStopTimes []BlockStopTimeInfo + var cumulativeDistance float64 + + for _, blockTrip := range blockTrips { + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) + if err != nil { + continue + } + + shapeRows, _ := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) + tripDistance := calculateTripDistance(shapeRows) + + for i, st := range stopTimes { + arrivalTime := st.ArrivalTime / 1e9 + if arrivalTime == 0 { + arrivalTime = st.DepartureTime / 1e9 + } + + var stopDistance float64 + if len(stopTimes) > 1 { + stopDistance = cumulativeDistance + (float64(i) / float64(len(stopTimes)-1) * tripDistance) + } else { + stopDistance = cumulativeDistance + } + + blockStopTimes = append(blockStopTimes, BlockStopTimeInfo{ + TripID: blockTrip.ID, + StopID: st.StopID, + ArrivalTime: arrivalTime, + StopSequence: int(st.StopSequence), + Distance: stopDistance, + IsFirstInTrip: i == 0, + IsLastInTrip: i == len(stopTimes)-1, + }) + } + + cumulativeDistance += tripDistance + } + + if len(blockStopTimes) == 0 { + return tripID, "", "", 0, nil + } + + closestIndex := -1 + var minTimeDiff int64 = math.MaxInt64 + + for i, bst := range blockStopTimes { + timeDiff := bst.ArrivalTime - effectiveTimeFromMidnight + if timeDiff < 0 { + timeDiff = -timeDiff + } + if timeDiff < minTimeDiff { + minTimeDiff = timeDiff + closestIndex = i + } + } + + if closestIndex < 0 { + return tripID, "", "", 0, nil + } + + activeTripID = blockStopTimes[closestIndex].TripID + closestStopID = blockStopTimes[closestIndex].StopID + distanceAlongTrip = blockStopTimes[closestIndex].Distance + + if closestIndex+1 < len(blockStopTimes) { + nextStopID = blockStopTimes[closestIndex+1].StopID + } + + return activeTripID, closestStopID, nextStopID, distanceAlongTrip, nil +} + +type BlockStopTimeInfo struct { + TripID string + StopID string + ArrivalTime int64 + StopSequence int + Distance float64 + IsFirstInTrip bool + IsLastInTrip bool +} + +func calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { + if len(shapeRows) < 2 { + return 0 + } + + totalDistance := 0.0 + for i := 1; i < len(shapeRows); i++ { + dist := utils.Distance( + shapeRows[i-1].Lat, shapeRows[i-1].Lon, + shapeRows[i].Lat, shapeRows[i].Lon, + ) + totalDistance += dist + } + return totalDistance +} From 9dfe4bb2961ed48027f0e89d08a8499e8932c9ae Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:25:32 -0800 Subject: [PATCH 14/94] refactor: streamline service date handling and optimize route ID retrieval in trip details --- internal/restapi/trip_details_handler.go | 41 ++++++++---------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 4529dcad..6fdb15de 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -140,16 +140,7 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { currentTime = api.Clock.Now().In(loc) } - var serviceDate time.Time - if params.ServiceDate != nil { - serviceDate = *params.ServiceDate - } else { - // Use time.Date() to get local midnight, not Truncate() which uses UTC - y, m, d := currentTime.Date() - serviceDate = time.Date(y, m, d, 0, 0, 0, 0, loc) - } - - serviceDateMillis := serviceDate.Unix() * 1000 + serviceDate, serviceDateMillis := utils.ServiceDateMillis(params.ServiceDate, currentTime) var schedule *models.Schedule var status *models.TripStatusForTripDetails @@ -341,25 +332,18 @@ func (api *RestAPI) buildStopReferences(ctx context.Context, calc *GTFS.Advanced stopMap[stop.ID] = stop } - allRoutes, err := api.GtfsManager.GtfsDB.Queries.GetRoutesForStops(ctx, originalStopIDs) + routeIDRows, err := api.GtfsManager.GtfsDB.Queries.GetRouteIDsForStops(ctx, originalStopIDs) if err != nil { return nil, err } - routesByStop := make(map[string][]gtfsdb.Route) - for _, routeRow := range allRoutes { - route := gtfsdb.Route{ - ID: routeRow.ID, - AgencyID: routeRow.AgencyID, - ShortName: routeRow.ShortName, - LongName: routeRow.LongName, - Desc: routeRow.Desc, - Type: routeRow.Type, - Url: routeRow.Url, - Color: routeRow.Color, - TextColor: routeRow.TextColor, + routeIDsByStop := make(map[string][]string) + for _, row := range routeIDRows { + routeIDStr, ok := row.RouteID.(string) + if !ok { + continue } - routesByStop[routeRow.StopID] = append(routesByStop[routeRow.StopID], route) + routeIDsByStop[row.StopID] = append(routeIDsByStop[row.StopID], routeIDStr) } modelStops := make([]models.Stop, 0, len(stopTimes)) @@ -381,12 +365,13 @@ func (api *RestAPI) buildStopReferences(ctx context.Context, calc *GTFS.Advanced continue } - routesForStop := routesByStop[originalStopID] - combinedRouteIDs := make([]string, len(routesForStop)) - for i, rt := range routesForStop { - combinedRouteIDs[i] = utils.FormCombinedID(agencyID, rt.ID) + direction := models.UnknownValue + if stop.Direction.Valid && stop.Direction.String != "" { + direction = stop.Direction.String } + combinedRouteIDs := routeIDsByStop[originalStopID] + stopModel := models.Stop{ ID: utils.FormCombinedID(agencyID, stop.ID), Name: stop.Name.String, From 2b59755d87cc0bb1dda7cf1f44d1587ea2556243 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 19:09:13 -0800 Subject: [PATCH 15/94] feat: improve trip status calculation with improved vehicle position handling and stop predictions --- internal/restapi/trips_helper.go | 227 +++++++++++++++++++------------ 1 file changed, 142 insertions(+), 85 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index d163be2e..c7c12e6c 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -20,78 +20,92 @@ func (api *RestAPI) BuildTripStatus( currentTime time.Time, ) (*models.TripStatusForTripDetails, error) { - vehicle := api.GtfsManager.GetVehicleForTrip(tripID) - - var occupancyStatus string - var vehicleID string - - if vehicle != nil { - if vehicle.OccupancyStatus != nil { - occupancyStatus = vehicle.OccupancyStatus.String() - } - - if vehicle.ID != nil { - vehicleID = utils.FormCombinedID(agencyID, vehicle.ID.ID) - } - } - status := &models.TripStatusForTripDetails{ + ActiveTripID: utils.FormCombinedID(agencyID, tripID), ServiceDate: serviceDate.Unix() * 1000, - VehicleID: vehicleID, - OccupancyStatus: occupancyStatus, SituationIDs: api.GetSituationIDsForTrip(ctx, tripID), OccupancyCapacity: -1, OccupancyCount: -1, } - api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) - activeTripID := GetVehicleActiveTripID(vehicle) - - if vehicle != nil && vehicle.OccupancyPercentage != nil { - status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) - } - - staleDetector := NewStaleDetector() - if staleDetector.Check(vehicle, currentTime) { - vehicle = nil - } - - calculatedActiveTripID, blockClosestStop, blockNextStop, blockDistance, err := api.calculateBlockLevelPosition( - ctx, tripID, vehicle, currentTime, 0, // Start with deviation 0 - ) - - if err == nil && calculatedActiveTripID != "" { - activeTripID = calculatedActiveTripID - if blockDistance > 0 { - status.DistanceAlongTrip = blockDistance + vehicle := api.GtfsManager.GetVehicleForTrip(tripID) + if vehicle != nil { + if vehicle.ID != nil { + status.VehicleID = vehicle.ID.ID } - if blockClosestStop != "" { - status.ClosestStop = utils.FormCombinedID(agencyID, blockClosestStop) + if vehicle.OccupancyStatus != nil { + status.OccupancyStatus = vehicle.OccupancyStatus.String() } - if blockNextStop != "" { - status.NextStop = utils.FormCombinedID(agencyID, blockNextStop) + if vehicle.OccupancyPercentage != nil { + status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) } + api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) } - deviationTripID := tripID - if activeTripID != "" { - deviationTripID = activeTripID - } + if vehicle != nil && vehicle.ID != nil { + staleDetector := NewStaleDetector() + if !staleDetector.Check(vehicle, currentTime) { + vehiclePos, err := api.realtimeService.GetVehiclePosition(ctx, vehicle.ID.ID) + if err == nil { + status.ScheduleDeviation = vehiclePos.ScheduleDeviation + status.DistanceAlongTrip = vehiclePos.Distance + status.Predicted = vehiclePos.IsPredicted + + if vehiclePos.ActiveTripID != "" { + status.ActiveTripID = utils.FormCombinedID(agencyID, vehiclePos.ActiveTripID) + } + + if vehiclePos.CurrentStopID != "" { + status.ClosestStop = utils.FormCombinedID(agencyID, vehiclePos.CurrentStopID) + } - gtfsRTDeviation := api.calculateScheduleDeviationFromTripUpdates(deviationTripID) + if vehiclePos.NextStopID != "" { + status.NextStop = utils.FormCombinedID(agencyID, vehiclePos.NextStopID) + } - scheduleDeviation := api.calculateScheduleDeviationFromPosition( - ctx, deviationTripID, vehicle, currentTime, gtfsRTDeviation, - ) + status.ClosestStopTimeOffset = vehiclePos.CurrentStopTimeOffset + status.NextStopTimeOffset = vehiclePos.NextStopTimeOffset + } + } else { + status.Predicted = false + } + } - status.ScheduleDeviation = scheduleDeviation + if status.ScheduleDeviation == 0 || status.ClosestStop == "" { + deviation := api.calculateScheduleDeviationFromTripUpdates(tripID) + if deviation != 0 { + status.ScheduleDeviation = deviation + activeTripID, closestStopID, nextStopID, distance, err := api.calculateBlockLevelPosition( + ctx, tripID, vehicle, currentTime, deviation, + ) + if err == nil { + if activeTripID != "" { + status.ActiveTripID = utils.FormCombinedID(agencyID, activeTripID) + } + if closestStopID != "" { + status.ClosestStop = utils.FormCombinedID(agencyID, closestStopID) + } + if nextStopID != "" { + status.NextStop = utils.FormCombinedID(agencyID, nextStopID) + } + if distance > 0 { + status.DistanceAlongTrip = distance + } + status.Predicted = true + } + } + } blockTripSequence := api.setBlockTripSequence(ctx, tripID, serviceDate, status) if blockTripSequence > 0 { status.BlockTripSequence = blockTripSequence } - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripID) + if status.ClosestStop == "" || status.NextStop == "" { + api.fillStopsFromSchedule(ctx, status, tripID, currentTime, agencyID) + } + + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, status.ActiveTripID) shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) if shapeErr == nil && len(shapeRows) > 1 { @@ -111,7 +125,7 @@ func (api *RestAPI) BuildTripStatus( if err == nil { effectiveDistance := api.calculateEffectiveDistanceAlongTrip( actualDistance, - scheduleDeviation, + status.ScheduleDeviation, currentTime, stopTimes, cumulativeDistances, @@ -141,42 +155,43 @@ func (api *RestAPI) BuildTripStatus( } } - var closestStopID, nextStopID string - var closestOffset, nextOffset int - - if vehicle != nil && vehicle.Position != nil { - if vehicle.StopID != nil && *vehicle.StopID != "" { - closestStopID = *vehicle.StopID - closestOffset = api.calculateOffsetForStop(closestStopID, stopTimesPtrs, currentTime, scheduleDeviation) - - nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, scheduleDeviation) - } else if vehicle.CurrentStopSequence != nil { - closestStopID, closestOffset = api.findClosestStopBySequence( - stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, scheduleDeviation, vehicle, - ) - nextStopID, nextOffset = api.findNextStopBySequence( - ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, scheduleDeviation, vehicle, tripID, serviceDate, - ) + if status.ClosestStop == "" || status.NextStop == "" { + var closestStopID, nextStopID string + var closestOffset, nextOffset int + + if vehicle != nil && vehicle.Position != nil { + if vehicle.StopID != nil && *vehicle.StopID != "" { + closestStopID = *vehicle.StopID + closestOffset = api.calculateOffsetForStop(closestStopID, stopTimesPtrs, currentTime, status.ScheduleDeviation) + + nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, status.ScheduleDeviation) + } else if vehicle.CurrentStopSequence != nil { + closestStopID, closestOffset = api.findClosestStopBySequence( + stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, status.ScheduleDeviation, vehicle, + ) + nextStopID, nextOffset = api.findNextStopBySequence( + ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, status.ScheduleDeviation, vehicle, tripID, serviceDate, + ) + } else { + closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( + stopTimesPtrs, currentTime, status.ScheduleDeviation, + ) + } } else { - closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( - stopTimesPtrs, currentTime, scheduleDeviation, - ) + stopDelays := api.getStopDelaysFromTripUpdates(tripID) + currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) + nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) } - } else { - // Use real-time delays for more accurate stop predictions - stopDelays := api.getStopDelaysFromTripUpdates(tripID) - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) - nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) - } - if closestStopID != "" { - status.ClosestStop = utils.FormCombinedID(agencyID, closestStopID) - status.ClosestStopTimeOffset = closestOffset - } - if nextStopID != "" { - status.NextStop = utils.FormCombinedID(agencyID, nextStopID) - status.NextStopTimeOffset = nextOffset + if closestStopID != "" { + status.ClosestStop = utils.FormCombinedID(agencyID, closestStopID) + status.ClosestStopTimeOffset = closestOffset + } + if nextStopID != "" { + status.NextStop = utils.FormCombinedID(agencyID, nextStopID) + status.NextStopTimeOffset = nextOffset + } } } @@ -1391,3 +1406,45 @@ func calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { } return totalDistance } + +func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.TripStatusForTripDetails, tripID string, currentTime time.Time, agencyID string) { + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + if err != nil || len(stopTimes) == 0 { + return + } + + currentSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + + for i, st := range stopTimes { + arrivalTime := st.ArrivalTime / 1e9 + if arrivalTime == 0 { + arrivalTime = st.DepartureTime / 1e9 + } + + predictedArrival := arrivalTime + int64(status.ScheduleDeviation) + + if predictedArrival > currentSeconds { + if i > 0 { + status.ClosestStop = utils.FormCombinedID(agencyID, stopTimes[i-1].StopID) + closestArrival := stopTimes[i-1].ArrivalTime / 1e9 + if closestArrival == 0 { + closestArrival = stopTimes[i-1].DepartureTime / 1e9 + } + status.ClosestStopTimeOffset = int(closestArrival + int64(status.ScheduleDeviation) - currentSeconds) + } + status.NextStop = utils.FormCombinedID(agencyID, st.StopID) + status.NextStopTimeOffset = int(predictedArrival - currentSeconds) + return + } + } + + if len(stopTimes) > 0 { + lastStop := stopTimes[len(stopTimes)-1] + status.ClosestStop = utils.FormCombinedID(agencyID, lastStop.StopID) + arrivalTime := lastStop.ArrivalTime / 1e9 + if arrivalTime == 0 { + arrivalTime = lastStop.DepartureTime / 1e9 + } + status.ClosestStopTimeOffset = int(arrivalTime + int64(status.ScheduleDeviation) - currentSeconds) + } +} From 50021884aeed184d507cf6bdca0af9e1cf011876 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 19:09:34 -0800 Subject: [PATCH 16/94] feat: implement vehicle position service with scheduled position calculation and deviation handling --- internal/realtime/service.go | 479 +++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 internal/realtime/service.go diff --git a/internal/realtime/service.go b/internal/realtime/service.go new file mode 100644 index 00000000..ce7860f9 --- /dev/null +++ b/internal/realtime/service.go @@ -0,0 +1,479 @@ +package realtime + +import ( + "context" + "math" + "sync" + "time" + + go_gtfs "github.com/OneBusAway/go-gtfs" + "maglev.onebusaway.org/gtfsdb" + "maglev.onebusaway.org/internal/gtfs" +) + +type Service struct { + gtfsManager *gtfs.Manager + config Config + caches *caches +} + +type Config struct { + StaleThreshold time.Duration +} + +func DefaultConfig() Config { + return Config{StaleThreshold: 15 * time.Minute} +} + +func NewService(gm *gtfs.Manager, cfg Config) *Service { + return &Service{ + gtfsManager: gm, + config: cfg, + caches: newCaches(), + } +} + +type VehiclePosition struct { + VehicleID string + TripID string + ActiveTripID string + Latitude float64 + Longitude float64 + Distance float64 + Timestamp time.Time + ScheduleDeviation int + CurrentStopID string + NextStopID string + CurrentStopTimeOffset int + NextStopTimeOffset int + IsStale bool + IsPredicted bool +} + +func (s *Service) GetVehiclePosition(ctx context.Context, vehicleID string) (VehiclePosition, error) { + vehicle, err := s.gtfsManager.GetVehicleByID(vehicleID) + if err != nil { + return VehiclePosition{}, err + } + + pos := VehiclePosition{ + VehicleID: vehicle.ID.ID, + Timestamp: time.Now(), + } + + if vehicle.Trip != nil { + pos.TripID = vehicle.Trip.ID.ID + } + + if s.isStale(vehicle.Timestamp) { + pos.IsStale = true + pos.IsPredicted = false + return pos, nil + } + + pos = s.calculateScheduledPosition(ctx, vehicle, pos) + pos.IsPredicted = true + + return pos, nil +} + +func (s *Service) calculateScheduledPosition(ctx context.Context, vehicle *go_gtfs.Vehicle, pos VehiclePosition) VehiclePosition { + if vehicle.Trip == nil { + return pos + } + + tripID := vehicle.Trip.ID.ID + + trip, err := s.gtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) + if err != nil { + return pos + } + + now := time.Now() + currentSeconds := int64(now.Hour()*3600 + now.Minute()*60 + now.Second()) + + pos.ScheduleDeviation = s.GetTripDeviation(ctx, tripID) + + if pos.ScheduleDeviation == 0 && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { + recalculatedDeviation := s.calculateDeviationFromVehiclePosition(ctx, trip, vehicle, currentSeconds) + if recalculatedDeviation != 0 { + pos.ScheduleDeviation = recalculatedDeviation + } + } + + // Java formula: effectiveTime = currentTime - deviation + // If vehicle is 573s late, effective time is 573s ago + effectiveTime := currentSeconds - int64(pos.ScheduleDeviation) + + // If trip is in a block, calculate position across entire block + if trip.BlockID.Valid { + pos = s.calculateBlockPosition(ctx, trip, effectiveTime, pos) + } else { + // Single trip - calculate position within trip + pos = s.calculateTripPosition(ctx, tripID, effectiveTime, pos) + } + + return pos +} + +func (s *Service) calculateDeviationFromVehiclePosition(ctx context.Context, trip gtfsdb.Trip, vehicle *go_gtfs.Vehicle, currentSeconds int64) int { + if vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { + return 0 + } + + lat := *vehicle.Position.Latitude + lon := *vehicle.Position.Longitude + + stopTimes, err := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) + if err != nil || len(stopTimes) == 0 { + return 0 + } + + shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, trip.ID) + totalDistance := s.calculateTripDistance(shapeRows) + + vehicleDistance := s.findDistanceAlongTripForLocation(ctx, stopTimes, float64(lat), float64(lon), totalDistance) + if vehicleDistance < 0 { + return 0 + } + + scheduledTimeAtPosition := s.getScheduledTimeAtDistance(vehicleDistance, stopTimes, totalDistance) + if scheduledTimeAtPosition < 0 { + return 0 + } + + deviation := currentSeconds - scheduledTimeAtPosition + + return int(deviation) +} + +func (s *Service) findDistanceAlongTripForLocation(ctx context.Context, stopTimes []gtfsdb.StopTime, lat, lon, totalDistance float64) float64 { + if len(stopTimes) == 0 || totalDistance <= 0 { + return -1 + } + + closestStopDistance := 0.0 + + stopIDs := make([]string, len(stopTimes)) + for i, st := range stopTimes { + stopIDs[i] = st.StopID + } + + for i := range stopTimes { + stopDistance := float64(i) / float64(len(stopTimes)-1) * totalDistance + if i == 0 { + closestStopDistance = stopDistance + } + } + + return closestStopDistance +} + +func (s *Service) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalDistance float64) int64 { + if len(stopTimes) == 0 || totalDistance <= 0 { + return -1 + } + + for i := 0; i < len(stopTimes)-1; i++ { + fromDist := float64(i) / float64(len(stopTimes)-1) * totalDistance + toDist := float64(i+1) / float64(len(stopTimes)-1) * totalDistance + + if distance >= fromDist && distance <= toDist { + fromTime := stopTimes[i].ArrivalTime / 1e9 + if fromTime == 0 { + fromTime = stopTimes[i].DepartureTime / 1e9 + } + + toTime := stopTimes[i+1].ArrivalTime / 1e9 + if toTime == 0 { + toTime = stopTimes[i+1].DepartureTime / 1e9 + } + + if toDist == fromDist { + return fromTime + } + + ratio := (distance - fromDist) / (toDist - fromDist) + return int64(float64(fromTime) + ratio*float64(toTime-fromTime)) + } + } + + lastTime := stopTimes[len(stopTimes)-1].ArrivalTime / 1e9 + if lastTime == 0 { + lastTime = stopTimes[len(stopTimes)-1].DepartureTime / 1e9 + } + return lastTime +} + +func (s *Service) calculateBlockPosition(ctx context.Context, trip gtfsdb.Trip, effectiveTime int64, pos VehiclePosition) VehiclePosition { + blockTrips, err := s.gtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) + if err != nil { + return pos + } + + var allStopTimes []BlockStopTime + cumulativeDistance := 0.0 + + for _, blockTrip := range blockTrips { + stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) + shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) + tripDistance := s.calculateTripDistance(shapeRows) + + for i, st := range stopTimes { + arrival := st.ArrivalTime / 1e9 + if arrival == 0 { + arrival = st.DepartureTime / 1e9 + } + + stopDist := cumulativeDistance + if len(stopTimes) > 1 { + stopDist += float64(i) / float64(len(stopTimes)-1) * tripDistance + } + + allStopTimes = append(allStopTimes, BlockStopTime{ + TripID: blockTrip.ID, + StopID: st.StopID, + ArrivalTime: arrival, + Distance: stopDist, + StopSequence: int(st.StopSequence), + }) + } + + cumulativeDistance += tripDistance + } + + if len(allStopTimes) == 0 { + return pos + } + + idx := s.findStopTimeIndex(allStopTimes, effectiveTime) + + if idx < 0 { + pos.ActiveTripID = allStopTimes[0].TripID + pos.CurrentStopID = allStopTimes[0].StopID + pos.Distance = allStopTimes[0].Distance + pos.CurrentStopTimeOffset = int(allStopTimes[0].ArrivalTime - effectiveTime) + if len(allStopTimes) > 1 { + pos.NextStopID = allStopTimes[1].StopID + pos.NextStopTimeOffset = int(allStopTimes[1].ArrivalTime - effectiveTime) + } + } else if idx >= len(allStopTimes)-1 { + lastIdx := len(allStopTimes) - 1 + pos.ActiveTripID = allStopTimes[lastIdx].TripID + pos.CurrentStopID = allStopTimes[lastIdx].StopID + pos.Distance = allStopTimes[lastIdx].Distance + pos.CurrentStopTimeOffset = int(allStopTimes[lastIdx].ArrivalTime - effectiveTime) + } else { + fromStop := allStopTimes[idx] + toStop := allStopTimes[idx+1] + + fromTime := fromStop.ArrivalTime + toTime := toStop.ArrivalTime + + var ratio float64 + if toTime > fromTime { + ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) + } + + pos.Distance = fromStop.Distance + ratio*(toStop.Distance-fromStop.Distance) + pos.ActiveTripID = fromStop.TripID + pos.CurrentStopID = fromStop.StopID + pos.NextStopID = toStop.StopID + pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) + pos.NextStopTimeOffset = int(toTime - effectiveTime) + } + + return pos +} + +func (s *Service) findStopTimeIndex(allStopTimes []BlockStopTime, effectiveTime int64) int { + low, high := 0, len(allStopTimes)-1 + + for low <= high { + mid := (low + high) / 2 + if allStopTimes[mid].ArrivalTime <= effectiveTime { + low = mid + 1 + } else { + high = mid - 1 + } + } + + return high +} + +func (s *Service) calculateTripPosition(ctx context.Context, tripID string, effectiveTime int64, pos VehiclePosition) VehiclePosition { + stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + if len(stopTimes) == 0 { + return pos + } + + shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + totalDistance := s.calculateTripDistance(shapeRows) + + type stopInfo struct { + stopID string + arrivalTime int64 + distance float64 + } + stopInfos := make([]stopInfo, len(stopTimes)) + for i, st := range stopTimes { + arrival := st.ArrivalTime / 1e9 + if arrival == 0 { + arrival = st.DepartureTime / 1e9 + } + dist := 0.0 + if len(stopTimes) > 1 { + dist = float64(i) / float64(len(stopTimes)-1) * totalDistance + } + stopInfos[i] = stopInfo{ + stopID: st.StopID, + arrivalTime: arrival, + distance: dist, + } + } + + idx := -1 + for i := 0; i < len(stopInfos)-1; i++ { + if effectiveTime >= stopInfos[i].arrivalTime && effectiveTime < stopInfos[i+1].arrivalTime { + idx = i + break + } + } + + if idx < 0 { + if effectiveTime < stopInfos[0].arrivalTime { + pos.CurrentStopID = stopInfos[0].stopID + pos.Distance = stopInfos[0].distance + pos.CurrentStopTimeOffset = int(stopInfos[0].arrivalTime - effectiveTime) + if len(stopInfos) > 1 { + pos.NextStopID = stopInfos[1].stopID + pos.NextStopTimeOffset = int(stopInfos[1].arrivalTime - effectiveTime) + } + } else { + lastIdx := len(stopInfos) - 1 + pos.CurrentStopID = stopInfos[lastIdx].stopID + pos.Distance = stopInfos[lastIdx].distance + pos.CurrentStopTimeOffset = int(stopInfos[lastIdx].arrivalTime - effectiveTime) + } + } else { + fromStop := stopInfos[idx] + toStop := stopInfos[idx+1] + + fromTime := fromStop.arrivalTime + toTime := toStop.arrivalTime + + var ratio float64 + if toTime > fromTime { + ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) + } + + pos.Distance = fromStop.distance + ratio*(toStop.distance-fromStop.distance) + pos.CurrentStopID = fromStop.stopID + pos.NextStopID = toStop.stopID + pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) + pos.NextStopTimeOffset = int(toTime - effectiveTime) + } + + pos.ActiveTripID = tripID + return pos +} + +func (s *Service) GetTripDeviation(ctx context.Context, tripID string) int { + if cached := s.caches.deviation.get(tripID); cached != nil { + if dev, ok := cached.(int); ok { + return dev + } + } + + updates := s.gtfsManager.GetTripUpdatesForTrip(tripID) + if len(updates) == 0 { + return 0 + } + + tu := updates[0] + deviation := 0 + + if tu.Delay != nil { + deviation = int(tu.Delay.Seconds()) + } else { + for _, stu := range tu.StopTimeUpdates { + if stu.Arrival != nil && stu.Arrival.Delay != nil { + deviation = int(stu.Arrival.Delay.Seconds()) + break + } + } + } + + s.caches.deviation.set(tripID, deviation) + return deviation +} + +func (s *Service) isStale(timestamp *time.Time) bool { + if timestamp == nil { + return true + } + return time.Since(*timestamp) > s.config.StaleThreshold +} + +func (s *Service) calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { + distance := 0.0 + for i := 1; i < len(shapeRows); i++ { + distance += haversine(shapeRows[i-1].Lat, shapeRows[i-1].Lon, shapeRows[i].Lat, shapeRows[i].Lon) + } + return distance +} + +type BlockStopTime struct { + TripID string + StopID string + ArrivalTime int64 + Distance float64 + StopSequence int +} + +func haversine(lat1, lon1, lat2, lon2 float64) float64 { + const R = 6371000 + phi1 := lat1 * math.Pi / 180 + phi2 := lat2 * math.Pi / 180 + deltaPhi := (lat2 - lat1) * math.Pi / 180 + deltaLambda := (lon2 - lon1) * math.Pi / 180 + + a := math.Sin(deltaPhi/2)*math.Sin(deltaPhi/2) + + math.Cos(phi1)*math.Cos(phi2)* + math.Sin(deltaLambda/2)*math.Sin(deltaLambda/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return R * c +} + +type caches struct { + vehicle *syncMap + deviation *syncMap +} + +func newCaches() *caches { + return &caches{ + vehicle: &syncMap{m: make(map[string]interface{})}, + deviation: &syncMap{m: make(map[string]interface{})}, + } +} + +type syncMap struct { + sync.RWMutex + m map[string]interface{} +} + +func (sm *syncMap) get(key string) interface{} { + sm.RLock() + defer sm.RUnlock() + return sm.m[key] +} + +func (sm *syncMap) set(key string, value interface{}) { + sm.Lock() + defer sm.Unlock() + sm.m[key] = value +} From 15f5c876862aa6e15838ad800289d446fc6af6a3 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 19:13:29 -0800 Subject: [PATCH 17/94] refactor: remove deprecated schedule deviation calculation methods (moved to rt service) --- internal/restapi/trips_helper.go | 121 ------------------------------- 1 file changed, 121 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index c7c12e6c..f19e9969 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -1045,127 +1045,6 @@ func (api *RestAPI) findStopsByScheduleDeviation( return closestStopID, closestOffset, nextStopID, nextOffset } -func (api *RestAPI) calculateScheduleDeviationFromPosition( - ctx context.Context, - tripID string, - vehicle *gtfs.Vehicle, - currentTime time.Time, - gtfsRTDeviation int, -) int { - if vehicle == nil || vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { - return gtfsRTDeviation - } - - actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) - if actualDistance <= 0 { - return gtfsRTDeviation - } - - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) - if err != nil || len(stopTimes) == 0 { - return gtfsRTDeviation - } - - serviceDateUnix := utils.CalculateServiceDate(currentTime).Unix() - - var totalDistance float64 - shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - if err == nil && len(shapeRows) > 1 { - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } - cumulativeDistances := preCalculateCumulativeDistances(shapePoints) - totalDistance = cumulativeDistances[len(cumulativeDistances)-1] - } - - scheduledTimeAtPosition := api.getScheduledTimeAtDistance(actualDistance, stopTimes, totalDistance) - if scheduledTimeAtPosition < 0 { - return gtfsRTDeviation - } - - currentTimestamp := currentTime.Unix() - effectiveScheduleTime := scheduledTimeAtPosition + serviceDateUnix - deviation := int(currentTimestamp - effectiveScheduleTime) - - return deviation -} - -func (api *RestAPI) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalTripDistance float64) int64 { - if len(stopTimes) == 0 || totalTripDistance <= 0 { - return -1 - } - - hasShapeDist := false - for _, st := range stopTimes { - if st.ShapeDistTraveled.Valid && st.ShapeDistTraveled.Float64 > 0 { - hasShapeDist = true - break - } - } - - if hasShapeDist { - for i := 0; i < len(stopTimes)-1; i++ { - if !stopTimes[i].ShapeDistTraveled.Valid || !stopTimes[i+1].ShapeDistTraveled.Valid { - continue - } - - fromDist := stopTimes[i].ShapeDistTraveled.Float64 - toDist := stopTimes[i+1].ShapeDistTraveled.Float64 - - if fromDist <= distance && distance <= toDist { - fromTime := stopTimes[i].ArrivalTime / 1e9 - if fromTime == 0 { - fromTime = stopTimes[i].DepartureTime / 1e9 - } - - toTime := stopTimes[i+1].ArrivalTime / 1e9 - if toTime == 0 { - toTime = stopTimes[i+1].DepartureTime / 1e9 - } - - ratio := (distance - fromDist) / (toDist - fromDist) - scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) - return int64(scheduledTime) - } - } - } else { - totalStops := len(stopTimes) - stopSpacing := totalTripDistance / float64(totalStops-1) - - for i := 0; i < totalStops-1; i++ { - fromDist := float64(i) * stopSpacing - toDist := float64(i+1) * stopSpacing - - if fromDist <= distance && distance <= toDist { - fromTime := stopTimes[i].ArrivalTime / 1e9 - if fromTime == 0 { - fromTime = stopTimes[i].DepartureTime / 1e9 - } - - toTime := stopTimes[i+1].ArrivalTime / 1e9 - if toTime == 0 { - toTime = stopTimes[i+1].DepartureTime / 1e9 - } - - ratio := (distance - fromDist) / (toDist - fromDist) - scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) - return int64(scheduledTime) - } - } - } - - lastStop := stopTimes[len(stopTimes)-1] - scheduledTime := lastStop.ArrivalTime / 1e9 - if scheduledTime == 0 { - scheduledTime = lastStop.DepartureTime / 1e9 - } - return scheduledTime -} - func (api *RestAPI) findClosestStopBySequence( stopTimes []*gtfsdb.StopTime, currentStopSequence uint32, From 49e51cbcc129540c6e55d3e7056ac223b377a24d Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 16 Feb 2026 06:14:00 +0200 Subject: [PATCH 18/94] refactor: remove realtime service implementation --- internal/realtime/service.go | 479 ----------------------------------- 1 file changed, 479 deletions(-) delete mode 100644 internal/realtime/service.go diff --git a/internal/realtime/service.go b/internal/realtime/service.go deleted file mode 100644 index ce7860f9..00000000 --- a/internal/realtime/service.go +++ /dev/null @@ -1,479 +0,0 @@ -package realtime - -import ( - "context" - "math" - "sync" - "time" - - go_gtfs "github.com/OneBusAway/go-gtfs" - "maglev.onebusaway.org/gtfsdb" - "maglev.onebusaway.org/internal/gtfs" -) - -type Service struct { - gtfsManager *gtfs.Manager - config Config - caches *caches -} - -type Config struct { - StaleThreshold time.Duration -} - -func DefaultConfig() Config { - return Config{StaleThreshold: 15 * time.Minute} -} - -func NewService(gm *gtfs.Manager, cfg Config) *Service { - return &Service{ - gtfsManager: gm, - config: cfg, - caches: newCaches(), - } -} - -type VehiclePosition struct { - VehicleID string - TripID string - ActiveTripID string - Latitude float64 - Longitude float64 - Distance float64 - Timestamp time.Time - ScheduleDeviation int - CurrentStopID string - NextStopID string - CurrentStopTimeOffset int - NextStopTimeOffset int - IsStale bool - IsPredicted bool -} - -func (s *Service) GetVehiclePosition(ctx context.Context, vehicleID string) (VehiclePosition, error) { - vehicle, err := s.gtfsManager.GetVehicleByID(vehicleID) - if err != nil { - return VehiclePosition{}, err - } - - pos := VehiclePosition{ - VehicleID: vehicle.ID.ID, - Timestamp: time.Now(), - } - - if vehicle.Trip != nil { - pos.TripID = vehicle.Trip.ID.ID - } - - if s.isStale(vehicle.Timestamp) { - pos.IsStale = true - pos.IsPredicted = false - return pos, nil - } - - pos = s.calculateScheduledPosition(ctx, vehicle, pos) - pos.IsPredicted = true - - return pos, nil -} - -func (s *Service) calculateScheduledPosition(ctx context.Context, vehicle *go_gtfs.Vehicle, pos VehiclePosition) VehiclePosition { - if vehicle.Trip == nil { - return pos - } - - tripID := vehicle.Trip.ID.ID - - trip, err := s.gtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) - if err != nil { - return pos - } - - now := time.Now() - currentSeconds := int64(now.Hour()*3600 + now.Minute()*60 + now.Second()) - - pos.ScheduleDeviation = s.GetTripDeviation(ctx, tripID) - - if pos.ScheduleDeviation == 0 && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { - recalculatedDeviation := s.calculateDeviationFromVehiclePosition(ctx, trip, vehicle, currentSeconds) - if recalculatedDeviation != 0 { - pos.ScheduleDeviation = recalculatedDeviation - } - } - - // Java formula: effectiveTime = currentTime - deviation - // If vehicle is 573s late, effective time is 573s ago - effectiveTime := currentSeconds - int64(pos.ScheduleDeviation) - - // If trip is in a block, calculate position across entire block - if trip.BlockID.Valid { - pos = s.calculateBlockPosition(ctx, trip, effectiveTime, pos) - } else { - // Single trip - calculate position within trip - pos = s.calculateTripPosition(ctx, tripID, effectiveTime, pos) - } - - return pos -} - -func (s *Service) calculateDeviationFromVehiclePosition(ctx context.Context, trip gtfsdb.Trip, vehicle *go_gtfs.Vehicle, currentSeconds int64) int { - if vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { - return 0 - } - - lat := *vehicle.Position.Latitude - lon := *vehicle.Position.Longitude - - stopTimes, err := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) - if err != nil || len(stopTimes) == 0 { - return 0 - } - - shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, trip.ID) - totalDistance := s.calculateTripDistance(shapeRows) - - vehicleDistance := s.findDistanceAlongTripForLocation(ctx, stopTimes, float64(lat), float64(lon), totalDistance) - if vehicleDistance < 0 { - return 0 - } - - scheduledTimeAtPosition := s.getScheduledTimeAtDistance(vehicleDistance, stopTimes, totalDistance) - if scheduledTimeAtPosition < 0 { - return 0 - } - - deviation := currentSeconds - scheduledTimeAtPosition - - return int(deviation) -} - -func (s *Service) findDistanceAlongTripForLocation(ctx context.Context, stopTimes []gtfsdb.StopTime, lat, lon, totalDistance float64) float64 { - if len(stopTimes) == 0 || totalDistance <= 0 { - return -1 - } - - closestStopDistance := 0.0 - - stopIDs := make([]string, len(stopTimes)) - for i, st := range stopTimes { - stopIDs[i] = st.StopID - } - - for i := range stopTimes { - stopDistance := float64(i) / float64(len(stopTimes)-1) * totalDistance - if i == 0 { - closestStopDistance = stopDistance - } - } - - return closestStopDistance -} - -func (s *Service) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalDistance float64) int64 { - if len(stopTimes) == 0 || totalDistance <= 0 { - return -1 - } - - for i := 0; i < len(stopTimes)-1; i++ { - fromDist := float64(i) / float64(len(stopTimes)-1) * totalDistance - toDist := float64(i+1) / float64(len(stopTimes)-1) * totalDistance - - if distance >= fromDist && distance <= toDist { - fromTime := stopTimes[i].ArrivalTime / 1e9 - if fromTime == 0 { - fromTime = stopTimes[i].DepartureTime / 1e9 - } - - toTime := stopTimes[i+1].ArrivalTime / 1e9 - if toTime == 0 { - toTime = stopTimes[i+1].DepartureTime / 1e9 - } - - if toDist == fromDist { - return fromTime - } - - ratio := (distance - fromDist) / (toDist - fromDist) - return int64(float64(fromTime) + ratio*float64(toTime-fromTime)) - } - } - - lastTime := stopTimes[len(stopTimes)-1].ArrivalTime / 1e9 - if lastTime == 0 { - lastTime = stopTimes[len(stopTimes)-1].DepartureTime / 1e9 - } - return lastTime -} - -func (s *Service) calculateBlockPosition(ctx context.Context, trip gtfsdb.Trip, effectiveTime int64, pos VehiclePosition) VehiclePosition { - blockTrips, err := s.gtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ - BlockID: trip.BlockID, - ServiceIds: []string{trip.ServiceID}, - }) - if err != nil { - return pos - } - - var allStopTimes []BlockStopTime - cumulativeDistance := 0.0 - - for _, blockTrip := range blockTrips { - stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) - shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) - tripDistance := s.calculateTripDistance(shapeRows) - - for i, st := range stopTimes { - arrival := st.ArrivalTime / 1e9 - if arrival == 0 { - arrival = st.DepartureTime / 1e9 - } - - stopDist := cumulativeDistance - if len(stopTimes) > 1 { - stopDist += float64(i) / float64(len(stopTimes)-1) * tripDistance - } - - allStopTimes = append(allStopTimes, BlockStopTime{ - TripID: blockTrip.ID, - StopID: st.StopID, - ArrivalTime: arrival, - Distance: stopDist, - StopSequence: int(st.StopSequence), - }) - } - - cumulativeDistance += tripDistance - } - - if len(allStopTimes) == 0 { - return pos - } - - idx := s.findStopTimeIndex(allStopTimes, effectiveTime) - - if idx < 0 { - pos.ActiveTripID = allStopTimes[0].TripID - pos.CurrentStopID = allStopTimes[0].StopID - pos.Distance = allStopTimes[0].Distance - pos.CurrentStopTimeOffset = int(allStopTimes[0].ArrivalTime - effectiveTime) - if len(allStopTimes) > 1 { - pos.NextStopID = allStopTimes[1].StopID - pos.NextStopTimeOffset = int(allStopTimes[1].ArrivalTime - effectiveTime) - } - } else if idx >= len(allStopTimes)-1 { - lastIdx := len(allStopTimes) - 1 - pos.ActiveTripID = allStopTimes[lastIdx].TripID - pos.CurrentStopID = allStopTimes[lastIdx].StopID - pos.Distance = allStopTimes[lastIdx].Distance - pos.CurrentStopTimeOffset = int(allStopTimes[lastIdx].ArrivalTime - effectiveTime) - } else { - fromStop := allStopTimes[idx] - toStop := allStopTimes[idx+1] - - fromTime := fromStop.ArrivalTime - toTime := toStop.ArrivalTime - - var ratio float64 - if toTime > fromTime { - ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) - } - - pos.Distance = fromStop.Distance + ratio*(toStop.Distance-fromStop.Distance) - pos.ActiveTripID = fromStop.TripID - pos.CurrentStopID = fromStop.StopID - pos.NextStopID = toStop.StopID - pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) - pos.NextStopTimeOffset = int(toTime - effectiveTime) - } - - return pos -} - -func (s *Service) findStopTimeIndex(allStopTimes []BlockStopTime, effectiveTime int64) int { - low, high := 0, len(allStopTimes)-1 - - for low <= high { - mid := (low + high) / 2 - if allStopTimes[mid].ArrivalTime <= effectiveTime { - low = mid + 1 - } else { - high = mid - 1 - } - } - - return high -} - -func (s *Service) calculateTripPosition(ctx context.Context, tripID string, effectiveTime int64, pos VehiclePosition) VehiclePosition { - stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) - if len(stopTimes) == 0 { - return pos - } - - shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - totalDistance := s.calculateTripDistance(shapeRows) - - type stopInfo struct { - stopID string - arrivalTime int64 - distance float64 - } - stopInfos := make([]stopInfo, len(stopTimes)) - for i, st := range stopTimes { - arrival := st.ArrivalTime / 1e9 - if arrival == 0 { - arrival = st.DepartureTime / 1e9 - } - dist := 0.0 - if len(stopTimes) > 1 { - dist = float64(i) / float64(len(stopTimes)-1) * totalDistance - } - stopInfos[i] = stopInfo{ - stopID: st.StopID, - arrivalTime: arrival, - distance: dist, - } - } - - idx := -1 - for i := 0; i < len(stopInfos)-1; i++ { - if effectiveTime >= stopInfos[i].arrivalTime && effectiveTime < stopInfos[i+1].arrivalTime { - idx = i - break - } - } - - if idx < 0 { - if effectiveTime < stopInfos[0].arrivalTime { - pos.CurrentStopID = stopInfos[0].stopID - pos.Distance = stopInfos[0].distance - pos.CurrentStopTimeOffset = int(stopInfos[0].arrivalTime - effectiveTime) - if len(stopInfos) > 1 { - pos.NextStopID = stopInfos[1].stopID - pos.NextStopTimeOffset = int(stopInfos[1].arrivalTime - effectiveTime) - } - } else { - lastIdx := len(stopInfos) - 1 - pos.CurrentStopID = stopInfos[lastIdx].stopID - pos.Distance = stopInfos[lastIdx].distance - pos.CurrentStopTimeOffset = int(stopInfos[lastIdx].arrivalTime - effectiveTime) - } - } else { - fromStop := stopInfos[idx] - toStop := stopInfos[idx+1] - - fromTime := fromStop.arrivalTime - toTime := toStop.arrivalTime - - var ratio float64 - if toTime > fromTime { - ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) - } - - pos.Distance = fromStop.distance + ratio*(toStop.distance-fromStop.distance) - pos.CurrentStopID = fromStop.stopID - pos.NextStopID = toStop.stopID - pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) - pos.NextStopTimeOffset = int(toTime - effectiveTime) - } - - pos.ActiveTripID = tripID - return pos -} - -func (s *Service) GetTripDeviation(ctx context.Context, tripID string) int { - if cached := s.caches.deviation.get(tripID); cached != nil { - if dev, ok := cached.(int); ok { - return dev - } - } - - updates := s.gtfsManager.GetTripUpdatesForTrip(tripID) - if len(updates) == 0 { - return 0 - } - - tu := updates[0] - deviation := 0 - - if tu.Delay != nil { - deviation = int(tu.Delay.Seconds()) - } else { - for _, stu := range tu.StopTimeUpdates { - if stu.Arrival != nil && stu.Arrival.Delay != nil { - deviation = int(stu.Arrival.Delay.Seconds()) - break - } - } - } - - s.caches.deviation.set(tripID, deviation) - return deviation -} - -func (s *Service) isStale(timestamp *time.Time) bool { - if timestamp == nil { - return true - } - return time.Since(*timestamp) > s.config.StaleThreshold -} - -func (s *Service) calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { - distance := 0.0 - for i := 1; i < len(shapeRows); i++ { - distance += haversine(shapeRows[i-1].Lat, shapeRows[i-1].Lon, shapeRows[i].Lat, shapeRows[i].Lon) - } - return distance -} - -type BlockStopTime struct { - TripID string - StopID string - ArrivalTime int64 - Distance float64 - StopSequence int -} - -func haversine(lat1, lon1, lat2, lon2 float64) float64 { - const R = 6371000 - phi1 := lat1 * math.Pi / 180 - phi2 := lat2 * math.Pi / 180 - deltaPhi := (lat2 - lat1) * math.Pi / 180 - deltaLambda := (lon2 - lon1) * math.Pi / 180 - - a := math.Sin(deltaPhi/2)*math.Sin(deltaPhi/2) + - math.Cos(phi1)*math.Cos(phi2)* - math.Sin(deltaLambda/2)*math.Sin(deltaLambda/2) - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) - - return R * c -} - -type caches struct { - vehicle *syncMap - deviation *syncMap -} - -func newCaches() *caches { - return &caches{ - vehicle: &syncMap{m: make(map[string]interface{})}, - deviation: &syncMap{m: make(map[string]interface{})}, - } -} - -type syncMap struct { - sync.RWMutex - m map[string]interface{} -} - -func (sm *syncMap) get(key string) interface{} { - sm.RLock() - defer sm.RUnlock() - return sm.m[key] -} - -func (sm *syncMap) set(key string, value interface{}) { - sm.Lock() - defer sm.Unlock() - sm.m[key] = value -} From 5eb996ab5a471f3189e3f5cafdda94eed51f1a84 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 16 Feb 2026 06:14:27 +0200 Subject: [PATCH 19/94] feat: add CalculateSecondsSinceServiceDate function to compute seconds since a given service date --- internal/utils/api.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/utils/api.go b/internal/utils/api.go index 04ad94b3..6de53b6e 100644 --- a/internal/utils/api.go +++ b/internal/utils/api.go @@ -28,6 +28,11 @@ func ServiceDateMillis(explicitServiceDate *time.Time, currentTime time.Time) (t return serviceDate, serviceDate.Unix() * 1000 } +func CalculateSecondsSinceServiceDate(currentTime time.Time, serviceDate time.Time) int64 { + duration := currentTime.Sub(serviceDate) + return int64(duration.Seconds()) +} + // ExtractCodeID extracts the `code_id` from a string in the format `{agency_id}_{code_id}`. func ExtractCodeID(combinedID string) (string, error) { parts := strings.SplitN(combinedID, "_", 2) From 0b0cce2e22139477532fe17ca61c5ea879757b85 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 17 Feb 2026 02:53:23 +0200 Subject: [PATCH 20/94] refactor: clean up GetVehicleStatusAndPhase and relocate getCurrentVehicleStopSequence function --- internal/restapi/vehicles_helper.go | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 5cd88d64..4772f051 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -9,7 +9,6 @@ import ( "maglev.onebusaway.org/internal/utils" ) -// GetVehicleStatusAndPhase returns status and phase based on GTFS-RT CurrentStatus func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase string) { if vehicle == nil { return "SCHEDULED", "scheduled" @@ -75,9 +74,7 @@ func (api *RestAPI) BuildVehicleStatus( } status.Predicted = true - status.Scheduled = false - } func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { @@ -88,14 +85,6 @@ func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { return vehicle.Trip.ID.ID } -func getCurrentVehicleStopSequence(vehicle *gtfs.Vehicle) *int { - if vehicle == nil || vehicle.CurrentStopSequence == nil { - return nil - } - val := int(*vehicle.CurrentStopSequence) - return &val -} - func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, actualPos models.Location) *models.Location { shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) if err != nil || len(shapeRows) < 2 { @@ -159,3 +148,11 @@ func projectPointToSegment(px, py, x1, y1, x2, y2 float64) (float64, models.Loca dist := utils.Distance(px, py, projLat, projLon) return dist, models.Location{Lat: projLat, Lon: projLon} } + +func getCurrentVehicleStopSequence(vehicle *gtfs.Vehicle) *int { + if vehicle == nil || vehicle.CurrentStopSequence == nil { + return nil + } + val := int(*vehicle.CurrentStopSequence) + return &val +} From 7154723833144837701a669cb9c23d4630e25d69 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 17 Feb 2026 02:55:58 +0200 Subject: [PATCH 21/94] feat: implement GetScheduleDeviation and GetStopDelaysFromTripUpdates functions for trip delay information --- internal/restapi/trip_updates_helper.go | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 internal/restapi/trip_updates_helper.go diff --git a/internal/restapi/trip_updates_helper.go b/internal/restapi/trip_updates_helper.go new file mode 100644 index 00000000..c9cc1f55 --- /dev/null +++ b/internal/restapi/trip_updates_helper.go @@ -0,0 +1,59 @@ +package restapi + +type StopDelayInfo struct { + ArrivalDelay int64 + DepartureDelay int64 +} + +func (api *RestAPI) GetScheduleDeviation(tripID string) int { + tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) + if len(tripUpdates) == 0 { + return 0 + } + + tu := tripUpdates[0] + + if tu.Delay != nil { + return int(tu.Delay.Seconds()) + } + + for _, stu := range tu.StopTimeUpdates { + if stu.Arrival != nil && stu.Arrival.Delay != nil { + return int(stu.Arrival.Delay.Seconds()) + } + if stu.Departure != nil && stu.Departure.Delay != nil { + return int(stu.Departure.Delay.Seconds()) + } + } + + return 0 +} + +func (api *RestAPI) GetStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { + delays := make(map[string]StopDelayInfo) + + tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) + if len(tripUpdates) == 0 { + return delays + } + + for _, stu := range tripUpdates[0].StopTimeUpdates { + if stu.StopID == nil { + continue + } + + info := StopDelayInfo{} + if stu.Arrival != nil && stu.Arrival.Delay != nil { + info.ArrivalDelay = int64(stu.Arrival.Delay.Seconds()) + } + if stu.Departure != nil && stu.Departure.Delay != nil { + info.DepartureDelay = int64(stu.Departure.Delay.Seconds()) + } + + if info.ArrivalDelay != 0 || info.DepartureDelay != 0 { + delays[*stu.StopID] = info + } + } + + return delays +} From ec57ce94978345e388ed8fa793521474cee3d518 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 17 Feb 2026 03:05:38 +0200 Subject: [PATCH 22/94] refactor: update BuildVehicleStatus to determine predicted and scheduled status based on vehicle data --- internal/restapi/vehicles_helper.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 4772f051..2f97f325 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -73,8 +73,9 @@ func (api *RestAPI) BuildVehicleStatus( status.ActiveTripID = utils.FormCombinedID(agencyID, tripID) } - status.Predicted = true - status.Scheduled = false + hasRealtimeData := vehicle.Position != nil || vehicle.Timestamp != nil + status.Predicted = hasRealtimeData + status.Scheduled = !hasRealtimeData } func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { From c72b44777646fc5821b937974f24b7e96de9252b Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 17 Feb 2026 03:36:24 +0200 Subject: [PATCH 23/94] refactor: rewrite BuildTripStatus to use GTFS-RT trip updates directly Replace realtimeService dependency with direct GTFS-RT trip update consumption for schedule deviation and stop position resolution. Use service-date-aware time calculations via CalculateSecondsSinceServiceDate. Remove dead code (block-level position calculation, redundant schedule deviation methods, pass-through setBlockTripSequence). Propagate request --- internal/restapi/trips_helper.go | 590 +++++++++---------------------- 1 file changed, 161 insertions(+), 429 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index f19e9969..3a078287 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -18,7 +18,6 @@ func (api *RestAPI) BuildTripStatus( agencyID, tripID string, serviceDate time.Time, currentTime time.Time, - ) (*models.TripStatusForTripDetails, error) { status := &models.TripStatusForTripDetails{ ActiveTripID: utils.FormCombinedID(agencyID, tripID), @@ -29,6 +28,7 @@ func (api *RestAPI) BuildTripStatus( } vehicle := api.GtfsManager.GetVehicleForTrip(tripID) + if vehicle != nil { if vehicle.ID != nil { status.VehicleID = vehicle.ID.ID @@ -36,77 +36,67 @@ func (api *RestAPI) BuildTripStatus( if vehicle.OccupancyStatus != nil { status.OccupancyStatus = vehicle.OccupancyStatus.String() } - if vehicle.OccupancyPercentage != nil { - status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) - } api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) } - if vehicle != nil && vehicle.ID != nil { - staleDetector := NewStaleDetector() - if !staleDetector.Check(vehicle, currentTime) { - vehiclePos, err := api.realtimeService.GetVehiclePosition(ctx, vehicle.ID.ID) - if err == nil { - status.ScheduleDeviation = vehiclePos.ScheduleDeviation - status.DistanceAlongTrip = vehiclePos.Distance - status.Predicted = vehiclePos.IsPredicted - - if vehiclePos.ActiveTripID != "" { - status.ActiveTripID = utils.FormCombinedID(agencyID, vehiclePos.ActiveTripID) - } + scheduleDeviation := api.GetScheduleDeviation(tripID) + if scheduleDeviation != 0 { + status.ScheduleDeviation = scheduleDeviation + status.Predicted = true + } - if vehiclePos.CurrentStopID != "" { - status.ClosestStop = utils.FormCombinedID(agencyID, vehiclePos.CurrentStopID) - } + _, activeTripRawID, err := utils.ExtractAgencyIDAndCodeID(status.ActiveTripID) + if err != nil { + return status, err + } - if vehiclePos.NextStopID != "" { - status.NextStop = utils.FormCombinedID(agencyID, vehiclePos.NextStopID) - } + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripRawID) + if err == nil && len(stopTimes) > 0 { + stopTimesPtrs := make([]*gtfsdb.StopTime, len(stopTimes)) + for i := range stopTimes { + stopTimesPtrs[i] = &stopTimes[i] + } - status.ClosestStopTimeOffset = vehiclePos.CurrentStopTimeOffset - status.NextStopTimeOffset = vehiclePos.NextStopTimeOffset + var closestStopID, nextStopID string + var closestOffset, nextOffset int + + if vehicle != nil && vehicle.Position != nil { + if vehicle.StopID != nil && *vehicle.StopID != "" { + closestStopID = *vehicle.StopID + closestOffset = api.calculateOffsetForStop(closestStopID, stopTimesPtrs, currentTime, serviceDate, scheduleDeviation) + nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, serviceDate, scheduleDeviation) + } else if vehicle.CurrentStopSequence != nil { + closestStopID, closestOffset = api.findClosestStopBySequence( + stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, + ) + nextStopID, nextOffset = api.findNextStopBySequence( + ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, tripID, serviceDate, + ) + } else { + closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( + stopTimesPtrs, currentTime, serviceDate, scheduleDeviation, + ) } } else { - status.Predicted = false + stopDelays := api.GetStopDelaysFromTripUpdates(tripID) + closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTime, serviceDate, stopTimesPtrs, stopDelays) + nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTime, serviceDate, stopTimesPtrs, stopDelays) } - } - if status.ScheduleDeviation == 0 || status.ClosestStop == "" { - deviation := api.calculateScheduleDeviationFromTripUpdates(tripID) - if deviation != 0 { - status.ScheduleDeviation = deviation - activeTripID, closestStopID, nextStopID, distance, err := api.calculateBlockLevelPosition( - ctx, tripID, vehicle, currentTime, deviation, - ) - if err == nil { - if activeTripID != "" { - status.ActiveTripID = utils.FormCombinedID(agencyID, activeTripID) - } - if closestStopID != "" { - status.ClosestStop = utils.FormCombinedID(agencyID, closestStopID) - } - if nextStopID != "" { - status.NextStop = utils.FormCombinedID(agencyID, nextStopID) - } - if distance > 0 { - status.DistanceAlongTrip = distance - } - status.Predicted = true - } + if closestStopID != "" { + status.ClosestStop = utils.FormCombinedID(agencyID, closestStopID) + status.ClosestStopTimeOffset = closestOffset + } + if nextStopID != "" { + status.NextStop = utils.FormCombinedID(agencyID, nextStopID) + status.NextStopTimeOffset = nextOffset } - } - - blockTripSequence := api.setBlockTripSequence(ctx, tripID, serviceDate, status) - if blockTripSequence > 0 { - status.BlockTripSequence = blockTripSequence } if status.ClosestStop == "" || status.NextStop == "" { - api.fillStopsFromSchedule(ctx, status, tripID, currentTime, agencyID) + api.fillStopsFromSchedule(ctx, status, tripID, currentTime, serviceDate, agencyID) } - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, status.ActiveTripID) - shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) if shapeErr == nil && len(shapeRows) > 1 { shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) @@ -121,78 +111,21 @@ func (api *RestAPI) BuildTripStatus( if vehicle != nil && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) + status.DistanceAlongTrip = actualDistance - if err == nil { - effectiveDistance := api.calculateEffectiveDistanceAlongTrip( - actualDistance, - status.ScheduleDeviation, - currentTime, - stopTimes, - cumulativeDistances, + if scheduleDeviation != 0 && err == nil { + scheduledDistance := api.calculateEffectiveDistanceAlongTrip( + ctx, actualDistance, scheduleDeviation, currentTime, serviceDate, + stopTimes, shapePoints, cumulativeDistances, ) - status.DistanceAlongTrip = effectiveDistance - } else { - status.DistanceAlongTrip = actualDistance + status.ScheduledDistanceAlongTrip = scheduledDistance } } } - if err == nil { - stopTimesPtrs := make([]*gtfsdb.StopTime, len(stopTimes)) - for i := range stopTimes { - stopTimesPtrs[i] = &stopTimes[i] - } - - if shapeErr != nil { - shapeRows = []gtfsdb.Shape{} - } - - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } - - if status.ClosestStop == "" || status.NextStop == "" { - var closestStopID, nextStopID string - var closestOffset, nextOffset int - - if vehicle != nil && vehicle.Position != nil { - if vehicle.StopID != nil && *vehicle.StopID != "" { - closestStopID = *vehicle.StopID - closestOffset = api.calculateOffsetForStop(closestStopID, stopTimesPtrs, currentTime, status.ScheduleDeviation) - - nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, status.ScheduleDeviation) - } else if vehicle.CurrentStopSequence != nil { - closestStopID, closestOffset = api.findClosestStopBySequence( - stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, status.ScheduleDeviation, vehicle, - ) - nextStopID, nextOffset = api.findNextStopBySequence( - ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, status.ScheduleDeviation, vehicle, tripID, serviceDate, - ) - } else { - closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( - stopTimesPtrs, currentTime, status.ScheduleDeviation, - ) - } - } else { - stopDelays := api.getStopDelaysFromTripUpdates(tripID) - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) - nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTimeSeconds, stopTimesPtrs, stopDelays) - } - - if closestStopID != "" { - status.ClosestStop = utils.FormCombinedID(agencyID, closestStopID) - status.ClosestStopTimeOffset = closestOffset - } - if nextStopID != "" { - status.NextStop = utils.FormCombinedID(agencyID, nextStopID) - status.NextStopTimeOffset = nextOffset - } - } + blockTripSequence := api.calculateBlockTripSequence(ctx, tripID, serviceDate) + if blockTripSequence > 0 { + status.BlockTripSequence = blockTripSequence } return status, nil @@ -234,7 +167,6 @@ func (api *RestAPI) BuildTripSchedule(ctx context.Context, agencyID string, serv return nil, err } - // Create a map for quick stop coordinate lookup stopCoords := make(map[string]struct{ lat, lon float64 }) for _, stop := range stops { stopCoords[stop.ID] = struct{ lat, lon float64 }{lat: stop.Lat, lon: stop.Lon} @@ -296,7 +228,50 @@ func (api *RestAPI) GetNextAndPreviousTripIDs(ctx context.Context, trip *gtfsdb. return nextTripID, previousTripID, stopTimes, nil } -func findClosestStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { +func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.TripStatusForTripDetails, tripID string, currentTime time.Time, serviceDate time.Time, agencyID string) { + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + if err != nil || len(stopTimes) == 0 { + return + } + + currentSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + + for i, st := range stopTimes { + arrivalTime := st.ArrivalTime / 1e9 + if arrivalTime == 0 { + arrivalTime = st.DepartureTime / 1e9 + } + + predictedArrival := arrivalTime + int64(status.ScheduleDeviation) + + if predictedArrival > currentSeconds { + if i > 0 { + status.ClosestStop = utils.FormCombinedID(agencyID, stopTimes[i-1].StopID) + closestArrival := stopTimes[i-1].ArrivalTime / 1e9 + if closestArrival == 0 { + closestArrival = stopTimes[i-1].DepartureTime / 1e9 + } + status.ClosestStopTimeOffset = int(closestArrival + int64(status.ScheduleDeviation) - currentSeconds) + } + status.NextStop = utils.FormCombinedID(agencyID, st.StopID) + status.NextStopTimeOffset = int(predictedArrival - currentSeconds) + return + } + } + + if len(stopTimes) > 0 { + lastStop := stopTimes[len(stopTimes)-1] + status.ClosestStop = utils.FormCombinedID(agencyID, lastStop.StopID) + arrivalTime := lastStop.ArrivalTime / 1e9 + if arrivalTime == 0 { + arrivalTime = lastStop.DepartureTime / 1e9 + } + status.ClosestStopTimeOffset = int(arrivalTime + int64(status.ScheduleDeviation) - currentSeconds) + } +} + +func findClosestStopByTimeWithDelays(currentTime time.Time, serviceDate time.Time, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) var minTimeDiff int64 = math.MaxInt64 var closestStopTimeSeconds int64 @@ -335,7 +310,8 @@ func findClosestStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfs return } -func findNextStopByTimeWithDelays(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { +func findNextStopByTimeWithDelays(currentTime time.Time, serviceDate time.Time, stopTimes []*gtfsdb.StopTime, stopDelays map[string]StopDelayInfo) (stopID string, offset int) { + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) var minTimeDiff int64 = math.MaxInt64 var nextStopTimeSeconds int64 @@ -427,7 +403,6 @@ func getDistanceAlongShapeInRange(lat, lon float64, shape []gtfs.ShapePoint, min return bestDist } -// IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) setBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time, status *models.TripStatusForTripDetails) int { return api.calculateBlockTripSequence(ctx, tripID, serviceDate) } @@ -435,7 +410,6 @@ func (api *RestAPI) setBlockTripSequence(ctx context.Context, tripID string, ser // calculateBlockTripSequence calculates the index of a trip within its block's ordered trip sequence // for trips that are active on the given service date. // Uses GetTripsByBlockIDOrdered to perform a single SQL JOIN instead of N+1 queries. -// IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time) int { blockID, err := api.GtfsManager.GtfsDB.Queries.GetBlockIDByTripID(ctx, tripID) @@ -546,11 +520,6 @@ func (api *RestAPI) calculateScheduleDeviationFromTripUpdates( return int(bestDeviation) } -type StopDelayInfo struct { - ArrivalDelay int64 - DepartureDelay int64 -} - func (api *RestAPI) getStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { delays := make(map[string]StopDelayInfo) @@ -638,7 +607,6 @@ func (api *RestAPI) calculatePreciseDistanceAlongTripWithCoords( // calculatePreciseDistanceAlongTrip is the legacy version that fetches stop coordinates from the database // Deprecated: Use calculatePreciseDistanceAlongTripWithCoords with batch-fetched coordinates instead -// IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) calculatePreciseDistanceAlongTrip(ctx context.Context, stopID string, shapePoints []gtfs.ShapePoint) float64 { if len(shapePoints) == 0 { return 0.0 @@ -677,112 +645,6 @@ func preCalculateCumulativeDistances(shapePoints []gtfs.ShapePoint) []float64 { return cumulativeDistances } -// calculateBatchStopDistances calculates distances for the entire trip using Monotonic Search (O(N+M)) -func (api *RestAPI) calculateBatchStopDistances( - timeStops []gtfsdb.StopTime, - shapePoints []gtfs.ShapePoint, - stopCoords map[string]struct{ lat, lon float64 }, - agencyID string, -) []models.StopTime { - - stopTimesList := make([]models.StopTime, 0, len(timeStops)) - - if len(shapePoints) < 2 { - for _, stopTime := range timeStops { - stopTimesList = append(stopTimesList, models.StopTime{ - StopID: utils.FormCombinedID(agencyID, stopTime.StopID), - ArrivalTime: int(stopTime.ArrivalTime / 1e9), - DepartureTime: int(stopTime.DepartureTime / 1e9), - StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), - DistanceAlongTrip: 0.0, - HistoricalOccupancy: "", - }) - } - return stopTimesList - } - - // Pre-calculate cumulative distances - cumulativeDistances := preCalculateCumulativeDistances(shapePoints) - if len(cumulativeDistances) != len(shapePoints) { - for _, stopTime := range timeStops { - stopTimesList = append(stopTimesList, models.StopTime{ - StopID: utils.FormCombinedID(agencyID, stopTime.StopID), - ArrivalTime: int(stopTime.ArrivalTime / 1e9), - DepartureTime: int(stopTime.DepartureTime / 1e9), - StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), - DistanceAlongTrip: 0.0, - HistoricalOccupancy: "", - }) - } - return stopTimesList - } - - lastMatchedIndex := 0 - - for _, stopTime := range timeStops { - var distanceAlongTrip float64 - - // Only calculate if we have valid coordinates - if coords, exists := stopCoords[stopTime.StopID]; exists { - stopLat := coords.lat - stopLon := coords.lon - - // ensure lastMatchedIndex didn't go out of bounds - if lastMatchedIndex >= len(shapePoints)-1 { - lastMatchedIndex = len(shapePoints) - 2 - } - - var minDistance = math.Inf(1) - var closestSegmentIndex = lastMatchedIndex - var projectionRatio float64 - - // Early exit threshold to speed up search - //This may be too conservative for some cases but helps performance significantly - const earlyExitThresholdMeters = 100.0 - - // Start from lastMatchedIndex - for i := lastMatchedIndex; i < len(shapePoints)-1; i++ { - distance, ratio := distanceToLineSegment( - stopLat, stopLon, - shapePoints[i].Latitude, shapePoints[i].Longitude, - shapePoints[i+1].Latitude, shapePoints[i+1].Longitude, - ) - - if distance < minDistance { - minDistance = distance - closestSegmentIndex = i - projectionRatio = ratio - lastMatchedIndex = i - } else if distance > minDistance+earlyExitThresholdMeters { - // Early exit: - break - } - } - - // Calculate distance along trip - cumulativeDistance := cumulativeDistances[closestSegmentIndex] - if closestSegmentIndex < len(shapePoints)-1 { - segmentDistance := utils.Distance( - shapePoints[closestSegmentIndex].Latitude, shapePoints[closestSegmentIndex].Longitude, - shapePoints[closestSegmentIndex+1].Latitude, shapePoints[closestSegmentIndex+1].Longitude, - ) - cumulativeDistance += segmentDistance * projectionRatio - } - distanceAlongTrip = cumulativeDistance - } - - stopTimesList = append(stopTimesList, models.StopTime{ - StopID: utils.FormCombinedID(agencyID, stopTime.StopID), - ArrivalTime: int(stopTime.ArrivalTime / 1e9), - DepartureTime: int(stopTime.DepartureTime / 1e9), - StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), - DistanceAlongTrip: distanceAlongTrip, - HistoricalOccupancy: "", - }) - } - return stopTimesList -} - // Helper function to calculate distance from point to line segment func distanceToLineSegment(px, py, x1, y1, x2, y2 float64) (distance, ratio float64) { dx := x2 - x1 @@ -873,23 +735,6 @@ func (r *TripAgencyResolver) GetAgencyNameByTripID(tripID string) string { return agency } -func (api *RestAPI) calculateEffectiveDistanceAlongTrip( - actualDistance float64, - scheduleDeviation int, - currentTime time.Time, - stopTimes []gtfsdb.StopTime, - cumulativeDistances []float64, -) float64 { - if scheduleDeviation == 0 || len(stopTimes) == 0 { - return actualDistance - } - - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) - - return api.interpolateDistanceAtScheduledTime(effectiveScheduleTime, stopTimes, cumulativeDistances) -} - func (api *RestAPI) interpolateDistanceAtScheduledTime( scheduledTime int64, stopTimes []gtfsdb.StopTime, @@ -931,9 +776,10 @@ func (api *RestAPI) calculateOffsetForStop( stopID string, stopTimes []*gtfsdb.StopTime, currentTime time.Time, + serviceDate time.Time, scheduleDeviation int, ) int { - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) for _, st := range stopTimes { if st.StopID == stopID { @@ -953,13 +799,14 @@ func (api *RestAPI) findNextStopAfter( currentStopID string, stopTimes []*gtfsdb.StopTime, currentTime time.Time, + serviceDate time.Time, scheduleDeviation int, ) (stopID string, offset int) { if len(stopTimes) == 0 { return "", 0 } - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) for i, st := range stopTimes { if st.StopID == currentStopID { @@ -982,14 +829,14 @@ func (api *RestAPI) findNextStopAfter( func (api *RestAPI) findStopsByScheduleDeviation( stopTimes []*gtfsdb.StopTime, currentTime time.Time, + serviceDate time.Time, scheduleDeviation int, ) (closestStopID string, closestOffset int, nextStopID string, nextOffset int) { if len(stopTimes) == 0 { return "", 0, "", 0 } - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) var closestStop *gtfsdb.StopTime @@ -1049,31 +896,20 @@ func (api *RestAPI) findClosestStopBySequence( stopTimes []*gtfsdb.StopTime, currentStopSequence uint32, currentTime time.Time, + serviceDate time.Time, scheduleDeviation int, vehicle *gtfs.Vehicle, ) (stopID string, offset int) { - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) for _, st := range stopTimes { if uint32(st.StopSequence) == currentStopSequence { - isAtCurrentStop := vehicle != nil && vehicle.CurrentStatus != nil && - *vehicle.CurrentStatus == gtfs.CurrentStatus(1) - - var closestStop *gtfsdb.StopTime - if isAtCurrentStop { - closestStop = st - } else { - closestStop = st - } - - if closestStop != nil { - stopTimeSeconds := closestStop.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = closestStop.DepartureTime / 1e9 - } - predictedArrival := stopTimeSeconds + int64(scheduleDeviation) - return closestStop.StopID, int(predictedArrival - currentTimeSeconds) + stopTimeSeconds := st.ArrivalTime / 1e9 + if stopTimeSeconds == 0 { + stopTimeSeconds = st.DepartureTime / 1e9 } + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return st.StopID, int(predictedArrival - currentTimeSeconds) } } @@ -1085,12 +921,13 @@ func (api *RestAPI) findNextStopBySequence( stopTimes []*gtfsdb.StopTime, currentStopSequence uint32, currentTime time.Time, + serviceDate time.Time, scheduleDeviation int, vehicle *gtfs.Vehicle, tripID string, - serviceDate time.Time, + serviceDateForBlock time.Time, ) (stopID string, offset int) { - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) isAtCurrentStop := vehicle != nil && vehicle.CurrentStatus != nil && *vehicle.CurrentStatus == gtfs.CurrentStatus(1) @@ -1103,7 +940,7 @@ func (api *RestAPI) findNextStopBySequence( if i+1 < len(stopTimes) { nextStop = stopTimes[i+1] } else { - nextStop = api.getFirstStopOfNextTripInBlock(ctx, tripID, serviceDate) + nextStop = api.getFirstStopOfNextTripInBlock(ctx, tripID, serviceDateForBlock) } } else { nextStop = st @@ -1156,174 +993,69 @@ func (api *RestAPI) getFirstStopOfNextTripInBlock(ctx context.Context, currentTr return nil } -func (api *RestAPI) calculateBlockLevelPosition( +func (api *RestAPI) calculateEffectiveDistanceAlongTrip( ctx context.Context, - tripID string, - vehicle *gtfs.Vehicle, - currentTime time.Time, + actualDistance float64, scheduleDeviation int, -) (activeTripID string, closestStopID string, nextStopID string, distanceAlongTrip float64, err error) { - trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) - if err != nil { - return tripID, "", "", 0, err - } - - if !trip.BlockID.Valid { - return tripID, "", "", 0, nil - } - - year, month, day := currentTime.Date() - serviceDate := time.Date(year, month, day, 0, 0, 0, 0, currentTime.Location()) - serviceDateUnix := serviceDate.Unix() - - currentTimestamp := currentTime.Unix() - effectiveScheduledTime := currentTimestamp - int64(scheduleDeviation) - effectiveTimeFromMidnight := effectiveScheduledTime - serviceDateUnix - - blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ - BlockID: trip.BlockID, - ServiceIds: []string{trip.ServiceID}, - }) - if err != nil { - return tripID, "", "", 0, err - } - - var blockStopTimes []BlockStopTimeInfo - var cumulativeDistance float64 - - for _, blockTrip := range blockTrips { - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) - if err != nil { - continue - } - - shapeRows, _ := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) - tripDistance := calculateTripDistance(shapeRows) - - for i, st := range stopTimes { - arrivalTime := st.ArrivalTime / 1e9 - if arrivalTime == 0 { - arrivalTime = st.DepartureTime / 1e9 - } - - var stopDistance float64 - if len(stopTimes) > 1 { - stopDistance = cumulativeDistance + (float64(i) / float64(len(stopTimes)-1) * tripDistance) - } else { - stopDistance = cumulativeDistance - } - - blockStopTimes = append(blockStopTimes, BlockStopTimeInfo{ - TripID: blockTrip.ID, - StopID: st.StopID, - ArrivalTime: arrivalTime, - StopSequence: int(st.StopSequence), - Distance: stopDistance, - IsFirstInTrip: i == 0, - IsLastInTrip: i == len(stopTimes)-1, - }) - } - - cumulativeDistance += tripDistance - } - - if len(blockStopTimes) == 0 { - return tripID, "", "", 0, nil + currentTime time.Time, + serviceDate time.Time, + stopTimes []gtfsdb.StopTime, + shapePoints []gtfs.ShapePoint, + cumulativeDistances []float64, +) float64 { + if scheduleDeviation == 0 || len(stopTimes) == 0 { + return actualDistance } - closestIndex := -1 - var minTimeDiff int64 = math.MaxInt64 - - for i, bst := range blockStopTimes { - timeDiff := bst.ArrivalTime - effectiveTimeFromMidnight - if timeDiff < 0 { - timeDiff = -timeDiff - } - if timeDiff < minTimeDiff { - minTimeDiff = timeDiff - closestIndex = i + stopDistances := make([]float64, len(stopTimes)) + for i, st := range stopTimes { + stop, err := api.GtfsManager.GtfsDB.Queries.GetStop(ctx, st.StopID) + if err == nil { + stopDistances[i] = api.calculatePreciseDistanceAlongTripWithCoords( + stop.Lat, stop.Lon, shapePoints, cumulativeDistances, + ) } } - if closestIndex < 0 { - return tripID, "", "", 0, nil - } - - activeTripID = blockStopTimes[closestIndex].TripID - closestStopID = blockStopTimes[closestIndex].StopID - distanceAlongTrip = blockStopTimes[closestIndex].Distance - - if closestIndex+1 < len(blockStopTimes) { - nextStopID = blockStopTimes[closestIndex+1].StopID - } - - return activeTripID, closestStopID, nextStopID, distanceAlongTrip, nil -} + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) -type BlockStopTimeInfo struct { - TripID string - StopID string - ArrivalTime int64 - StopSequence int - Distance float64 - IsFirstInTrip bool - IsLastInTrip bool + return interpolateDistanceAtScheduledTime(effectiveScheduleTime, stopTimes, stopDistances) } -func calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { - if len(shapeRows) < 2 { +func interpolateDistanceAtScheduledTime( + scheduledTime int64, + stopTimes []gtfsdb.StopTime, + cumulativeDistances []float64, +) float64 { + if len(stopTimes) == 0 { return 0 } - totalDistance := 0.0 - for i := 1; i < len(shapeRows); i++ { - dist := utils.Distance( - shapeRows[i-1].Lat, shapeRows[i-1].Lon, - shapeRows[i].Lat, shapeRows[i].Lon, - ) - totalDistance += dist - } - return totalDistance -} + for i := 0; i < len(stopTimes)-1; i++ { + fromStop := stopTimes[i] + toStop := stopTimes[i+1] -func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.TripStatusForTripDetails, tripID string, currentTime time.Time, agencyID string) { - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) - if err != nil || len(stopTimes) == 0 { - return - } + fromTime := fromStop.DepartureTime / 1e9 + toTime := toStop.ArrivalTime / 1e9 - currentSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) + if scheduledTime >= fromTime && scheduledTime <= toTime { + if toTime == fromTime { + return cumulativeDistances[i] + } - for i, st := range stopTimes { - arrivalTime := st.ArrivalTime / 1e9 - if arrivalTime == 0 { - arrivalTime = st.DepartureTime / 1e9 - } + timeRatio := float64(scheduledTime-fromTime) / float64(toTime-fromTime) - predictedArrival := arrivalTime + int64(status.ScheduleDeviation) + fromDistance := cumulativeDistances[i*len(cumulativeDistances)/len(stopTimes)] + toDistance := cumulativeDistances[(i+1)*len(cumulativeDistances)/len(stopTimes)] - if predictedArrival > currentSeconds { - if i > 0 { - status.ClosestStop = utils.FormCombinedID(agencyID, stopTimes[i-1].StopID) - closestArrival := stopTimes[i-1].ArrivalTime / 1e9 - if closestArrival == 0 { - closestArrival = stopTimes[i-1].DepartureTime / 1e9 - } - status.ClosestStopTimeOffset = int(closestArrival + int64(status.ScheduleDeviation) - currentSeconds) - } - status.NextStop = utils.FormCombinedID(agencyID, st.StopID) - status.NextStopTimeOffset = int(predictedArrival - currentSeconds) - return + return fromDistance + timeRatio*(toDistance-fromDistance) } } - if len(stopTimes) > 0 { - lastStop := stopTimes[len(stopTimes)-1] - status.ClosestStop = utils.FormCombinedID(agencyID, lastStop.StopID) - arrivalTime := lastStop.ArrivalTime / 1e9 - if arrivalTime == 0 { - arrivalTime = lastStop.DepartureTime / 1e9 - } - status.ClosestStopTimeOffset = int(arrivalTime + int64(status.ScheduleDeviation) - currentSeconds) + if scheduledTime < stopTimes[0].ArrivalTime/1e9 { + return 0 } + + return cumulativeDistances[len(cumulativeDistances)-1] } From d12c181bf1aecc4bbeae1518c02aed0bdb5af2a7 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sat, 24 Jan 2026 02:56:44 +0200 Subject: [PATCH 24/94] feat: improve trip status calculations with real-time stop delays + rt status --- internal/restapi/trips_helper.go | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 3a078287..6a7ae69e 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -826,6 +826,111 @@ func (api *RestAPI) findNextStopAfter( return "", 0 } +func (api *RestAPI) calculateBatchStopDistances( + timeStops []gtfsdb.StopTime, + shapePoints []gtfs.ShapePoint, + stopCoords map[string]struct{ lat, lon float64 }, + agencyID string, +) []models.StopTime { + + stopTimesList := make([]models.StopTime, 0, len(timeStops)) + + if len(shapePoints) < 2 { + for _, stopTime := range timeStops { + stopTimesList = append(stopTimesList, models.StopTime{ + StopID: utils.FormCombinedID(agencyID, stopTime.StopID), + ArrivalTime: int(stopTime.ArrivalTime / 1e9), + DepartureTime: int(stopTime.DepartureTime / 1e9), + StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), + DistanceAlongTrip: 0.0, + HistoricalOccupancy: "", + }) + } + return stopTimesList + } + + // Pre-calculate cumulative distances + cumulativeDistances := preCalculateCumulativeDistances(shapePoints) + if len(cumulativeDistances) != len(shapePoints) { + for _, stopTime := range timeStops { + stopTimesList = append(stopTimesList, models.StopTime{ + StopID: utils.FormCombinedID(agencyID, stopTime.StopID), + ArrivalTime: int(stopTime.ArrivalTime / 1e9), + DepartureTime: int(stopTime.DepartureTime / 1e9), + StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), + DistanceAlongTrip: 0.0, + HistoricalOccupancy: "", + }) + } + return stopTimesList + } + + lastMatchedIndex := 0 + + for _, stopTime := range timeStops { + var distanceAlongTrip float64 + + // Only calculate if we have valid coordinates + if coords, exists := stopCoords[stopTime.StopID]; exists { + stopLat := coords.lat + stopLon := coords.lon + + // ensure lastMatchedIndex didn't go out of bounds + if lastMatchedIndex >= len(shapePoints)-1 { + lastMatchedIndex = len(shapePoints) - 2 + } + + var minDistance = math.Inf(1) + var closestSegmentIndex = lastMatchedIndex + var projectionRatio float64 + + // Early exit threshold to speed up search + //This may be too conservative for some cases but helps performance significantly + const earlyExitThresholdMeters = 100.0 + + // Start from lastMatchedIndex + for i := lastMatchedIndex; i < len(shapePoints)-1; i++ { + distance, ratio := distanceToLineSegment( + stopLat, stopLon, + shapePoints[i].Latitude, shapePoints[i].Longitude, + shapePoints[i+1].Latitude, shapePoints[i+1].Longitude, + ) + + if distance < minDistance { + minDistance = distance + closestSegmentIndex = i + projectionRatio = ratio + lastMatchedIndex = i + } else if distance > minDistance+earlyExitThresholdMeters { + // Early exit: + break + } + } + + // Calculate distance along trip + cumulativeDistance := cumulativeDistances[closestSegmentIndex] + if closestSegmentIndex < len(shapePoints)-1 { + segmentDistance := utils.Distance( + shapePoints[closestSegmentIndex].Latitude, shapePoints[closestSegmentIndex].Longitude, + shapePoints[closestSegmentIndex+1].Latitude, shapePoints[closestSegmentIndex+1].Longitude, + ) + cumulativeDistance += segmentDistance * projectionRatio + } + distanceAlongTrip = cumulativeDistance + } + + stopTimesList = append(stopTimesList, models.StopTime{ + StopID: utils.FormCombinedID(agencyID, stopTime.StopID), + ArrivalTime: int(stopTime.ArrivalTime / 1e9), + DepartureTime: int(stopTime.DepartureTime / 1e9), + StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), + DistanceAlongTrip: distanceAlongTrip, + HistoricalOccupancy: "", + }) + } + return stopTimesList +} + func (api *RestAPI) findStopsByScheduleDeviation( stopTimes []*gtfsdb.StopTime, currentTime time.Time, From f079af6ef102db77cb2fe8267771ef64a0a0b2f7 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:13:43 -0800 Subject: [PATCH 25/94] feat: enhance vehicle status handling and add position projection onto route --- internal/restapi/vehicles_helper.go | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 2f97f325..7d2a7959 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -157,3 +157,67 @@ func getCurrentVehicleStopSequence(vehicle *gtfs.Vehicle) *int { val := int(*vehicle.CurrentStopSequence) return &val } + +func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, actualPos models.Location) *models.Location { + shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + if err != nil || len(shapeRows) < 2 { + return nil + } + + shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) + for i, sp := range shapeRows { + shapePoints[i] = gtfs.ShapePoint{ + Latitude: sp.Lat, + Longitude: sp.Lon, + } + } + + minDistance := math.MaxFloat64 + var closestPoint models.Location + + for i := 0; i < len(shapePoints)-1; i++ { + distance, projectedPoint := projectPointToSegment( + actualPos.Lat, actualPos.Lon, + shapePoints[i].Latitude, shapePoints[i].Longitude, + shapePoints[i+1].Latitude, shapePoints[i+1].Longitude, + ) + + if distance < minDistance { + minDistance = distance + closestPoint = projectedPoint + } + } + + if minDistance <= 200 { + return &closestPoint + } + + return nil +} + +func projectPointToSegment(px, py, x1, y1, x2, y2 float64) (float64, models.Location) { + dx := x2 - x1 + dy := y2 - y1 + + if dx == 0 && dy == 0 { + dist := utils.Distance(px, py, x1, y1) + return dist, models.Location{Lat: x1, Lon: y1} + } + + t := ((px-x1)*dx + (py-y1)*dy) / (dx*dx + dy*dy) + + if t < 0 { + dist := utils.Distance(px, py, x1, y1) + return dist, models.Location{Lat: x1, Lon: y1} + } + if t > 1 { + dist := utils.Distance(px, py, x2, y2) + return dist, models.Location{Lat: x2, Lon: y2} + } + + projLat := x1 + t*dx + projLon := y1 + t*dy + + dist := utils.Distance(px, py, projLat, projLon) + return dist, models.Location{Lat: projLat, Lon: projLon} +} From cfe46d907ba7720ea7a608dfc21a2f7a6bb8bdcf Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 14:25:13 -0800 Subject: [PATCH 26/94] feat: improve trip status calculation with block-level position and effective distance handling --- internal/restapi/trip_details_handler.go | 5 - internal/restapi/trips_helper.go | 325 ++++++++++++++++++----- internal/restapi/vehicles_helper.go | 64 ----- 3 files changed, 262 insertions(+), 132 deletions(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 6fdb15de..7b7c2462 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -365,11 +365,6 @@ func (api *RestAPI) buildStopReferences(ctx context.Context, calc *GTFS.Advanced continue } - direction := models.UnknownValue - if stop.Direction.Valid && stop.Direction.String != "" { - direction = stop.Direction.String - } - combinedRouteIDs := routeIDsByStop[originalStopID] stopModel := models.Stop{ diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 6a7ae69e..a314db08 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -3,7 +3,6 @@ package restapi import ( "context" "math" - "sort" "time" "github.com/OneBusAway/go-gtfs" @@ -411,75 +410,24 @@ func (api *RestAPI) setBlockTripSequence(ctx context.Context, tripID string, ser // for trips that are active on the given service date. // Uses GetTripsByBlockIDOrdered to perform a single SQL JOIN instead of N+1 queries. func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time) int { - blockID, err := api.GtfsManager.GtfsDB.Queries.GetBlockIDByTripID(ctx, tripID) - - if err != nil || !blockID.Valid || blockID.String == "" { - return 0 - } - - blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockID(ctx, blockID) - if err != nil || len(blockTrips) == 0 { + // Get the trip to find both block_id and service_id in one query + trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) + if err != nil { return 0 } - tripIDs := make([]string, len(blockTrips)) - for i, bt := range blockTrips { - tripIDs[i] = bt.ID - } - - allStopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTripIDs(ctx, tripIDs) + // Use the optimized query that JOINs trips with stop_times in SQL, + // ordered by first departure time — replaces N+1 queries + orderedTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) if err != nil { return 0 } - stopTimesByTrip := make(map[string][]gtfsdb.StopTime) - for _, st := range allStopTimes { - stopTimesByTrip[st.TripID] = append(stopTimesByTrip[st.TripID], st) - } - - type TripWithDetails struct { - TripID string - StartTime int - } - - activeTrips := []TripWithDetails{} - - for _, blockTrip := range blockTrips { - isActive, err := api.GtfsManager.IsServiceActiveOnDate(ctx, blockTrip.ServiceID, serviceDate) - if err != nil || isActive == 0 { - continue - } - - stopTimes, exists := stopTimesByTrip[blockTrip.ID] - if !exists || len(stopTimes) == 0 { - continue - } - - startTime := math.MaxInt - for _, st := range stopTimes { - if st.DepartureTime > 0 && int(st.DepartureTime) < startTime { - startTime = int(st.DepartureTime) - } - } - - if startTime != math.MaxInt { - activeTrips = append(activeTrips, TripWithDetails{ - TripID: blockTrip.ID, - StartTime: startTime, - }) - } - } - - // Third, sort trips by start time, then by trip ID for deterministic ordering - sort.Slice(activeTrips, func(i, j int) bool { - if activeTrips[i].StartTime != activeTrips[j].StartTime { - return activeTrips[i].StartTime < activeTrips[j].StartTime - } - return activeTrips[i].TripID < activeTrips[j].TripID - }) - - for i, trip := range activeTrips { - if trip.TripID == tripID { + for i, t := range orderedTrips { + if t.ID == tripID { return i } } @@ -1164,3 +1112,254 @@ func interpolateDistanceAtScheduledTime( return cumulativeDistances[len(cumulativeDistances)-1] } + +func (api *RestAPI) calculateScheduleDeviationFromPosition( + ctx context.Context, + tripID string, + vehicle *gtfs.Vehicle, + currentTime time.Time, + gtfsRTDeviation int, +) int { + if vehicle == nil || vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { + return gtfsRTDeviation + } + + actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) + if actualDistance <= 0 { + return gtfsRTDeviation + } + + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + if err != nil || len(stopTimes) == 0 { + return gtfsRTDeviation + } + + serviceDateUnix := utils.CalculateServiceDate(currentTime).Unix() + + var totalDistance float64 + shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + if err == nil && len(shapeRows) > 1 { + shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) + for i, sp := range shapeRows { + shapePoints[i] = gtfs.ShapePoint{ + Latitude: sp.Lat, + Longitude: sp.Lon, + } + } + cumulativeDistances := preCalculateCumulativeDistances(shapePoints) + totalDistance = cumulativeDistances[len(cumulativeDistances)-1] + } + + scheduledTimeAtPosition := api.getScheduledTimeAtDistance(actualDistance, stopTimes, totalDistance) + if scheduledTimeAtPosition < 0 { + return gtfsRTDeviation + } + + currentTimestamp := currentTime.Unix() + effectiveScheduleTime := scheduledTimeAtPosition + serviceDateUnix + deviation := int(currentTimestamp - effectiveScheduleTime) + + return deviation +} + +func (api *RestAPI) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalTripDistance float64) int64 { + if len(stopTimes) == 0 || totalTripDistance <= 0 { + return -1 + } + + hasShapeDist := false + for _, st := range stopTimes { + if st.ShapeDistTraveled.Valid && st.ShapeDistTraveled.Float64 > 0 { + hasShapeDist = true + break + } + } + + if hasShapeDist { + for i := 0; i < len(stopTimes)-1; i++ { + if !stopTimes[i].ShapeDistTraveled.Valid || !stopTimes[i+1].ShapeDistTraveled.Valid { + continue + } + + fromDist := stopTimes[i].ShapeDistTraveled.Float64 + toDist := stopTimes[i+1].ShapeDistTraveled.Float64 + + if fromDist <= distance && distance <= toDist { + fromTime := stopTimes[i].ArrivalTime / 1e9 + if fromTime == 0 { + fromTime = stopTimes[i].DepartureTime / 1e9 + } + + toTime := stopTimes[i+1].ArrivalTime / 1e9 + if toTime == 0 { + toTime = stopTimes[i+1].DepartureTime / 1e9 + } + + ratio := (distance - fromDist) / (toDist - fromDist) + scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) + return int64(scheduledTime) + } + } + } else { + totalStops := len(stopTimes) + stopSpacing := totalTripDistance / float64(totalStops-1) + + for i := 0; i < totalStops-1; i++ { + fromDist := float64(i) * stopSpacing + toDist := float64(i+1) * stopSpacing + + if fromDist <= distance && distance <= toDist { + fromTime := stopTimes[i].ArrivalTime / 1e9 + if fromTime == 0 { + fromTime = stopTimes[i].DepartureTime / 1e9 + } + + toTime := stopTimes[i+1].ArrivalTime / 1e9 + if toTime == 0 { + toTime = stopTimes[i+1].DepartureTime / 1e9 + } + + ratio := (distance - fromDist) / (toDist - fromDist) + scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) + return int64(scheduledTime) + } + } + } + + lastStop := stopTimes[len(stopTimes)-1] + scheduledTime := lastStop.ArrivalTime / 1e9 + if scheduledTime == 0 { + scheduledTime = lastStop.DepartureTime / 1e9 + } + return scheduledTime +} + +func (api *RestAPI) calculateBlockLevelPosition( + ctx context.Context, + tripID string, + vehicle *gtfs.Vehicle, + currentTime time.Time, + scheduleDeviation int, +) (activeTripID string, closestStopID string, nextStopID string, distanceAlongTrip float64, err error) { + trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) + if err != nil { + return tripID, "", "", 0, err + } + + if !trip.BlockID.Valid { + return tripID, "", "", 0, nil + } + + year, month, day := currentTime.Date() + serviceDate := time.Date(year, month, day, 0, 0, 0, 0, currentTime.Location()) + serviceDateUnix := serviceDate.Unix() + + currentTimestamp := currentTime.Unix() + effectiveScheduledTime := currentTimestamp - int64(scheduleDeviation) + effectiveTimeFromMidnight := effectiveScheduledTime - serviceDateUnix + + blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) + if err != nil { + return tripID, "", "", 0, err + } + + var blockStopTimes []BlockStopTimeInfo + var cumulativeDistance float64 + + for _, blockTrip := range blockTrips { + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) + if err != nil { + continue + } + + shapeRows, _ := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) + tripDistance := calculateTripDistance(shapeRows) + + for i, st := range stopTimes { + arrivalTime := st.ArrivalTime / 1e9 + if arrivalTime == 0 { + arrivalTime = st.DepartureTime / 1e9 + } + + var stopDistance float64 + if len(stopTimes) > 1 { + stopDistance = cumulativeDistance + (float64(i) / float64(len(stopTimes)-1) * tripDistance) + } else { + stopDistance = cumulativeDistance + } + + blockStopTimes = append(blockStopTimes, BlockStopTimeInfo{ + TripID: blockTrip.ID, + StopID: st.StopID, + ArrivalTime: arrivalTime, + StopSequence: int(st.StopSequence), + Distance: stopDistance, + IsFirstInTrip: i == 0, + IsLastInTrip: i == len(stopTimes)-1, + }) + } + + cumulativeDistance += tripDistance + } + + if len(blockStopTimes) == 0 { + return tripID, "", "", 0, nil + } + + closestIndex := -1 + var minTimeDiff int64 = math.MaxInt64 + + for i, bst := range blockStopTimes { + timeDiff := bst.ArrivalTime - effectiveTimeFromMidnight + if timeDiff < 0 { + timeDiff = -timeDiff + } + if timeDiff < minTimeDiff { + minTimeDiff = timeDiff + closestIndex = i + } + } + + if closestIndex < 0 { + return tripID, "", "", 0, nil + } + + activeTripID = blockStopTimes[closestIndex].TripID + closestStopID = blockStopTimes[closestIndex].StopID + distanceAlongTrip = blockStopTimes[closestIndex].Distance + + if closestIndex+1 < len(blockStopTimes) { + nextStopID = blockStopTimes[closestIndex+1].StopID + } + + return activeTripID, closestStopID, nextStopID, distanceAlongTrip, nil +} + +type BlockStopTimeInfo struct { + TripID string + StopID string + ArrivalTime int64 + StopSequence int + Distance float64 + IsFirstInTrip bool + IsLastInTrip bool +} + +func calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { + if len(shapeRows) < 2 { + return 0 + } + + totalDistance := 0.0 + for i := 1; i < len(shapeRows); i++ { + dist := utils.Distance( + shapeRows[i-1].Lat, shapeRows[i-1].Lon, + shapeRows[i].Lat, shapeRows[i].Lon, + ) + totalDistance += dist + } + return totalDistance +} diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 7d2a7959..2f97f325 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -157,67 +157,3 @@ func getCurrentVehicleStopSequence(vehicle *gtfs.Vehicle) *int { val := int(*vehicle.CurrentStopSequence) return &val } - -func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, actualPos models.Location) *models.Location { - shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - if err != nil || len(shapeRows) < 2 { - return nil - } - - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } - - minDistance := math.MaxFloat64 - var closestPoint models.Location - - for i := 0; i < len(shapePoints)-1; i++ { - distance, projectedPoint := projectPointToSegment( - actualPos.Lat, actualPos.Lon, - shapePoints[i].Latitude, shapePoints[i].Longitude, - shapePoints[i+1].Latitude, shapePoints[i+1].Longitude, - ) - - if distance < minDistance { - minDistance = distance - closestPoint = projectedPoint - } - } - - if minDistance <= 200 { - return &closestPoint - } - - return nil -} - -func projectPointToSegment(px, py, x1, y1, x2, y2 float64) (float64, models.Location) { - dx := x2 - x1 - dy := y2 - y1 - - if dx == 0 && dy == 0 { - dist := utils.Distance(px, py, x1, y1) - return dist, models.Location{Lat: x1, Lon: y1} - } - - t := ((px-x1)*dx + (py-y1)*dy) / (dx*dx + dy*dy) - - if t < 0 { - dist := utils.Distance(px, py, x1, y1) - return dist, models.Location{Lat: x1, Lon: y1} - } - if t > 1 { - dist := utils.Distance(px, py, x2, y2) - return dist, models.Location{Lat: x2, Lon: y2} - } - - projLat := x1 + t*dx - projLon := y1 + t*dy - - dist := utils.Distance(px, py, projLat, projLon) - return dist, models.Location{Lat: projLat, Lon: projLon} -} From 765f6b738c3473a79af8f96b6872108f0e1379d7 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 13 Feb 2026 19:09:34 -0800 Subject: [PATCH 27/94] feat: implement vehicle position service with scheduled position calculation and deviation handling --- internal/realtime/service.go | 479 +++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 internal/realtime/service.go diff --git a/internal/realtime/service.go b/internal/realtime/service.go new file mode 100644 index 00000000..ce7860f9 --- /dev/null +++ b/internal/realtime/service.go @@ -0,0 +1,479 @@ +package realtime + +import ( + "context" + "math" + "sync" + "time" + + go_gtfs "github.com/OneBusAway/go-gtfs" + "maglev.onebusaway.org/gtfsdb" + "maglev.onebusaway.org/internal/gtfs" +) + +type Service struct { + gtfsManager *gtfs.Manager + config Config + caches *caches +} + +type Config struct { + StaleThreshold time.Duration +} + +func DefaultConfig() Config { + return Config{StaleThreshold: 15 * time.Minute} +} + +func NewService(gm *gtfs.Manager, cfg Config) *Service { + return &Service{ + gtfsManager: gm, + config: cfg, + caches: newCaches(), + } +} + +type VehiclePosition struct { + VehicleID string + TripID string + ActiveTripID string + Latitude float64 + Longitude float64 + Distance float64 + Timestamp time.Time + ScheduleDeviation int + CurrentStopID string + NextStopID string + CurrentStopTimeOffset int + NextStopTimeOffset int + IsStale bool + IsPredicted bool +} + +func (s *Service) GetVehiclePosition(ctx context.Context, vehicleID string) (VehiclePosition, error) { + vehicle, err := s.gtfsManager.GetVehicleByID(vehicleID) + if err != nil { + return VehiclePosition{}, err + } + + pos := VehiclePosition{ + VehicleID: vehicle.ID.ID, + Timestamp: time.Now(), + } + + if vehicle.Trip != nil { + pos.TripID = vehicle.Trip.ID.ID + } + + if s.isStale(vehicle.Timestamp) { + pos.IsStale = true + pos.IsPredicted = false + return pos, nil + } + + pos = s.calculateScheduledPosition(ctx, vehicle, pos) + pos.IsPredicted = true + + return pos, nil +} + +func (s *Service) calculateScheduledPosition(ctx context.Context, vehicle *go_gtfs.Vehicle, pos VehiclePosition) VehiclePosition { + if vehicle.Trip == nil { + return pos + } + + tripID := vehicle.Trip.ID.ID + + trip, err := s.gtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) + if err != nil { + return pos + } + + now := time.Now() + currentSeconds := int64(now.Hour()*3600 + now.Minute()*60 + now.Second()) + + pos.ScheduleDeviation = s.GetTripDeviation(ctx, tripID) + + if pos.ScheduleDeviation == 0 && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { + recalculatedDeviation := s.calculateDeviationFromVehiclePosition(ctx, trip, vehicle, currentSeconds) + if recalculatedDeviation != 0 { + pos.ScheduleDeviation = recalculatedDeviation + } + } + + // Java formula: effectiveTime = currentTime - deviation + // If vehicle is 573s late, effective time is 573s ago + effectiveTime := currentSeconds - int64(pos.ScheduleDeviation) + + // If trip is in a block, calculate position across entire block + if trip.BlockID.Valid { + pos = s.calculateBlockPosition(ctx, trip, effectiveTime, pos) + } else { + // Single trip - calculate position within trip + pos = s.calculateTripPosition(ctx, tripID, effectiveTime, pos) + } + + return pos +} + +func (s *Service) calculateDeviationFromVehiclePosition(ctx context.Context, trip gtfsdb.Trip, vehicle *go_gtfs.Vehicle, currentSeconds int64) int { + if vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { + return 0 + } + + lat := *vehicle.Position.Latitude + lon := *vehicle.Position.Longitude + + stopTimes, err := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) + if err != nil || len(stopTimes) == 0 { + return 0 + } + + shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, trip.ID) + totalDistance := s.calculateTripDistance(shapeRows) + + vehicleDistance := s.findDistanceAlongTripForLocation(ctx, stopTimes, float64(lat), float64(lon), totalDistance) + if vehicleDistance < 0 { + return 0 + } + + scheduledTimeAtPosition := s.getScheduledTimeAtDistance(vehicleDistance, stopTimes, totalDistance) + if scheduledTimeAtPosition < 0 { + return 0 + } + + deviation := currentSeconds - scheduledTimeAtPosition + + return int(deviation) +} + +func (s *Service) findDistanceAlongTripForLocation(ctx context.Context, stopTimes []gtfsdb.StopTime, lat, lon, totalDistance float64) float64 { + if len(stopTimes) == 0 || totalDistance <= 0 { + return -1 + } + + closestStopDistance := 0.0 + + stopIDs := make([]string, len(stopTimes)) + for i, st := range stopTimes { + stopIDs[i] = st.StopID + } + + for i := range stopTimes { + stopDistance := float64(i) / float64(len(stopTimes)-1) * totalDistance + if i == 0 { + closestStopDistance = stopDistance + } + } + + return closestStopDistance +} + +func (s *Service) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalDistance float64) int64 { + if len(stopTimes) == 0 || totalDistance <= 0 { + return -1 + } + + for i := 0; i < len(stopTimes)-1; i++ { + fromDist := float64(i) / float64(len(stopTimes)-1) * totalDistance + toDist := float64(i+1) / float64(len(stopTimes)-1) * totalDistance + + if distance >= fromDist && distance <= toDist { + fromTime := stopTimes[i].ArrivalTime / 1e9 + if fromTime == 0 { + fromTime = stopTimes[i].DepartureTime / 1e9 + } + + toTime := stopTimes[i+1].ArrivalTime / 1e9 + if toTime == 0 { + toTime = stopTimes[i+1].DepartureTime / 1e9 + } + + if toDist == fromDist { + return fromTime + } + + ratio := (distance - fromDist) / (toDist - fromDist) + return int64(float64(fromTime) + ratio*float64(toTime-fromTime)) + } + } + + lastTime := stopTimes[len(stopTimes)-1].ArrivalTime / 1e9 + if lastTime == 0 { + lastTime = stopTimes[len(stopTimes)-1].DepartureTime / 1e9 + } + return lastTime +} + +func (s *Service) calculateBlockPosition(ctx context.Context, trip gtfsdb.Trip, effectiveTime int64, pos VehiclePosition) VehiclePosition { + blockTrips, err := s.gtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: []string{trip.ServiceID}, + }) + if err != nil { + return pos + } + + var allStopTimes []BlockStopTime + cumulativeDistance := 0.0 + + for _, blockTrip := range blockTrips { + stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) + shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) + tripDistance := s.calculateTripDistance(shapeRows) + + for i, st := range stopTimes { + arrival := st.ArrivalTime / 1e9 + if arrival == 0 { + arrival = st.DepartureTime / 1e9 + } + + stopDist := cumulativeDistance + if len(stopTimes) > 1 { + stopDist += float64(i) / float64(len(stopTimes)-1) * tripDistance + } + + allStopTimes = append(allStopTimes, BlockStopTime{ + TripID: blockTrip.ID, + StopID: st.StopID, + ArrivalTime: arrival, + Distance: stopDist, + StopSequence: int(st.StopSequence), + }) + } + + cumulativeDistance += tripDistance + } + + if len(allStopTimes) == 0 { + return pos + } + + idx := s.findStopTimeIndex(allStopTimes, effectiveTime) + + if idx < 0 { + pos.ActiveTripID = allStopTimes[0].TripID + pos.CurrentStopID = allStopTimes[0].StopID + pos.Distance = allStopTimes[0].Distance + pos.CurrentStopTimeOffset = int(allStopTimes[0].ArrivalTime - effectiveTime) + if len(allStopTimes) > 1 { + pos.NextStopID = allStopTimes[1].StopID + pos.NextStopTimeOffset = int(allStopTimes[1].ArrivalTime - effectiveTime) + } + } else if idx >= len(allStopTimes)-1 { + lastIdx := len(allStopTimes) - 1 + pos.ActiveTripID = allStopTimes[lastIdx].TripID + pos.CurrentStopID = allStopTimes[lastIdx].StopID + pos.Distance = allStopTimes[lastIdx].Distance + pos.CurrentStopTimeOffset = int(allStopTimes[lastIdx].ArrivalTime - effectiveTime) + } else { + fromStop := allStopTimes[idx] + toStop := allStopTimes[idx+1] + + fromTime := fromStop.ArrivalTime + toTime := toStop.ArrivalTime + + var ratio float64 + if toTime > fromTime { + ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) + } + + pos.Distance = fromStop.Distance + ratio*(toStop.Distance-fromStop.Distance) + pos.ActiveTripID = fromStop.TripID + pos.CurrentStopID = fromStop.StopID + pos.NextStopID = toStop.StopID + pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) + pos.NextStopTimeOffset = int(toTime - effectiveTime) + } + + return pos +} + +func (s *Service) findStopTimeIndex(allStopTimes []BlockStopTime, effectiveTime int64) int { + low, high := 0, len(allStopTimes)-1 + + for low <= high { + mid := (low + high) / 2 + if allStopTimes[mid].ArrivalTime <= effectiveTime { + low = mid + 1 + } else { + high = mid - 1 + } + } + + return high +} + +func (s *Service) calculateTripPosition(ctx context.Context, tripID string, effectiveTime int64, pos VehiclePosition) VehiclePosition { + stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + if len(stopTimes) == 0 { + return pos + } + + shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + totalDistance := s.calculateTripDistance(shapeRows) + + type stopInfo struct { + stopID string + arrivalTime int64 + distance float64 + } + stopInfos := make([]stopInfo, len(stopTimes)) + for i, st := range stopTimes { + arrival := st.ArrivalTime / 1e9 + if arrival == 0 { + arrival = st.DepartureTime / 1e9 + } + dist := 0.0 + if len(stopTimes) > 1 { + dist = float64(i) / float64(len(stopTimes)-1) * totalDistance + } + stopInfos[i] = stopInfo{ + stopID: st.StopID, + arrivalTime: arrival, + distance: dist, + } + } + + idx := -1 + for i := 0; i < len(stopInfos)-1; i++ { + if effectiveTime >= stopInfos[i].arrivalTime && effectiveTime < stopInfos[i+1].arrivalTime { + idx = i + break + } + } + + if idx < 0 { + if effectiveTime < stopInfos[0].arrivalTime { + pos.CurrentStopID = stopInfos[0].stopID + pos.Distance = stopInfos[0].distance + pos.CurrentStopTimeOffset = int(stopInfos[0].arrivalTime - effectiveTime) + if len(stopInfos) > 1 { + pos.NextStopID = stopInfos[1].stopID + pos.NextStopTimeOffset = int(stopInfos[1].arrivalTime - effectiveTime) + } + } else { + lastIdx := len(stopInfos) - 1 + pos.CurrentStopID = stopInfos[lastIdx].stopID + pos.Distance = stopInfos[lastIdx].distance + pos.CurrentStopTimeOffset = int(stopInfos[lastIdx].arrivalTime - effectiveTime) + } + } else { + fromStop := stopInfos[idx] + toStop := stopInfos[idx+1] + + fromTime := fromStop.arrivalTime + toTime := toStop.arrivalTime + + var ratio float64 + if toTime > fromTime { + ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) + } + + pos.Distance = fromStop.distance + ratio*(toStop.distance-fromStop.distance) + pos.CurrentStopID = fromStop.stopID + pos.NextStopID = toStop.stopID + pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) + pos.NextStopTimeOffset = int(toTime - effectiveTime) + } + + pos.ActiveTripID = tripID + return pos +} + +func (s *Service) GetTripDeviation(ctx context.Context, tripID string) int { + if cached := s.caches.deviation.get(tripID); cached != nil { + if dev, ok := cached.(int); ok { + return dev + } + } + + updates := s.gtfsManager.GetTripUpdatesForTrip(tripID) + if len(updates) == 0 { + return 0 + } + + tu := updates[0] + deviation := 0 + + if tu.Delay != nil { + deviation = int(tu.Delay.Seconds()) + } else { + for _, stu := range tu.StopTimeUpdates { + if stu.Arrival != nil && stu.Arrival.Delay != nil { + deviation = int(stu.Arrival.Delay.Seconds()) + break + } + } + } + + s.caches.deviation.set(tripID, deviation) + return deviation +} + +func (s *Service) isStale(timestamp *time.Time) bool { + if timestamp == nil { + return true + } + return time.Since(*timestamp) > s.config.StaleThreshold +} + +func (s *Service) calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { + distance := 0.0 + for i := 1; i < len(shapeRows); i++ { + distance += haversine(shapeRows[i-1].Lat, shapeRows[i-1].Lon, shapeRows[i].Lat, shapeRows[i].Lon) + } + return distance +} + +type BlockStopTime struct { + TripID string + StopID string + ArrivalTime int64 + Distance float64 + StopSequence int +} + +func haversine(lat1, lon1, lat2, lon2 float64) float64 { + const R = 6371000 + phi1 := lat1 * math.Pi / 180 + phi2 := lat2 * math.Pi / 180 + deltaPhi := (lat2 - lat1) * math.Pi / 180 + deltaLambda := (lon2 - lon1) * math.Pi / 180 + + a := math.Sin(deltaPhi/2)*math.Sin(deltaPhi/2) + + math.Cos(phi1)*math.Cos(phi2)* + math.Sin(deltaLambda/2)*math.Sin(deltaLambda/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return R * c +} + +type caches struct { + vehicle *syncMap + deviation *syncMap +} + +func newCaches() *caches { + return &caches{ + vehicle: &syncMap{m: make(map[string]interface{})}, + deviation: &syncMap{m: make(map[string]interface{})}, + } +} + +type syncMap struct { + sync.RWMutex + m map[string]interface{} +} + +func (sm *syncMap) get(key string) interface{} { + sm.RLock() + defer sm.RUnlock() + return sm.m[key] +} + +func (sm *syncMap) set(key string, value interface{}) { + sm.Lock() + defer sm.Unlock() + sm.m[key] = value +} From 605f9e48ee8f4c952e4bfd43ff0aa598e5082376 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 16 Feb 2026 06:14:00 +0200 Subject: [PATCH 28/94] refactor: remove realtime service implementation --- internal/realtime/service.go | 479 ----------------------------------- 1 file changed, 479 deletions(-) delete mode 100644 internal/realtime/service.go diff --git a/internal/realtime/service.go b/internal/realtime/service.go deleted file mode 100644 index ce7860f9..00000000 --- a/internal/realtime/service.go +++ /dev/null @@ -1,479 +0,0 @@ -package realtime - -import ( - "context" - "math" - "sync" - "time" - - go_gtfs "github.com/OneBusAway/go-gtfs" - "maglev.onebusaway.org/gtfsdb" - "maglev.onebusaway.org/internal/gtfs" -) - -type Service struct { - gtfsManager *gtfs.Manager - config Config - caches *caches -} - -type Config struct { - StaleThreshold time.Duration -} - -func DefaultConfig() Config { - return Config{StaleThreshold: 15 * time.Minute} -} - -func NewService(gm *gtfs.Manager, cfg Config) *Service { - return &Service{ - gtfsManager: gm, - config: cfg, - caches: newCaches(), - } -} - -type VehiclePosition struct { - VehicleID string - TripID string - ActiveTripID string - Latitude float64 - Longitude float64 - Distance float64 - Timestamp time.Time - ScheduleDeviation int - CurrentStopID string - NextStopID string - CurrentStopTimeOffset int - NextStopTimeOffset int - IsStale bool - IsPredicted bool -} - -func (s *Service) GetVehiclePosition(ctx context.Context, vehicleID string) (VehiclePosition, error) { - vehicle, err := s.gtfsManager.GetVehicleByID(vehicleID) - if err != nil { - return VehiclePosition{}, err - } - - pos := VehiclePosition{ - VehicleID: vehicle.ID.ID, - Timestamp: time.Now(), - } - - if vehicle.Trip != nil { - pos.TripID = vehicle.Trip.ID.ID - } - - if s.isStale(vehicle.Timestamp) { - pos.IsStale = true - pos.IsPredicted = false - return pos, nil - } - - pos = s.calculateScheduledPosition(ctx, vehicle, pos) - pos.IsPredicted = true - - return pos, nil -} - -func (s *Service) calculateScheduledPosition(ctx context.Context, vehicle *go_gtfs.Vehicle, pos VehiclePosition) VehiclePosition { - if vehicle.Trip == nil { - return pos - } - - tripID := vehicle.Trip.ID.ID - - trip, err := s.gtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) - if err != nil { - return pos - } - - now := time.Now() - currentSeconds := int64(now.Hour()*3600 + now.Minute()*60 + now.Second()) - - pos.ScheduleDeviation = s.GetTripDeviation(ctx, tripID) - - if pos.ScheduleDeviation == 0 && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { - recalculatedDeviation := s.calculateDeviationFromVehiclePosition(ctx, trip, vehicle, currentSeconds) - if recalculatedDeviation != 0 { - pos.ScheduleDeviation = recalculatedDeviation - } - } - - // Java formula: effectiveTime = currentTime - deviation - // If vehicle is 573s late, effective time is 573s ago - effectiveTime := currentSeconds - int64(pos.ScheduleDeviation) - - // If trip is in a block, calculate position across entire block - if trip.BlockID.Valid { - pos = s.calculateBlockPosition(ctx, trip, effectiveTime, pos) - } else { - // Single trip - calculate position within trip - pos = s.calculateTripPosition(ctx, tripID, effectiveTime, pos) - } - - return pos -} - -func (s *Service) calculateDeviationFromVehiclePosition(ctx context.Context, trip gtfsdb.Trip, vehicle *go_gtfs.Vehicle, currentSeconds int64) int { - if vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { - return 0 - } - - lat := *vehicle.Position.Latitude - lon := *vehicle.Position.Longitude - - stopTimes, err := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) - if err != nil || len(stopTimes) == 0 { - return 0 - } - - shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, trip.ID) - totalDistance := s.calculateTripDistance(shapeRows) - - vehicleDistance := s.findDistanceAlongTripForLocation(ctx, stopTimes, float64(lat), float64(lon), totalDistance) - if vehicleDistance < 0 { - return 0 - } - - scheduledTimeAtPosition := s.getScheduledTimeAtDistance(vehicleDistance, stopTimes, totalDistance) - if scheduledTimeAtPosition < 0 { - return 0 - } - - deviation := currentSeconds - scheduledTimeAtPosition - - return int(deviation) -} - -func (s *Service) findDistanceAlongTripForLocation(ctx context.Context, stopTimes []gtfsdb.StopTime, lat, lon, totalDistance float64) float64 { - if len(stopTimes) == 0 || totalDistance <= 0 { - return -1 - } - - closestStopDistance := 0.0 - - stopIDs := make([]string, len(stopTimes)) - for i, st := range stopTimes { - stopIDs[i] = st.StopID - } - - for i := range stopTimes { - stopDistance := float64(i) / float64(len(stopTimes)-1) * totalDistance - if i == 0 { - closestStopDistance = stopDistance - } - } - - return closestStopDistance -} - -func (s *Service) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalDistance float64) int64 { - if len(stopTimes) == 0 || totalDistance <= 0 { - return -1 - } - - for i := 0; i < len(stopTimes)-1; i++ { - fromDist := float64(i) / float64(len(stopTimes)-1) * totalDistance - toDist := float64(i+1) / float64(len(stopTimes)-1) * totalDistance - - if distance >= fromDist && distance <= toDist { - fromTime := stopTimes[i].ArrivalTime / 1e9 - if fromTime == 0 { - fromTime = stopTimes[i].DepartureTime / 1e9 - } - - toTime := stopTimes[i+1].ArrivalTime / 1e9 - if toTime == 0 { - toTime = stopTimes[i+1].DepartureTime / 1e9 - } - - if toDist == fromDist { - return fromTime - } - - ratio := (distance - fromDist) / (toDist - fromDist) - return int64(float64(fromTime) + ratio*float64(toTime-fromTime)) - } - } - - lastTime := stopTimes[len(stopTimes)-1].ArrivalTime / 1e9 - if lastTime == 0 { - lastTime = stopTimes[len(stopTimes)-1].DepartureTime / 1e9 - } - return lastTime -} - -func (s *Service) calculateBlockPosition(ctx context.Context, trip gtfsdb.Trip, effectiveTime int64, pos VehiclePosition) VehiclePosition { - blockTrips, err := s.gtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ - BlockID: trip.BlockID, - ServiceIds: []string{trip.ServiceID}, - }) - if err != nil { - return pos - } - - var allStopTimes []BlockStopTime - cumulativeDistance := 0.0 - - for _, blockTrip := range blockTrips { - stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) - shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) - tripDistance := s.calculateTripDistance(shapeRows) - - for i, st := range stopTimes { - arrival := st.ArrivalTime / 1e9 - if arrival == 0 { - arrival = st.DepartureTime / 1e9 - } - - stopDist := cumulativeDistance - if len(stopTimes) > 1 { - stopDist += float64(i) / float64(len(stopTimes)-1) * tripDistance - } - - allStopTimes = append(allStopTimes, BlockStopTime{ - TripID: blockTrip.ID, - StopID: st.StopID, - ArrivalTime: arrival, - Distance: stopDist, - StopSequence: int(st.StopSequence), - }) - } - - cumulativeDistance += tripDistance - } - - if len(allStopTimes) == 0 { - return pos - } - - idx := s.findStopTimeIndex(allStopTimes, effectiveTime) - - if idx < 0 { - pos.ActiveTripID = allStopTimes[0].TripID - pos.CurrentStopID = allStopTimes[0].StopID - pos.Distance = allStopTimes[0].Distance - pos.CurrentStopTimeOffset = int(allStopTimes[0].ArrivalTime - effectiveTime) - if len(allStopTimes) > 1 { - pos.NextStopID = allStopTimes[1].StopID - pos.NextStopTimeOffset = int(allStopTimes[1].ArrivalTime - effectiveTime) - } - } else if idx >= len(allStopTimes)-1 { - lastIdx := len(allStopTimes) - 1 - pos.ActiveTripID = allStopTimes[lastIdx].TripID - pos.CurrentStopID = allStopTimes[lastIdx].StopID - pos.Distance = allStopTimes[lastIdx].Distance - pos.CurrentStopTimeOffset = int(allStopTimes[lastIdx].ArrivalTime - effectiveTime) - } else { - fromStop := allStopTimes[idx] - toStop := allStopTimes[idx+1] - - fromTime := fromStop.ArrivalTime - toTime := toStop.ArrivalTime - - var ratio float64 - if toTime > fromTime { - ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) - } - - pos.Distance = fromStop.Distance + ratio*(toStop.Distance-fromStop.Distance) - pos.ActiveTripID = fromStop.TripID - pos.CurrentStopID = fromStop.StopID - pos.NextStopID = toStop.StopID - pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) - pos.NextStopTimeOffset = int(toTime - effectiveTime) - } - - return pos -} - -func (s *Service) findStopTimeIndex(allStopTimes []BlockStopTime, effectiveTime int64) int { - low, high := 0, len(allStopTimes)-1 - - for low <= high { - mid := (low + high) / 2 - if allStopTimes[mid].ArrivalTime <= effectiveTime { - low = mid + 1 - } else { - high = mid - 1 - } - } - - return high -} - -func (s *Service) calculateTripPosition(ctx context.Context, tripID string, effectiveTime int64, pos VehiclePosition) VehiclePosition { - stopTimes, _ := s.gtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) - if len(stopTimes) == 0 { - return pos - } - - shapeRows, _ := s.gtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - totalDistance := s.calculateTripDistance(shapeRows) - - type stopInfo struct { - stopID string - arrivalTime int64 - distance float64 - } - stopInfos := make([]stopInfo, len(stopTimes)) - for i, st := range stopTimes { - arrival := st.ArrivalTime / 1e9 - if arrival == 0 { - arrival = st.DepartureTime / 1e9 - } - dist := 0.0 - if len(stopTimes) > 1 { - dist = float64(i) / float64(len(stopTimes)-1) * totalDistance - } - stopInfos[i] = stopInfo{ - stopID: st.StopID, - arrivalTime: arrival, - distance: dist, - } - } - - idx := -1 - for i := 0; i < len(stopInfos)-1; i++ { - if effectiveTime >= stopInfos[i].arrivalTime && effectiveTime < stopInfos[i+1].arrivalTime { - idx = i - break - } - } - - if idx < 0 { - if effectiveTime < stopInfos[0].arrivalTime { - pos.CurrentStopID = stopInfos[0].stopID - pos.Distance = stopInfos[0].distance - pos.CurrentStopTimeOffset = int(stopInfos[0].arrivalTime - effectiveTime) - if len(stopInfos) > 1 { - pos.NextStopID = stopInfos[1].stopID - pos.NextStopTimeOffset = int(stopInfos[1].arrivalTime - effectiveTime) - } - } else { - lastIdx := len(stopInfos) - 1 - pos.CurrentStopID = stopInfos[lastIdx].stopID - pos.Distance = stopInfos[lastIdx].distance - pos.CurrentStopTimeOffset = int(stopInfos[lastIdx].arrivalTime - effectiveTime) - } - } else { - fromStop := stopInfos[idx] - toStop := stopInfos[idx+1] - - fromTime := fromStop.arrivalTime - toTime := toStop.arrivalTime - - var ratio float64 - if toTime > fromTime { - ratio = float64(effectiveTime-fromTime) / float64(toTime-fromTime) - } - - pos.Distance = fromStop.distance + ratio*(toStop.distance-fromStop.distance) - pos.CurrentStopID = fromStop.stopID - pos.NextStopID = toStop.stopID - pos.CurrentStopTimeOffset = int(fromTime - effectiveTime) - pos.NextStopTimeOffset = int(toTime - effectiveTime) - } - - pos.ActiveTripID = tripID - return pos -} - -func (s *Service) GetTripDeviation(ctx context.Context, tripID string) int { - if cached := s.caches.deviation.get(tripID); cached != nil { - if dev, ok := cached.(int); ok { - return dev - } - } - - updates := s.gtfsManager.GetTripUpdatesForTrip(tripID) - if len(updates) == 0 { - return 0 - } - - tu := updates[0] - deviation := 0 - - if tu.Delay != nil { - deviation = int(tu.Delay.Seconds()) - } else { - for _, stu := range tu.StopTimeUpdates { - if stu.Arrival != nil && stu.Arrival.Delay != nil { - deviation = int(stu.Arrival.Delay.Seconds()) - break - } - } - } - - s.caches.deviation.set(tripID, deviation) - return deviation -} - -func (s *Service) isStale(timestamp *time.Time) bool { - if timestamp == nil { - return true - } - return time.Since(*timestamp) > s.config.StaleThreshold -} - -func (s *Service) calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { - distance := 0.0 - for i := 1; i < len(shapeRows); i++ { - distance += haversine(shapeRows[i-1].Lat, shapeRows[i-1].Lon, shapeRows[i].Lat, shapeRows[i].Lon) - } - return distance -} - -type BlockStopTime struct { - TripID string - StopID string - ArrivalTime int64 - Distance float64 - StopSequence int -} - -func haversine(lat1, lon1, lat2, lon2 float64) float64 { - const R = 6371000 - phi1 := lat1 * math.Pi / 180 - phi2 := lat2 * math.Pi / 180 - deltaPhi := (lat2 - lat1) * math.Pi / 180 - deltaLambda := (lon2 - lon1) * math.Pi / 180 - - a := math.Sin(deltaPhi/2)*math.Sin(deltaPhi/2) + - math.Cos(phi1)*math.Cos(phi2)* - math.Sin(deltaLambda/2)*math.Sin(deltaLambda/2) - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) - - return R * c -} - -type caches struct { - vehicle *syncMap - deviation *syncMap -} - -func newCaches() *caches { - return &caches{ - vehicle: &syncMap{m: make(map[string]interface{})}, - deviation: &syncMap{m: make(map[string]interface{})}, - } -} - -type syncMap struct { - sync.RWMutex - m map[string]interface{} -} - -func (sm *syncMap) get(key string) interface{} { - sm.RLock() - defer sm.RUnlock() - return sm.m[key] -} - -func (sm *syncMap) set(key string, value interface{}) { - sm.Lock() - defer sm.Unlock() - sm.m[key] = value -} From b5d4b7727cf42ca5b01edbbff843dd926e7ee39e Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 17 Feb 2026 03:36:24 +0200 Subject: [PATCH 29/94] refactor: rewrite BuildTripStatus to use GTFS-RT trip updates directly Replace realtimeService dependency with direct GTFS-RT trip update consumption for schedule deviation and stop position resolution. Use service-date-aware time calculations via CalculateSecondsSinceServiceDate. Remove dead code (block-level position calculation, redundant schedule deviation methods, pass-through setBlockTripSequence). Propagate request --- internal/restapi/trips_helper.go | 130 ------------------------------- 1 file changed, 130 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index a314db08..b5e2137e 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -1233,133 +1233,3 @@ func (api *RestAPI) getScheduledTimeAtDistance(distance float64, stopTimes []gtf } return scheduledTime } - -func (api *RestAPI) calculateBlockLevelPosition( - ctx context.Context, - tripID string, - vehicle *gtfs.Vehicle, - currentTime time.Time, - scheduleDeviation int, -) (activeTripID string, closestStopID string, nextStopID string, distanceAlongTrip float64, err error) { - trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) - if err != nil { - return tripID, "", "", 0, err - } - - if !trip.BlockID.Valid { - return tripID, "", "", 0, nil - } - - year, month, day := currentTime.Date() - serviceDate := time.Date(year, month, day, 0, 0, 0, 0, currentTime.Location()) - serviceDateUnix := serviceDate.Unix() - - currentTimestamp := currentTime.Unix() - effectiveScheduledTime := currentTimestamp - int64(scheduleDeviation) - effectiveTimeFromMidnight := effectiveScheduledTime - serviceDateUnix - - blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ - BlockID: trip.BlockID, - ServiceIds: []string{trip.ServiceID}, - }) - if err != nil { - return tripID, "", "", 0, err - } - - var blockStopTimes []BlockStopTimeInfo - var cumulativeDistance float64 - - for _, blockTrip := range blockTrips { - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, blockTrip.ID) - if err != nil { - continue - } - - shapeRows, _ := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) - tripDistance := calculateTripDistance(shapeRows) - - for i, st := range stopTimes { - arrivalTime := st.ArrivalTime / 1e9 - if arrivalTime == 0 { - arrivalTime = st.DepartureTime / 1e9 - } - - var stopDistance float64 - if len(stopTimes) > 1 { - stopDistance = cumulativeDistance + (float64(i) / float64(len(stopTimes)-1) * tripDistance) - } else { - stopDistance = cumulativeDistance - } - - blockStopTimes = append(blockStopTimes, BlockStopTimeInfo{ - TripID: blockTrip.ID, - StopID: st.StopID, - ArrivalTime: arrivalTime, - StopSequence: int(st.StopSequence), - Distance: stopDistance, - IsFirstInTrip: i == 0, - IsLastInTrip: i == len(stopTimes)-1, - }) - } - - cumulativeDistance += tripDistance - } - - if len(blockStopTimes) == 0 { - return tripID, "", "", 0, nil - } - - closestIndex := -1 - var minTimeDiff int64 = math.MaxInt64 - - for i, bst := range blockStopTimes { - timeDiff := bst.ArrivalTime - effectiveTimeFromMidnight - if timeDiff < 0 { - timeDiff = -timeDiff - } - if timeDiff < minTimeDiff { - minTimeDiff = timeDiff - closestIndex = i - } - } - - if closestIndex < 0 { - return tripID, "", "", 0, nil - } - - activeTripID = blockStopTimes[closestIndex].TripID - closestStopID = blockStopTimes[closestIndex].StopID - distanceAlongTrip = blockStopTimes[closestIndex].Distance - - if closestIndex+1 < len(blockStopTimes) { - nextStopID = blockStopTimes[closestIndex+1].StopID - } - - return activeTripID, closestStopID, nextStopID, distanceAlongTrip, nil -} - -type BlockStopTimeInfo struct { - TripID string - StopID string - ArrivalTime int64 - StopSequence int - Distance float64 - IsFirstInTrip bool - IsLastInTrip bool -} - -func calculateTripDistance(shapeRows []gtfsdb.Shape) float64 { - if len(shapeRows) < 2 { - return 0 - } - - totalDistance := 0.0 - for i := 1; i < len(shapeRows); i++ { - dist := utils.Distance( - shapeRows[i-1].Lat, shapeRows[i-1].Lon, - shapeRows[i].Lat, shapeRows[i].Lon, - ) - totalDistance += dist - } - return totalDistance -} From 9854c6c87547ccac9a484cc052f564f2a91ab696 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Thu, 19 Feb 2026 18:35:53 -0800 Subject: [PATCH 30/94] refactor: remove unused trip update handling functions and streamline block trip sequence calculation --- internal/restapi/trips_helper.go | 227 ------------------------------- 1 file changed, 227 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index b5e2137e..91120db2 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -402,10 +402,6 @@ func getDistanceAlongShapeInRange(lat, lon float64, shape []gtfs.ShapePoint, min return bestDist } -func (api *RestAPI) setBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time, status *models.TripStatusForTripDetails) int { - return api.calculateBlockTripSequence(ctx, tripID, serviceDate) -} - // calculateBlockTripSequence calculates the index of a trip within its block's ordered trip sequence // for trips that are active on the given service date. // Uses GetTripsByBlockIDOrdered to perform a single SQL JOIN instead of N+1 queries. @@ -434,72 +430,6 @@ func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID strin return 0 } -func (api *RestAPI) calculateScheduleDeviationFromTripUpdates( - tripID string, -) int { - tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) - if len(tripUpdates) == 0 { - return 0 - } - - tripUpdate := tripUpdates[0] - - if tripUpdate.Delay != nil { - return int(tripUpdate.Delay.Seconds()) - } - - var bestDeviation int64 = 0 - var foundRelevantUpdate bool - - for _, stopTimeUpdate := range tripUpdate.StopTimeUpdates { - if stopTimeUpdate.Arrival != nil && stopTimeUpdate.Arrival.Delay != nil { - bestDeviation = int64(*stopTimeUpdate.Arrival.Delay / 1e9) - foundRelevantUpdate = true - } else if stopTimeUpdate.Departure != nil && stopTimeUpdate.Departure.Delay != nil { - bestDeviation = int64(*stopTimeUpdate.Departure.Delay / 1e9) - foundRelevantUpdate = true - } - - if foundRelevantUpdate { - break - } - } - - return int(bestDeviation) -} - -func (api *RestAPI) getStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { - delays := make(map[string]StopDelayInfo) - - tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) - if len(tripUpdates) == 0 { - return delays - } - - tripUpdate := tripUpdates[0] - - for _, stopTimeUpdate := range tripUpdate.StopTimeUpdates { - if stopTimeUpdate.StopID == nil { - continue - } - - info := StopDelayInfo{} - if stopTimeUpdate.Arrival != nil && stopTimeUpdate.Arrival.Delay != nil { - info.ArrivalDelay = int64(stopTimeUpdate.Arrival.Delay.Seconds()) - } - if stopTimeUpdate.Departure != nil && stopTimeUpdate.Departure.Delay != nil { - info.DepartureDelay = int64(stopTimeUpdate.Departure.Delay.Seconds()) - } - - // Only add if we have at least one delay value - if info.ArrivalDelay != 0 || info.DepartureDelay != 0 { - delays[*stopTimeUpdate.StopID] = info - } - } - - return delays -} - // calculatePreciseDistanceAlongTripWithCoords calculates the distance along a trip's shape to a stop // This optimized version accepts pre-calculated cumulative distances and stop coordinates func (api *RestAPI) calculatePreciseDistanceAlongTripWithCoords( @@ -683,43 +613,6 @@ func (r *TripAgencyResolver) GetAgencyNameByTripID(tripID string) string { return agency } -func (api *RestAPI) interpolateDistanceAtScheduledTime( - scheduledTime int64, - stopTimes []gtfsdb.StopTime, - cumulativeDistances []float64, -) float64 { - if len(stopTimes) == 0 { - return 0 - } - - for i := 0; i < len(stopTimes)-1; i++ { - fromStop := stopTimes[i] - toStop := stopTimes[i+1] - - fromTime := fromStop.DepartureTime / 1e9 - toTime := toStop.ArrivalTime / 1e9 - - if scheduledTime >= fromTime && scheduledTime <= toTime { - if toTime == fromTime { - return cumulativeDistances[i] - } - - timeRatio := float64(scheduledTime-fromTime) / float64(toTime-fromTime) - - fromDistance := cumulativeDistances[i*len(cumulativeDistances)/len(stopTimes)] - toDistance := cumulativeDistances[(i+1)*len(cumulativeDistances)/len(stopTimes)] - - return fromDistance + timeRatio*(toDistance-fromDistance) - } - } - - if scheduledTime < stopTimes[0].ArrivalTime/1e9 { - return 0 - } - - return cumulativeDistances[len(cumulativeDistances)-1] -} - func (api *RestAPI) calculateOffsetForStop( stopID string, stopTimes []*gtfsdb.StopTime, @@ -1113,123 +1006,3 @@ func interpolateDistanceAtScheduledTime( return cumulativeDistances[len(cumulativeDistances)-1] } -func (api *RestAPI) calculateScheduleDeviationFromPosition( - ctx context.Context, - tripID string, - vehicle *gtfs.Vehicle, - currentTime time.Time, - gtfsRTDeviation int, -) int { - if vehicle == nil || vehicle.Position == nil || vehicle.Position.Latitude == nil || vehicle.Position.Longitude == nil { - return gtfsRTDeviation - } - - actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) - if actualDistance <= 0 { - return gtfsRTDeviation - } - - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) - if err != nil || len(stopTimes) == 0 { - return gtfsRTDeviation - } - - serviceDateUnix := utils.CalculateServiceDate(currentTime).Unix() - - var totalDistance float64 - shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - if err == nil && len(shapeRows) > 1 { - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } - cumulativeDistances := preCalculateCumulativeDistances(shapePoints) - totalDistance = cumulativeDistances[len(cumulativeDistances)-1] - } - - scheduledTimeAtPosition := api.getScheduledTimeAtDistance(actualDistance, stopTimes, totalDistance) - if scheduledTimeAtPosition < 0 { - return gtfsRTDeviation - } - - currentTimestamp := currentTime.Unix() - effectiveScheduleTime := scheduledTimeAtPosition + serviceDateUnix - deviation := int(currentTimestamp - effectiveScheduleTime) - - return deviation -} - -func (api *RestAPI) getScheduledTimeAtDistance(distance float64, stopTimes []gtfsdb.StopTime, totalTripDistance float64) int64 { - if len(stopTimes) == 0 || totalTripDistance <= 0 { - return -1 - } - - hasShapeDist := false - for _, st := range stopTimes { - if st.ShapeDistTraveled.Valid && st.ShapeDistTraveled.Float64 > 0 { - hasShapeDist = true - break - } - } - - if hasShapeDist { - for i := 0; i < len(stopTimes)-1; i++ { - if !stopTimes[i].ShapeDistTraveled.Valid || !stopTimes[i+1].ShapeDistTraveled.Valid { - continue - } - - fromDist := stopTimes[i].ShapeDistTraveled.Float64 - toDist := stopTimes[i+1].ShapeDistTraveled.Float64 - - if fromDist <= distance && distance <= toDist { - fromTime := stopTimes[i].ArrivalTime / 1e9 - if fromTime == 0 { - fromTime = stopTimes[i].DepartureTime / 1e9 - } - - toTime := stopTimes[i+1].ArrivalTime / 1e9 - if toTime == 0 { - toTime = stopTimes[i+1].DepartureTime / 1e9 - } - - ratio := (distance - fromDist) / (toDist - fromDist) - scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) - return int64(scheduledTime) - } - } - } else { - totalStops := len(stopTimes) - stopSpacing := totalTripDistance / float64(totalStops-1) - - for i := 0; i < totalStops-1; i++ { - fromDist := float64(i) * stopSpacing - toDist := float64(i+1) * stopSpacing - - if fromDist <= distance && distance <= toDist { - fromTime := stopTimes[i].ArrivalTime / 1e9 - if fromTime == 0 { - fromTime = stopTimes[i].DepartureTime / 1e9 - } - - toTime := stopTimes[i+1].ArrivalTime / 1e9 - if toTime == 0 { - toTime = stopTimes[i+1].DepartureTime / 1e9 - } - - ratio := (distance - fromDist) / (toDist - fromDist) - scheduledTime := float64(fromTime) + ratio*float64(toTime-fromTime) - return int64(scheduledTime) - } - } - } - - lastStop := stopTimes[len(stopTimes)-1] - scheduledTime := lastStop.ArrivalTime / 1e9 - if scheduledTime == 0 { - scheduledTime = lastStop.DepartureTime / 1e9 - } - return scheduledTime -} From a439fa8c7e9670f86cdc6efea763f428fb2d5f54 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 03:50:50 +0200 Subject: [PATCH 31/94] feat: only adjust next stop logic when the vehicle status is in stopped at case --- internal/restapi/trips_helper.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 91120db2..06900766 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -63,7 +63,13 @@ func (api *RestAPI) BuildTripStatus( if vehicle.StopID != nil && *vehicle.StopID != "" { closestStopID = *vehicle.StopID closestOffset = api.calculateOffsetForStop(closestStopID, stopTimesPtrs, currentTime, serviceDate, scheduleDeviation) - nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, serviceDate, scheduleDeviation) + isStoppedAt := vehicle.CurrentStatus != nil && *vehicle.CurrentStatus == gtfs.CurrentStatus(1) + if isStoppedAt { + nextStopID, nextOffset = api.findNextStopAfter(closestStopID, stopTimesPtrs, currentTime, serviceDate, scheduleDeviation) + } else { + nextStopID = closestStopID + nextOffset = closestOffset + } } else if vehicle.CurrentStopSequence != nil { closestStopID, closestOffset = api.findClosestStopBySequence( stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, @@ -1005,4 +1011,3 @@ func interpolateDistanceAtScheduledTime( return cumulativeDistances[len(cumulativeDistances)-1] } - From 64299038b6b7d1dfa357e366683b1ea5a4a95b33 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 06:19:59 +0200 Subject: [PATCH 32/94] fix: update schedule deviation calculation to use active trip ID rather than the tripID that come from params --- internal/restapi/trips_helper.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 06900766..af3c7cff 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -38,17 +38,18 @@ func (api *RestAPI) BuildTripStatus( api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) } - scheduleDeviation := api.GetScheduleDeviation(tripID) - if scheduleDeviation != 0 { - status.ScheduleDeviation = scheduleDeviation - status.Predicted = true - } - _, activeTripRawID, err := utils.ExtractAgencyIDAndCodeID(status.ActiveTripID) if err != nil { return status, err } + scheduleDeviation := api.GetScheduleDeviation(activeTripRawID) + + if scheduleDeviation != 0 { + status.ScheduleDeviation = scheduleDeviation + status.Predicted = true + } + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripRawID) if err == nil && len(stopTimes) > 0 { stopTimesPtrs := make([]*gtfsdb.StopTime, len(stopTimes)) From acea2a14ac6556f21eaf19899cd40b605345b182 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 07:45:38 +0200 Subject: [PATCH 33/94] fix: update vehicle ID assignment to use combined agency ID and improve block trip sequence calculation with active service IDs --- internal/restapi/trips_helper.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index af3c7cff..c9d68c1f 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -30,7 +30,7 @@ func (api *RestAPI) BuildTripStatus( if vehicle != nil { if vehicle.ID != nil { - status.VehicleID = vehicle.ID.ID + status.VehicleID = utils.FormCombinedID(agencyID, vehicle.ID.ID) } if vehicle.OccupancyStatus != nil { status.OccupancyStatus = vehicle.OccupancyStatus.String() @@ -413,17 +413,20 @@ func getDistanceAlongShapeInRange(lat, lon float64, shape []gtfs.ShapePoint, min // for trips that are active on the given service date. // Uses GetTripsByBlockIDOrdered to perform a single SQL JOIN instead of N+1 queries. func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time) int { - // Get the trip to find both block_id and service_id in one query trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) if err != nil { return 0 } - // Use the optimized query that JOINs trips with stop_times in SQL, - // ordered by first departure time — replaces N+1 queries + formattedDate := serviceDate.Format("20060102") + activeServiceIDs, err := api.GtfsManager.GtfsDB.Queries.GetActiveServiceIDsForDate(ctx, formattedDate) + if err != nil || len(activeServiceIDs) == 0 { + return 0 + } + orderedTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ BlockID: trip.BlockID, - ServiceIds: []string{trip.ServiceID}, + ServiceIds: activeServiceIDs, }) if err != nil { return 0 From 2eb3cc7f554654dbbae9c4a895537997fec922cf Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:20:03 +0200 Subject: [PATCH 34/94] docs: update comments for GetVehicleStatusAndPhase function to clarify vehicle status determination logic --- internal/restapi/vehicles_helper.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 2f97f325..aadcd301 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -9,6 +9,16 @@ import ( "maglev.onebusaway.org/internal/utils" ) +/* +Note!! +GetVehicleStatusAndPhase returns the OBA status and phase for a vehicle. +Java reference: VehicleStatusServiceImpl.java (handleVehicleLocationRecord) +onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/realtime/VehicleStatusServiceImpl.java +The Java implementation does not map directly to GTFS-RT CurrentStatus values. +Instead, it uses a simple rule: if a vehicle location record has been received, +the trip is "in_progress"; otherwise it remains "scheduled". The phase is +determined solely by the presence of the vehicle, not by its GTFS-RT stop status. +*/ func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase string) { if vehicle == nil { return "SCHEDULED", "scheduled" From 25b5d97eca237249e1a0288b9c9f77b977926f6b Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:30:26 +0200 Subject: [PATCH 35/94] refactor: rmove the code logic to vehicles_helper file --- internal/restapi/stale_detector.go | 44 ------------------------------ 1 file changed, 44 deletions(-) delete mode 100644 internal/restapi/stale_detector.go diff --git a/internal/restapi/stale_detector.go b/internal/restapi/stale_detector.go deleted file mode 100644 index 3a5c46ee..00000000 --- a/internal/restapi/stale_detector.go +++ /dev/null @@ -1,44 +0,0 @@ -package restapi - -import ( - "time" - - "github.com/OneBusAway/go-gtfs" -) - -type StaleDetector struct { - threshold time.Duration -} - -func NewStaleDetector() *StaleDetector { - return &StaleDetector{ - threshold: 15 * time.Minute, - } -} - -func (d *StaleDetector) WithThreshold(threshold time.Duration) *StaleDetector { - d.threshold = threshold - return d -} - -func (d *StaleDetector) Check(vehicle *gtfs.Vehicle, currentTime time.Time) bool { - if vehicle == nil { - return true - } - - if vehicle.Timestamp == nil { - return true - } - - age := currentTime.Sub(*vehicle.Timestamp) - - return age > d.threshold -} - -func (d *StaleDetector) Age(vehicle *gtfs.Vehicle, currentTime time.Time) time.Duration { - if vehicle == nil || vehicle.Timestamp == nil { - return d.threshold + 1 - } - - return currentTime.Sub(*vehicle.Timestamp) -} From c7ef29e924c6dbb27f11bceb30405531fa047f6c Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:49:18 +0200 Subject: [PATCH 36/94] feat: implement StaleDetector to manage vehicle data freshness and update GetVehicleStatusAndPhase logic --- internal/restapi/vehicles_helper.go | 71 +++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index aadcd301..ffc10dac 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -3,12 +3,67 @@ package restapi import ( "context" "math" + "time" "github.com/OneBusAway/go-gtfs" + gtfsrt "github.com/OneBusAway/go-gtfs/proto" "maglev.onebusaway.org/internal/models" "maglev.onebusaway.org/internal/utils" ) +// StaleDetector checks whether a vehicle's real-time data is too old to trust. +// +// Java reference: GtfsRealtimeSource.handleCombinedUpdates() +// onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/realtime/gtfs_realtime/GtfsRealtimeSource.java +// +// The Java implementation removes vehicle records from the active map when their +// last update time is more than 15 minutes in the past. We mirror that threshold +// here so stale vehicles are treated as absent rather than as live vehicles. +type StaleDetector struct { + threshold time.Duration +} + +func NewStaleDetector() *StaleDetector { + return &StaleDetector{threshold: 15 * time.Minute} +} + +func (d *StaleDetector) WithThreshold(threshold time.Duration) *StaleDetector { + d.threshold = threshold + return d +} + +// Check returns true when the vehicle's timestamp is missing or older than the threshold. +func (d *StaleDetector) Check(vehicle *gtfs.Vehicle, currentTime time.Time) bool { + if vehicle == nil || vehicle.Timestamp == nil { + return true + } + return currentTime.Sub(*vehicle.Timestamp) > d.threshold +} + +var defaultStaleDetector = NewStaleDetector() + +// scheduleRelationshipStatus converts a GTFS-RT TripDescriptor_ScheduleRelationship to +// the OBA status string. +// +// Java reference: GtfsRealtimeTripLibrary.java +// onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/realtime/gtfs_realtime/GtfsRealtimeTripLibrary.java +// +// Java calls record.setStatus(blockDescriptor.getScheduleRelationship().toString()), which +// produces the enum name as-is ("SCHEDULED", "CANCELED", "ADDED", "DUPLICATED"). The status +// "default" is only used when no real-time data exists at all (TripStatusBeanServiceImpl line 253). +func scheduleRelationshipStatus(sr gtfs.TripScheduleRelationship) string { + switch sr { + case gtfsrt.TripDescriptor_CANCELED: + return "CANCELED" + case gtfsrt.TripDescriptor_ADDED: + return "ADDED" + case gtfsrt.TripDescriptor_DUPLICATED: + return "DUPLICATED" + default: + return "SCHEDULED" + } +} + /* Note!! GetVehicleStatusAndPhase returns the OBA status and phase for a vehicle. @@ -18,17 +73,25 @@ The Java implementation does not map directly to GTFS-RT CurrentStatus values. Instead, it uses a simple rule: if a vehicle location record has been received, the trip is "in_progress"; otherwise it remains "scheduled". The phase is determined solely by the presence of the vehicle, not by its GTFS-RT stop status. +Status comes from the trip's schedule relationship ("SCHEDULED", "CANCELED", "ADDED", "DUPLICATED"). +"default" is only returned when no real-time data exists at all. */ func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase string) { if vehicle == nil { - return "SCHEDULED", "scheduled" + return "default", "" } + sr := gtfsrt.TripDescriptor_SCHEDULED + if vehicle.Trip != nil { + sr = vehicle.Trip.ID.ScheduleRelationship + } + status = scheduleRelationshipStatus(sr) + if vehicle.CurrentStatus != nil { - return "SCHEDULED", "in_progress" + phase = "in_progress" } - return "SCHEDULED", "scheduled" + return status, phase } func (api *RestAPI) BuildVehicleStatus( @@ -38,7 +101,7 @@ func (api *RestAPI) BuildVehicleStatus( agencyID string, status *models.TripStatusForTripDetails, ) { - if vehicle == nil { + if vehicle == nil || defaultStaleDetector.Check(vehicle, time.Now()) { status.Status, status.Phase = GetVehicleStatusAndPhase(nil) return } From ec6da1d4a54f8cbb9fdaf68fd67baf2fb15dd89e Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:49:31 +0200 Subject: [PATCH 37/94] fix: simplify vehicle ID assignment and improve stop distance calculations --- internal/restapi/trips_helper.go | 65 +++++++++++--------------------- 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index c9d68c1f..b58a7107 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -30,7 +30,7 @@ func (api *RestAPI) BuildTripStatus( if vehicle != nil { if vehicle.ID != nil { - status.VehicleID = utils.FormCombinedID(agencyID, vehicle.ID.ID) + status.VehicleID = vehicle.ID.ID } if vehicle.OccupancyStatus != nil { status.OccupancyStatus = vehicle.OccupancyStatus.String() @@ -119,7 +119,7 @@ func (api *RestAPI) BuildTripStatus( actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) status.DistanceAlongTrip = actualDistance - if scheduleDeviation != 0 && err == nil { + if scheduleDeviation != 0 && len(stopTimes) > 0 { scheduledDistance := api.calculateEffectiveDistanceAlongTrip( ctx, actualDistance, scheduleDeviation, currentTime, serviceDate, stopTimes, shapePoints, cumulativeDistances, @@ -593,36 +593,6 @@ func (api *RestAPI) GetSituationIDsForTrip(ctx context.Context, tripID string) [ return situationIDs } -type TripAgencyResolver struct { - RouteToAgency map[string]string - TripToRoute map[string]string -} - -// NewTripAgencyResolver creates a new TripAgencyResolver that maps trip IDs to their respective agency IDs. -func NewTripAgencyResolver(allRoutes []gtfsdb.Route, allTrips []gtfsdb.Trip) *TripAgencyResolver { - routeToAgency := make(map[string]string, len(allRoutes)) - for _, route := range allRoutes { - routeToAgency[route.ID] = route.AgencyID - } - tripToRoute := make(map[string]string, len(allTrips)) - for _, trip := range allTrips { - tripToRoute[trip.ID] = trip.RouteID - } - return &TripAgencyResolver{ - RouteToAgency: routeToAgency, - TripToRoute: tripToRoute, - } -} - -// GetAgencyNameByTripID retrieves the agency name for a given trip ID. -func (r *TripAgencyResolver) GetAgencyNameByTripID(tripID string) string { - routeID := r.TripToRoute[tripID] - - agency := r.RouteToAgency[routeID] - - return agency -} - func (api *RestAPI) calculateOffsetForStop( stopID string, stopTimes []*gtfsdb.StopTime, @@ -963,14 +933,28 @@ func (api *RestAPI) calculateEffectiveDistanceAlongTrip( return actualDistance } + stopIDs := make([]string, len(stopTimes)) + for i, st := range stopTimes { + stopIDs[i] = st.StopID + } + stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, stopIDs) + if err != nil { + return actualDistance + } + stopByID := make(map[string]gtfsdb.Stop, len(stops)) + for _, s := range stops { + stopByID[s.ID] = s + } + stopDistances := make([]float64, len(stopTimes)) for i, st := range stopTimes { - stop, err := api.GtfsManager.GtfsDB.Queries.GetStop(ctx, st.StopID) - if err == nil { - stopDistances[i] = api.calculatePreciseDistanceAlongTripWithCoords( - stop.Lat, stop.Lon, shapePoints, cumulativeDistances, - ) + stop, ok := stopByID[st.StopID] + if !ok { + return actualDistance } + stopDistances[i] = api.calculatePreciseDistanceAlongTripWithCoords( + stop.Lat, stop.Lon, shapePoints, cumulativeDistances, + ) } currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) @@ -984,7 +968,7 @@ func interpolateDistanceAtScheduledTime( stopTimes []gtfsdb.StopTime, cumulativeDistances []float64, ) float64 { - if len(stopTimes) == 0 { + if len(stopTimes) == 0 || len(cumulativeDistances) != len(stopTimes) { return 0 } @@ -1002,10 +986,7 @@ func interpolateDistanceAtScheduledTime( timeRatio := float64(scheduledTime-fromTime) / float64(toTime-fromTime) - fromDistance := cumulativeDistances[i*len(cumulativeDistances)/len(stopTimes)] - toDistance := cumulativeDistances[(i+1)*len(cumulativeDistances)/len(stopTimes)] - - return fromDistance + timeRatio*(toDistance-fromDistance) + return cumulativeDistances[i] + timeRatio*(cumulativeDistances[i+1]-cumulativeDistances[i]) } } From bd035e318ea2341597d6ae30fd4784ad01103209 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:49:42 +0200 Subject: [PATCH 38/94] fix: correct vehicle ID assertion in TestBuildTripStatus_VehicleIDFormat --- internal/restapi/trips_helper_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index ee82a689..704a4ffd 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -517,7 +517,7 @@ func TestBuildTripStatus_VehicleIDFormat(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, model) - assert.Equal(t, utils.FormCombinedID(agencyID, vehicleID), model.VehicleID) + assert.Equal(t, vehicleID, model.VehicleID) } // BenchmarkDistanceToLineSegment benchmarks the line segment distance calculation From 46427d9e56f077845defc69471d9704f32a87645 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:58:55 +0200 Subject: [PATCH 39/94] feat: add timestamp to MockAddVehicle for vehicle tracking --- internal/gtfs/gtfs_manager_mock.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index dc63d118..ad758fce 100644 --- a/internal/gtfs/gtfs_manager_mock.go +++ b/internal/gtfs/gtfs_manager_mock.go @@ -1,6 +1,8 @@ package gtfs import ( + "time" + "github.com/OneBusAway/go-gtfs" ) @@ -34,8 +36,10 @@ func (m *Manager) MockAddVehicle(vehicleID, tripID, routeID string) { return } } + now := time.Now() m.realTimeVehicles = append(m.realTimeVehicles, gtfs.Vehicle{ - ID: >fs.VehicleID{ID: vehicleID}, + ID: >fs.VehicleID{ID: vehicleID}, + Timestamp: &now, Trip: >fs.Trip{ ID: gtfs.TripID{ ID: tripID, From bd2ed35d1d7a8e93eb4bfb72b1e30eb586b0cb4a Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Fri, 20 Feb 2026 08:59:00 +0200 Subject: [PATCH 40/94] fix: update vehicle phase logic to reflect trip status accurately --- internal/restapi/vehicles_helper.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index ffc10dac..78eeabd7 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -87,7 +87,9 @@ func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase strin } status = scheduleRelationshipStatus(sr) - if vehicle.CurrentStatus != nil { + // Java sets phase to IN_PROGRESS whenever a vehicle location record is received, + // regardless of GTFS-RT CurrentStatus — unless the trip is canceled. + if sr != gtfsrt.TripDescriptor_CANCELED { phase = "in_progress" } From fe0628340ed9360d9661505666a6e979b4ab6913 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:08:46 +0200 Subject: [PATCH 41/94] feat: add functions to convert GTFS stop-time values from nanoseconds to seconds, this will help to stop using /1e9 pattern --- internal/utils/api.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/utils/api.go b/internal/utils/api.go index 6de53b6e..61f3806a 100644 --- a/internal/utils/api.go +++ b/internal/utils/api.go @@ -33,6 +33,22 @@ func CalculateSecondsSinceServiceDate(currentTime time.Time, serviceDate time.Ti return int64(duration.Seconds()) } +// NanosToSeconds converts a GTFS stop-time value (stored as nanoseconds since midnight) +// to seconds since midnight. +func NanosToSeconds(nanos int64) int64 { + return nanos / 1e9 +} + +// EffectiveStopTimeSeconds returns the effective stop time in seconds since midnight, +// using arrivalTimeNanos with a fallback to departureTimeNanos when arrival is zero. +// Both inputs are nanoseconds since midnight (the GTFS database storage format). +func EffectiveStopTimeSeconds(arrivalTimeNanos, departureTimeNanos int64) int64 { + if arrivalTimeNanos > 0 { + return arrivalTimeNanos / 1e9 + } + return departureTimeNanos / 1e9 +} + // ExtractCodeID extracts the `code_id` from a string in the format `{agency_id}_{code_id}`. func ExtractCodeID(combinedID string) (string, error) { parts := strings.SplitN(combinedID, "_", 2) From 19c6a6c6a8ecadc983de3e2cea327479e761f81c Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:09:04 +0200 Subject: [PATCH 42/94] fix: update NanosToSeconds function comment for clarity on GTFS stop-time value --- internal/utils/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/utils/api.go b/internal/utils/api.go index 61f3806a..a2c2dc9c 100644 --- a/internal/utils/api.go +++ b/internal/utils/api.go @@ -33,7 +33,7 @@ func CalculateSecondsSinceServiceDate(currentTime time.Time, serviceDate time.Ti return int64(duration.Seconds()) } -// NanosToSeconds converts a GTFS stop-time value (stored as nanoseconds since midnight) +// Converts a GTFS stop-time value (stored as nanoseconds in db since midnight) // to seconds since midnight. func NanosToSeconds(nanos int64) int64 { return nanos / 1e9 From 38ee862e31397649a43979c20f50b65a713db928 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:09:23 +0200 Subject: [PATCH 43/94] feat: Add MockAddTripUpdate function for managing real-time trip updates --- internal/gtfs/gtfs_manager_mock.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index ad758fce..1c04065a 100644 --- a/internal/gtfs/gtfs_manager_mock.go +++ b/internal/gtfs/gtfs_manager_mock.go @@ -62,3 +62,19 @@ func (m *Manager) MockAddTrip(tripID, agencyID, routeID string) { Route: >fs.Route{Id: routeID}, }) } + +func (m *Manager) MockAddTripUpdate(tripID string, delay *time.Duration, stopTimeUpdates []gtfs.StopTimeUpdate) { + m.realTimeMutex.Lock() + defer m.realTimeMutex.Unlock() + + trip := gtfs.Trip{ + ID: gtfs.TripID{ID: tripID}, + Delay: delay, + StopTimeUpdates: stopTimeUpdates, + } + m.realTimeTrips = append(m.realTimeTrips, trip) + if m.realTimeTripLookup == nil { + m.realTimeTripLookup = make(map[string]int) + } + m.realTimeTripLookup[tripID] = len(m.realTimeTrips) - 1 +} From 5d8fb3acf4f30d40f47e0d2960b96207ab8b34f2 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:13:32 +0200 Subject: [PATCH 44/94] refactor: extract shapeRowsToPoints function for converting shape rows to ShapePoint slice --- internal/restapi/shape_distance_helpers.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/restapi/shape_distance_helpers.go b/internal/restapi/shape_distance_helpers.go index 3e3c0074..c1ff1487 100644 --- a/internal/restapi/shape_distance_helpers.go +++ b/internal/restapi/shape_distance_helpers.go @@ -4,8 +4,18 @@ import ( "context" "github.com/OneBusAway/go-gtfs" + "maglev.onebusaway.org/gtfsdb" ) +// shapeRowsToPoints converts database shape rows to gtfs.ShapePoint slice. +func shapeRowsToPoints(rows []gtfsdb.Shape) []gtfs.ShapePoint { + pts := make([]gtfs.ShapePoint, len(rows)) + for i, sp := range rows { + pts[i] = gtfs.ShapePoint{Latitude: sp.Lat, Longitude: sp.Lon} + } + return pts +} + // IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) getStopDistanceAlongShape(ctx context.Context, tripID, stopID string) float64 { stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) @@ -27,10 +37,7 @@ func (api *RestAPI) getStopDistanceAlongShape(ctx context.Context, tripID, stopI return 0 } - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{Latitude: sp.Lat, Longitude: sp.Lon} - } + shapePoints := shapeRowsToPoints(shapeRows) return getDistanceAlongShape(stop.Lat, stop.Lon, shapePoints) } @@ -46,10 +53,7 @@ func (api *RestAPI) getVehicleDistanceAlongShapeContextual(ctx context.Context, return 0 } - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{Latitude: sp.Lat, Longitude: sp.Lon} - } + shapePoints := shapeRowsToPoints(shapeRows) lat := float64(*vehicle.Position.Latitude) lon := float64(*vehicle.Position.Longitude) From 365776b2bb6be9366339af8e1281da80a22c9b9c Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:13:50 +0200 Subject: [PATCH 45/94] fix: update GetVehicleStatusAndPhase to align with Java behavior and improve vehicle status handling --- internal/restapi/vehicles_helper.go | 39 ++++++----------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 78eeabd7..e96ec0d6 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -78,7 +78,10 @@ Status comes from the trip's schedule relationship ("SCHEDULED", "CANCELED", "AD */ func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase string) { if vehicle == nil { - return "default", "" + // "default" matches the Java OBA behavior. In TripStatusBeanServiceImpl.getBlockLocationAsStatusBean() + // (line 252-253), status is unconditionally set to "default" first. When no real-time data exists, + // Java file: onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/beans/TripStatusBeanServiceImpl.java + return "default", "scheduled" } sr := gtfsrt.TripDescriptor_SCHEDULED @@ -103,7 +106,7 @@ func (api *RestAPI) BuildVehicleStatus( agencyID string, status *models.TripStatusForTripDetails, ) { - if vehicle == nil || defaultStaleDetector.Check(vehicle, time.Now()) { + if vehicle == nil || defaultStaleDetector.Check(vehicle, api.Clock.Now()) { status.Status, status.Phase = GetVehicleStatusAndPhase(nil) return } @@ -167,13 +170,7 @@ func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, return nil } - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } + shapePoints := shapeRowsToPoints(shapeRows) minDistance := math.MaxFloat64 var closestPoint models.Location @@ -199,29 +196,7 @@ func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, } func projectPointToSegment(px, py, x1, y1, x2, y2 float64) (float64, models.Location) { - dx := x2 - x1 - dy := y2 - y1 - - if dx == 0 && dy == 0 { - dist := utils.Distance(px, py, x1, y1) - return dist, models.Location{Lat: x1, Lon: y1} - } - - t := ((px-x1)*dx + (py-y1)*dy) / (dx*dx + dy*dy) - - if t < 0 { - dist := utils.Distance(px, py, x1, y1) - return dist, models.Location{Lat: x1, Lon: y1} - } - if t > 1 { - dist := utils.Distance(px, py, x2, y2) - return dist, models.Location{Lat: x2, Lon: y2} - } - - projLat := x1 + t*dx - projLon := y1 + t*dy - - dist := utils.Distance(px, py, projLat, projLon) + dist, _, projLat, projLon := projectOntoSegment(px, py, x1, y1, x2, y2) return dist, models.Location{Lat: projLat, Lon: projLon} } From 94696a2936c7dc9ae48d1db63ca7e1bda7cf7ce8 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:14:10 +0200 Subject: [PATCH 46/94] test: add unit tests for GetVehicleStatusAndPhase and StaleDetector functions --- internal/restapi/vehicles_helper_test.go | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 internal/restapi/vehicles_helper_test.go diff --git a/internal/restapi/vehicles_helper_test.go b/internal/restapi/vehicles_helper_test.go new file mode 100644 index 00000000..2efb2e02 --- /dev/null +++ b/internal/restapi/vehicles_helper_test.go @@ -0,0 +1,201 @@ +package restapi + +import ( + "context" + "testing" + "time" + + "github.com/OneBusAway/go-gtfs" + gtfsrt "github.com/OneBusAway/go-gtfs/proto" + "github.com/stretchr/testify/assert" + "maglev.onebusaway.org/internal/models" +) + +func TestGetVehicleStatusAndPhase_NilVehicle(t *testing.T) { + status, phase := GetVehicleStatusAndPhase(nil) + assert.Equal(t, "default", status) + assert.Equal(t, "scheduled", phase) +} + +func TestGetVehicleStatusAndPhase_ScheduledTrip(t *testing.T) { + sr := gtfsrt.TripDescriptor_SCHEDULED + vehicle := >fs.Vehicle{ + Trip: >fs.Trip{ + ID: gtfs.TripID{ScheduleRelationship: sr}, + }, + } + status, phase := GetVehicleStatusAndPhase(vehicle) + assert.Equal(t, "SCHEDULED", status) + assert.Equal(t, "in_progress", phase) +} + +func TestGetVehicleStatusAndPhase_CanceledTrip(t *testing.T) { + sr := gtfsrt.TripDescriptor_CANCELED + vehicle := >fs.Vehicle{ + Trip: >fs.Trip{ + ID: gtfs.TripID{ScheduleRelationship: sr}, + }, + } + status, phase := GetVehicleStatusAndPhase(vehicle) + assert.Equal(t, "CANCELED", status) + assert.Equal(t, "", phase, "canceled trip should have empty phase") +} + +func TestGetVehicleStatusAndPhase_AddedTrip(t *testing.T) { + sr := gtfsrt.TripDescriptor_ADDED + vehicle := >fs.Vehicle{ + Trip: >fs.Trip{ + ID: gtfs.TripID{ScheduleRelationship: sr}, + }, + } + status, phase := GetVehicleStatusAndPhase(vehicle) + assert.Equal(t, "ADDED", status) + assert.Equal(t, "in_progress", phase) +} + +func TestGetVehicleStatusAndPhase_DuplicatedTrip(t *testing.T) { + sr := gtfsrt.TripDescriptor_DUPLICATED + vehicle := >fs.Vehicle{ + Trip: >fs.Trip{ + ID: gtfs.TripID{ScheduleRelationship: sr}, + }, + } + status, phase := GetVehicleStatusAndPhase(vehicle) + assert.Equal(t, "DUPLICATED", status) + assert.Equal(t, "in_progress", phase) +} + +func TestGetVehicleStatusAndPhase_NoTripInfo(t *testing.T) { + // Vehicle present but no Trip field — should default to SCHEDULED + vehicle := >fs.Vehicle{} + status, phase := GetVehicleStatusAndPhase(vehicle) + assert.Equal(t, "SCHEDULED", status) + assert.Equal(t, "in_progress", phase) +} + +func TestStaleDetector_NilVehicle(t *testing.T) { + d := NewStaleDetector() + assert.True(t, d.Check(nil, time.Now()), "nil vehicle should be considered stale") +} + +func TestStaleDetector_NilTimestamp(t *testing.T) { + d := NewStaleDetector() + vehicle := >fs.Vehicle{} // Timestamp is nil + assert.True(t, d.Check(vehicle, time.Now()), "vehicle with nil timestamp should be considered stale") +} + +func TestStaleDetector_FreshVehicle(t *testing.T) { + d := NewStaleDetector() + now := time.Now() + recent := now.Add(-5 * time.Minute) + vehicle := >fs.Vehicle{Timestamp: &recent} + assert.False(t, d.Check(vehicle, now), "vehicle updated 5 minutes ago should not be stale with 15-minute threshold") +} + +func TestStaleDetector_StaleVehicle(t *testing.T) { + d := NewStaleDetector() + now := time.Now() + old := now.Add(-20 * time.Minute) + vehicle := >fs.Vehicle{Timestamp: &old} + assert.True(t, d.Check(vehicle, now), "vehicle updated 20 minutes ago should be stale with 15-minute threshold") +} + +func TestStaleDetector_ExactThreshold(t *testing.T) { + d := NewStaleDetector() + now := time.Now() + exactly := now.Add(-15 * time.Minute) + vehicle := >fs.Vehicle{Timestamp: &exactly} + // Exactly at threshold is NOT stale (threshold is strict >) + assert.False(t, d.Check(vehicle, now), "vehicle at exactly 15 minutes should not be stale") +} + +func TestStaleDetector_WithCustomThreshold(t *testing.T) { + d := NewStaleDetector().WithThreshold(5 * time.Minute) + now := time.Now() + + fresh := now.Add(-3 * time.Minute) + freshVehicle := >fs.Vehicle{Timestamp: &fresh} + assert.False(t, d.Check(freshVehicle, now), "3-minute old vehicle should not be stale with 5-minute threshold") + + stale := now.Add(-6 * time.Minute) + staleVehicle := >fs.Vehicle{Timestamp: &stale} + assert.True(t, d.Check(staleVehicle, now), "6-minute old vehicle should be stale with 5-minute threshold") +} + +func TestBuildVehicleStatus_NilVehicleSetsDefaultStatus(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + status := &models.TripStatusForTripDetails{} + api.BuildVehicleStatus(ctx, nil, "any-trip", "any-agency", status) + + assert.Equal(t, "default", status.Status) + assert.Equal(t, "scheduled", status.Phase) + assert.False(t, status.Predicted) + assert.False(t, status.Scheduled) +} + +func TestBuildVehicleStatus_StaleVehicleSetsDefaultStatus(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + old := time.Now().Add(-20 * time.Minute) + vehicle := >fs.Vehicle{ + ID: >fs.VehicleID{ID: "v1"}, + Timestamp: &old, + } + + status := &models.TripStatusForTripDetails{} + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) + + assert.Equal(t, "default", status.Status) + assert.Equal(t, "scheduled", status.Phase) +} + +func TestBuildVehicleStatus_FreshVehicleWithPosition_PredictedTrue(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + now := time.Now() + lat := float32(37.7749) + lon := float32(-122.4194) + vehicle := >fs.Vehicle{ + ID: >fs.VehicleID{ID: "v1"}, + Timestamp: &now, + Position: >fs.Position{ + Latitude: &lat, + Longitude: &lon, + }, + } + + status := &models.TripStatusForTripDetails{} + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) + + assert.True(t, status.Predicted, "vehicle with position should have Predicted=true") + assert.False(t, status.Scheduled) + assert.Equal(t, "SCHEDULED", status.Status) + assert.Equal(t, "in_progress", status.Phase) +} + +func TestBuildVehicleStatus_FreshVehicleNoPosition_PredictedTrue(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + now := time.Now() + vehicle := >fs.Vehicle{ + ID: >fs.VehicleID{ID: "v1"}, + Timestamp: &now, + // No Position + } + + status := &models.TripStatusForTripDetails{} + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) + + // Timestamp alone counts as real-time data + assert.True(t, status.Predicted) + assert.False(t, status.Scheduled) +} From 38ba04c3846b2cb51c0691a679f146a2f11c18d9 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:15:07 +0200 Subject: [PATCH 47/94] refactor: use the shalpeRowsToPoints directly --- internal/restapi/block_distance_helper.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/restapi/block_distance_helper.go b/internal/restapi/block_distance_helper.go index 6ad65ae2..c53e99ad 100644 --- a/internal/restapi/block_distance_helper.go +++ b/internal/restapi/block_distance_helper.go @@ -59,10 +59,7 @@ func (api *RestAPI) getBlockDistanceToStop(ctx context.Context, targetTripID, ta shapeRows, _ := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, blockTrip.ID) totalDist := 0.0 if len(shapeRows) > 1 { - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{Latitude: sp.Lat, Longitude: sp.Lon} - } + shapePoints := shapeRowsToPoints(shapeRows) totalDist = preCalculateCumulativeDistances(shapePoints)[len(shapePoints)-1] } From e8da79f9748f3bb7255731080616d03fceb0dda3 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:15:18 +0200 Subject: [PATCH 48/94] fix: update time conversion in transformBlockToEntry to use NanosToSeconds func --- internal/restapi/block_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/restapi/block_handler.go b/internal/restapi/block_handler.go index 5b81f8d6..d7acd9b1 100644 --- a/internal/restapi/block_handler.go +++ b/internal/restapi/block_handler.go @@ -137,8 +137,8 @@ func transformBlockToEntry(block []gtfsdb.GetBlockDetailsRow, blockID, agencyID BlockSequence: int(stop.StopSequence - 1), DistanceAlongBlock: blockDistance, StopTime: models.StopTime{ - ArrivalTime: int(stop.ArrivalTime / 1e9), - DepartureTime: int(stop.DepartureTime / 1e9), + ArrivalTime: int(utils.NanosToSeconds(stop.ArrivalTime)), + DepartureTime: int(utils.NanosToSeconds(stop.DepartureTime)), DropOffType: int(stop.DropOffType.Int64), PickupType: int(stop.PickupType.Int64), StopID: utils.FormCombinedID(agencyID, stop.StopID), From 8a3549827d6590d05e86b3166777f5e9322eddb2 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:15:33 +0200 Subject: [PATCH 49/94] fix: update arrival and departure time conversion to use NanosToSeconds func --- internal/restapi/schedule_for_route_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/restapi/schedule_for_route_handler.go b/internal/restapi/schedule_for_route_handler.go index e6e0fc37..32e9333f 100644 --- a/internal/restapi/schedule_for_route_handler.go +++ b/internal/restapi/schedule_for_route_handler.go @@ -158,8 +158,8 @@ func (api *RestAPI) scheduleForRouteHandler(w http.ResponseWriter, r *http.Reque } stopTimesList := make([]models.RouteStopTime, 0, len(stopTimes)) for _, st := range stopTimes { - arrivalSec := int(st.ArrivalTime / 1e9) - departureSec := int(st.DepartureTime / 1e9) + arrivalSec := int(utils.NanosToSeconds(st.ArrivalTime)) + departureSec := int(utils.NanosToSeconds(st.DepartureTime)) stopTimesList = append(stopTimesList, models.RouteStopTime{ ArrivalEnabled: true, ArrivalTime: arrivalSec, From 074af8fdbb0e8838f173de191e8c059689b7e7e4 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:15:43 +0200 Subject: [PATCH 50/94] fix: update parameter parsing in TestParseTripIdDetailsParams_Unit to use parseTripParams function --- internal/restapi/trip_details_handler_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/restapi/trip_details_handler_test.go b/internal/restapi/trip_details_handler_test.go index 41f52576..1a0b2770 100644 --- a/internal/restapi/trip_details_handler_test.go +++ b/internal/restapi/trip_details_handler_test.go @@ -380,7 +380,7 @@ func TestParseTripIdDetailsParams_Unit(t *testing.T) { defer api.Shutdown() req := httptest.NewRequest("GET", "/?includeTrip=false&includeSchedule=false&serviceDate=1609459200000", nil) - params, errs := api.parseTripIdDetailsParams(req) + params, errs := api.parseTripParams(req, true) assert.Nil(t, errs) assert.False(t, params.IncludeTrip) @@ -388,7 +388,7 @@ func TestParseTripIdDetailsParams_Unit(t *testing.T) { assert.NotNil(t, params.ServiceDate) reqDefault := httptest.NewRequest("GET", "/", nil) - paramsDefault, errsDefault := api.parseTripIdDetailsParams(reqDefault) + paramsDefault, errsDefault := api.parseTripParams(reqDefault, true) assert.Nil(t, errsDefault) assert.True(t, paramsDefault.IncludeTrip) @@ -396,7 +396,7 @@ func TestParseTripIdDetailsParams_Unit(t *testing.T) { assert.True(t, paramsDefault.IncludeSchedule) reqInvalid := httptest.NewRequest("GET", "/?time=invalid&serviceDate=invalid", nil) - _, errsInvalid := api.parseTripIdDetailsParams(reqInvalid) + _, errsInvalid := api.parseTripParams(reqInvalid, true) assert.NotNil(t, errsInvalid) assert.Contains(t, errsInvalid, "time") From 476d088c518f77348438ab500586c6a2bfb5529d Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:16:40 +0200 Subject: [PATCH 51/94] refactor: rename TripDetailsParams to TripParams and update parsing function --- internal/restapi/trip_details_handler.go | 26 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 7b7c2462..c1c39762 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -12,7 +12,9 @@ import ( "maglev.onebusaway.org/internal/utils" ) -type TripDetailsParams struct { +// TripParams holds the common query parameters for trip-related endpoints +// (trip-details, trip-for-vehicle, etc.). +type TripParams struct { ServiceDate *time.Time IncludeTrip bool IncludeSchedule bool @@ -20,11 +22,13 @@ type TripDetailsParams struct { Time *time.Time } -// parseTripIdDetailsParams parses and validates parameters. -func (api *RestAPI) parseTripIdDetailsParams(r *http.Request) (TripDetailsParams, map[string][]string) { - params := TripDetailsParams{ +// parseTripParams parses and validates the common trip query parameters. +// includeScheduleDefault controls the default value of IncludeSchedule when the +// parameter is not present in the request (true for trip-details, false for trip-for-vehicle). +func (api *RestAPI) parseTripParams(r *http.Request, includeScheduleDefault bool) (TripParams, map[string][]string) { + params := TripParams{ IncludeTrip: true, - IncludeSchedule: true, + IncludeSchedule: includeScheduleDefault, IncludeStatus: true, } @@ -107,7 +111,7 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { defer api.GtfsManager.RUnlock() // Capture parsing errors - params, fieldErrors := api.parseTripIdDetailsParams(r) + params, fieldErrors := api.parseTripParams(r, true) if len(fieldErrors) > 0 { api.validationErrorResponse(w, r, fieldErrors) return @@ -157,7 +161,13 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { } } - situationsIDs := api.GetSituationIDsForTrip(r.Context(), tripID) + var situationsIDs []string + if status != nil && len(status.SituationIDs) > 0 { + situationsIDs = status.SituationIDs + } else { + situationsIDs = api.GetSituationIDsForTrip(r.Context(), tripID) + } + tripDetails := &models.TripDetails{ TripID: utils.FormCombinedID(agencyID, trip.ID), ServiceDate: serviceDateMillis, @@ -166,7 +176,7 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { SituationIDs: situationsIDs, } - if status != nil && status.VehicleID != "" { + if status != nil { tripDetails.Status = status } From 8f40e13cb678cd5d7e80439cf43293d47634148e Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:17:55 +0200 Subject: [PATCH 52/94] fix: update occupancy capacity handling and time conversions in BuildTripStatus to use NanoToSeconds func --- internal/restapi/trips_helper.go | 120 ++++++++++++------------------- 1 file changed, 44 insertions(+), 76 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index b58a7107..cf68df1c 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -35,17 +35,20 @@ func (api *RestAPI) BuildTripStatus( if vehicle.OccupancyStatus != nil { status.OccupancyStatus = vehicle.OccupancyStatus.String() } - api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) + if vehicle.OccupancyPercentage != nil { + status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) + } } + api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) _, activeTripRawID, err := utils.ExtractAgencyIDAndCodeID(status.ActiveTripID) if err != nil { return status, err } - scheduleDeviation := api.GetScheduleDeviation(activeTripRawID) + scheduleDeviation, hasRealtimeTripUpdate := api.GetScheduleDeviation(activeTripRawID) - if scheduleDeviation != 0 { + if hasRealtimeTripUpdate { status.ScheduleDeviation = scheduleDeviation status.Predicted = true } @@ -84,7 +87,7 @@ func (api *RestAPI) BuildTripStatus( ) } } else { - stopDelays := api.GetStopDelaysFromTripUpdates(tripID) + stopDelays := api.GetStopDelaysFromTripUpdates(activeTripRawID) closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTime, serviceDate, stopTimesPtrs, stopDelays) nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTime, serviceDate, stopTimesPtrs, stopDelays) } @@ -105,13 +108,7 @@ func (api *RestAPI) BuildTripStatus( shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) if shapeErr == nil && len(shapeRows) > 1 { - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } + shapePoints := shapeRowsToPoints(shapeRows) cumulativeDistances := preCalculateCumulativeDistances(shapePoints) status.TotalDistanceAlongTrip = cumulativeDistances[len(cumulativeDistances)-1] @@ -147,13 +144,7 @@ func (api *RestAPI) BuildTripSchedule(ctx context.Context, agencyID string, serv shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, trip.ID) var shapePoints []gtfs.ShapePoint if err == nil && len(shapeRows) > 0 { - shapePoints = make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } + shapePoints = shapeRowsToPoints(shapeRows) } var nextTripID, previousTripID string @@ -243,20 +234,13 @@ func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.Tr currentSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) for i, st := range stopTimes { - arrivalTime := st.ArrivalTime / 1e9 - if arrivalTime == 0 { - arrivalTime = st.DepartureTime / 1e9 - } - + arrivalTime := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) predictedArrival := arrivalTime + int64(status.ScheduleDeviation) if predictedArrival > currentSeconds { if i > 0 { status.ClosestStop = utils.FormCombinedID(agencyID, stopTimes[i-1].StopID) - closestArrival := stopTimes[i-1].ArrivalTime / 1e9 - if closestArrival == 0 { - closestArrival = stopTimes[i-1].DepartureTime / 1e9 - } + closestArrival := utils.EffectiveStopTimeSeconds(stopTimes[i-1].ArrivalTime, stopTimes[i-1].DepartureTime) status.ClosestStopTimeOffset = int(closestArrival + int64(status.ScheduleDeviation) - currentSeconds) } status.NextStop = utils.FormCombinedID(agencyID, st.StopID) @@ -268,10 +252,7 @@ func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.Tr if len(stopTimes) > 0 { lastStop := stopTimes[len(stopTimes)-1] status.ClosestStop = utils.FormCombinedID(agencyID, lastStop.StopID) - arrivalTime := lastStop.ArrivalTime / 1e9 - if arrivalTime == 0 { - arrivalTime = lastStop.DepartureTime / 1e9 - } + arrivalTime := utils.EffectiveStopTimeSeconds(lastStop.ArrivalTime, lastStop.DepartureTime) status.ClosestStopTimeOffset = int(arrivalTime + int64(status.ScheduleDeviation) - currentSeconds) } } @@ -284,9 +265,9 @@ func findClosestStopByTimeWithDelays(currentTime time.Time, serviceDate time.Tim for _, st := range stopTimes { var stopTimeSeconds int64 if st.DepartureTime > 0 { - stopTimeSeconds = st.DepartureTime / 1e9 + stopTimeSeconds = utils.NanosToSeconds(st.DepartureTime) } else if st.ArrivalTime > 0 { - stopTimeSeconds = st.ArrivalTime / 1e9 + stopTimeSeconds = utils.NanosToSeconds(st.ArrivalTime) } else { continue } @@ -324,9 +305,9 @@ func findNextStopByTimeWithDelays(currentTime time.Time, serviceDate time.Time, for _, st := range stopTimes { var stopTimeSeconds int64 if st.DepartureTime > 0 { - stopTimeSeconds = st.DepartureTime / 1e9 + stopTimeSeconds = utils.NanosToSeconds(st.DepartureTime) } else if st.ArrivalTime > 0 { - stopTimeSeconds = st.ArrivalTime / 1e9 + stopTimeSeconds = utils.NanosToSeconds(st.ArrivalTime) } else { continue } @@ -533,14 +514,15 @@ func preCalculateCumulativeDistances(shapePoints []gtfs.ShapePoint) []float64 { return cumulativeDistances } -// Helper function to calculate distance from point to line segment -func distanceToLineSegment(px, py, x1, y1, x2, y2 float64) (distance, ratio float64) { +// projectOntoSegment is the shared implementation for projecting a point onto a line segment. +// Returns the distance from point to the closest point on the segment, the projection ratio t ∈ [0,1], +func projectOntoSegment(px, py, x1, y1, x2, y2 float64) (distance, ratio float64, projLat, projLon float64) { dx := x2 - x1 dy := y2 - y1 if dx == 0 && dy == 0 { // Line segment is a point - return utils.Distance(px, py, x1, y1), 0 + return utils.Distance(px, py, x1, y1), 0, x1, y1 } // Calculate the parameter t for the projection of point onto the line @@ -557,7 +539,14 @@ func distanceToLineSegment(px, py, x1, y1, x2, y2 float64) (distance, ratio floa closestX := x1 + t*dx closestY := y1 + t*dy - return utils.Distance(px, py, closestX, closestY), t + return utils.Distance(px, py, closestX, closestY), t, closestX, closestY +} + +// distanceToLineSegment returns the distance from a point to the closest point on a line segment +// and the projection ratio t ∈ [0,1]. +func distanceToLineSegment(px, py, x1, y1, x2, y2 float64) (distance, ratio float64) { + d, r, _, _ := projectOntoSegment(px, py, x1, y1, x2, y2) + return d, r } // IMPORTANT: Caller must hold manager.RLock() before calling this method. @@ -604,10 +593,7 @@ func (api *RestAPI) calculateOffsetForStop( for _, st := range stopTimes { if st.StopID == stopID { - stopTimeSeconds := st.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = st.DepartureTime / 1e9 - } + stopTimeSeconds := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) predictedArrival := stopTimeSeconds + int64(scheduleDeviation) return int(predictedArrival - currentTimeSeconds) } @@ -633,10 +619,7 @@ func (api *RestAPI) findNextStopAfter( if st.StopID == currentStopID { if i+1 < len(stopTimes) { nextSt := stopTimes[i+1] - stopTimeSeconds := nextSt.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = nextSt.DepartureTime / 1e9 - } + stopTimeSeconds := utils.EffectiveStopTimeSeconds(nextSt.ArrivalTime, nextSt.DepartureTime) predictedArrival := stopTimeSeconds + int64(scheduleDeviation) return nextSt.StopID, int(predictedArrival - currentTimeSeconds) } @@ -660,8 +643,8 @@ func (api *RestAPI) calculateBatchStopDistances( for _, stopTime := range timeStops { stopTimesList = append(stopTimesList, models.StopTime{ StopID: utils.FormCombinedID(agencyID, stopTime.StopID), - ArrivalTime: int(stopTime.ArrivalTime / 1e9), - DepartureTime: int(stopTime.DepartureTime / 1e9), + ArrivalTime: int(utils.NanosToSeconds(stopTime.ArrivalTime)), + DepartureTime: int(utils.NanosToSeconds(stopTime.DepartureTime)), StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), DistanceAlongTrip: 0.0, HistoricalOccupancy: "", @@ -676,8 +659,8 @@ func (api *RestAPI) calculateBatchStopDistances( for _, stopTime := range timeStops { stopTimesList = append(stopTimesList, models.StopTime{ StopID: utils.FormCombinedID(agencyID, stopTime.StopID), - ArrivalTime: int(stopTime.ArrivalTime / 1e9), - DepartureTime: int(stopTime.DepartureTime / 1e9), + ArrivalTime: int(utils.NanosToSeconds(stopTime.ArrivalTime)), + DepartureTime: int(utils.NanosToSeconds(stopTime.DepartureTime)), StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), DistanceAlongTrip: 0.0, HistoricalOccupancy: "", @@ -742,8 +725,8 @@ func (api *RestAPI) calculateBatchStopDistances( stopTimesList = append(stopTimesList, models.StopTime{ StopID: utils.FormCombinedID(agencyID, stopTime.StopID), - ArrivalTime: int(stopTime.ArrivalTime / 1e9), - DepartureTime: int(stopTime.DepartureTime / 1e9), + ArrivalTime: int(utils.NanosToSeconds(stopTime.ArrivalTime)), + DepartureTime: int(utils.NanosToSeconds(stopTime.DepartureTime)), StopHeadsign: utils.NullStringOrEmpty(stopTime.StopHeadsign), DistanceAlongTrip: distanceAlongTrip, HistoricalOccupancy: "", @@ -769,10 +752,7 @@ func (api *RestAPI) findStopsByScheduleDeviation( var closestTimeDiff int64 = math.MaxInt64 for _, st := range stopTimes { - stopTime := st.ArrivalTime / 1e9 - if stopTime == 0 { - stopTime = st.DepartureTime / 1e9 - } + stopTime := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) timeDiff := stopTime - effectiveScheduleTime if timeDiff < 0 { @@ -791,10 +771,7 @@ func (api *RestAPI) findStopsByScheduleDeviation( closestStopID = closestStop.StopID - closestStopTime := closestStop.ArrivalTime / 1e9 - if closestStopTime == 0 { - closestStopTime = closestStop.DepartureTime / 1e9 - } + closestStopTime := utils.EffectiveStopTimeSeconds(closestStop.ArrivalTime, closestStop.DepartureTime) predictedClosestArrival := closestStopTime + int64(scheduleDeviation) closestOffset = int(predictedClosestArrival - currentTimeSeconds) @@ -804,10 +781,7 @@ func (api *RestAPI) findStopsByScheduleDeviation( nextSt := stopTimes[i+1] nextStopID = nextSt.StopID - nextStopTime := nextSt.ArrivalTime / 1e9 - if nextStopTime == 0 { - nextStopTime = nextSt.DepartureTime / 1e9 - } + nextStopTime := utils.EffectiveStopTimeSeconds(nextSt.ArrivalTime, nextSt.DepartureTime) predictedNextArrival := nextStopTime + int64(scheduleDeviation) nextOffset = int(predictedNextArrival - currentTimeSeconds) } @@ -830,10 +804,7 @@ func (api *RestAPI) findClosestStopBySequence( for _, st := range stopTimes { if uint32(st.StopSequence) == currentStopSequence { - stopTimeSeconds := st.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = st.DepartureTime / 1e9 - } + stopTimeSeconds := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) predictedArrival := stopTimeSeconds + int64(scheduleDeviation) return st.StopID, int(predictedArrival - currentTimeSeconds) } @@ -873,10 +844,7 @@ func (api *RestAPI) findNextStopBySequence( } if nextStop != nil { - stopTimeSeconds := nextStop.ArrivalTime / 1e9 - if stopTimeSeconds == 0 { - stopTimeSeconds = nextStop.DepartureTime / 1e9 - } + stopTimeSeconds := utils.EffectiveStopTimeSeconds(nextStop.ArrivalTime, nextStop.DepartureTime) predictedArrival := stopTimeSeconds + int64(scheduleDeviation) return nextStop.StopID, int(predictedArrival - currentTimeSeconds) } @@ -976,8 +944,8 @@ func interpolateDistanceAtScheduledTime( fromStop := stopTimes[i] toStop := stopTimes[i+1] - fromTime := fromStop.DepartureTime / 1e9 - toTime := toStop.ArrivalTime / 1e9 + fromTime := utils.NanosToSeconds(fromStop.DepartureTime) + toTime := utils.NanosToSeconds(toStop.ArrivalTime) if scheduledTime >= fromTime && scheduledTime <= toTime { if toTime == fromTime { @@ -990,7 +958,7 @@ func interpolateDistanceAtScheduledTime( } } - if scheduledTime < stopTimes[0].ArrivalTime/1e9 { + if scheduledTime < utils.NanosToSeconds(stopTimes[0].ArrivalTime) { return 0 } From 8db5f2b65f91a7ec64fe56671cb4f9bbda88c184 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:18:10 +0200 Subject: [PATCH 53/94] feat: add functions to find closest and next stops by time with delay handling in trip status --- internal/restapi/trips_helper_test.go | 170 ++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index 704a4ffd..115ae2c1 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "maglev.onebusaway.org/gtfsdb" + "maglev.onebusaway.org/internal/models" "maglev.onebusaway.org/internal/utils" ) @@ -520,6 +521,175 @@ func TestBuildTripStatus_VehicleIDFormat(t *testing.T) { assert.Equal(t, vehicleID, model.VehicleID) } +func makeStopTimePtrs(stops []gtfsdb.StopTime) []*gtfsdb.StopTime { + ptrs := make([]*gtfsdb.StopTime, len(stops)) + for i := range stops { + ptrs[i] = &stops[i] + } + return ptrs +} + +func secondsToNanos(s int64) int64 { return s * int64(time.Second) } + +func TestFindClosestStopByTimeWithDelays_NoDelays(t *testing.T) { + // serviceDate at midnight UTC; currentTime = 08:00:00 UTC + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := []gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600)}, // 07:00 + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600)}, // 08:00 — exact match + {StopID: "s3", ArrivalTime: secondsToNanos(9 * 3600)}, // 09:00 + } + + stopID, _ := findClosestStopByTimeWithDelays(currentTime, serviceDate, makeStopTimePtrs(stops), nil) + assert.Equal(t, "s2", stopID) +} + +func TestFindClosestStopByTimeWithDelays_WithDelay(t *testing.T) { + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := []gtfsdb.StopTime{ + {StopID: "s1", DepartureTime: secondsToNanos(7 * 3600)}, // scheduled 07:00 + {StopID: "s2", DepartureTime: secondsToNanos(9 * 3600)}, // scheduled 09:00 + } + // delay of +60 minutes pushes s1 to 08:00 — closest to currentTime + delays := map[string]StopDelayInfo{ + "s1": {DepartureDelay: 3600}, + } + + stopID, _ := findClosestStopByTimeWithDelays(currentTime, serviceDate, makeStopTimePtrs(stops), delays) + assert.Equal(t, "s1", stopID) +} + +func TestFindClosestStopByTimeWithDelays_EmptyStops(t *testing.T) { + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stopID, offset := findClosestStopByTimeWithDelays(currentTime, serviceDate, nil, nil) + assert.Equal(t, "", stopID) + assert.Equal(t, 0, offset) +} + +func TestFindNextStopByTimeWithDelays_NoDelays(t *testing.T) { + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := []gtfsdb.StopTime{ + {StopID: "s1", DepartureTime: secondsToNanos(7 * 3600)}, // past + {StopID: "s2", DepartureTime: secondsToNanos(9 * 3600)}, // first future stop + {StopID: "s3", DepartureTime: secondsToNanos(10 * 3600)}, // later future + } + + stopID, offset := findNextStopByTimeWithDelays(currentTime, serviceDate, makeStopTimePtrs(stops), nil) + assert.Equal(t, "s2", stopID) + assert.Equal(t, 3600, offset, "offset should be 3600 seconds (1 hour)") +} + +func TestFindNextStopByTimeWithDelays_AllStopsPast(t *testing.T) { + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC) + + stops := []gtfsdb.StopTime{ + {StopID: "s1", DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", DepartureTime: secondsToNanos(9 * 3600)}, + } + + stopID, _ := findNextStopByTimeWithDelays(currentTime, serviceDate, makeStopTimePtrs(stops), nil) + assert.Equal(t, "", stopID, "no next stop when all are in the past") +} + +func TestFindNextStopByTimeWithDelays_WithDelay(t *testing.T) { + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 30, 0, 0, time.UTC) + + stops := []gtfsdb.StopTime{ + {StopID: "s1", DepartureTime: secondsToNanos(8 * 3600)}, // scheduled 08:00 + } + // +90 minute delay pushes it to 09:30, making it the next stop + delays := map[string]StopDelayInfo{ + "s1": {DepartureDelay: 90 * 60}, + } + + stopID, offset := findNextStopByTimeWithDelays(currentTime, serviceDate, makeStopTimePtrs(stops), delays) + assert.Equal(t, "s1", stopID) + // predicted arrival = 09:30 - current 08:30 = 3600s + assert.Equal(t, 3600, offset) +} + +func TestFillStopsFromSchedule_BeforeAllStops(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + + agencyID := agencies[0].Id + tripID := trips[0].ID + + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + require.NoError(t, err) + require.NotEmpty(t, stopTimes) + + // Set currentTime well before the first stop + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := serviceDate.Add(time.Second) // 00:00:01 — before any stop + + status := &models.TripStatusForTripDetails{} + api.fillStopsFromSchedule(ctx, status, tripID, currentTime, serviceDate, agencyID) + + // When before all stops, NextStop should be the first stop + assert.NotEmpty(t, status.NextStop, "NextStop should be set when currentTime is before all stops") +} + +func TestFillStopsFromSchedule_AfterAllStops(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + + agencyID := agencies[0].Id + tripID := trips[0].ID + + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + require.NoError(t, err) + require.NotEmpty(t, stopTimes) + + // Set currentTime well past the last stop (next day) + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := serviceDate.Add(30 * time.Hour) + + status := &models.TripStatusForTripDetails{} + api.fillStopsFromSchedule(ctx, status, tripID, currentTime, serviceDate, agencyID) + + // When past all stops, ClosestStop should be the last stop + assert.NotEmpty(t, status.ClosestStop, "ClosestStop should be set to last stop when past all stops") + assert.Empty(t, status.NextStop, "NextStop should be empty when past all stops") +} + +func TestFillStopsFromSchedule_InvalidTripID(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + status := &models.TripStatusForTripDetails{} + + // Should not panic or set any stops for an invalid trip + api.fillStopsFromSchedule(ctx, status, "non-existent-trip", serviceDate, serviceDate, "any-agency") + + assert.Empty(t, status.ClosestStop) + assert.Empty(t, status.NextStop) +} + // BenchmarkDistanceToLineSegment benchmarks the line segment distance calculation func BenchmarkDistanceToLineSegment(b *testing.B) { px, py := 0.5, 1.0 From de26a4ab58b2edb30da02d05d424b88fc512ea54 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:18:25 +0200 Subject: [PATCH 54/94] refactor: simplify shape points creation in buildScheduleForTrip function --- internal/restapi/trips_for_location_handler.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/restapi/trips_for_location_handler.go b/internal/restapi/trips_for_location_handler.go index fbca89ac..ec68bf26 100644 --- a/internal/restapi/trips_for_location_handler.go +++ b/internal/restapi/trips_for_location_handler.go @@ -346,10 +346,7 @@ func (api *RestAPI) buildScheduleForTrip( shapeRows, _ := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) var shapePoints []gtfs.ShapePoint if len(shapeRows) > 1 { - shapePoints = make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{Latitude: sp.Lat, Longitude: sp.Lon} - } + shapePoints = shapeRowsToPoints(shapeRows) } trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) From bf590302c144c3657168adf6213fb9763bc9e5ad Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:18:48 +0200 Subject: [PATCH 55/94] fix: update GetScheduleDeviation to return a boolean indicating that RT data exist --- internal/restapi/trip_updates_helper.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/restapi/trip_updates_helper.go b/internal/restapi/trip_updates_helper.go index c9cc1f55..5b29f72b 100644 --- a/internal/restapi/trip_updates_helper.go +++ b/internal/restapi/trip_updates_helper.go @@ -5,28 +5,28 @@ type StopDelayInfo struct { DepartureDelay int64 } -func (api *RestAPI) GetScheduleDeviation(tripID string) int { +func (api *RestAPI) GetScheduleDeviation(tripID string) (int, bool) { tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) if len(tripUpdates) == 0 { - return 0 + return 0, false } tu := tripUpdates[0] if tu.Delay != nil { - return int(tu.Delay.Seconds()) + return int(tu.Delay.Seconds()), true } for _, stu := range tu.StopTimeUpdates { if stu.Arrival != nil && stu.Arrival.Delay != nil { - return int(stu.Arrival.Delay.Seconds()) + return int(stu.Arrival.Delay.Seconds()), true } if stu.Departure != nil && stu.Departure.Delay != nil { - return int(stu.Departure.Delay.Seconds()) + return int(stu.Departure.Delay.Seconds()), true } } - return 0 + return 0, true } func (api *RestAPI) GetStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { From 9bec5f30a649d3c973a2f833f107ada68b458e31 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:18:57 +0200 Subject: [PATCH 56/94] refactor: update parseTripParams function comment for clarity --- internal/restapi/trip_details_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index c1c39762..620aa677 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -22,7 +22,7 @@ type TripParams struct { Time *time.Time } -// parseTripParams parses and validates the common trip query parameters. +// parseTripParams parses and validates the common trip query params // includeScheduleDefault controls the default value of IncludeSchedule when the // parameter is not present in the request (true for trip-details, false for trip-for-vehicle). func (api *RestAPI) parseTripParams(r *http.Request, includeScheduleDefault bool) (TripParams, map[string][]string) { From c0f09c895574d172bb568687a9239632e7958a94 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:19:20 +0200 Subject: [PATCH 57/94] test: Add tests for GetScheduleDeviation and GetStopDelaysFromTripUpdates functions --- internal/restapi/trip_updates_helper_test.go | 231 +++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 internal/restapi/trip_updates_helper_test.go diff --git a/internal/restapi/trip_updates_helper_test.go b/internal/restapi/trip_updates_helper_test.go new file mode 100644 index 00000000..29e36605 --- /dev/null +++ b/internal/restapi/trip_updates_helper_test.go @@ -0,0 +1,231 @@ +package restapi + +import ( + "testing" + "time" + + "github.com/OneBusAway/go-gtfs" + "github.com/stretchr/testify/assert" +) + +func TestGetScheduleDeviation_NoUpdates(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + deviation, hasData := api.GetScheduleDeviation("no-such-trip") + assert.Equal(t, 0, deviation) + assert.False(t, hasData, "no trip updates should return hasData=false") +} + +func TestGetScheduleDeviation_TripLevelDelay(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + delay := 90 * time.Second + api.GtfsManager.MockAddTripUpdate("trip-delay-test", &delay, nil) + + deviation, hasData := api.GetScheduleDeviation("trip-delay-test") + assert.Equal(t, 90, deviation) + assert.True(t, hasData) +} + +func TestGetScheduleDeviation_StopLevelArrivalDelay(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopID := "stop-1" + arrivalDelay := 60 * time.Second + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Arrival: >fs.StopTimeEvent{Delay: &arrivalDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-arrival-test", nil, updates) + + deviation, hasData := api.GetScheduleDeviation("trip-arrival-test") + assert.Equal(t, 60, deviation) + assert.True(t, hasData) +} + +func TestGetScheduleDeviation_StopLevelDepartureDelay(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopID := "stop-1" + departureDelay := 120 * time.Second + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Departure: >fs.StopTimeEvent{Delay: &departureDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-departure-test", nil, updates) + + deviation, hasData := api.GetScheduleDeviation("trip-departure-test") + assert.Equal(t, 120, deviation) + assert.True(t, hasData) +} + +func TestGetScheduleDeviation_TripLevelDelayTakesPrecedence(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + tripDelay := 30 * time.Second + stopID := "stop-1" + stopDelay := 90 * time.Second + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Arrival: >fs.StopTimeEvent{Delay: &stopDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-precedence-test", &tripDelay, updates) + + deviation, hasData := api.GetScheduleDeviation("trip-precedence-test") + assert.Equal(t, 30, deviation, "trip-level delay should take precedence over stop-level delay") + assert.True(t, hasData) +} + +func TestGetScheduleDeviation_StopUpdateWithNoDelay(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopID := "stop-1" + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Arrival: >fs.StopTimeEvent{}, // no Delay set + }, + } + api.GtfsManager.MockAddTripUpdate("trip-nodelay-test", nil, updates) + + deviation, hasData := api.GetScheduleDeviation("trip-nodelay-test") + assert.Equal(t, 0, deviation) + assert.True(t, hasData, "trip update exists so hasData should be true even with zero deviation") +} + +func TestGetScheduleDeviation_ZeroDeviationIsDistinguishedFromNoData(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + // Trip with explicit zero-second delay — should return (0, true) + zeroDelay := time.Duration(0) + api.GtfsManager.MockAddTripUpdate("trip-zero-delay", &zeroDelay, nil) + + deviation, hasData := api.GetScheduleDeviation("trip-zero-delay") + assert.Equal(t, 0, deviation) + assert.True(t, hasData, "zero delay with trip update should still report hasData=true") + + // Nonexistent trip — should return (0, false) + deviation2, hasData2 := api.GetScheduleDeviation("nonexistent-trip") + assert.Equal(t, 0, deviation2) + assert.False(t, hasData2, "nonexistent trip should report hasData=false") +} + +func TestGetStopDelaysFromTripUpdates_NoUpdates(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + delays := api.GetStopDelaysFromTripUpdates("no-such-trip") + assert.Empty(t, delays) +} + +func TestGetStopDelaysFromTripUpdates_WithArrivalDelay(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopID := "stop-A" + arrivalDelay := 45 * time.Second + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Arrival: >fs.StopTimeEvent{Delay: &arrivalDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-stop-delays-arrival", nil, updates) + + delays := api.GetStopDelaysFromTripUpdates("trip-stop-delays-arrival") + assert.Len(t, delays, 1) + assert.Equal(t, int64(45), delays["stop-A"].ArrivalDelay) + assert.Equal(t, int64(0), delays["stop-A"].DepartureDelay) +} + +func TestGetStopDelaysFromTripUpdates_WithDepartureDelay(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopID := "stop-B" + departureDelay := 75 * time.Second + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Departure: >fs.StopTimeEvent{Delay: &departureDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-stop-delays-departure", nil, updates) + + delays := api.GetStopDelaysFromTripUpdates("trip-stop-delays-departure") + assert.Len(t, delays, 1) + assert.Equal(t, int64(0), delays["stop-B"].ArrivalDelay) + assert.Equal(t, int64(75), delays["stop-B"].DepartureDelay) +} + +func TestGetStopDelaysFromTripUpdates_SkipsStopWithNoStopID(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + arrivalDelay := 30 * time.Second + updates := []gtfs.StopTimeUpdate{ + { + StopID: nil, // no stop ID — should be skipped + Arrival: >fs.StopTimeEvent{Delay: &arrivalDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-nil-stopid", nil, updates) + + delays := api.GetStopDelaysFromTripUpdates("trip-nil-stopid") + assert.Empty(t, delays, "stop updates without StopID should be skipped") +} + +func TestGetStopDelaysFromTripUpdates_SkipsStopWithZeroDelays(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopID := "stop-C" + zeroDelay := time.Duration(0) + updates := []gtfs.StopTimeUpdate{ + { + StopID: &stopID, + Arrival: >fs.StopTimeEvent{Delay: &zeroDelay}, + }, + } + api.GtfsManager.MockAddTripUpdate("trip-zero-delays", nil, updates) + + delays := api.GetStopDelaysFromTripUpdates("trip-zero-delays") + assert.Empty(t, delays, "stops with zero delays should be excluded") +} + +func TestGetStopDelaysFromTripUpdates_MultipleStops(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + stopA := "stop-A" + stopB := "stop-B" + stopC := "stop-C" + delayA := 30 * time.Second + delayB := 60 * time.Second + + updates := []gtfs.StopTimeUpdate{ + {StopID: &stopA, Arrival: >fs.StopTimeEvent{Delay: &delayA}}, + {StopID: &stopB, Departure: >fs.StopTimeEvent{Delay: &delayB}}, + {StopID: &stopC}, // no delay — should be omitted + } + api.GtfsManager.MockAddTripUpdate("trip-multi-stops", nil, updates) + + delays := api.GetStopDelaysFromTripUpdates("trip-multi-stops") + assert.Len(t, delays, 2) + assert.Equal(t, int64(30), delays["stop-A"].ArrivalDelay) + assert.Equal(t, int64(60), delays["stop-B"].DepartureDelay) + assert.NotContains(t, delays, "stop-C") +} From 5cf569ad893d88c3a358354151821a9e1e3c5e3f Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:19:36 +0200 Subject: [PATCH 58/94] refactor: remove TripForVehicleParams and related parsing logic --- internal/restapi/trip_for_vehicle_handler.go | 72 +------------------- 1 file changed, 1 insertion(+), 71 deletions(-) diff --git a/internal/restapi/trip_for_vehicle_handler.go b/internal/restapi/trip_for_vehicle_handler.go index 27a01281..7efeb618 100644 --- a/internal/restapi/trip_for_vehicle_handler.go +++ b/internal/restapi/trip_for_vehicle_handler.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "net/http" - "strconv" "time" "maglev.onebusaway.org/gtfsdb" @@ -14,75 +13,6 @@ import ( "maglev.onebusaway.org/internal/utils" ) -type TripForVehicleParams struct { - ServiceDate *time.Time - IncludeTrip bool - IncludeSchedule bool - IncludeStatus bool - Time *time.Time -} - -// parseTripForVehicleParams parses and validates parameters. -func (api *RestAPI) parseTripForVehicleParams(r *http.Request) (TripForVehicleParams, map[string][]string) { - params := TripForVehicleParams{ - IncludeTrip: true, - IncludeSchedule: false, - IncludeStatus: true, - } - - fieldErrors := make(map[string][]string) - - // Validate serviceDate - if serviceDateStr := r.URL.Query().Get("serviceDate"); serviceDateStr != "" { - if serviceDateMs, err := strconv.ParseInt(serviceDateStr, 10, 64); err == nil { - serviceDate := time.Unix(serviceDateMs/1000, 0) - params.ServiceDate = &serviceDate - } else { - fieldErrors["serviceDate"] = []string{"must be a valid Unix timestamp in milliseconds"} - } - } - - if includeTripStr := r.URL.Query().Get("includeTrip"); includeTripStr != "" { - if val, err := strconv.ParseBool(includeTripStr); err == nil { - params.IncludeTrip = val - } else { - fieldErrors["includeTrip"] = []string{"must be a boolean value (true/false)"} - } - } - - if includeScheduleStr := r.URL.Query().Get("includeSchedule"); includeScheduleStr != "" { - if val, err := strconv.ParseBool(includeScheduleStr); err == nil { - params.IncludeSchedule = val - } else { - fieldErrors["includeSchedule"] = []string{"must be a boolean value (true/false)"} - } - } - - if includeStatusStr := r.URL.Query().Get("includeStatus"); includeStatusStr != "" { - if val, err := strconv.ParseBool(includeStatusStr); err == nil { - params.IncludeStatus = val - } else { - fieldErrors["includeStatus"] = []string{"must be a boolean value (true/false)"} - } - } - - // Validate time - if timeStr := r.URL.Query().Get("time"); timeStr != "" { - if timeMs, err := strconv.ParseInt(timeStr, 10, 64); err == nil { - timeParam := time.Unix(timeMs/1000, 0) - params.Time = &timeParam - } else { - fieldErrors["time"] = []string{"must be a valid Unix timestamp in milliseconds"} - } - } - - if len(fieldErrors) > 0 { - return params, fieldErrors - } - - return params, nil -} - func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request) { queryParamID := utils.ExtractIDFromParams(r) @@ -126,7 +56,7 @@ func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request ctx := r.Context() // Capture parsing errors - params, fieldErrors := api.parseTripForVehicleParams(r) + params, fieldErrors := api.parseTripParams(r, false) if len(fieldErrors) > 0 { api.validationErrorResponse(w, r, fieldErrors) return From 4bf8733e9b509ce095cb60869f314a4df22077cb Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Sun, 22 Feb 2026 21:19:42 +0200 Subject: [PATCH 59/94] refactor: update parseTripForVehicleParams calls to use parseTripParams --- internal/restapi/trip_for_vehicle_handler_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/restapi/trip_for_vehicle_handler_test.go b/internal/restapi/trip_for_vehicle_handler_test.go index d91fc59f..03914172 100644 --- a/internal/restapi/trip_for_vehicle_handler_test.go +++ b/internal/restapi/trip_for_vehicle_handler_test.go @@ -674,14 +674,14 @@ func TestParseTripForVehicleParams_Unit(t *testing.T) { defer api.Shutdown() req := httptest.NewRequest("GET", "/?includeStatus=false&time=1609459200000", nil) - params, errs := api.parseTripForVehicleParams(req) + params, errs := api.parseTripParams(req, false) assert.Nil(t, errs) assert.False(t, params.IncludeStatus) assert.NotNil(t, params.Time) reqDefault := httptest.NewRequest("GET", "/", nil) - paramsDefault, errsDefault := api.parseTripForVehicleParams(reqDefault) + paramsDefault, errsDefault := api.parseTripParams(reqDefault, false) assert.Nil(t, errsDefault) assert.True(t, paramsDefault.IncludeTrip) @@ -689,7 +689,7 @@ func TestParseTripForVehicleParams_Unit(t *testing.T) { assert.True(t, paramsDefault.IncludeStatus) reqInvalid := httptest.NewRequest("GET", "/?serviceDate=invalid&time=invalid", nil) - _, errsInvalid := api.parseTripForVehicleParams(reqInvalid) + _, errsInvalid := api.parseTripParams(reqInvalid, false) assert.NotNil(t, errsInvalid) assert.Contains(t, errsInvalid, "serviceDate") From cf488e0dd635afe626481c9367fe78c3bf83c90a Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:02:15 +0200 Subject: [PATCH 60/94] refactor: update vehicle status logic to correctly set Predicted and Scheduled states --- internal/restapi/trips_helper.go | 5 ++++- internal/restapi/vehicles_helper.go | 4 ---- internal/restapi/vehicles_helper_test.go | 14 +++++--------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 9fa6b9f6..6a11844b 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -50,9 +50,12 @@ func (api *RestAPI) BuildTripStatus( if hasRealtimeTripUpdate { status.ScheduleDeviation = scheduleDeviation - status.Predicted = true } + hasVehicleRealtimeData := vehicle != nil && !defaultStaleDetector.Check(vehicle, currentTime) + status.Predicted = hasVehicleRealtimeData || hasRealtimeTripUpdate + status.Scheduled = !status.Predicted + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripRawID) if err == nil && len(stopTimes) > 0 { stopTimesPtrs := make([]*gtfsdb.StopTime, len(stopTimes)) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index e96ec0d6..4642ac07 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -150,10 +150,6 @@ func (api *RestAPI) BuildVehicleStatus( } else { status.ActiveTripID = utils.FormCombinedID(agencyID, tripID) } - - hasRealtimeData := vehicle.Position != nil || vehicle.Timestamp != nil - status.Predicted = hasRealtimeData - status.Scheduled = !hasRealtimeData } func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { diff --git a/internal/restapi/vehicles_helper_test.go b/internal/restapi/vehicles_helper_test.go index 2efb2e02..7ec2b311 100644 --- a/internal/restapi/vehicles_helper_test.go +++ b/internal/restapi/vehicles_helper_test.go @@ -132,8 +132,7 @@ func TestBuildVehicleStatus_NilVehicleSetsDefaultStatus(t *testing.T) { assert.Equal(t, "default", status.Status) assert.Equal(t, "scheduled", status.Phase) - assert.False(t, status.Predicted) - assert.False(t, status.Scheduled) + assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") } func TestBuildVehicleStatus_StaleVehicleSetsDefaultStatus(t *testing.T) { @@ -154,7 +153,7 @@ func TestBuildVehicleStatus_StaleVehicleSetsDefaultStatus(t *testing.T) { assert.Equal(t, "scheduled", status.Phase) } -func TestBuildVehicleStatus_FreshVehicleWithPosition_PredictedTrue(t *testing.T) { +func TestBuildVehicleStatus_FreshVehicleWithPosition_SetsLocationAndPhase(t *testing.T) { api := createTestApi(t) defer api.Shutdown() ctx := context.Background() @@ -174,13 +173,12 @@ func TestBuildVehicleStatus_FreshVehicleWithPosition_PredictedTrue(t *testing.T) status := &models.TripStatusForTripDetails{} api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) - assert.True(t, status.Predicted, "vehicle with position should have Predicted=true") - assert.False(t, status.Scheduled) + assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") assert.Equal(t, "SCHEDULED", status.Status) assert.Equal(t, "in_progress", status.Phase) } -func TestBuildVehicleStatus_FreshVehicleNoPosition_PredictedTrue(t *testing.T) { +func TestBuildVehicleStatus_FreshVehicleNoPosition_DoesNotSetPredicted(t *testing.T) { api := createTestApi(t) defer api.Shutdown() ctx := context.Background() @@ -195,7 +193,5 @@ func TestBuildVehicleStatus_FreshVehicleNoPosition_PredictedTrue(t *testing.T) { status := &models.TripStatusForTripDetails{} api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) - // Timestamp alone counts as real-time data - assert.True(t, status.Predicted) - assert.False(t, status.Scheduled) + assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") } From 26560d1f0882bc4ca1a07d598fcdd620838ee468 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:18:48 +0200 Subject: [PATCH 61/94] refactor: add logging for BuildTripStatus if it return error it should log --- internal/restapi/trip_details_handler.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 7a117fe6..939d3719 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -2,6 +2,7 @@ package restapi import ( "context" + "log/slog" "net/http" "strconv" "time" @@ -135,7 +136,13 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { var status *models.TripStatusForTripDetails if params.IncludeStatus { - status, _ = api.BuildTripStatus(ctx, agencyID, trip.ID, serviceDate, currentTime) + var statusErr error + status, statusErr = api.BuildTripStatus(ctx, agencyID, trip.ID, serviceDate, currentTime) + if statusErr != nil { + slog.Warn("BuildTripStatus failed", + slog.String("trip_id", trip.ID), + slog.String("error", statusErr.Error())) + } } if params.IncludeSchedule { From 3fcefa31a81a13e9f1d96f2d4a1abb24a81da33f Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:19:10 +0200 Subject: [PATCH 62/94] refactor: MockAddVehicle and MockAddVehicleWithOptions to update trip lookup --- internal/gtfs/gtfs_manager_mock.go | 43 +++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index 1c04065a..0a3c2b2e 100644 --- a/internal/gtfs/gtfs_manager_mock.go +++ b/internal/gtfs/gtfs_manager_mock.go @@ -48,7 +48,48 @@ func (m *Manager) MockAddVehicle(vehicleID, tripID, routeID string) { }, }) - m.realTimeVehicleLookupByVehicle[vehicleID] = len(m.realTimeVehicles) - 1 + idx := len(m.realTimeVehicles) - 1 + m.realTimeVehicleLookupByVehicle[vehicleID] = idx + if tripID != "" { + m.realTimeVehicleLookupByTrip[tripID] = idx + } +} + +type MockVehicleOptions struct { + Position *gtfs.Position + CurrentStopSequence *uint32 + StopID *string + CurrentStatus *gtfs.CurrentStatus +} + +func (m *Manager) MockAddVehicleWithOptions(vehicleID, tripID, routeID string, opts MockVehicleOptions) { + for _, v := range m.realTimeVehicles { + if v.ID.ID == vehicleID { + return + } + } + now := time.Now() + v := gtfs.Vehicle{ + ID: >fs.VehicleID{ID: vehicleID}, + Timestamp: &now, + Trip: >fs.Trip{ + ID: gtfs.TripID{ + ID: tripID, + RouteID: routeID, + }, + }, + Position: opts.Position, + CurrentStopSequence: opts.CurrentStopSequence, + StopID: opts.StopID, + CurrentStatus: opts.CurrentStatus, + } + m.realTimeVehicles = append(m.realTimeVehicles, v) + + idx := len(m.realTimeVehicles) - 1 + m.realTimeVehicleLookupByVehicle[vehicleID] = idx + if tripID != "" { + m.realTimeVehicleLookupByTrip[tripID] = idx + } } func (m *Manager) MockAddTrip(tripID, agencyID, routeID string) { From 0ae1780a3172048068e0836e972d67fad9e39b0e Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:20:45 +0200 Subject: [PATCH 63/94] refactor: Include all stops that have a real-time update, even if the delay is zero. --- internal/restapi/trip_updates_helper.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/restapi/trip_updates_helper.go b/internal/restapi/trip_updates_helper.go index 5b29f72b..b92fe58c 100644 --- a/internal/restapi/trip_updates_helper.go +++ b/internal/restapi/trip_updates_helper.go @@ -50,9 +50,7 @@ func (api *RestAPI) GetStopDelaysFromTripUpdates(tripID string) map[string]StopD info.DepartureDelay = int64(stu.Departure.Delay.Seconds()) } - if info.ArrivalDelay != 0 || info.DepartureDelay != 0 { - delays[*stu.StopID] = info - } + delays[*stu.StopID] = info } return delays From 91db0739a1b0d1d9707f44c646c32203eb7aadcd Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:22:08 +0200 Subject: [PATCH 64/94] refactor: Treat nil timestamp as not-stale when the vehicle has other real-time data --- internal/restapi/vehicles_helper.go | 55 +++++++++++++++++------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 4642ac07..1f113b14 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -32,11 +32,13 @@ func (d *StaleDetector) WithThreshold(threshold time.Duration) *StaleDetector { return d } -// Check returns true when the vehicle's timestamp is missing or older than the threshold. func (d *StaleDetector) Check(vehicle *gtfs.Vehicle, currentTime time.Time) bool { - if vehicle == nil || vehicle.Timestamp == nil { + if vehicle == nil { return true } + if vehicle.Timestamp == nil { + return vehicle.Position == nil + } return currentTime.Sub(*vehicle.Timestamp) > d.threshold } @@ -64,18 +66,17 @@ func scheduleRelationshipStatus(sr gtfs.TripScheduleRelationship) string { } } -/* -Note!! -GetVehicleStatusAndPhase returns the OBA status and phase for a vehicle. -Java reference: VehicleStatusServiceImpl.java (handleVehicleLocationRecord) -onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/realtime/VehicleStatusServiceImpl.java -The Java implementation does not map directly to GTFS-RT CurrentStatus values. -Instead, it uses a simple rule: if a vehicle location record has been received, -the trip is "in_progress"; otherwise it remains "scheduled". The phase is -determined solely by the presence of the vehicle, not by its GTFS-RT stop status. -Status comes from the trip's schedule relationship ("SCHEDULED", "CANCELED", "ADDED", "DUPLICATED"). -"default" is only returned when no real-time data exists at all. -*/ +// GetVehicleStatusAndPhase returns the OBA status and phase for a vehicle. +// +// Java reference: VehicleStatusServiceImpl.java (handleVehicleLocationRecord) +// onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/realtime/VehicleStatusServiceImpl.java +// +// The Java implementation does not map directly to GTFS-RT CurrentStatus values. +// Instead, it uses a simple rule: if a vehicle location record has been received, +// the trip is "in_progress"; otherwise it remains "scheduled". The phase is +// determined solely by the presence of the vehicle, not by its GTFS-RT stop status. +// Status comes from the trip's schedule relationship ("SCHEDULED", "CANCELED", "ADDED", "DUPLICATED"). +// "default" is only returned when no real-time data exists at all. func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase string) { if vehicle == nil { // "default" matches the Java OBA behavior. In TripStatusBeanServiceImpl.getBlockLocationAsStatusBean() @@ -111,8 +112,10 @@ func (api *RestAPI) BuildVehicleStatus( return } + var lastUpdateTime int64 if vehicle.Timestamp != nil { - status.LastUpdateTime = api.GtfsManager.GetVehicleLastUpdateTime(vehicle) + lastUpdateTime = api.GtfsManager.GetVehicleLastUpdateTime(vehicle) + status.LastUpdateTime = lastUpdateTime } if vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { @@ -121,16 +124,13 @@ func (api *RestAPI) BuildVehicleStatus( Lon: float64(*vehicle.Position.Longitude), } status.LastKnownLocation = actualPosition - - projectedPosition := api.projectPositionOntoRoute(ctx, tripID, actualPosition) - if projectedPosition != nil { - status.Position = *projectedPosition - } else { - status.Position = actualPosition - } + // Position is initially set to the raw GPS position. + // BuildTripStatus refines this via shape projection once shape data + // is fetched, avoiding a duplicate GetShapePointsByTripID query. + status.Position = actualPosition if vehicle.Timestamp != nil { - status.LastLocationUpdateTime = api.GtfsManager.GetVehicleLastUpdateTime(vehicle) + status.LastLocationUpdateTime = lastUpdateTime } } @@ -167,6 +167,15 @@ func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, } shapePoints := shapeRowsToPoints(shapeRows) + return projectPositionWithShapePoints(shapePoints, actualPos) +} + +// projectPositionWithShapePoints projects actualPos onto the nearest segment +// of the given shape, returning nil if no segment is within 200 m. +func projectPositionWithShapePoints(shapePoints []gtfs.ShapePoint, actualPos models.Location) *models.Location { + if len(shapePoints) < 2 { + return nil + } minDistance := math.MaxFloat64 var closestPoint models.Location From cdf9f369d5f7bbcda58b53ce83ca97a6a6788aaa Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:22:22 +0200 Subject: [PATCH 65/94] refactor: Update stale detection tests for vehicles with nil timestamp and position --- internal/restapi/vehicles_helper_test.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/restapi/vehicles_helper_test.go b/internal/restapi/vehicles_helper_test.go index 7ec2b311..9bd055e9 100644 --- a/internal/restapi/vehicles_helper_test.go +++ b/internal/restapi/vehicles_helper_test.go @@ -78,10 +78,23 @@ func TestStaleDetector_NilVehicle(t *testing.T) { assert.True(t, d.Check(nil, time.Now()), "nil vehicle should be considered stale") } -func TestStaleDetector_NilTimestamp(t *testing.T) { +func TestStaleDetector_NilTimestamp_NoPosition(t *testing.T) { d := NewStaleDetector() - vehicle := >fs.Vehicle{} // Timestamp is nil - assert.True(t, d.Check(vehicle, time.Now()), "vehicle with nil timestamp should be considered stale") + vehicle := >fs.Vehicle{} + assert.True(t, d.Check(vehicle, time.Now()), "vehicle with nil timestamp and no position should be considered stale") +} + +func TestStaleDetector_NilTimestamp_WithPosition(t *testing.T) { + d := NewStaleDetector() + lat := float32(37.7749) + lon := float32(-122.4194) + vehicle := >fs.Vehicle{ + Position: >fs.Position{ + Latitude: &lat, + Longitude: &lon, + }, + } + assert.False(t, d.Check(vehicle, time.Now()), "vehicle with nil timestamp but valid position should not be stale") } func TestStaleDetector_FreshVehicle(t *testing.T) { From d1afb934b3f475909d1db5f9ce5e23b02f78aaae Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:22:41 +0200 Subject: [PATCH 66/94] refactor: Update occupancy capacity handling and refine GPS position projection in BuildTripStatus --- internal/restapi/trips_helper.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 6a11844b..ccdc819e 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -35,9 +35,11 @@ func (api *RestAPI) BuildTripStatus( if vehicle.OccupancyStatus != nil { status.OccupancyStatus = vehicle.OccupancyStatus.String() } - if vehicle.OccupancyPercentage != nil { - status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) - } + // NOTE: GTFS-RT OccupancyPercentage (0-100%) has no direct equivalent in the + // OBA TripStatus schema. The Java OBA server populates occupancyCapacity from + // agency-provided vehicle capacity data, not from GTFS-RT percentages. + // We intentionally leave OccupancyCapacity at its default (-1) here. + // See: TripStatusBeanServiceImpl.java in onebusaway-transit-data-federation. } api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) @@ -116,6 +118,13 @@ func (api *RestAPI) BuildTripStatus( status.TotalDistanceAlongTrip = cumulativeDistances[len(cumulativeDistances)-1] if vehicle != nil && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { + // Refine the raw GPS position (set by BuildVehicleStatus) by projecting + // it onto the route shape. Reuses the already-fetched shapePoints. + actualPosition := status.LastKnownLocation + if projected := projectPositionWithShapePoints(shapePoints, actualPosition); projected != nil { + status.Position = *projected + } + actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) status.DistanceAlongTrip = actualDistance From 4df058a83bed1d9ed0053a927f53910b639cc361 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:22:52 +0200 Subject: [PATCH 67/94] refactor: Add tests for finding closest and next stops by sequence with various scenarios --- internal/restapi/trips_helper_test.go | 298 ++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index c191fcbc..5d506864 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -924,6 +924,304 @@ func BenchmarkOptimized_MonotonicBatch(b *testing.B) { } } +func TestFindClosestStopBySequence_Match(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s into service day + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", StopSequence: 2, ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", StopSequence: 3, ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + stopID, offset := api.findClosestStopBySequence(stops, 2, currentTime, serviceDate, 0, nil) + assert.Equal(t, "s2", stopID) + // predicted arrival = 8*3600 + 0(deviation) = 28800; offset = 28800 - 28800 = 0 + assert.Equal(t, 0, offset, "vehicle at on-time stop means offset == 0") +} + +func TestFindClosestStopBySequence_WithDeviation(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + }) + + // Vehicle is 5 minutes late + stopID, offset := api.findClosestStopBySequence(stops, 1, currentTime, serviceDate, 300, nil) + assert.Equal(t, "s1", stopID) + // predicted arrival = 8*3600 + 300 = 29100; current = 28800; offset = 300 + assert.Equal(t, 300, offset, "offset should reflect 5-minute delay") +} + +func TestFindClosestStopBySequence_NoMatch(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600)}, + }) + + stopID, offset := api.findClosestStopBySequence(stops, 99, currentTime, serviceDate, 0, nil) + assert.Empty(t, stopID, "no stop should match sequence 99") + assert.Equal(t, 0, offset) +} + +func TestFindNextStopBySequence_InTransit(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", StopSequence: 2, ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", StopSequence: 3, ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + // Vehicle is IN_TRANSIT_TO (CurrentStatus 2 = the default for nil) + inTransit := gtfs.CurrentStatus(2) + vehicle := >fs.Vehicle{CurrentStatus: &inTransit} + + // When in transit, the current sequence stop IS the next stop + stopID, offset := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1", serviceDate) + assert.Equal(t, "s2", stopID) + assert.Equal(t, 0, offset) +} + +func TestFindNextStopBySequence_StoppedAt(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", StopSequence: 2, ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", StopSequence: 3, ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + // Vehicle is STOPPED_AT (CurrentStatus 1) at stop sequence 2 + stoppedAt := gtfs.CurrentStatus(1) + vehicle := >fs.Vehicle{CurrentStatus: &stoppedAt} + + stopID, offset := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1", serviceDate) + // Stopped at s2, so next stop should be s3 + assert.Equal(t, "s3", stopID) + // s3 arrival = 9*3600 + 0(deviation) = 32400; current = 28800; offset = 3600 + assert.Equal(t, 3600, offset) +} + +func TestFindNextStopBySequence_StoppedAtLastStop(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s2", StopSequence: 2, ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + stoppedAt := gtfs.CurrentStatus(1) + vehicle := >fs.Vehicle{CurrentStatus: &stoppedAt} + + // Stopped at last stop (sequence 2), no next stop in this trip + // because "trip1" doesn't exist in the DB. So we expect empty. + stopID, _ := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1", serviceDate) + assert.Empty(t, stopID, "no next stop when stopped at last stop of trip without block continuation") +} + +func TestFindNextStopBySequence_NoMatch(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600)}, + }) + + stopID, offset := api.findNextStopBySequence(ctx, stops, 99, currentTime, serviceDate, 0, nil, "trip1", serviceDate) + assert.Empty(t, stopID) + assert.Equal(t, 0, offset) +} + +func TestFindStopsByScheduleDeviation_OnTime(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + closestStopID, closestOffset, nextStopID, nextOffset := api.findStopsByScheduleDeviation(stops, currentTime, serviceDate, 0) + + // With 0 deviation, effective schedule time = 28800 = 8*3600 → closest is s2 + assert.Equal(t, "s2", closestStopID) + // predicted arrival for s2 = 28800 + 0 = 28800; current = 28800; offset = 0 + assert.Equal(t, 0, closestOffset) + // Next stop after s2 is s3 + assert.Equal(t, "s3", nextStopID) + // predicted arrival for s3 = 32400 + 0 = 32400; current = 28800; offset = 3600 + assert.Equal(t, 3600, nextOffset) +} + +func TestFindStopsByScheduleDeviation_Late(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 5, 0, 0, time.UTC) // 28800 + 300 = 29100s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + // Vehicle is 5 minutes late (300s deviation) + // effectiveScheduleTime = 29100 - 300 = 28800 → closest is still s2 + closestStopID, closestOffset, nextStopID, nextOffset := api.findStopsByScheduleDeviation(stops, currentTime, serviceDate, 300) + + assert.Equal(t, "s2", closestStopID) + // predicted arrival = 28800 + 300 = 29100; current = 29100; offset = 0 + assert.Equal(t, 0, closestOffset) + assert.Equal(t, "s3", nextStopID) + // predicted next arrival = 32400 + 300 = 32700; current = 29100; offset = 3600 + assert.Equal(t, 3600, nextOffset) +} + +func TestFindStopsByScheduleDeviation_Empty(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + closestStopID, closestOffset, nextStopID, nextOffset := api.findStopsByScheduleDeviation(nil, currentTime, serviceDate, 0) + assert.Empty(t, closestStopID) + assert.Equal(t, 0, closestOffset) + assert.Empty(t, nextStopID) + assert.Equal(t, 0, nextOffset) +} + +func TestFindStopsByScheduleDeviation_LastStop(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) // 32400s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + closestStopID, _, nextStopID, _ := api.findStopsByScheduleDeviation(stops, currentTime, serviceDate, 0) + assert.Equal(t, "s2", closestStopID, "should identify last stop as closest") + assert.Empty(t, nextStopID, "no next stop after the last one") +} + +func TestInterpolateDistanceAtScheduledTime_BetweenStops(t *testing.T) { + stopTimes := []gtfsdb.StopTime{ + {DepartureTime: secondsToNanos(100), ArrivalTime: secondsToNanos(100)}, + {DepartureTime: secondsToNanos(200), ArrivalTime: secondsToNanos(200)}, + } + distances := []float64{0.0, 1000.0} + + // Midpoint in time → midpoint in distance + d := interpolateDistanceAtScheduledTime(150, stopTimes, distances) + assert.InDelta(t, 500.0, d, 0.01, "midpoint time should give midpoint distance") +} + +func TestInterpolateDistanceAtScheduledTime_AtStopBoundaries(t *testing.T) { + stopTimes := []gtfsdb.StopTime{ + {DepartureTime: secondsToNanos(100), ArrivalTime: secondsToNanos(100)}, + {DepartureTime: secondsToNanos(200), ArrivalTime: secondsToNanos(200)}, + {DepartureTime: secondsToNanos(300), ArrivalTime: secondsToNanos(300)}, + } + distances := []float64{0.0, 500.0, 1500.0} + + // At exact first departure + d := interpolateDistanceAtScheduledTime(100, stopTimes, distances) + assert.InDelta(t, 0.0, d, 0.01, "at first departure should be distance 0") + + // At exact second arrival + d = interpolateDistanceAtScheduledTime(200, stopTimes, distances) + assert.InDelta(t, 500.0, d, 0.01, "at second stop should be distance 500") +} + +func TestInterpolateDistanceAtScheduledTime_BeforeFirstStop(t *testing.T) { + stopTimes := []gtfsdb.StopTime{ + {DepartureTime: secondsToNanos(100), ArrivalTime: secondsToNanos(100)}, + {DepartureTime: secondsToNanos(200), ArrivalTime: secondsToNanos(200)}, + } + distances := []float64{0.0, 1000.0} + + d := interpolateDistanceAtScheduledTime(50, stopTimes, distances) + assert.Equal(t, 0.0, d, "before first stop should return 0") +} + +func TestInterpolateDistanceAtScheduledTime_AfterLastStop(t *testing.T) { + stopTimes := []gtfsdb.StopTime{ + {DepartureTime: secondsToNanos(100), ArrivalTime: secondsToNanos(100)}, + {DepartureTime: secondsToNanos(200), ArrivalTime: secondsToNanos(200)}, + } + distances := []float64{0.0, 1000.0} + + d := interpolateDistanceAtScheduledTime(999, stopTimes, distances) + assert.Equal(t, 1000.0, d, "after last stop should return total distance") +} + +func TestInterpolateDistanceAtScheduledTime_EmptyInput(t *testing.T) { + assert.Equal(t, 0.0, interpolateDistanceAtScheduledTime(100, nil, nil)) + assert.Equal(t, 0.0, interpolateDistanceAtScheduledTime(100, + []gtfsdb.StopTime{{DepartureTime: secondsToNanos(100)}}, + []float64{0.0, 1.0}), // mismatched lengths + ) +} + +func TestInterpolateDistanceAtScheduledTime_MultipleSegments(t *testing.T) { + stopTimes := []gtfsdb.StopTime{ + {DepartureTime: secondsToNanos(0), ArrivalTime: secondsToNanos(0)}, + {DepartureTime: secondsToNanos(100), ArrivalTime: secondsToNanos(100)}, + {DepartureTime: secondsToNanos(300), ArrivalTime: secondsToNanos(300)}, + } + distances := []float64{0.0, 500.0, 1500.0} + + // 75% through first segment: time=75 of [0,100] → 75% of [0, 500] = 375 + d := interpolateDistanceAtScheduledTime(75, stopTimes, distances) + assert.InDelta(t, 375.0, d, 0.01) + + // 50% through second segment: time=200 of [100,300] → 50% of [500, 1500] = 1000 + d = interpolateDistanceAtScheduledTime(200, stopTimes, distances) + assert.InDelta(t, 1000.0, d, 0.01) +} + func TestGetDistanceAlongShape_Projection(t *testing.T) { shape := []gtfs.ShapePoint{ {Latitude: 0.0, Longitude: 0.0}, From eea402947fa5095e3189b76b0ec8ba3abecb616e Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:42:22 +0200 Subject: [PATCH 68/94] refactor: Remove unused projectPositionOntoRoute function from vehicles_helper --- internal/restapi/vehicles_helper.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 1f113b14..efa03c4b 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -160,16 +160,6 @@ func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { return vehicle.Trip.ID.ID } -func (api *RestAPI) projectPositionOntoRoute(ctx context.Context, tripID string, actualPos models.Location) *models.Location { - shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) - if err != nil || len(shapeRows) < 2 { - return nil - } - - shapePoints := shapeRowsToPoints(shapeRows) - return projectPositionWithShapePoints(shapePoints, actualPos) -} - // projectPositionWithShapePoints projects actualPos onto the nearest segment // of the given shape, returning nil if no segment is within 200 m. func projectPositionWithShapePoints(shapePoints []gtfs.ShapePoint, actualPos models.Location) *models.Location { From 17227ad36bfa17dedeaccf635a06cd94f80c731d Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:42:30 +0200 Subject: [PATCH 69/94] refactor: Update stop delay tests to include stops with zero delays --- internal/restapi/trip_updates_helper_test.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/restapi/trip_updates_helper_test.go b/internal/restapi/trip_updates_helper_test.go index 29e36605..8ff3fdfb 100644 --- a/internal/restapi/trip_updates_helper_test.go +++ b/internal/restapi/trip_updates_helper_test.go @@ -188,7 +188,7 @@ func TestGetStopDelaysFromTripUpdates_SkipsStopWithNoStopID(t *testing.T) { assert.Empty(t, delays, "stop updates without StopID should be skipped") } -func TestGetStopDelaysFromTripUpdates_SkipsStopWithZeroDelays(t *testing.T) { +func TestGetStopDelaysFromTripUpdates_IncludesStopWithZeroDelays(t *testing.T) { api := createTestApi(t) defer api.Shutdown() @@ -203,7 +203,9 @@ func TestGetStopDelaysFromTripUpdates_SkipsStopWithZeroDelays(t *testing.T) { api.GtfsManager.MockAddTripUpdate("trip-zero-delays", nil, updates) delays := api.GetStopDelaysFromTripUpdates("trip-zero-delays") - assert.Empty(t, delays, "stops with zero delays should be excluded") + assert.Len(t, delays, 1, "stops with zero delays should be included") + assert.Contains(t, delays, "stop-C") + assert.Equal(t, int64(0), delays["stop-C"].ArrivalDelay) } func TestGetStopDelaysFromTripUpdates_MultipleStops(t *testing.T) { @@ -219,13 +221,15 @@ func TestGetStopDelaysFromTripUpdates_MultipleStops(t *testing.T) { updates := []gtfs.StopTimeUpdate{ {StopID: &stopA, Arrival: >fs.StopTimeEvent{Delay: &delayA}}, {StopID: &stopB, Departure: >fs.StopTimeEvent{Delay: &delayB}}, - {StopID: &stopC}, // no delay — should be omitted + {StopID: &stopC}, // no delay events — still included with zero values } api.GtfsManager.MockAddTripUpdate("trip-multi-stops", nil, updates) delays := api.GetStopDelaysFromTripUpdates("trip-multi-stops") - assert.Len(t, delays, 2) + assert.Len(t, delays, 3, "all stops with StopID should be included") assert.Equal(t, int64(30), delays["stop-A"].ArrivalDelay) assert.Equal(t, int64(60), delays["stop-B"].DepartureDelay) - assert.NotContains(t, delays, "stop-C") + assert.Contains(t, delays, "stop-C") + assert.Equal(t, int64(0), delays["stop-C"].ArrivalDelay) + assert.Equal(t, int64(0), delays["stop-C"].DepartureDelay) } From 83d66f1c9fd0f43bf8b211fccf54ca1110d57352 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 03:42:51 +0200 Subject: [PATCH 70/94] refactor: Replace createTestApi with direct RestAPI instantiation in sequence tests ( we don't need the db in these tests) --- internal/restapi/trips_helper_test.go | 30 +++++++++------------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index 5d506864..70f19cf0 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -925,8 +925,7 @@ func BenchmarkOptimized_MonotonicBatch(b *testing.B) { } func TestFindClosestStopBySequence_Match(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s into service day @@ -944,8 +943,7 @@ func TestFindClosestStopBySequence_Match(t *testing.T) { } func TestFindClosestStopBySequence_WithDeviation(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) @@ -962,8 +960,7 @@ func TestFindClosestStopBySequence_WithDeviation(t *testing.T) { } func TestFindClosestStopBySequence_NoMatch(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) @@ -978,8 +975,7 @@ func TestFindClosestStopBySequence_NoMatch(t *testing.T) { } func TestFindNextStopBySequence_InTransit(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} ctx := context.Background() serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) @@ -1002,8 +998,7 @@ func TestFindNextStopBySequence_InTransit(t *testing.T) { } func TestFindNextStopBySequence_StoppedAt(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} ctx := context.Background() serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) @@ -1049,8 +1044,7 @@ func TestFindNextStopBySequence_StoppedAtLastStop(t *testing.T) { } func TestFindNextStopBySequence_NoMatch(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} ctx := context.Background() serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) @@ -1066,8 +1060,7 @@ func TestFindNextStopBySequence_NoMatch(t *testing.T) { } func TestFindStopsByScheduleDeviation_OnTime(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s @@ -1091,8 +1084,7 @@ func TestFindStopsByScheduleDeviation_OnTime(t *testing.T) { } func TestFindStopsByScheduleDeviation_Late(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 8, 5, 0, 0, time.UTC) // 28800 + 300 = 29100s @@ -1116,8 +1108,7 @@ func TestFindStopsByScheduleDeviation_Late(t *testing.T) { } func TestFindStopsByScheduleDeviation_Empty(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) @@ -1130,8 +1121,7 @@ func TestFindStopsByScheduleDeviation_Empty(t *testing.T) { } func TestFindStopsByScheduleDeviation_LastStop(t *testing.T) { - api := createTestApi(t) - defer api.Shutdown() + api := &RestAPI{} serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) currentTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) // 32400s From 8228121bdbeb6c13f74a5827d9dcaea5fa1967a9 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:06:26 +0200 Subject: [PATCH 71/94] refactor: Correct return value for GetScheduleDeviation when no delay data is present --- internal/restapi/trip_updates_helper.go | 2 +- internal/restapi/trip_updates_helper_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/restapi/trip_updates_helper.go b/internal/restapi/trip_updates_helper.go index b92fe58c..d37d4a6d 100644 --- a/internal/restapi/trip_updates_helper.go +++ b/internal/restapi/trip_updates_helper.go @@ -26,7 +26,7 @@ func (api *RestAPI) GetScheduleDeviation(tripID string) (int, bool) { } } - return 0, true + return 0, false } func (api *RestAPI) GetStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { diff --git a/internal/restapi/trip_updates_helper_test.go b/internal/restapi/trip_updates_helper_test.go index 8ff3fdfb..337134f3 100644 --- a/internal/restapi/trip_updates_helper_test.go +++ b/internal/restapi/trip_updates_helper_test.go @@ -102,7 +102,7 @@ func TestGetScheduleDeviation_StopUpdateWithNoDelay(t *testing.T) { deviation, hasData := api.GetScheduleDeviation("trip-nodelay-test") assert.Equal(t, 0, deviation) - assert.True(t, hasData, "trip update exists so hasData should be true even with zero deviation") + assert.False(t, hasData, "trip update with no delay data should report hasData=false") } func TestGetScheduleDeviation_ZeroDeviationIsDistinguishedFromNoData(t *testing.T) { From 115f7bf3604227d805396d60dff23c1e6e96a902 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:11:49 +0200 Subject: [PATCH 72/94] refactor: Update BuildVehicleStatus to accept currentTime param and adjust related tests --- internal/restapi/trips_helper.go | 2 +- internal/restapi/vehicles_helper.go | 3 ++- internal/restapi/vehicles_helper_test.go | 12 +++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index ccdc819e..350f8ce7 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -41,7 +41,7 @@ func (api *RestAPI) BuildTripStatus( // We intentionally leave OccupancyCapacity at its default (-1) here. // See: TripStatusBeanServiceImpl.java in onebusaway-transit-data-federation. } - api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) + api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status, currentTime) _, activeTripRawID, err := utils.ExtractAgencyIDAndCodeID(status.ActiveTripID) if err != nil { diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index efa03c4b..4a4c07f5 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -106,8 +106,9 @@ func (api *RestAPI) BuildVehicleStatus( tripID string, agencyID string, status *models.TripStatusForTripDetails, + currentTime time.Time, ) { - if vehicle == nil || defaultStaleDetector.Check(vehicle, api.Clock.Now()) { + if vehicle == nil || defaultStaleDetector.Check(vehicle, currentTime) { status.Status, status.Phase = GetVehicleStatusAndPhase(nil) return } diff --git a/internal/restapi/vehicles_helper_test.go b/internal/restapi/vehicles_helper_test.go index 9bd055e9..0b1ba5e3 100644 --- a/internal/restapi/vehicles_helper_test.go +++ b/internal/restapi/vehicles_helper_test.go @@ -140,8 +140,9 @@ func TestBuildVehicleStatus_NilVehicleSetsDefaultStatus(t *testing.T) { defer api.Shutdown() ctx := context.Background() + now := time.Now() status := &models.TripStatusForTripDetails{} - api.BuildVehicleStatus(ctx, nil, "any-trip", "any-agency", status) + api.BuildVehicleStatus(ctx, nil, "any-trip", "any-agency", status, now) assert.Equal(t, "default", status.Status) assert.Equal(t, "scheduled", status.Phase) @@ -153,14 +154,15 @@ func TestBuildVehicleStatus_StaleVehicleSetsDefaultStatus(t *testing.T) { defer api.Shutdown() ctx := context.Background() - old := time.Now().Add(-20 * time.Minute) + now := time.Now() + old := now.Add(-20 * time.Minute) vehicle := >fs.Vehicle{ ID: >fs.VehicleID{ID: "v1"}, Timestamp: &old, } status := &models.TripStatusForTripDetails{} - api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status, now) assert.Equal(t, "default", status.Status) assert.Equal(t, "scheduled", status.Phase) @@ -184,7 +186,7 @@ func TestBuildVehicleStatus_FreshVehicleWithPosition_SetsLocationAndPhase(t *tes } status := &models.TripStatusForTripDetails{} - api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status, now) assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") assert.Equal(t, "SCHEDULED", status.Status) @@ -204,7 +206,7 @@ func TestBuildVehicleStatus_FreshVehicleNoPosition_DoesNotSetPredicted(t *testin } status := &models.TripStatusForTripDetails{} - api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status) + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status, now) assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") } From 5720fba82e5ce33c80d890a97d00f7d9a375d547 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:13:56 +0200 Subject: [PATCH 73/94] refactor: Add check for valid BlockID in calculateBlockTripSequence function --- internal/restapi/trips_helper.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 350f8ce7..65cb841e 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -450,6 +450,10 @@ func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID strin return 0 } + if !trip.BlockID.Valid { + return 0 + } + formattedDate := serviceDate.Format("20060102") activeServiceIDs, err := api.GtfsManager.GtfsDB.Queries.GetActiveServiceIDsForDate(ctx, formattedDate) if err != nil || len(activeServiceIDs) == 0 { From 568da3258ecc29da98d99bcedf70e542ed4d9c91 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:15:06 +0200 Subject: [PATCH 74/94] refactor: Set status to nil on error --- internal/restapi/trip_details_handler.go | 1 + internal/restapi/trip_for_vehicle_handler.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 939d3719..3feb5c0c 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -142,6 +142,7 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { slog.Warn("BuildTripStatus failed", slog.String("trip_id", trip.ID), slog.String("error", statusErr.Error())) + status = nil } } diff --git a/internal/restapi/trip_for_vehicle_handler.go b/internal/restapi/trip_for_vehicle_handler.go index 655f66fd..d3ae39fe 100644 --- a/internal/restapi/trip_for_vehicle_handler.go +++ b/internal/restapi/trip_for_vehicle_handler.go @@ -74,7 +74,7 @@ func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request "tripID", tripID, "agencyID", agencyID, "error", statusErr) - // Proceeding with nil status, as it's an optional field + status = nil } } From c4efbd6b7b2b84c4623ab79cf5522c346dd3cb42 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:16:50 +0200 Subject: [PATCH 75/94] refactor: Optimize findStopsByScheduleDeviation to use index for closest stop --- internal/restapi/trips_helper.go | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 65cb841e..ff2d7d3f 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -798,8 +798,9 @@ func (api *RestAPI) findStopsByScheduleDeviation( var closestStop *gtfsdb.StopTime var closestTimeDiff int64 = math.MaxInt64 + var closestIndex int - for _, st := range stopTimes { + for i, st := range stopTimes { stopTime := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) timeDiff := stopTime - effectiveScheduleTime @@ -810,6 +811,7 @@ func (api *RestAPI) findStopsByScheduleDeviation( if timeDiff < closestTimeDiff { closestTimeDiff = timeDiff closestStop = st + closestIndex = i } } @@ -823,18 +825,13 @@ func (api *RestAPI) findStopsByScheduleDeviation( predictedClosestArrival := closestStopTime + int64(scheduleDeviation) closestOffset = int(predictedClosestArrival - currentTimeSeconds) - for i, st := range stopTimes { - if st.StopID == closestStopID { - if i+1 < len(stopTimes) { - nextSt := stopTimes[i+1] - nextStopID = nextSt.StopID + if closestIndex+1 < len(stopTimes) { + nextSt := stopTimes[closestIndex+1] + nextStopID = nextSt.StopID - nextStopTime := utils.EffectiveStopTimeSeconds(nextSt.ArrivalTime, nextSt.DepartureTime) - predictedNextArrival := nextStopTime + int64(scheduleDeviation) - nextOffset = int(predictedNextArrival - currentTimeSeconds) - } - break - } + nextStopTime := utils.EffectiveStopTimeSeconds(nextSt.ArrivalTime, nextSt.DepartureTime) + predictedNextArrival := nextStopTime + int64(scheduleDeviation) + nextOffset = int(predictedNextArrival - currentTimeSeconds) } return closestStopID, closestOffset, nextStopID, nextOffset From 3dc38cf2f568023e637e69c792bb13f2a4e06e86 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:17:25 +0200 Subject: [PATCH 76/94] refactor: Update TripID in TripDetails to use combined ID format --- internal/restapi/trip_for_vehicle_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/restapi/trip_for_vehicle_handler.go b/internal/restapi/trip_for_vehicle_handler.go index d3ae39fe..ec08e953 100644 --- a/internal/restapi/trip_for_vehicle_handler.go +++ b/internal/restapi/trip_for_vehicle_handler.go @@ -119,7 +119,7 @@ func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request } entry := &models.TripDetails{ - TripID: tripID, + TripID: utils.FormCombinedID(agencyID, tripID), ServiceDate: serviceDateMillis, Frequency: nil, Status: status, From 083bfaf28e17e844c077992cc834e0d3f48b7c6d Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 08:18:09 +0200 Subject: [PATCH 77/94] refactor: Simplify StaleDetector WithThreshold method to return a new instance --- internal/restapi/vehicles_helper.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 4a4c07f5..e786e405 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -28,8 +28,7 @@ func NewStaleDetector() *StaleDetector { } func (d *StaleDetector) WithThreshold(threshold time.Duration) *StaleDetector { - d.threshold = threshold - return d + return &StaleDetector{threshold: threshold} } func (d *StaleDetector) Check(vehicle *gtfs.Vehicle, currentTime time.Time) bool { From ce1d932baeece3d0bda5b88f59616410f1b88c86 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:16:53 +0200 Subject: [PATCH 78/94] refactor: Update vehicle status comments to clarify GTFS-RT schedule relationship --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4036bea7..9c58afea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,9 +220,10 @@ dateStr, parsedTime, fieldErrors, ok := utils.ParseTimeParameter(timeParam, loca ### Vehicle Status (`internal/restapi/vehicles_helper.go`) ```go -// Convert GTFS-RT status to OneBusAway format +// Convert GTFS-RT schedule relationship to OneBusAway status and phase status, phase := GetVehicleStatusAndPhase(vehicle) -// Returns: ("IN_TRANSIT_TO", "in_progress"), ("STOPPED_AT", "stopped"), etc. +// Returns: ("SCHEDULED", "in_progress"), ("CANCELED", ""), ("ADDED", "in_progress"), ("DUPLICATED", "in_progress") +// For nil vehicle: ("default", "scheduled") ``` ## Database Management From 0e2051dee197c8a17e2e3430b1a919ec12e3fc7a Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:17:02 +0200 Subject: [PATCH 79/94] refactor: Implement MockResetRealTimeData method to clear mock data for tests --- internal/gtfs/gtfs_manager_mock.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index 0a3c2b2e..e6283cf7 100644 --- a/internal/gtfs/gtfs_manager_mock.go +++ b/internal/gtfs/gtfs_manager_mock.go @@ -119,3 +119,16 @@ func (m *Manager) MockAddTripUpdate(tripID string, delay *time.Duration, stopTim } m.realTimeTripLookup[tripID] = len(m.realTimeTrips) - 1 } + +// MockResetRealTimeData clears all mock real-time vehicles and trip updates. +// Call this in t.Cleanup() to prevent test interference when using a shared Manager. +func (m *Manager) MockResetRealTimeData() { + m.realTimeMutex.Lock() + defer m.realTimeMutex.Unlock() + + m.realTimeVehicles = nil + m.realTimeVehicleLookupByVehicle = make(map[string]int) + m.realTimeVehicleLookupByTrip = make(map[string]int) + m.realTimeTrips = nil + m.realTimeTripLookup = make(map[string]int) +} From 2d43f6129064b5b86701426a5ace2064796c8c10 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:17:08 +0200 Subject: [PATCH 80/94] refactor: Remove cleanup comment from MockResetRealTimeData method --- internal/gtfs/gtfs_manager_mock.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index e6283cf7..4a6d5ac5 100644 --- a/internal/gtfs/gtfs_manager_mock.go +++ b/internal/gtfs/gtfs_manager_mock.go @@ -121,7 +121,6 @@ func (m *Manager) MockAddTripUpdate(tripID string, delay *time.Duration, stopTim } // MockResetRealTimeData clears all mock real-time vehicles and trip updates. -// Call this in t.Cleanup() to prevent test interference when using a shared Manager. func (m *Manager) MockResetRealTimeData() { m.realTimeMutex.Lock() defer m.realTimeMutex.Unlock() From a569c1e4b95e7ccf300b5ffc5d35438cc865f473 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:17:17 +0200 Subject: [PATCH 81/94] refactor: Add tests for bearing conversion in BuildVehicleStatus --- internal/restapi/vehicles_helper_test.go | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/internal/restapi/vehicles_helper_test.go b/internal/restapi/vehicles_helper_test.go index 0b1ba5e3..1c3703f3 100644 --- a/internal/restapi/vehicles_helper_test.go +++ b/internal/restapi/vehicles_helper_test.go @@ -210,3 +210,72 @@ func TestBuildVehicleStatus_FreshVehicleNoPosition_DoesNotSetPredicted(t *testin assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") } + +func TestBuildVehicleStatus_BearingConversion(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + tests := []struct { + name string + bearing float32 + expectedOrientation float64 + }{ + { + name: "North (0°) → 90°", + bearing: 0, + expectedOrientation: 90, + }, + { + name: "East (90°) → 0°", + bearing: 90, + expectedOrientation: 0, + }, + { + name: "South (180°) → 270°", + bearing: 180, + expectedOrientation: 270, + }, + { + name: "West (270°) → 180°", + bearing: 270, + expectedOrientation: 180, + }, + { + name: "NW (315°) → 135°", + bearing: 315, + expectedOrientation: 135, + }, + { + name: "Bearing > 90 wraps (120°) → 330°", + bearing: 120, + expectedOrientation: 330, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + lat := float32(37.7749) + lon := float32(-122.4194) + bearing := tt.bearing + vehicle := >fs.Vehicle{ + ID: >fs.VehicleID{ID: "v-bearing"}, + Timestamp: &now, + Position: >fs.Position{ + Latitude: &lat, + Longitude: &lon, + Bearing: &bearing, + }, + } + + status := &models.TripStatusForTripDetails{} + api.BuildVehicleStatus(ctx, vehicle, "any-trip", "any-agency", status, now) + + assert.Equal(t, tt.expectedOrientation, status.Orientation, + "Orientation should be (90 - bearing) with wraparound") + assert.Equal(t, tt.expectedOrientation, status.LastKnownOrientation, + "LastKnownOrientation should match Orientation") + }) + } +} From d3edc010958a65b5122a2d0e3687c6f16588b494 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:17:31 +0200 Subject: [PATCH 82/94] refactor: Improve logging in fillStopsFromSchedule and remove unused vehicle parameter --- internal/restapi/trips_helper.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index ff2d7d3f..25397fba 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -2,6 +2,7 @@ package restapi import ( "context" + "log/slog" "math" "time" @@ -11,7 +12,6 @@ import ( "maglev.onebusaway.org/internal/utils" ) -// IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) BuildTripStatus( ctx context.Context, agencyID, tripID string, @@ -81,7 +81,7 @@ func (api *RestAPI) BuildTripStatus( } } else if vehicle.CurrentStopSequence != nil { closestStopID, closestOffset = api.findClosestStopBySequence( - stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, + stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, ) nextStopID, nextOffset = api.findNextStopBySequence( ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, tripID, serviceDate, @@ -239,7 +239,13 @@ func (api *RestAPI) GetNextAndPreviousTripIDs(ctx context.Context, trip *gtfsdb. func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.TripStatusForTripDetails, tripID string, currentTime time.Time, serviceDate time.Time, agencyID string) { stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) - if err != nil || len(stopTimes) == 0 { + if err != nil { + slog.Warn("fillStopsFromSchedule: failed to get stop times", + slog.String("trip_id", tripID), + slog.String("error", err.Error())) + return + } + if len(stopTimes) == 0 { return } @@ -843,7 +849,6 @@ func (api *RestAPI) findClosestStopBySequence( currentTime time.Time, serviceDate time.Time, scheduleDeviation int, - vehicle *gtfs.Vehicle, ) (stopID string, offset int) { currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) From fc58af1ad7953db6549e752f13b392e6c11264b7 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:17:41 +0200 Subject: [PATCH 83/94] refactor: Add cleanup for MockResetRealTimeData in test setup --- internal/restapi/trip_for_vehicle_handler_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/restapi/trip_for_vehicle_handler_test.go b/internal/restapi/trip_for_vehicle_handler_test.go index 03914172..06ba9bf0 100644 --- a/internal/restapi/trip_for_vehicle_handler_test.go +++ b/internal/restapi/trip_for_vehicle_handler_test.go @@ -22,6 +22,7 @@ func setupTestApiWithMockVehicle(t *testing.T) (*RestAPI, string, string) { api := createTestApi(t) // Initialize the logger to prevent nil pointer panics during handler execution api.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + t.Cleanup(api.GtfsManager.MockResetRealTimeData) // Note: caller is responsible for calling api.Shutdown() @@ -257,6 +258,7 @@ func TestTripForVehicleHandlerWithNonExistentTrip(t *testing.T) { // Initialize the logger to prevent nil pointer panics during handler execution api.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) agencyID := api.GtfsManager.GetAgencies()[0].Id From 1354bd833ddbfedcd8335b6659e07eb8e2720e41 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:17:49 +0200 Subject: [PATCH 84/94] refactor: Ensure MockResetRealTimeData is called in all schedule deviation tests --- internal/restapi/trip_updates_helper_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/restapi/trip_updates_helper_test.go b/internal/restapi/trip_updates_helper_test.go index 337134f3..8c8d1483 100644 --- a/internal/restapi/trip_updates_helper_test.go +++ b/internal/restapi/trip_updates_helper_test.go @@ -20,6 +20,7 @@ func TestGetScheduleDeviation_NoUpdates(t *testing.T) { func TestGetScheduleDeviation_TripLevelDelay(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) delay := 90 * time.Second api.GtfsManager.MockAddTripUpdate("trip-delay-test", &delay, nil) @@ -32,6 +33,7 @@ func TestGetScheduleDeviation_TripLevelDelay(t *testing.T) { func TestGetScheduleDeviation_StopLevelArrivalDelay(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopID := "stop-1" arrivalDelay := 60 * time.Second @@ -51,6 +53,7 @@ func TestGetScheduleDeviation_StopLevelArrivalDelay(t *testing.T) { func TestGetScheduleDeviation_StopLevelDepartureDelay(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopID := "stop-1" departureDelay := 120 * time.Second @@ -70,6 +73,7 @@ func TestGetScheduleDeviation_StopLevelDepartureDelay(t *testing.T) { func TestGetScheduleDeviation_TripLevelDelayTakesPrecedence(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) tripDelay := 30 * time.Second stopID := "stop-1" @@ -90,6 +94,7 @@ func TestGetScheduleDeviation_TripLevelDelayTakesPrecedence(t *testing.T) { func TestGetScheduleDeviation_StopUpdateWithNoDelay(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopID := "stop-1" updates := []gtfs.StopTimeUpdate{ @@ -108,6 +113,7 @@ func TestGetScheduleDeviation_StopUpdateWithNoDelay(t *testing.T) { func TestGetScheduleDeviation_ZeroDeviationIsDistinguishedFromNoData(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) // Trip with explicit zero-second delay — should return (0, true) zeroDelay := time.Duration(0) @@ -134,6 +140,7 @@ func TestGetStopDelaysFromTripUpdates_NoUpdates(t *testing.T) { func TestGetStopDelaysFromTripUpdates_WithArrivalDelay(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopID := "stop-A" arrivalDelay := 45 * time.Second @@ -154,6 +161,7 @@ func TestGetStopDelaysFromTripUpdates_WithArrivalDelay(t *testing.T) { func TestGetStopDelaysFromTripUpdates_WithDepartureDelay(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopID := "stop-B" departureDelay := 75 * time.Second @@ -174,6 +182,7 @@ func TestGetStopDelaysFromTripUpdates_WithDepartureDelay(t *testing.T) { func TestGetStopDelaysFromTripUpdates_SkipsStopWithNoStopID(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) arrivalDelay := 30 * time.Second updates := []gtfs.StopTimeUpdate{ @@ -191,6 +200,7 @@ func TestGetStopDelaysFromTripUpdates_SkipsStopWithNoStopID(t *testing.T) { func TestGetStopDelaysFromTripUpdates_IncludesStopWithZeroDelays(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopID := "stop-C" zeroDelay := time.Duration(0) @@ -211,6 +221,7 @@ func TestGetStopDelaysFromTripUpdates_IncludesStopWithZeroDelays(t *testing.T) { func TestGetStopDelaysFromTripUpdates_MultipleStops(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) stopA := "stop-A" stopB := "stop-B" From e7d421e3550a78dad101940955bd59e81a5cb819 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Mon, 23 Feb 2026 09:18:06 +0200 Subject: [PATCH 85/94] refactor: Add tests for BuildTripStatus functionality and vehicle position handling --- internal/restapi/trips_helper_test.go | 199 +++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 3 deletions(-) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index 70f19cf0..b463db50 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "maglev.onebusaway.org/gtfsdb" + internalgtfs "maglev.onebusaway.org/internal/gtfs" "maglev.onebusaway.org/internal/models" "maglev.onebusaway.org/internal/utils" ) @@ -495,9 +496,201 @@ func TestBuildStopTimesList_ErrorHandling(t *testing.T) { }) } +func TestBuildTripStatus_VehicleWithPosition_FindsStops(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + ctx := context.Background() + + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + agencyID := agencies[0].Id + + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + + // Find a trip with stop times so we can exercise the stop-finding branch + var tripID string + var stopTimes []gtfsdb.StopTime + for _, trip := range trips { + st, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) + if err == nil && len(st) >= 2 { + tripID = trip.ID + stopTimes = st + break + } + } + require.NotEmpty(t, tripID, "Need a trip with at least 2 stop times") + + // Look up coordinates for the first stop so the vehicle is nearby + firstStopID := stopTimes[0].StopID + stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, []string{firstStopID}) + require.NoError(t, err) + require.NotEmpty(t, stops) + + lat := float32(stops[0].Lat) + lon := float32(stops[0].Lon) + + routeID := trips[0].Route.Id + vehicleID := "VEHICLE_POS_TEST" + + api.GtfsManager.MockAddVehicleWithOptions(vehicleID, tripID, routeID, internalgtfs.MockVehicleOptions{ + Position: >fs.Position{ + Latitude: &lat, + Longitude: &lon, + }, + }) + + // Set currentTime during the trip using the first stop's arrival time + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + arrivalSeconds := utils.EffectiveStopTimeSeconds(stopTimes[0].ArrivalTime, stopTimes[0].DepartureTime) + currentTime := serviceDate.Add(time.Duration(arrivalSeconds) * time.Second) + + status, err := api.BuildTripStatus(ctx, agencyID, tripID, serviceDate, currentTime) + require.NoError(t, err) + require.NotNil(t, status) + + // Vehicle has position, so the stop-finding code should have run and found stops + assert.NotEmpty(t, status.ClosestStop, "ClosestStop should be populated when vehicle has position") + assert.NotEmpty(t, status.NextStop, "NextStop should be populated when vehicle has position and is not at last stop") + + // Vehicle is fresh, so status should reflect real-time data + assert.Equal(t, "SCHEDULED", status.Status) + assert.Equal(t, "in_progress", status.Phase) + assert.NotZero(t, status.LastKnownLocation.Lat, "LastKnownLocation should be set from vehicle position") +} + +func TestBuildTripStatus_ScheduleDeviation_SetsPredicted(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + ctx := context.Background() + + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + agencyID := agencies[0].Id + + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + tripID := trips[0].ID + routeID := trips[0].Route.Id + + // Add a trip update with a 120-second delay (no vehicle, just trip update) + delay := 120 * time.Second + api.GtfsManager.MockAddTripUpdate(tripID, &delay, nil) + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := serviceDate.Add(8 * time.Hour) + + api.GtfsManager.MockAddAgency(agencyID, agencies[0].Name) + api.GtfsManager.MockAddRoute(routeID, agencyID, routeID) + api.GtfsManager.MockAddTrip(tripID, agencyID, routeID) + + status, err := api.BuildTripStatus(ctx, agencyID, tripID, serviceDate, currentTime) + require.NoError(t, err) + require.NotNil(t, status) + + assert.Equal(t, 120, status.ScheduleDeviation, "ScheduleDeviation should reflect the trip update delay") + assert.True(t, status.Predicted, "Predicted should be true when trip update exists") + assert.False(t, status.Scheduled, "Scheduled should be false when predicted is true") +} + +func TestBuildTripStatus_NoRealtimeData_SetsScheduled(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + ctx := context.Background() + + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + agencyID := agencies[0].Id + + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + tripID := trips[0].ID + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := serviceDate.Add(8 * time.Hour) + + // No vehicle, no trip updates — purely scheduled + status, err := api.BuildTripStatus(ctx, agencyID, tripID, serviceDate, currentTime) + require.NoError(t, err) + require.NotNil(t, status) + + assert.Equal(t, 0, status.ScheduleDeviation, "ScheduleDeviation should be 0 with no real-time data") + assert.False(t, status.Predicted, "Predicted should be false with no real-time data") + assert.True(t, status.Scheduled, "Scheduled should be true with no real-time data") + assert.Equal(t, "default", status.Status) + assert.Equal(t, "scheduled", status.Phase) +} + +func TestBuildTripStatus_ShapeData_ComputesDistanceAlongTrip(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + ctx := context.Background() + + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + agencyID := agencies[0].Id + + // Find a trip that has both shape data and stop times + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + + var tripID, routeID string + for _, trip := range trips { + shapeRows, err := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, trip.ID) + if err == nil && len(shapeRows) > 1 { + st, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) + if err == nil && len(st) >= 2 { + tripID = trip.ID + routeID = trip.Route.Id + break + } + } + } + require.NotEmpty(t, tripID, "Need a trip with shape data and stop times") + + // Get a mid-route stop to position the vehicle + stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, tripID) + require.NoError(t, err) + + midIdx := len(stopTimes) / 2 + midStopID := stopTimes[midIdx].StopID + stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, []string{midStopID}) + require.NoError(t, err) + require.NotEmpty(t, stops) + + lat := float32(stops[0].Lat) + lon := float32(stops[0].Lon) + vehicleID := "VEHICLE_SHAPE_TEST" + + api.GtfsManager.MockAddVehicleWithOptions(vehicleID, tripID, routeID, internalgtfs.MockVehicleOptions{ + Position: >fs.Position{ + Latitude: &lat, + Longitude: &lon, + }, + }) + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + arrivalSeconds := utils.EffectiveStopTimeSeconds(stopTimes[midIdx].ArrivalTime, stopTimes[midIdx].DepartureTime) + currentTime := serviceDate.Add(time.Duration(arrivalSeconds) * time.Second) + + status, err := api.BuildTripStatus(ctx, agencyID, tripID, serviceDate, currentTime) + require.NoError(t, err) + require.NotNil(t, status) + + assert.Greater(t, status.TotalDistanceAlongTrip, 0.0, "TotalDistanceAlongTrip should be > 0 with shape data") + assert.Greater(t, status.DistanceAlongTrip, 0.0, "DistanceAlongTrip should be > 0 for a vehicle mid-route") + assert.Less(t, status.DistanceAlongTrip, status.TotalDistanceAlongTrip, + "DistanceAlongTrip should be less than total for a mid-route vehicle") +} + func TestBuildTripStatus_VehicleIDFormat(t *testing.T) { api := createTestApi(t) defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) agencyStatic := api.GtfsManager.GetAgencies()[0] trips := api.GtfsManager.GetTrips() @@ -936,7 +1129,7 @@ func TestFindClosestStopBySequence_Match(t *testing.T) { {StopID: "s3", StopSequence: 3, ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, }) - stopID, offset := api.findClosestStopBySequence(stops, 2, currentTime, serviceDate, 0, nil) + stopID, offset := api.findClosestStopBySequence(stops, 2, currentTime, serviceDate, 0) assert.Equal(t, "s2", stopID) // predicted arrival = 8*3600 + 0(deviation) = 28800; offset = 28800 - 28800 = 0 assert.Equal(t, 0, offset, "vehicle at on-time stop means offset == 0") @@ -953,7 +1146,7 @@ func TestFindClosestStopBySequence_WithDeviation(t *testing.T) { }) // Vehicle is 5 minutes late - stopID, offset := api.findClosestStopBySequence(stops, 1, currentTime, serviceDate, 300, nil) + stopID, offset := api.findClosestStopBySequence(stops, 1, currentTime, serviceDate, 300) assert.Equal(t, "s1", stopID) // predicted arrival = 8*3600 + 300 = 29100; current = 28800; offset = 300 assert.Equal(t, 300, offset, "offset should reflect 5-minute delay") @@ -969,7 +1162,7 @@ func TestFindClosestStopBySequence_NoMatch(t *testing.T) { {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600)}, }) - stopID, offset := api.findClosestStopBySequence(stops, 99, currentTime, serviceDate, 0, nil) + stopID, offset := api.findClosestStopBySequence(stops, 99, currentTime, serviceDate, 0) assert.Empty(t, stopID, "no stop should match sequence 99") assert.Equal(t, 0, offset) } From 7dcb91811776ed6bb7154a6ecb0759249dd526bb Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 24 Feb 2026 01:27:12 +0200 Subject: [PATCH 86/94] refactor: Update BuildTripSchedule call to use location and streamline situation ID handling --- internal/restapi/trip_for_vehicle_handler.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/restapi/trip_for_vehicle_handler.go b/internal/restapi/trip_for_vehicle_handler.go index ec08e953..b8e8f881 100644 --- a/internal/restapi/trip_for_vehicle_handler.go +++ b/internal/restapi/trip_for_vehicle_handler.go @@ -98,7 +98,7 @@ func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request var schedule *models.Schedule if params.IncludeSchedule { var scheduleErr error - schedule, scheduleErr = api.BuildTripSchedule(ctx, agencyID, serviceDate, &trip, time.Local) + schedule, scheduleErr = api.BuildTripSchedule(ctx, agencyID, serviceDate, &trip, loc) if scheduleErr != nil { api.Logger.Warn("failed to build trip schedule", "tripID", tripID, @@ -107,15 +107,11 @@ func (api *RestAPI) tripForVehicleHandler(w http.ResponseWriter, r *http.Request } } - situationIDs := []string{} - - if status != nil { - alerts := api.GtfsManager.GetAlertsForTrip(r.Context(), vehicle.Trip.ID.ID) - for _, alert := range alerts { - if alert.ID != "" { - situationIDs = append(situationIDs, alert.ID) - } - } + var situationIDs []string + if status != nil && len(status.SituationIDs) > 0 { + situationIDs = status.SituationIDs + } else { + situationIDs = api.GetSituationIDsForTrip(r.Context(), tripID) } entry := &models.TripDetails{ From 89bd5cfa45f379e75abb992ad1b319075e3e436f Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 24 Feb 2026 01:27:19 +0200 Subject: [PATCH 87/94] refactor: Add tests for calculateOffsetForStop and findNextStopAfter functions --- internal/restapi/trips_helper_test.go | 214 ++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index b463db50..4df413e0 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -883,6 +883,220 @@ func TestFillStopsFromSchedule_InvalidTripID(t *testing.T) { assert.Empty(t, status.NextStop) } +func TestCalculateOffsetForStop_MatchingStop(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + offset := api.calculateOffsetForStop("s2", stops, currentTime, serviceDate, 0) + // predicted arrival = 28800 + 0 = 28800; current = 28800; offset = 0 + assert.Equal(t, 0, offset, "on-time vehicle at exact stop time should have offset 0") +} + +func TestCalculateOffsetForStop_NonMatchingStop(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + }) + + offset := api.calculateOffsetForStop("nonexistent", stops, currentTime, serviceDate, 0) + assert.Equal(t, 0, offset, "non-matching stop should return 0") +} + +func TestCalculateOffsetForStop_WithDeviation(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + }) + + // 5-minute late deviation + offset := api.calculateOffsetForStop("s1", stops, currentTime, serviceDate, 300) + // predicted arrival = 28800 + 300 = 29100; current = 28800; offset = 300 + assert.Equal(t, 300, offset, "offset should reflect 5-minute delay") +} + +func TestCalculateOffsetForStop_EmptyStops(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + offset := api.calculateOffsetForStop("s1", nil, currentTime, serviceDate, 0) + assert.Equal(t, 0, offset, "empty stop times should return 0") +} + +func TestFindNextStopAfter_MidTrip(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + nextStopID, nextOffset := api.findNextStopAfter("s2", stops, currentTime, serviceDate, 0) + assert.Equal(t, "s3", nextStopID, "next stop after s2 should be s3") + // predicted arrival for s3 = 32400 + 0 = 32400; current = 28800; offset = 3600 + assert.Equal(t, 3600, nextOffset, "offset should be 1 hour") +} + +func TestFindNextStopAfter_LastStop(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + nextStopID, nextOffset := api.findNextStopAfter("s2", stops, currentTime, serviceDate, 0) + assert.Empty(t, nextStopID, "no next stop after last stop") + assert.Equal(t, 0, nextOffset, "offset should be 0 when no next stop") +} + +func TestFindNextStopAfter_WithDeviation(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) // 28800s + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + }) + + // 5-minute late deviation + nextStopID, nextOffset := api.findNextStopAfter("s1", stops, currentTime, serviceDate, 300) + assert.Equal(t, "s2", nextStopID) + // predicted arrival for s2 = 28800 + 300 = 29100; current = 28800; offset = 300 + assert.Equal(t, 300, nextOffset, "offset should include schedule deviation") +} + +func TestFindNextStopAfter_NonMatchingStop(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + }) + + nextStopID, nextOffset := api.findNextStopAfter("nonexistent", stops, currentTime, serviceDate, 0) + assert.Empty(t, nextStopID, "non-matching stop should return empty") + assert.Equal(t, 0, nextOffset) +} + +func TestFindNextStopAfter_EmptyStops(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + currentTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + + nextStopID, nextOffset := api.findNextStopAfter("s1", nil, currentTime, serviceDate, 0) + assert.Empty(t, nextStopID, "empty stops should return empty") + assert.Equal(t, 0, nextOffset) +} + +func TestBuildTripStatus_VehicleWithStopID_FindsStops(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + ctx := context.Background() + + agencies := api.GtfsManager.GetAgencies() + require.NotEmpty(t, agencies) + agencyID := agencies[0].Id + + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips) + + // Find a trip with at least 3 stop times so we can place the vehicle mid-trip + var tripID string + var stopTimes []gtfsdb.StopTime + for _, trip := range trips { + st, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) + if err == nil && len(st) >= 3 { + tripID = trip.ID + stopTimes = st + break + } + } + require.NotEmpty(t, tripID, "Need a trip with at least 3 stop times") + + // Use the middle stop so there is both a closest and a next stop + midIdx := len(stopTimes) / 2 + midStopID := stopTimes[midIdx].StopID + + // Look up coordinates for the mid stop + stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, []string{midStopID}) + require.NoError(t, err) + require.NotEmpty(t, stops) + + lat := float32(stops[0].Lat) + lon := float32(stops[0].Lon) + + routeID := trips[0].Route.Id + vehicleID := "VEHICLE_STOPID_TEST" + + // Mark the vehicle as STOPPED_AT to exercise the StopID + isStoppedAt branch + stoppedAt := gtfs.CurrentStatus(1) + api.GtfsManager.MockAddVehicleWithOptions(vehicleID, tripID, routeID, internalgtfs.MockVehicleOptions{ + Position: >fs.Position{ + Latitude: &lat, + Longitude: &lon, + }, + StopID: &midStopID, + CurrentStatus: &stoppedAt, + }) + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + arrivalSeconds := utils.EffectiveStopTimeSeconds(stopTimes[midIdx].ArrivalTime, stopTimes[midIdx].DepartureTime) + currentTime := serviceDate.Add(time.Duration(arrivalSeconds) * time.Second) + + status, err := api.BuildTripStatus(ctx, agencyID, tripID, serviceDate, currentTime) + require.NoError(t, err) + require.NotNil(t, status) + + // The StopID branch should have identified the closest stop + assert.NotEmpty(t, status.ClosestStop, "ClosestStop should be populated when vehicle has StopID") + assert.Contains(t, status.ClosestStop, midStopID, + "ClosestStop should contain the vehicle's reported StopID") + + // Because the vehicle is STOPPED_AT a mid-trip stop, NextStop should be the following stop + if midIdx+1 < len(stopTimes) { + assert.NotEmpty(t, status.NextStop, "NextStop should be populated when stopped at a mid-trip stop") + assert.Contains(t, status.NextStop, stopTimes[midIdx+1].StopID, + "NextStop should be the stop after the vehicle's current stop") + } + + // Vehicle is fresh so status/phase reflect ScheduleRelationship (SCHEDULED → "SCHEDULED"/"in_progress"), + // not CurrentStatus. CurrentStatus only affects the stop-finding branch, not GetVehicleStatusAndPhase. + assert.Equal(t, "SCHEDULED", status.Status) + assert.Equal(t, "in_progress", status.Phase) + assert.NotZero(t, status.LastKnownLocation.Lat, "LastKnownLocation should be set from vehicle position") +} + // BenchmarkDistanceToLineSegment benchmarks the line segment distance calculation func BenchmarkDistanceToLineSegment(b *testing.B) { px, py := 0.5, 1.0 From 830826136204677dd3fefb20f7deb064e48c72a5 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Tue, 24 Feb 2026 01:27:25 +0200 Subject: [PATCH 88/94] refactor: Add logging for error handling in BuildTripStatus and related functions --- internal/restapi/trips_helper.go | 33 +++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 25397fba..0b425701 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -59,6 +59,11 @@ func (api *RestAPI) BuildTripStatus( status.Scheduled = !status.Predicted stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripRawID) + if err != nil { + slog.Warn("BuildTripStatus: failed to get stop times", + slog.String("trip_id", activeTripRawID), + slog.String("error", err.Error())) + } if err == nil && len(stopTimes) > 0 { stopTimesPtrs := make([]*gtfsdb.StopTime, len(stopTimes)) for i := range stopTimes { @@ -112,6 +117,11 @@ func (api *RestAPI) BuildTripStatus( } shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + if shapeErr != nil { + slog.Warn("BuildTripStatus: failed to get shape points", + slog.String("trip_id", tripID), + slog.String("error", shapeErr.Error())) + } if shapeErr == nil && len(shapeRows) > 1 { shapePoints := shapeRowsToPoints(shapeRows) cumulativeDistances := preCalculateCumulativeDistances(shapePoints) @@ -281,6 +291,10 @@ func findClosestStopByTimeWithDelays(currentTime time.Time, serviceDate time.Tim var closestStopTimeSeconds int64 for _, st := range stopTimes { + // NOTE: Intentionally prefers DepartureTime over ArrivalTime, unlike + // EffectiveStopTimeSeconds which prefers arrival. When per-stop delays + // are available (from GTFS-RT StopTimeUpdates), departure delays are the + // more relevant metric for predicting when the vehicle leaves a stop. var stopTimeSeconds int64 if st.DepartureTime > 0 { stopTimeSeconds = utils.NanosToSeconds(st.DepartureTime) @@ -321,6 +335,9 @@ func findNextStopByTimeWithDelays(currentTime time.Time, serviceDate time.Time, var nextStopTimeSeconds int64 for _, st := range stopTimes { + // NOTE: Intentionally prefers DepartureTime over ArrivalTime, unlike + // EffectiveStopTimeSeconds which prefers arrival. See comment in + // findClosestStopByTimeWithDelays for rationale. var stopTimeSeconds int64 if st.DepartureTime > 0 { stopTimeSeconds = utils.NanosToSeconds(st.DepartureTime) @@ -453,6 +470,9 @@ func getDistanceAlongShapeInRange(lat, lon float64, shape []gtfs.ShapePoint, min func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID string, serviceDate time.Time) int { trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) if err != nil { + slog.Warn("calculateBlockTripSequence: failed to get trip", + slog.String("trip_id", tripID), + slog.String("error", err.Error())) return 0 } @@ -462,7 +482,14 @@ func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID strin formattedDate := serviceDate.Format("20060102") activeServiceIDs, err := api.GtfsManager.GtfsDB.Queries.GetActiveServiceIDsForDate(ctx, formattedDate) - if err != nil || len(activeServiceIDs) == 0 { + if err != nil { + slog.Warn("calculateBlockTripSequence: failed to get active service IDs", + slog.String("trip_id", tripID), + slog.String("date", formattedDate), + slog.String("error", err.Error())) + return 0 + } + if len(activeServiceIDs) == 0 { return 0 } @@ -471,6 +498,10 @@ func (api *RestAPI) calculateBlockTripSequence(ctx context.Context, tripID strin ServiceIds: activeServiceIDs, }) if err != nil { + slog.Warn("calculateBlockTripSequence: failed to get ordered block trips", + slog.String("trip_id", tripID), + slog.String("block_id", trip.BlockID.String), + slog.String("error", err.Error())) return 0 } From cb99dc6292fd0e9640c2432551aebac712af45ae Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 25 Feb 2026 06:33:07 +0200 Subject: [PATCH 89/94] docs: Update comment for shapeRowsToPoints to clarify distance computation --- internal/restapi/shape_distance_helpers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/restapi/shape_distance_helpers.go b/internal/restapi/shape_distance_helpers.go index c1ff1487..f3320b74 100644 --- a/internal/restapi/shape_distance_helpers.go +++ b/internal/restapi/shape_distance_helpers.go @@ -8,6 +8,8 @@ import ( ) // shapeRowsToPoints converts database shape rows to gtfs.ShapePoint slice. +// ShapeDistTraveled is intentionally dropped; cumulative distances are recomputed +// from scratch via preCalculateCumulativeDistances to ensure consistency. func shapeRowsToPoints(rows []gtfsdb.Shape) []gtfs.ShapePoint { pts := make([]gtfs.ShapePoint, len(rows)) for i, sp := range rows { From 33f594d0599451b463d9ac9503205cc1822fd154 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 25 Feb 2026 06:33:14 +0200 Subject: [PATCH 90/94] refactor: Add mutex locking to MockAddVehicle and MockAddVehicleWithOptions for thread safety --- internal/gtfs/gtfs_manager_mock.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index 4a6d5ac5..575d9f1e 100644 --- a/internal/gtfs/gtfs_manager_mock.go +++ b/internal/gtfs/gtfs_manager_mock.go @@ -31,6 +31,9 @@ func (m *Manager) MockAddRoute(id, agencyID, name string) { }) } func (m *Manager) MockAddVehicle(vehicleID, tripID, routeID string) { + m.realTimeMutex.Lock() + defer m.realTimeMutex.Unlock() + for _, v := range m.realTimeVehicles { if v.ID.ID == vehicleID { return @@ -63,6 +66,9 @@ type MockVehicleOptions struct { } func (m *Manager) MockAddVehicleWithOptions(vehicleID, tripID, routeID string, opts MockVehicleOptions) { + m.realTimeMutex.Lock() + defer m.realTimeMutex.Unlock() + for _, v := range m.realTimeVehicles { if v.ID.ID == vehicleID { return From 24326dbdca9a74548f309fafab4d469785f8d987 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 25 Feb 2026 06:33:21 +0200 Subject: [PATCH 91/94] refactor: Enhance error handling in tripDetailsHandler with logging for BuildTripSchedule failures --- internal/restapi/trip_details_handler.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index 3feb5c0c..19e7c1c1 100644 --- a/internal/restapi/trip_details_handler.go +++ b/internal/restapi/trip_details_handler.go @@ -149,8 +149,10 @@ func (api *RestAPI) tripDetailsHandler(w http.ResponseWriter, r *http.Request) { if params.IncludeSchedule { schedule, err = api.BuildTripSchedule(ctx, agencyID, serviceDate, &trip, loc) if err != nil { - api.serverErrorResponse(w, r, err) - return + slog.Warn("BuildTripSchedule failed", + slog.String("trip_id", trip.ID), + slog.String("error", err.Error())) + schedule = nil } } From 719fd350bbb1c92db4a98f0d18cc66429c47ea95 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 25 Feb 2026 06:34:53 +0200 Subject: [PATCH 92/94] docs: add comments in trip_updates_helper and vehicles_helper for clarity on schedule statuses and behavior --- internal/restapi/trip_updates_helper.go | 7 +++++++ internal/restapi/vehicles_helper.go | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/restapi/trip_updates_helper.go b/internal/restapi/trip_updates_helper.go index d37d4a6d..97403d8b 100644 --- a/internal/restapi/trip_updates_helper.go +++ b/internal/restapi/trip_updates_helper.go @@ -5,6 +5,10 @@ type StopDelayInfo struct { DepartureDelay int64 } +// GetScheduleDeviation returns the schedule deviation in seconds for the given trip +// (positive = late, negative = early) and whether any real-time trip update was found. +// It prefers the trip-level delay from GTFS-RT; if absent, it falls back to the first +// per-stop arrival or departure delay in the StopTimeUpdates list. func (api *RestAPI) GetScheduleDeviation(tripID string) (int, bool) { tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) if len(tripUpdates) == 0 { @@ -29,6 +33,9 @@ func (api *RestAPI) GetScheduleDeviation(tripID string) (int, bool) { return 0, false } +// GetStopDelaysFromTripUpdates returns a map of stop ID → per-stop delay information +// (arrival and departure delays in seconds) derived from the GTFS-RT StopTimeUpdates +// for the given trip. Returns an empty map when no real-time data is available. func (api *RestAPI) GetStopDelaysFromTripUpdates(tripID string) map[string]StopDelayInfo { delays := make(map[string]StopDelayInfo) diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index e786e405..987eeac8 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -61,6 +61,10 @@ func scheduleRelationshipStatus(sr gtfs.TripScheduleRelationship) string { case gtfsrt.TripDescriptor_DUPLICATED: return "DUPLICATED" default: + // UNSCHEDULED, REPLACEMENT, and DELETED all map to "SCHEDULED" here. + // This matches the Java OBA behavior where only CANCELED, ADDED, and DUPLICATED + // receive distinct statuses. A DELETED trip appearing as "SCHEDULED" is + // intentional: the Java server does not expose a DELETED status to clients. return "SCHEDULED" } } @@ -92,6 +96,8 @@ func GetVehicleStatusAndPhase(vehicle *gtfs.Vehicle) (status string, phase strin // Java sets phase to IN_PROGRESS whenever a vehicle location record is received, // regardless of GTFS-RT CurrentStatus — unless the trip is canceled. + // For CANCELED trips phase is intentionally left as "" (empty string), matching + // the Java OBA null-phase behavior for canceled trips. if sr != gtfsrt.TripDescriptor_CANCELED { phase = "in_progress" } @@ -125,8 +131,9 @@ func (api *RestAPI) BuildVehicleStatus( } status.LastKnownLocation = actualPosition // Position is initially set to the raw GPS position. - // BuildTripStatus refines this via shape projection once shape data - // is fetched, avoiding a duplicate GetShapePointsByTripID query. + // BuildTripStatus will refine this by projecting it onto the route shape + // after fetching shape data. Note: getVehicleDistanceAlongShapeContextual + // makes its own GetShapePointsByTripID call; these two fetches are separate. status.Position = actualPosition if vehicle.Timestamp != nil { From ee5ee9d26898fc284920b9afe8adce9f2a59f4d6 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 25 Feb 2026 06:35:04 +0200 Subject: [PATCH 93/94] refactor: Update BuildTripStatus to use activeTripRawID and improve logging for trip retrieval errors --- internal/restapi/trips_helper.go | 46 ++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 8cee41c5..8286b11f 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -14,6 +14,7 @@ import ( "maglev.onebusaway.org/internal/utils" ) +// IMPORTANT: Caller must hold manager.RLock() before calling this method. func (api *RestAPI) BuildTripStatus( ctx context.Context, agencyID, tripID string, @@ -32,7 +33,7 @@ func (api *RestAPI) BuildTripStatus( if vehicle != nil { if vehicle.ID != nil { - status.VehicleID = vehicle.ID.ID + status.VehicleID = utils.FormCombinedID(agencyID, vehicle.ID.ID) } if vehicle.OccupancyStatus != nil { status.OccupancyStatus = vehicle.OccupancyStatus.String() @@ -91,7 +92,7 @@ func (api *RestAPI) BuildTripStatus( stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, ) nextStopID, nextOffset = api.findNextStopBySequence( - ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, tripID, serviceDate, + ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, tripID, ) } else { closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( @@ -115,13 +116,13 @@ func (api *RestAPI) BuildTripStatus( } if status.ClosestStop == "" || status.NextStop == "" { - api.fillStopsFromSchedule(ctx, status, tripID, currentTime, serviceDate, agencyID) + api.fillStopsFromSchedule(ctx, status, activeTripRawID, currentTime, serviceDate, agencyID) } - shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, tripID) + shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, activeTripRawID) if shapeErr != nil { slog.Warn("BuildTripStatus: failed to get shape points", - slog.String("trip_id", tripID), + slog.String("trip_id", activeTripRawID), slog.String("error", shapeErr.Error())) } if shapeErr == nil && len(shapeRows) > 1 { @@ -137,7 +138,7 @@ func (api *RestAPI) BuildTripStatus( status.Position = *projected } - actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) + actualDistance := api.getVehicleDistanceAlongShapeContextual(ctx, activeTripRawID, vehicle) status.DistanceAlongTrip = actualDistance if scheduleDeviation != 0 && len(stopTimes) > 0 { @@ -268,18 +269,20 @@ func (api *RestAPI) fillStopsFromSchedule(ctx context.Context, status *models.Tr predictedArrival := arrivalTime + int64(status.ScheduleDeviation) if predictedArrival > currentSeconds { - if i > 0 { + if i > 0 && status.ClosestStop == "" { status.ClosestStop = utils.FormCombinedID(agencyID, stopTimes[i-1].StopID) closestArrival := utils.EffectiveStopTimeSeconds(stopTimes[i-1].ArrivalTime, stopTimes[i-1].DepartureTime) status.ClosestStopTimeOffset = int(closestArrival + int64(status.ScheduleDeviation) - currentSeconds) } - status.NextStop = utils.FormCombinedID(agencyID, st.StopID) - status.NextStopTimeOffset = int(predictedArrival - currentSeconds) + if status.NextStop == "" { + status.NextStop = utils.FormCombinedID(agencyID, st.StopID) + status.NextStopTimeOffset = int(predictedArrival - currentSeconds) + } return } } - if len(stopTimes) > 0 { + if len(stopTimes) > 0 && status.ClosestStop == "" { lastStop := stopTimes[len(stopTimes)-1] status.ClosestStop = utils.FormCombinedID(agencyID, lastStop.StopID) arrivalTime := utils.EffectiveStopTimeSeconds(lastStop.ArrivalTime, lastStop.DepartureTime) @@ -916,7 +919,6 @@ func (api *RestAPI) findNextStopBySequence( scheduleDeviation int, vehicle *gtfs.Vehicle, tripID string, - serviceDateForBlock time.Time, ) (stopID string, offset int) { currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) @@ -931,7 +933,7 @@ func (api *RestAPI) findNextStopBySequence( if i+1 < len(stopTimes) { nextStop = stopTimes[i+1] } else { - nextStop = api.getFirstStopOfNextTripInBlock(ctx, tripID, serviceDateForBlock) + nextStop = api.getFirstStopOfNextTripInBlock(ctx, tripID, serviceDate) } } else { nextStop = st @@ -950,7 +952,13 @@ func (api *RestAPI) findNextStopBySequence( func (api *RestAPI) getFirstStopOfNextTripInBlock(ctx context.Context, currentTripID string, serviceDate time.Time) *gtfsdb.StopTime { trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, currentTripID) - if err != nil || !trip.BlockID.Valid { + if err != nil { + slog.Warn("getFirstStopOfNextTripInBlock: failed to get trip", + slog.String("trip_id", currentTripID), + slog.String("error", err.Error())) + return nil + } + if !trip.BlockID.Valid { return nil } @@ -959,6 +967,10 @@ func (api *RestAPI) getFirstStopOfNextTripInBlock(ctx context.Context, currentTr ServiceIds: []string{trip.ServiceID}, }) if err != nil { + slog.Warn("getFirstStopOfNextTripInBlock: failed to get ordered block trips", + slog.String("trip_id", currentTripID), + slog.String("block_id", trip.BlockID.String), + slog.String("error", err.Error())) return nil } @@ -973,7 +985,13 @@ func (api *RestAPI) getFirstStopOfNextTripInBlock(ctx context.Context, currentTr if currentIndex >= 0 && currentIndex+1 < len(orderedTrips) { nextTripID := orderedTrips[currentIndex+1].ID nextTripStopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, nextTripID) - if err == nil && len(nextTripStopTimes) > 0 { + if err != nil { + slog.Warn("getFirstStopOfNextTripInBlock: failed to get stop times for next trip", + slog.String("next_trip_id", nextTripID), + slog.String("error", err.Error())) + return nil + } + if len(nextTripStopTimes) > 0 { return &nextTripStopTimes[0] } } From 78fa2e3cb81c36009c47ff58e6d4f76ea2da6572 Mon Sep 17 00:00:00 2001 From: Ahmedhossamdev Date: Wed, 25 Feb 2026 06:35:11 +0200 Subject: [PATCH 94/94] refactor: Update vehicle ID assertion in TestBuildTripStatus and simplify findNextStopBySequence calls --- internal/restapi/trips_helper_test.go | 80 +++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index 4df413e0..67ce643a 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -711,7 +711,7 @@ func TestBuildTripStatus_VehicleIDFormat(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, model) - assert.Equal(t, vehicleID, model.VehicleID) + assert.Equal(t, utils.FormCombinedID(agencyID, vehicleID), model.VehicleID) } func makeStopTimePtrs(stops []gtfsdb.StopTime) []*gtfsdb.StopTime { @@ -1399,7 +1399,7 @@ func TestFindNextStopBySequence_InTransit(t *testing.T) { vehicle := >fs.Vehicle{CurrentStatus: &inTransit} // When in transit, the current sequence stop IS the next stop - stopID, offset := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1", serviceDate) + stopID, offset := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1") assert.Equal(t, "s2", stopID) assert.Equal(t, 0, offset) } @@ -1421,7 +1421,7 @@ func TestFindNextStopBySequence_StoppedAt(t *testing.T) { stoppedAt := gtfs.CurrentStatus(1) vehicle := >fs.Vehicle{CurrentStatus: &stoppedAt} - stopID, offset := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1", serviceDate) + stopID, offset := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1") // Stopped at s2, so next stop should be s3 assert.Equal(t, "s3", stopID) // s3 arrival = 9*3600 + 0(deviation) = 32400; current = 28800; offset = 3600 @@ -1446,7 +1446,7 @@ func TestFindNextStopBySequence_StoppedAtLastStop(t *testing.T) { // Stopped at last stop (sequence 2), no next stop in this trip // because "trip1" doesn't exist in the DB. So we expect empty. - stopID, _ := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1", serviceDate) + stopID, _ := api.findNextStopBySequence(ctx, stops, 2, currentTime, serviceDate, 0, vehicle, "trip1") assert.Empty(t, stopID, "no next stop when stopped at last stop of trip without block continuation") } @@ -1461,7 +1461,7 @@ func TestFindNextStopBySequence_NoMatch(t *testing.T) { {StopID: "s1", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600)}, }) - stopID, offset := api.findNextStopBySequence(ctx, stops, 99, currentTime, serviceDate, 0, nil, "trip1", serviceDate) + stopID, offset := api.findNextStopBySequence(ctx, stops, 99, currentTime, serviceDate, 0, nil, "trip1") assert.Empty(t, stopID) assert.Equal(t, 0, offset) } @@ -1655,3 +1655,73 @@ func TestGetDistanceAlongShape_LoopingRoute(t *testing.T) { assert.InDelta(t, expectedDist, actualDist, 5.0, "Should identify distance at the start of the loop, not jump to the end") } + +// TestFindStopsByScheduleDeviation_Early verifies that a negative deviation (early vehicle) +// produces correct offsets. effectiveScheduleTime = currentSeconds - (-300) = currentSeconds + 300, +// which shifts the "effective" clock forward, making the vehicle appear ahead of schedule. +func TestFindStopsByScheduleDeviation_Early(t *testing.T) { + api := &RestAPI{} + + serviceDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + // currentTime = 08:05 (29100s since midnight) + currentTime := time.Date(2024, 1, 1, 8, 5, 0, 0, time.UTC) + + stops := makeStopTimePtrs([]gtfsdb.StopTime{ + {StopID: "s1", ArrivalTime: secondsToNanos(7 * 3600), DepartureTime: secondsToNanos(7 * 3600)}, + {StopID: "s2", ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + {StopID: "s3", ArrivalTime: secondsToNanos(9 * 3600), DepartureTime: secondsToNanos(9 * 3600)}, + }) + + // Vehicle is 5 minutes early (deviation = -300s). + // effectiveScheduleTime = 29100 - (-300) = 29400 = 8h10m → closest to s2 (28800s) + closestStopID, closestOffset, nextStopID, nextOffset := api.findStopsByScheduleDeviation(stops, currentTime, serviceDate, -300) + + assert.Equal(t, "s2", closestStopID, "early vehicle: closest stop should be s2") + // predicted arrival at s2 = 28800 + (-300) = 28500; current = 29100; offset = -600 (already passed) + assert.Equal(t, -600, closestOffset, "early vehicle: closestOffset should be negative (stop already passed)") + + assert.Equal(t, "s3", nextStopID, "early vehicle: next stop should be s3") + // predicted arrival at s3 = 32400 + (-300) = 32100; current = 29100; offset = 3000 + assert.Equal(t, 3000, nextOffset, "early vehicle: nextOffset should reflect earlier predicted arrival") +} + +// TestGetFirstStopOfNextTripInBlock_WithBlockContinuation verifies that when a vehicle +// stops at the last stop of a trip that belongs to a block, the function correctly +// returns the first stop of the next trip in that block. +func TestGetFirstStopOfNextTripInBlock_WithBlockContinuation(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + // Find a trip that belongs to a block with at least two trips. + trips := api.GtfsManager.GetTrips() + require.NotEmpty(t, trips, "need at least one trip in test data") + + // Locate a trip that has a block ID and more than one trip in the block. + var targetTripID string + for _, trip := range trips { + if trip.BlockID == "" { + continue + } + blockID := trip.BlockID + count := 0 + for _, other := range trips { + if other.BlockID == blockID { + count++ + } + } + if count >= 2 { + targetTripID = trip.ID + break + } + } + + if targetTripID == "" { + t.Skip("no multi-trip block found in test data; skipping block continuation test") + } + + serviceDate := time.Now() + result := api.getFirstStopOfNextTripInBlock(ctx, targetTripID, serviceDate) + assert.NotNil(t, result, "should find the first stop of the next block trip") + assert.NotEmpty(t, result.StopID, "returned stop should have a non-empty StopID") +}