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 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= diff --git a/gtfsdb/query.sql b/gtfsdb/query.sql index b8046fae..f0616cc0 100644 --- a/gtfsdb/query.sql +++ b/gtfsdb/query.sql @@ -629,16 +629,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 @@ -984,7 +984,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 12903d19..6f3d4bef 100644 --- a/gtfsdb/query.sql.go +++ b/gtfsdb/query.sql.go @@ -3250,11 +3250,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 diff --git a/internal/gtfs/gtfs_manager_mock.go b/internal/gtfs/gtfs_manager_mock.go index dc63d118..575d9f1e 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" ) @@ -29,13 +31,18 @@ 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 } } + 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, @@ -44,7 +51,51 @@ 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) { + m.realTimeMutex.Lock() + defer m.realTimeMutex.Unlock() + + 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) { @@ -58,3 +109,31 @@ 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 +} + +// MockResetRealTimeData clears all mock real-time vehicles and trip updates. +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) +} 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] } diff --git a/internal/restapi/block_handler.go b/internal/restapi/block_handler.go index 39d59892..c50e5318 100644 --- a/internal/restapi/block_handler.go +++ b/internal/restapi/block_handler.go @@ -129,8 +129,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), diff --git a/internal/restapi/schedule_for_route_handler.go b/internal/restapi/schedule_for_route_handler.go index e167bb5e..bafd854c 100644 --- a/internal/restapi/schedule_for_route_handler.go +++ b/internal/restapi/schedule_for_route_handler.go @@ -149,8 +149,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, diff --git a/internal/restapi/shape_distance_helpers.go b/internal/restapi/shape_distance_helpers.go index 3e3c0074..f3320b74 100644 --- a/internal/restapi/shape_distance_helpers.go +++ b/internal/restapi/shape_distance_helpers.go @@ -4,8 +4,20 @@ import ( "context" "github.com/OneBusAway/go-gtfs" + "maglev.onebusaway.org/gtfsdb" ) +// 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 { + 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 +39,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 +55,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) diff --git a/internal/restapi/trip_details_handler.go b/internal/restapi/trip_details_handler.go index e88e49b2..19e7c1c1 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" @@ -12,7 +13,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 +23,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 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) { + params := TripParams{ IncludeTrip: true, - IncludeSchedule: true, + IncludeSchedule: includeScheduleDefault, IncludeStatus: true, } @@ -92,7 +97,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 @@ -125,41 +130,48 @@ 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 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())) + status = nil + } } 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 } } + 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, Schedule: schedule, Frequency: nil, - SituationIDs: api.GetSituationIDsForTrip(r.Context(), tripID), + SituationIDs: situationsIDs, } - if status != nil && status.VehicleID != "" { + if status != nil { tripDetails.Status = status } @@ -210,6 +222,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 { 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") diff --git a/internal/restapi/trip_for_vehicle_handler.go b/internal/restapi/trip_for_vehicle_handler.go index 82934a02..108776b9 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) { parsed, _ := utils.GetParsedIDFromContext(r.Context()) agencyID := parsed.AgencyID @@ -110,7 +40,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 @@ -133,15 +63,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 { @@ -152,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 } } @@ -176,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, @@ -185,19 +107,15 @@ 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{ - TripID: tripID, + TripID: utils.FormCombinedID(agencyID, tripID), ServiceDate: serviceDateMillis, Frequency: nil, Status: status, diff --git a/internal/restapi/trip_for_vehicle_handler_test.go b/internal/restapi/trip_for_vehicle_handler_test.go index d91fc59f..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 @@ -674,14 +676,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 +691,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") diff --git a/internal/restapi/trip_updates_helper.go b/internal/restapi/trip_updates_helper.go new file mode 100644 index 00000000..97403d8b --- /dev/null +++ b/internal/restapi/trip_updates_helper.go @@ -0,0 +1,64 @@ +package restapi + +type StopDelayInfo struct { + ArrivalDelay int64 + 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 { + return 0, false + } + + tu := tripUpdates[0] + + if tu.Delay != nil { + 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()), true + } + if stu.Departure != nil && stu.Departure.Delay != nil { + return int(stu.Departure.Delay.Seconds()), true + } + } + + 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) + + 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()) + } + + delays[*stu.StopID] = info + } + + return delays +} diff --git a/internal/restapi/trip_updates_helper_test.go b/internal/restapi/trip_updates_helper_test.go new file mode 100644 index 00000000..8c8d1483 --- /dev/null +++ b/internal/restapi/trip_updates_helper_test.go @@ -0,0 +1,246 @@ +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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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.False(t, hasData, "trip update with no delay data should report hasData=false") +} + +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) + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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_IncludesStopWithZeroDelays(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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.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) { + api := createTestApi(t) + defer api.Shutdown() + t.Cleanup(api.GtfsManager.MockResetRealTimeData) + + 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 events — still included with zero values + } + api.GtfsManager.MockAddTripUpdate("trip-multi-stops", nil, updates) + + delays := api.GetStopDelaysFromTripUpdates("trip-multi-stops") + 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.Contains(t, delays, "stop-C") + assert.Equal(t, int64(0), delays["stop-C"].ArrivalDelay) + assert.Equal(t, int64(0), delays["stop-C"].DepartureDelay) +} diff --git a/internal/restapi/trips_for_location_handler.go b/internal/restapi/trips_for_location_handler.go index 22827173..0ab5a1e5 100644 --- a/internal/restapi/trips_for_location_handler.go +++ b/internal/restapi/trips_for_location_handler.go @@ -354,10 +354,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) diff --git a/internal/restapi/trips_helper.go b/internal/restapi/trips_helper.go index 090a9d69..8286b11f 100644 --- a/internal/restapi/trips_helper.go +++ b/internal/restapi/trips_helper.go @@ -4,10 +4,8 @@ import ( "context" "database/sql" "errors" - "fmt" "log/slog" "math" - "sort" "time" "github.com/OneBusAway/go-gtfs" @@ -22,91 +20,89 @@ func (api *RestAPI) BuildTripStatus( agencyID, tripID string, serviceDate time.Time, currentTime time.Time, - ) (*models.TripStatusForTripDetails, error) { - vehicle := api.GtfsManager.GetVehicleForTrip(tripID) + status := &models.TripStatusForTripDetails{ + ActiveTripID: utils.FormCombinedID(agencyID, tripID), + ServiceDate: serviceDate.Unix() * 1000, + SituationIDs: api.GetSituationIDsForTrip(ctx, tripID), + OccupancyCapacity: -1, + OccupancyCount: -1, + } - var occupancyStatus string - var vehicleID string + vehicle := api.GtfsManager.GetVehicleForTrip(tripID) if vehicle != nil { - if vehicle.OccupancyStatus != nil { - occupancyStatus = vehicle.OccupancyStatus.String() - } - if vehicle.ID != nil { - vehicleID = utils.FormCombinedID(agencyID, vehicle.ID.ID) + status.VehicleID = utils.FormCombinedID(agencyID, vehicle.ID.ID) } + if vehicle.OccupancyStatus != nil { + status.OccupancyStatus = vehicle.OccupancyStatus.String() + } + // 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, currentTime) - status := &models.TripStatusForTripDetails{ - ServiceDate: serviceDate.Unix() * 1000, - VehicleID: vehicleID, - OccupancyStatus: occupancyStatus, - SituationIDs: []string{}, - } - - api.BuildVehicleStatus(ctx, vehicle, tripID, agencyID, status) - activeTripID := GetVehicleActiveTripID(vehicle) - - if vehicle != nil && vehicle.OccupancyPercentage != nil { - status.OccupancyCapacity = int(*vehicle.OccupancyPercentage) + _, activeTripRawID, err := utils.ExtractAgencyIDAndCodeID(status.ActiveTripID) + if err != nil { + return status, err } - scheduleDeviation := api.calculateScheduleDeviationFromTripUpdates(tripID) - status.ScheduleDeviation = scheduleDeviation + scheduleDeviation, hasRealtimeTripUpdate := api.GetScheduleDeviation(activeTripRawID) - blockTripSequence := api.setBlockTripSequence(ctx, tripID, serviceDate, status) - if blockTripSequence > 0 { - status.BlockTripSequence = blockTripSequence + if hasRealtimeTripUpdate { + status.ScheduleDeviation = scheduleDeviation } - 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, - } - } - status.TotalDistanceAlongTrip = preCalculateCumulativeDistances(shapePoints)[len(shapePoints)-1] + hasVehicleRealtimeData := vehicle != nil && !defaultStaleDetector.Check(vehicle, currentTime) + status.Predicted = hasVehicleRealtimeData || hasRealtimeTripUpdate + status.Scheduled = !status.Predicted - if vehicle != nil && vehicle.Position != nil && vehicle.Position.Latitude != nil && vehicle.Position.Longitude != nil { - status.DistanceAlongTrip = api.getVehicleDistanceAlongShapeContextual(ctx, tripID, vehicle) - } + 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())) } - - stopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, activeTripID) - if err == nil { + if err == nil && len(stopTimes) > 0 { 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 { - shapeRows = []gtfsdb.Shape{} - } - - shapePoints := make([]gtfs.ShapePoint, len(shapeRows)) - for i, sp := range shapeRows { - shapePoints[i] = gtfs.ShapePoint{ - Latitude: sp.Lat, - Longitude: sp.Lon, - } - } - var closestStopID, nextStopID string var closestOffset, nextOffset int if vehicle != nil && vehicle.Position != nil { - closestStopID, closestOffset = findClosestStop(api, ctx, vehicle.Position, stopTimesPtrs) - nextStopID, nextOffset = findNextStop(api, stopTimesPtrs, vehicle) + if vehicle.StopID != nil && *vehicle.StopID != "" { + closestStopID = *vehicle.StopID + closestOffset = api.calculateOffsetForStop(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, + ) + nextStopID, nextOffset = api.findNextStopBySequence( + ctx, stopTimesPtrs, *vehicle.CurrentStopSequence, currentTime, serviceDate, scheduleDeviation, vehicle, tripID, + ) + } else { + closestStopID, closestOffset, nextStopID, nextOffset = api.findStopsByScheduleDeviation( + stopTimesPtrs, currentTime, serviceDate, scheduleDeviation, + ) + } } else { - currentTimeSeconds := int64(currentTime.Hour()*3600 + currentTime.Minute()*60 + currentTime.Second()) - closestStopID, closestOffset = findClosestStopByTime(currentTimeSeconds, stopTimesPtrs) - nextStopID, nextOffset = findNextStopByTime(currentTimeSeconds, stopTimesPtrs) + stopDelays := api.GetStopDelaysFromTripUpdates(activeTripRawID) + closestStopID, closestOffset = findClosestStopByTimeWithDelays(currentTime, serviceDate, stopTimesPtrs, stopDelays) + nextStopID, nextOffset = findNextStopByTimeWithDelays(currentTime, serviceDate, stopTimesPtrs, stopDelays) } if closestStopID != "" { @@ -119,6 +115,47 @@ func (api *RestAPI) BuildTripStatus( } } + if status.ClosestStop == "" || status.NextStop == "" { + api.fillStopsFromSchedule(ctx, status, activeTripRawID, currentTime, serviceDate, agencyID) + } + + shapeRows, shapeErr := api.GtfsManager.GtfsDB.Queries.GetShapePointsByTripID(ctx, activeTripRawID) + if shapeErr != nil { + slog.Warn("BuildTripStatus: failed to get shape points", + slog.String("trip_id", activeTripRawID), + slog.String("error", shapeErr.Error())) + } + if shapeErr == nil && len(shapeRows) > 1 { + shapePoints := shapeRowsToPoints(shapeRows) + cumulativeDistances := preCalculateCumulativeDistances(shapePoints) + 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, activeTripRawID, vehicle) + status.DistanceAlongTrip = actualDistance + + if scheduleDeviation != 0 && len(stopTimes) > 0 { + scheduledDistance := api.calculateEffectiveDistanceAlongTrip( + ctx, actualDistance, scheduleDeviation, currentTime, serviceDate, + stopTimes, shapePoints, cumulativeDistances, + ) + status.ScheduledDistanceAlongTrip = scheduledDistance + } + } + } + + blockTripSequence := api.calculateBlockTripSequence(ctx, tripID, serviceDate) + if blockTripSequence > 0 { + status.BlockTripSequence = blockTripSequence + } + return status, nil } @@ -132,13 +169,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 @@ -158,7 +189,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} @@ -181,234 +211,171 @@ 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 { + if len(orderedTrips) == 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 { - 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, -) (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 - - for i, st := range stopTimes { - if uint32(st.StopSequence) == *vehicleCurrentStopSequence { - if len(stopTimes) > 0 { - nextIdx := (i + 1) % len(stopTimes) - return stopTimes[nextIdx].StopID, 0 - } - } + 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) (stopID string, offset int) { - if pos == nil || pos.Latitude == nil || pos.Longitude == nil { - return "", 0 + stopTimes, err = api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, trip.ID) + if err != nil { + return nextTripID, previousTripID, nil, err } - var minDist = math.MaxFloat64 - - stopIDs := make([]string, len(stopTimes)) - for i, st := range stopTimes { - stopIDs[i] = st.StopID - } + return nextTripID, previousTripID, stopTimes, nil +} - stops, err := api.GtfsManager.GtfsDB.Queries.GetStopsByIDs(ctx, stopIDs) +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 { - return "", 0 + slog.Warn("fillStopsFromSchedule: failed to get stop times", + slog.String("trip_id", tripID), + slog.String("error", err.Error())) + return } - - stopMap := make(map[string]gtfsdb.Stop) - for _, stop := range stops { - stopMap[stop.ID] = stop + if len(stopTimes) == 0 { + return } - for _, st := range stopTimes { - stop, exists := stopMap[st.StopID] - if !exists { - continue - } + currentSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) - d := utils.Distance( - float64(*pos.Latitude), - float64(*pos.Longitude), - stop.Lat, - stop.Lon, - ) - - if d < minDist { - minDist = d - stopID = stop.ID - offset = int(st.StopSequence) + for i, st := range stopTimes { + arrivalTime := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) + predictedArrival := arrivalTime + int64(status.ScheduleDeviation) + + if predictedArrival > currentSeconds { + 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) + } + if status.NextStop == "" { + status.NextStop = utils.FormCombinedID(agencyID, st.StopID) + status.NextStopTimeOffset = int(predictedArrival - currentSeconds) + } + return } } - return + 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) + status.ClosestStopTimeOffset = int(arrivalTime + int64(status.ScheduleDeviation) - currentSeconds) + } } -func findClosestStopByTime(currentTimeSeconds int64, stopTimes []*gtfsdb.StopTime) (stopID string, offset int) { +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 for _, st := range stopTimes { - var stopTime int64 + // 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 { - stopTime = int64(st.DepartureTime) + stopTimeSeconds = utils.NanosToSeconds(st.DepartureTime) } else if st.ArrivalTime > 0 { - stopTime = int64(st.ArrivalTime) + stopTimeSeconds = utils.NanosToSeconds(st.ArrivalTime) } 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) { +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 for _, st := range stopTimes { - var stopTime int64 + // NOTE: Intentionally prefers DepartureTime over ArrivalTime, unlike + // EffectiveStopTimeSeconds which prefers arrival. See comment in + // findClosestStopByTimeWithDelays for rationale. + var stopTimeSeconds int64 if st.DepartureTime > 0 { - stopTime = int64(st.DepartureTime) + stopTimeSeconds = utils.NanosToSeconds(st.DepartureTime) } else if st.ArrivalTime > 0 { - stopTime = int64(st.ArrivalTime) + stopTimeSeconds = utils.NanosToSeconds(st.ArrivalTime) } 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 } @@ -502,118 +469,53 @@ func getDistanceAlongShapeInRange(lat, lon float64, shape []gtfs.ShapePoint, min return interpolateDistance(cumulativeDistances, segmentLength, closestSegmentIndex, projectionRatio) } -// 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) -} - // calculateBlockTripSequence calculates the index of a trip within its block's ordered trip sequence -// for trips that are active on the given service date -// IMPORTANT: Caller must hold manager.RLock() before calling this method. +// 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 == "" { + 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 } - blockTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockID(ctx, blockID) - if err != nil || len(blockTrips) == 0 { + if !trip.BlockID.Valid { 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) + formattedDate := serviceDate.Format("20060102") + activeServiceIDs, err := api.GtfsManager.GtfsDB.Queries.GetActiveServiceIDsForDate(ctx, formattedDate) 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 } - - 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, - }) - } + if len(activeServiceIDs) == 0 { + return 0 } - // 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 + orderedTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + ServiceIds: activeServiceIDs, }) - - for i, trip := range activeTrips { - if trip.TripID == tripID { - return i - } - } - return 0 -} - -func (api *RestAPI) calculateScheduleDeviationFromTripUpdates( - tripID string, -) int { - tripUpdates := api.GtfsManager.GetTripUpdatesForTrip(tripID) - if len(tripUpdates) == 0 { + 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 } - tripUpdate := tripUpdates[0] - - 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) - foundRelevantUpdate = true - } else if stopTimeUpdate.Departure != nil && stopTimeUpdate.Departure.Delay != nil { - bestDeviation = int64(*stopTimeUpdate.Departure.Delay) - foundRelevantUpdate = true - } - - if foundRelevantUpdate { - break + for i, t := range orderedTrips { + if t.ID == tripID { + return i } } - - return int(bestDeviation) + return 0 } // calculatePreciseDistanceAlongTripWithCoords calculates the distance along a trip's shape to a stop @@ -665,7 +567,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 @@ -704,7 +605,133 @@ func preCalculateCumulativeDistances(shapePoints []gtfs.ShapePoint) []float64 { return cumulativeDistances } -// calculateBatchStopDistances calculates distances for the entire trip using Monotonic Search (O(N+M)) +// 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, x1, y1 + } + + // Calculate the parameter t for the projection of point onto the line + t := ((px-x1)*dx + (py-y1)*dy) / (dx*dx + dy*dy) + + // Clamp t to [0, 1] to stay within the line segment + if t < 0 { + t = 0 + } else if t > 1 { + t = 1 + } + + // Find the closest point on the line segment + closestX := x1 + t*dx + closestY := y1 + t*dy + + 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. +func (api *RestAPI) GetSituationIDsForTrip(ctx context.Context, tripID string) []string { + var routeID string + var agencyID string + + if api.GtfsManager.GtfsDB != nil { + trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) + if err == nil { + routeID = trip.RouteID + route, err := api.GtfsManager.GtfsDB.Queries.GetRoute(ctx, routeID) + if err == nil { + agencyID = route.AgencyID + } else if !errors.Is(err, sql.ErrNoRows) { + api.Logger.Warn("Failed to fetch route for alerts; degrading to trip+route matching only", + slog.String("trip_id", tripID), + slog.String("route_id", routeID), + slog.Any("error", err), + ) + } + } else if !errors.Is(err, sql.ErrNoRows) { + api.Logger.Warn("Failed to fetch trip for alerts; degrading to trip matching only", + slog.String("trip_id", tripID), + slog.Any("error", err), + ) + } + } + + alerts := api.GtfsManager.GetAlertsByIDs(tripID, routeID, agencyID) + + situationIDs := []string{} + for _, alert := range alerts { + if alert.ID == "" { + continue + } + if agencyID != "" { + situationIDs = append(situationIDs, utils.FormCombinedID(agencyID, alert.ID)) + } else { + situationIDs = append(situationIDs, alert.ID) + } + } + + return situationIDs +} + +func (api *RestAPI) calculateOffsetForStop( + stopID string, + stopTimes []*gtfsdb.StopTime, + currentTime time.Time, + serviceDate time.Time, + scheduleDeviation int, +) int { + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + + for _, st := range stopTimes { + if st.StopID == stopID { + stopTimeSeconds := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return int(predictedArrival - currentTimeSeconds) + } + } + + return 0 +} + +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 := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + + for i, st := range stopTimes { + if st.StopID == currentStopID { + if i+1 < len(stopTimes) { + nextSt := stopTimes[i+1] + stopTimeSeconds := utils.EffectiveStopTimeSeconds(nextSt.ArrivalTime, nextSt.DepartureTime) + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return nextSt.StopID, int(predictedArrival - currentTimeSeconds) + } + break + } + } + + return "", 0 +} + func (api *RestAPI) calculateBatchStopDistances( timeStops []gtfsdb.StopTime, shapePoints []gtfs.ShapePoint, @@ -718,8 +745,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: "", @@ -734,8 +761,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: "", @@ -799,8 +826,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: "", @@ -809,75 +836,245 @@ func (api *RestAPI) calculateBatchStopDistances( 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 - dy := y2 - y1 +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 + } - if dx == 0 && dy == 0 { - // Line segment is a point - return utils.Distance(px, py, x1, y1), 0 + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) + + var closestStop *gtfsdb.StopTime + var closestTimeDiff int64 = math.MaxInt64 + var closestIndex int + + for i, st := range stopTimes { + stopTime := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) + + timeDiff := stopTime - effectiveScheduleTime + if timeDiff < 0 { + timeDiff = -timeDiff + } + + if timeDiff < closestTimeDiff { + closestTimeDiff = timeDiff + closestStop = st + closestIndex = i + } } - // Calculate the parameter t for the projection of point onto the line - t := ((px-x1)*dx + (py-y1)*dy) / (dx*dx + dy*dy) + if closestStop == nil { + return "", 0, "", 0 + } - // Clamp t to [0, 1] to stay within the line segment - if t < 0 { - t = 0 - } else if t > 1 { - t = 1 + closestStopID = closestStop.StopID + + closestStopTime := utils.EffectiveStopTimeSeconds(closestStop.ArrivalTime, closestStop.DepartureTime) + predictedClosestArrival := closestStopTime + int64(scheduleDeviation) + closestOffset = int(predictedClosestArrival - currentTimeSeconds) + + 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) } - // Find the closest point on the line segment - closestX := x1 + t*dx - closestY := y1 + t*dy + return closestStopID, closestOffset, nextStopID, nextOffset +} + +func (api *RestAPI) findClosestStopBySequence( + stopTimes []*gtfsdb.StopTime, + currentStopSequence uint32, + currentTime time.Time, + serviceDate time.Time, + scheduleDeviation int, +) (stopID string, offset int) { + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + + for _, st := range stopTimes { + if uint32(st.StopSequence) == currentStopSequence { + stopTimeSeconds := utils.EffectiveStopTimeSeconds(st.ArrivalTime, st.DepartureTime) + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return st.StopID, int(predictedArrival - currentTimeSeconds) + } + } - return utils.Distance(px, py, closestX, closestY), t + return "", 0 } -// IMPORTANT: Caller must hold manager.RLock() before calling this method. -func (api *RestAPI) GetSituationIDsForTrip(ctx context.Context, tripID string) []string { - var routeID string - var agencyID string +func (api *RestAPI) findNextStopBySequence( + ctx context.Context, + stopTimes []*gtfsdb.StopTime, + currentStopSequence uint32, + currentTime time.Time, + serviceDate time.Time, + scheduleDeviation int, + vehicle *gtfs.Vehicle, + tripID string, +) (stopID string, offset int) { + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) - if api.GtfsManager.GtfsDB != nil { - trip, err := api.GtfsManager.GtfsDB.Queries.GetTrip(ctx, tripID) - if err == nil { - routeID = trip.RouteID - route, err := api.GtfsManager.GtfsDB.Queries.GetRoute(ctx, routeID) - if err == nil { - agencyID = route.AgencyID - } else if !errors.Is(err, sql.ErrNoRows) { - api.Logger.Warn("Failed to fetch route for alerts; degrading to trip+route matching only", - slog.String("trip_id", tripID), - slog.String("route_id", routeID), - slog.Any("error", err), - ) + 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 := utils.EffectiveStopTimeSeconds(nextStop.ArrivalTime, nextStop.DepartureTime) + predictedArrival := stopTimeSeconds + int64(scheduleDeviation) + return nextStop.StopID, int(predictedArrival - currentTimeSeconds) } - } else if !errors.Is(err, sql.ErrNoRows) { - api.Logger.Warn("Failed to fetch trip for alerts; degrading to trip matching only", - slog.String("trip_id", tripID), - slog.Any("error", err), - ) } } - alerts := api.GtfsManager.GetAlertsByIDs(tripID, routeID, agencyID) + return "", 0 +} - situationIDs := []string{} - for _, alert := range alerts { - if alert.ID == "" { - continue +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 { + 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 + } + + orderedTrips, err := api.GtfsManager.GtfsDB.Queries.GetTripsByBlockIDOrdered(ctx, gtfsdb.GetTripsByBlockIDOrderedParams{ + BlockID: trip.BlockID, + 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 + } + + currentIndex := -1 + for i, t := range orderedTrips { + if t.ID == currentTripID { + currentIndex = i + break } - if agencyID != "" { - situationIDs = append(situationIDs, utils.FormCombinedID(agencyID, alert.ID)) - } else { - situationIDs = append(situationIDs, alert.ID) + } + + if currentIndex >= 0 && currentIndex+1 < len(orderedTrips) { + nextTripID := orderedTrips[currentIndex+1].ID + nextTripStopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTrip(ctx, nextTripID) + 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] } } - return situationIDs + return nil +} + +func (api *RestAPI) calculateEffectiveDistanceAlongTrip( + ctx context.Context, + actualDistance float64, + scheduleDeviation int, + currentTime time.Time, + serviceDate time.Time, + stopTimes []gtfsdb.StopTime, + shapePoints []gtfs.ShapePoint, + cumulativeDistances []float64, +) float64 { + if scheduleDeviation == 0 || len(stopTimes) == 0 { + 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, ok := stopByID[st.StopID] + if !ok { + return actualDistance + } + stopDistances[i] = api.calculatePreciseDistanceAlongTripWithCoords( + stop.Lat, stop.Lon, shapePoints, cumulativeDistances, + ) + } + + currentTimeSeconds := utils.CalculateSecondsSinceServiceDate(currentTime, serviceDate) + effectiveScheduleTime := currentTimeSeconds - int64(scheduleDeviation) + + return interpolateDistanceAtScheduledTime(effectiveScheduleTime, stopTimes, stopDistances) +} + +func interpolateDistanceAtScheduledTime( + scheduledTime int64, + stopTimes []gtfsdb.StopTime, + cumulativeDistances []float64, +) float64 { + if len(stopTimes) == 0 || len(cumulativeDistances) != len(stopTimes) { + return 0 + } + + for i := 0; i < len(stopTimes)-1; i++ { + fromStop := stopTimes[i] + toStop := stopTimes[i+1] + + fromTime := utils.NanosToSeconds(fromStop.DepartureTime) + toTime := utils.NanosToSeconds(toStop.ArrivalTime) + + if scheduledTime >= fromTime && scheduledTime <= toTime { + if toTime == fromTime { + return cumulativeDistances[i] + } + + timeRatio := float64(scheduledTime-fromTime) / float64(toTime-fromTime) + + return cumulativeDistances[i] + timeRatio*(cumulativeDistances[i+1]-cumulativeDistances[i]) + } + } + + if scheduledTime < utils.NanosToSeconds(stopTimes[0].ArrivalTime) { + return 0 + } + + return cumulativeDistances[len(cumulativeDistances)-1] } func interpolateDistance(cumulativeDistances []float64, segmentLength float64, index int, projectionRatio float64) float64 { diff --git a/internal/restapi/trips_helper_test.go b/internal/restapi/trips_helper_test.go index 507f9671..67ce643a 100644 --- a/internal/restapi/trips_helper_test.go +++ b/internal/restapi/trips_helper_test.go @@ -10,6 +10,8 @@ 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" ) @@ -494,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() @@ -520,6 +714,389 @@ func TestBuildTripStatus_VehicleIDFormat(t *testing.T) { assert.Equal(t, utils.FormCombinedID(agencyID, 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) +} + +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 @@ -754,6 +1331,294 @@ func BenchmarkOptimized_MonotonicBatch(b *testing.B) { } } +func TestFindClosestStopBySequence_Match(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 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) + 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 := &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", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600), DepartureTime: secondsToNanos(8 * 3600)}, + }) + + // Vehicle is 5 minutes late + 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") +} + +func TestFindClosestStopBySequence_NoMatch(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", StopSequence: 1, ArrivalTime: secondsToNanos(8 * 3600)}, + }) + + stopID, offset := api.findClosestStopBySequence(stops, 99, currentTime, serviceDate, 0) + assert.Empty(t, stopID, "no stop should match sequence 99") + assert.Equal(t, 0, offset) +} + +func TestFindNextStopBySequence_InTransit(t *testing.T) { + api := &RestAPI{} + 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") + assert.Equal(t, "s2", stopID) + assert.Equal(t, 0, offset) +} + +func TestFindNextStopBySequence_StoppedAt(t *testing.T) { + api := &RestAPI{} + 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") + // 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") + assert.Empty(t, stopID, "no next stop when stopped at last stop of trip without block continuation") +} + +func TestFindNextStopBySequence_NoMatch(t *testing.T) { + api := &RestAPI{} + 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") + assert.Empty(t, stopID) + assert.Equal(t, 0, offset) +} + +func TestFindStopsByScheduleDeviation_OnTime(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)}, + }) + + 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 := &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 + + 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 := &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) + + 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 := &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 + + 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}, @@ -790,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") +} diff --git a/internal/restapi/vehicles_helper.go b/internal/restapi/vehicles_helper.go index 68d926ba..987eeac8 100644 --- a/internal/restapi/vehicles_helper.go +++ b/internal/restapi/vehicles_helper.go @@ -2,53 +2,143 @@ 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" ) -// 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 { - return "SCHEDULED", "scheduled" +// 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 { + return &StaleDetector{threshold: threshold} +} + +func (d *StaleDetector) Check(vehicle *gtfs.Vehicle, currentTime time.Time) bool { + if vehicle == nil { + return true } + if vehicle.Timestamp == nil { + return vehicle.Position == nil + } + return currentTime.Sub(*vehicle.Timestamp) > d.threshold +} - 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" +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", "scheduled" + // 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" } } +// 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() + // (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 + if vehicle.Trip != nil { + sr = vehicle.Trip.ID.ScheduleRelationship + } + status = scheduleRelationshipStatus(sr) + + // 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" + } + + return status, phase +} + func (api *RestAPI) BuildVehicleStatus( ctx context.Context, vehicle *gtfs.Vehicle, tripID string, agencyID string, status *models.TripStatusForTripDetails, + currentTime time.Time, ) { - if vehicle == nil { + if vehicle == nil || defaultStaleDetector.Check(vehicle, currentTime) { status.Status, status.Phase = GetVehicleStatusAndPhase(nil) 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 { - 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 + // Position is initially set to the raw GPS position. + // 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 { + status.LastLocationUpdateTime = lastUpdateTime + } } if vehicle.Position != nil && vehicle.Position.Bearing != nil { @@ -67,11 +157,6 @@ func (api *RestAPI) BuildVehicleStatus( } else { status.ActiveTripID = utils.FormCombinedID(agencyID, tripID) } - - status.Predicted = true - - status.Scheduled = false - } func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { @@ -82,6 +167,41 @@ func GetVehicleActiveTripID(vehicle *gtfs.Vehicle) string { return vehicle.Trip.ID.ID } +// 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 + + 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) { + dist, _, projLat, projLon := projectOntoSegment(px, py, x1, y1, x2, y2) + return dist, models.Location{Lat: projLat, Lon: projLon} +} + func getCurrentVehicleStopSequence(vehicle *gtfs.Vehicle) *int { if vehicle == nil || vehicle.CurrentStopSequence == nil { return nil diff --git a/internal/restapi/vehicles_helper_test.go b/internal/restapi/vehicles_helper_test.go new file mode 100644 index 00000000..1c3703f3 --- /dev/null +++ b/internal/restapi/vehicles_helper_test.go @@ -0,0 +1,281 @@ +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_NoPosition(t *testing.T) { + d := NewStaleDetector() + 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) { + 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() + + now := time.Now() + status := &models.TripStatusForTripDetails{} + api.BuildVehicleStatus(ctx, nil, "any-trip", "any-agency", status, now) + + assert.Equal(t, "default", status.Status) + assert.Equal(t, "scheduled", status.Phase) + assert.False(t, status.Predicted, "BuildVehicleStatus must not set Predicted") +} + +func TestBuildVehicleStatus_StaleVehicleSetsDefaultStatus(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + ctx := context.Background() + + 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, now) + + assert.Equal(t, "default", status.Status) + assert.Equal(t, "scheduled", status.Phase) +} + +func TestBuildVehicleStatus_FreshVehicleWithPosition_SetsLocationAndPhase(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, now) + + 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_DoesNotSetPredicted(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, now) + + 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") + }) + } +} diff --git a/internal/utils/api.go b/internal/utils/api.go index 31c3efad..2828151f 100644 --- a/internal/utils/api.go +++ b/internal/utils/api.go @@ -14,6 +14,42 @@ 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 +} + +func CalculateSecondsSinceServiceDate(currentTime time.Time, serviceDate time.Time) int64 { + duration := currentTime.Sub(serviceDate) + return int64(duration.Seconds()) +} + +// 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 +} + +// 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)