diff options
Diffstat (limited to 'src/Enmarcha.Backend/Services/Processors/RealTime')
5 files changed, 1011 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs new file mode 100644 index 0000000..20dc356 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs @@ -0,0 +1,231 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Sources.TranviasCoruna; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class CorunaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CorunaRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger<CorunaRealTimeProcessor> _logger; + private readonly ShapeTraversalService _shapeService; + + public CorunaRealTimeProcessor( + CorunaRealtimeEstimatesProvider realtime, + FeedService feedService, + ILogger<CorunaRealTimeProcessor> logger, + ShapeTraversalService shapeService) + { + _realtime = realtime; + _feedService = feedService; + _logger = logger; + _shapeService = shapeService; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("tranvias:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("tranvias", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = + _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + System.Diagnostics.Activity.Current?.SetTag("realtime.count", realtime.Count); + + var usedTripIds = new HashSet<string>(); + + foreach (var estimate in realtime) + { + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.RouteId.Trim()) + .Select(a => new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true + }) + .Where(x => x.RouteMatch) // Strict route matching + .Where(x => x.TimeDiff is >= -5 + and <= 15) // Allow 5m early (RealTime < Schedule) or 15m late (RealTime > Schedule) + .OrderBy(x => x.TimeDiff < 0 ? Math.Abs(x.TimeDiff) * 2 : x.TimeDiff) // Best time fit + .FirstOrDefault(); + + if (bestMatch == null) + { + continue; + } + + var arrival = bestMatch.Arrival; + + var scheduledMinutes = arrival.Estimate.Minutes; + arrival.Estimate.Minutes = estimate.Minutes; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimate.Minutes - scheduledMinutes; + if (delayMinutes != 0) + { + arrival.Delay = new DelayBadge { Minutes = delayMinutes }; + } + + // Populate vehicle information + var busInfo = GetBusInfoByNumber(estimate.VehicleNumber); + arrival.VehicleInformation = new VehicleBadge + { + Identifier = estimate.VehicleNumber, + Make = busInfo?.Make, + Model = busInfo?.Model, + Kind = busInfo?.Kind, + Year = busInfo?.Year + }; + + // Calculate position + if (stopLocation != null) + { + Position? currentPosition = null; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && + otpArrival.Trip.Geometry?.Points != null) + { + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + // Ensure meters is positive + var meters = Math.Max(0, estimate.Metres); + var result = _shapeService.GetBusPosition(shape, stopLocation, meters); + + currentPosition = result.BusPosition; + + if (currentPosition != null) + { + _logger.LogInformation( + "Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, + currentPosition.Latitude, currentPosition.Longitude); + } + + // Populate Shape GeoJSON + if (!context.IsReduced && currentPosition != null) + { + var features = new List<object>(); + features.Add(new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + }); + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + } + } + + usedTripIds.Add(arrival.TripId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Tranvías real-time data for stop {StopId}", context.StopId); + } + } + + private static bool IsRouteMatch(string a, string b) + { + return a == b || a.Contains(b) || b.Contains(a); + } + + private (string Make, string Model, string Kind, string Year)? GetBusInfoByNumber(string identifier) + { + int number = int.Parse(identifier); + + return number switch + { + // 2000 + >= 326 and <= 336 => ("MB", "O405N2 Venus", "RIG", "2000"), + 337 => ("MB", "O405G Alce", "ART", "2000"), + // 2002-2003 + >= 340 and <= 344 => ("MAN", "NG313F Delfos Venus", "ART", "2002"), + >= 345 and <= 347 => ("MAN", "NG313F Delfos Venus", "ART", "2003"), + // 2004 + >= 348 and <= 349 => ("MAN", "NG313F Delfos Venus", "ART", "2004"), + >= 350 and <= 355 => ("MAN", "NL263F Luxor II", "RIG", "2004"), + // 2005 + >= 356 and <= 359 => ("MAN", "NL263F Luxor II", "RIG", "2005"), + >= 360 and <= 362 => ("MAN", "NG313F Delfos", "ART", "2005"), + // 2007 + >= 363 and <= 370 => ("MAN", "NL273F Luxor II", "RIG", "2007"), + // 2008 + >= 371 and <= 377 => ("MAN", "NL273F Luxor II", "RIG", "2008"), + // 2009 + >= 378 and <= 387 => ("MAN", "NL273F Luxor II", "RIG", "2009"), + // 2012 + >= 388 and <= 392 => ("MAN", "NL283F Ceres", "RIG", "2012"), + >= 393 and <= 395 => ("MAN", "NG323F Ceres", "ART", "2012"), + // 2013 + >= 396 and <= 403 => ("MAN", "NL283F Ceres", "RIG", "2013"), + // 2014 + >= 404 and <= 407 => ("MB", "Citaro C2", "RIG", "2014"), + >= 408 and <= 411 => ("MAN", "NL283F Ceres", "RIG", "2014"), + // 2015 + >= 412 and <= 414 => ("MB", "Citaro C2 G", "ART", "2015"), + >= 415 and <= 419 => ("MB", "Citaro C2", "RIG", "2015"), + // 2016 + >= 420 and <= 427 => ("MB", "Citaro C2", "RIG", "2016"), + // 2024 + 428 => ("MAN", "Lion's City 12 E", "RIG", "2024"), + // 2025 + 429 => ("MAN", "Lion's City 18", "RIG", "2025"), + >= 430 and <= 432 => ("MAN", "Lion's City 12", "RIG", "2025"), + _ => null + }; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs new file mode 100644 index 0000000..804b09c --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs @@ -0,0 +1,307 @@ +using Enmarcha.Sources.CtagShuttle; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class CtagShuttleRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CtagShuttleRealtimeEstimatesProvider _shuttleProvider; + private readonly ShapeTraversalService _shapeService; + private readonly ILogger<CtagShuttleRealTimeProcessor> _logger; + + // Maximum distance (in meters) a GPS coordinate can be from the route shape to be considered valid + private const double MaxDistanceFromShape = 100.0; + + // Maximum age (in minutes) for position data to be considered fresh + private const double MaxPositionAgeMinutes = 3.0; + + public CtagShuttleRealTimeProcessor( + CtagShuttleRealtimeEstimatesProvider shuttleProvider, + ShapeTraversalService shapeService, + ILogger<CtagShuttleRealTimeProcessor> logger) + { + _shuttleProvider = shuttleProvider; + _shapeService = shapeService; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + // Only process shuttle stops + if (!context.StopId.StartsWith("shuttle:")) + { + return; + } + + try + { + // Fetch current shuttle status + var status = await _shuttleProvider.GetShuttleStatus(); + System.Diagnostics.Activity.Current?.SetTag("shuttle.status", status.StatusValue); + + // Validate position timestamp - skip if data is stale (>3 minutes old) + var positionAge = (context.NowLocal - status.LastPositionAt).TotalMinutes; + if (positionAge > MaxPositionAgeMinutes) + { + _logger.LogInformation( + "Shuttle position is stale ({Age:F1} minutes old), skipping real-time update", + positionAge); + return; + } + + // Skip processing if shuttle is idle + if (status.StatusValue.Equals("idle", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Shuttle is idle, skipping real-time update"); + return; + } + + // No arrivals to process + if (context.Arrivals.Count == 0) + { + _logger.LogWarning("No scheduled arrivals found for shuttle stop {StopId}", context.StopId); + return; + } + + // Transform shuttle GPS position to EPSG:25829 (meters) + var shuttlePosition = _shapeService.TransformToEpsg25829(status.Latitude, status.Longitude); + _logger.LogDebug("Shuttle position: Lat={Lat}, Lon={Lon} -> X={X}, Y={Y}", + status.Latitude, status.Longitude, shuttlePosition.X, shuttlePosition.Y); + + // Get the shape from the first arrival (assuming single circular route) + var firstArrival = context.Arrivals.First(); + if (firstArrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival || + otpArrival.Trip.Geometry?.Points == null) + { + _logger.LogWarning("No shape geometry available for shuttle trip"); + return; + } + + // Decode polyline and create shape + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + if (shape.Points.Count == 0) + { + _logger.LogWarning("Shape has no points"); + return; + } + + // Find closest point on shape to shuttle's current position + var (closestPointIndex, distanceToShape) = FindClosestPointOnShape(shape.Points.ToList(), shuttlePosition); + + // Validate that shuttle is reasonably close to the route + if (distanceToShape > MaxDistanceFromShape) + { + _logger.LogWarning( + "Shuttle position is {Distance:F1}m from route (threshold: {Threshold}m), skipping update", + distanceToShape, MaxDistanceFromShape); + return; + } + + // Calculate distance from shape start to shuttle's current position + var shuttleDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), closestPointIndex); + _logger.LogDebug("Shuttle is {Distance:F1}m along the shape", shuttleDistanceAlongShape); + + // Calculate total shape length + var totalShapeLength = CalculateTotalShapeLength(shape.Points.ToArray()); + + if (context.StopLocation == null) + { + _logger.LogWarning("Stop location not available for shuttle stop {StopId}", context.StopId); + return; + } + + // Transform stop location to EPSG:25829 + var stopLocation = _shapeService.TransformToEpsg25829( + context.StopLocation.Latitude, + context.StopLocation.Longitude); + + // Find closest point on shape to this stop + var (stopPointIndex, _) = FindClosestPointOnShape(shape.Points.ToList(), stopLocation); + var stopDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), stopPointIndex); + + // Calculate remaining distance from shuttle to stop + var remainingDistance = stopDistanceAlongShape - shuttleDistanceAlongShape; + + // Handle circular route wraparound (if shuttle is past the stop on the loop) + if (remainingDistance < 0) + { + remainingDistance += totalShapeLength; + } + + _logger.LogDebug("Remaining distance to stop: {Distance:F1}m", remainingDistance); + + // Calculate estimated minutes based on distance and reasonable shuttle speed + // Assume average urban shuttle speed of 20 km/h = 333 meters/minute + const double metersPerMinute = 333.0; + int estimatedMinutesForActive; + + if (remainingDistance < 50) // Within 50 meters + { + estimatedMinutesForActive = 0; + } + else + { + // Calculate time based on distance + var minutesFromDistance = remainingDistance / metersPerMinute; + estimatedMinutesForActive = (int)Math.Ceiling(minutesFromDistance); + } + + _logger.LogDebug("Calculated ETA: {Minutes} min for {Distance:F1}m", estimatedMinutesForActive, remainingDistance); + + // Find the active trip - should be one where: + // 1. Scheduled time is in the future (or very recent past, max -2 min for "arriving now" scenarios) + // 2. Scheduled time is reasonably close to our calculated ETA + var activeArrival = context.Arrivals + .Where(a => a.Estimate.Minutes >= -2) // Only consider upcoming or very recent arrivals + .Select(a => new + { + Arrival = a, + TimeDiff = Math.Abs(a.Estimate.Minutes - estimatedMinutesForActive) + }) + .Where(x => x.TimeDiff < 45) // Only consider if within 45 minutes difference from our estimate + .OrderBy(x => x.TimeDiff) + .FirstOrDefault()?.Arrival; + + // Fallback: if no good match, use the next upcoming arrival + if (activeArrival == null) + { + activeArrival = context.Arrivals + .Where(a => a.Estimate.Minutes >= 0) + .OrderBy(a => a.Estimate.Minutes) + .FirstOrDefault(); + + _logger.LogDebug("No matching arrival found, using next upcoming trip"); + } + + // If we found an active trip, update it with real-time data + if (activeArrival != null) + { + var scheduledMinutes = activeArrival.Estimate.Minutes; + activeArrival.Estimate.Minutes = estimatedMinutesForActive; + activeArrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimatedMinutesForActive - scheduledMinutes; + if (delayMinutes != 0) + { + activeArrival.Delay = new DelayBadge { Minutes = delayMinutes }; + } + + // Set current position for visualization + var shuttleWgs84 = new Position + { + Latitude = status.Latitude, + Longitude = status.Longitude + }; + + // Calculate bearing from shuttle to next point on shape + if (closestPointIndex < shape.Points.Count - 1) + { + var currentPoint = shape.Points[closestPointIndex]; + var nextPoint = shape.Points[closestPointIndex + 1]; + var dx = nextPoint.X - currentPoint.X; + var dy = nextPoint.Y - currentPoint.Y; + var bearing = Math.Atan2(dx, dy) * 180.0 / Math.PI; + if (bearing < 0) bearing += 360.0; + shuttleWgs84.Bearing = (int)Math.Round(bearing); + } + + activeArrival.CurrentPosition = shuttleWgs84; + + _logger.LogInformation( + "Updated active trip {TripId}: {Minutes} min (was {Scheduled} min, delay: {Delay} min, distance: {Distance:F1}m)", + activeArrival.TripId, estimatedMinutesForActive, scheduledMinutes, delayMinutes, remainingDistance); + + _logger.LogInformation( + "Shuttle position set: Lat={Lat}, Lon={Lon}, Bearing={Bearing}°", + shuttleWgs84.Latitude, shuttleWgs84.Longitude, shuttleWgs84.Bearing); + } + else + { + _logger.LogWarning("Could not determine active trip for shuttle"); + } + + System.Diagnostics.Activity.Current?.SetTag("shuttle.active_trip_updated", activeArrival != null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing shuttle real-time data for stop {StopId}", context.StopId); + // Don't throw - allow scheduled data to be returned + } + } + + /// <summary> + /// Finds the closest point on the shape to the given location and returns the index and distance + /// </summary> + private (int Index, double Distance) FindClosestPointOnShape(List<Epsg25829> shapePoints, Epsg25829 location) + { + var minDistance = double.MaxValue; + var closestIndex = 0; + + for (int i = 0; i < shapePoints.Count; i++) + { + var distance = CalculateDistance(shapePoints[i], location); + if (distance < minDistance) + { + minDistance = distance; + closestIndex = i; + } + } + + return (closestIndex, minDistance); + } + + /// <summary> + /// Calculates Euclidean distance between two points in meters + /// </summary> + private double CalculateDistance(Epsg25829 p1, Epsg25829 p2) + { + var dx = p1.X - p2.X; + var dy = p1.Y - p2.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + /// <summary> + /// Calculates the total distance along the shape from the start to a given index + /// </summary> + private double CalculateTotalDistanceToPoint(Epsg25829[] shapePoints, int endIndex) + { + if (endIndex <= 0 || shapePoints.Length == 0) + { + return 0; + } + + double totalDistance = 0; + for (int i = 1; i <= endIndex && i < shapePoints.Length; i++) + { + totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); + } + + return totalDistance; + } + + /// <summary> + /// Calculates the total length of the entire shape + /// </summary> + private double CalculateTotalShapeLength(Epsg25829[] shapePoints) + { + if (shapePoints.Length <= 1) + { + return 0; + } + + double totalDistance = 0; + for (int i = 1; i < shapePoints.Length; i++) + { + totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); + } + + return totalDistance; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs new file mode 100644 index 0000000..26ffa24 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Sources.GtfsRealtime; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public partial class RenfeRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly GtfsRealtimeEstimatesProvider _realtime; + private readonly ILogger<RenfeRealTimeProcessor> _logger; + + public RenfeRealTimeProcessor( + GtfsRealtimeEstimatesProvider realtime, + ILogger<RenfeRealTimeProcessor> logger + ) + { + _realtime = realtime; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("renfe:")) return; + + try + { + var delays = await _realtime.GetRenfeDelays(); + var positions = await _realtime.GetRenfePositions(); + System.Diagnostics.Activity.Current?.SetTag("realtime.count", delays.Count); + + foreach (Arrival contextArrival in context.Arrivals) + { + var trainNumber = RenfeTrainNumberExpression.Match(contextArrival.TripId).Groups[1].Value; + + contextArrival.Headsign.Destination = trainNumber + " - " + contextArrival.Headsign.Destination; + + if (delays.TryGetValue(trainNumber, out var delay)) + { + if (delay is null) + { + // TODO: Indicate train got cancelled + _logger.LogDebug("Train {TrainNumber} has no delay information, skipping", trainNumber); + continue; + } + + var delayMinutes = delay.Value / 60; + contextArrival.Delay = new DelayBadge() + { + Minutes = delayMinutes + }; + + var oldEstimate = contextArrival.Estimate.Minutes; + contextArrival.Estimate.Minutes += delayMinutes; + contextArrival.Estimate.Precision = ArrivalPrecision.Confident; + + if (contextArrival.Estimate.Minutes < 0) + { + _logger.LogDebug("Train {TrainNumber} supposedly departed already ({OldEstimate} + {DelayMinutes} minutes), marking as deleted. ", trainNumber, oldEstimate, delayMinutes); + contextArrival.Delete = true; + } + } + + if (positions.TryGetValue(trainNumber, out var position)) + { + contextArrival.CurrentPosition = new Position + { + Latitude = position.Latitude, + Longitude = position.Longitude, + Bearing = null // TODO: Set the proper degrees + }; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Renfe real-time data"); + } + } + + [GeneratedRegex(@"renfe:(?:\d{4}[A-Z]|)(\d{5})")] + public partial Regex RenfeTrainNumberExpression { get; } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs new file mode 100644 index 0000000..2cfdf28 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs @@ -0,0 +1,94 @@ +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Sources.Tussa; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class TussaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly SantiagoRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger<TussaRealTimeProcessor> _logger; + + public TussaRealTimeProcessor( + SantiagoRealtimeEstimatesProvider realtime, + FeedService feedService, + ILogger<TussaRealTimeProcessor> logger) + { + _realtime = realtime; + _feedService = feedService; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("tussa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("tussa", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + System.Diagnostics.Activity.Current?.SetTag("realtime.count", realtime.Count); + + var usedTripIds = new HashSet<string>(); + + foreach (var estimate in realtime) + { + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.Id.ToString()) + .Select(a => new + { + Arrival = a, + TimeDiff = estimate.MinutesToArrive - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true + }) + .Where(x => x.RouteMatch) // Strict route matching + .Where(x => x.TimeDiff is >= -5 and <= 35) // Allow 2m early (RealTime < Schedule) or 25m late (RealTime > Schedule) + .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit + .FirstOrDefault(); + + if (bestMatch is null) + { + context.Arrivals.Add(new Arrival + { + TripId = $"tussa:rt:{estimate.Id}:{estimate.MinutesToArrive}", + Route = new RouteInfo + { + GtfsId = $"tussa:{estimate.Id}", + ShortName = estimate.Sinoptico, + Colour = estimate.Colour, + TextColour = ContrastHelper.GetBestTextColour(estimate.Colour) + }, + Headsign = new HeadsignInfo + { + Badge = "T.REAL", + Destination = estimate.Name + }, + Estimate = new ArrivalDetails + { + Minutes = estimate.MinutesToArrive, + Precision = ArrivalPrecision.Confident + } + }); + continue; + } + + var arrival = bestMatch.Arrival; + + arrival.Estimate.Minutes = estimate.MinutesToArrive; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + + usedTripIds.Add(arrival.TripId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Santiago real-time data for stop {StopId}", context.StopId); + } + } + +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs new file mode 100644 index 0000000..0227f9c --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs @@ -0,0 +1,295 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Costasdev.VigoTransitApi; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly VigoTransitApiClient _api; + private readonly FeedService _feedService; + private readonly ILogger<VitrasaRealTimeProcessor> _logger; + private readonly ShapeTraversalService _shapeService; + private readonly AppConfiguration _configuration; + + public VitrasaRealTimeProcessor( + VigoTransitApiClient api, + FeedService feedService, + ILogger<VitrasaRealTimeProcessor> logger, + ShapeTraversalService shapeService, + IOptions<AppConfiguration> options) + { + _api = api; + _feedService = feedService; + _logger = logger; + _shapeService = shapeService; + _configuration = options.Value; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("vitrasa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + // Load schedule + var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); + + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + + var realtime = await _api.GetStopEstimates(numericStopId); + var estimates = realtime.Estimates + .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) + .ToList(); + + System.Diagnostics.Activity.Current?.SetTag("realtime.count", estimates.Count); + + var usedTripIds = new HashSet<string>(); + var newArrivals = new List<Arrival>(); + + foreach (var estimate in estimates) + { + var estimateRouteNormalized = _feedService.NormalizeRouteNameForMatching(estimate.Route); + + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.ShortName.Trim() == estimate.Line.Trim()) + .Select(a => + { + // Use tripHeadsign from GTFS if available, otherwise fall back to stop-level headsign + string scheduleHeadsign = a.Headsign.Destination; + if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArr && !string.IsNullOrWhiteSpace(otpArr.Trip.TripHeadsign)) + { + scheduleHeadsign = otpArr.Trip.TripHeadsign; + } + var arrivalRouteNormalized = _feedService.NormalizeRouteNameForMatching(scheduleHeadsign); + string? arrivalLongNameNormalized = null; + string? arrivalLastStopNormalized = null; + + if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) + { + if (otpArrival.Trip.Route.LongName != null) + { + arrivalLongNameNormalized = _feedService.NormalizeRouteNameForMatching(otpArrival.Trip.Route.LongName); + } + + var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); + if (lastStop != null) + { + arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); + } + } + + // Strict route matching logic ported from VitrasaTransitProvider + // Check against Headsign, LongName, and LastStop + var routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalRouteNormalized); + + if (!routeMatch && arrivalLongNameNormalized != null) + { + routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLongNameNormalized); + } + + if (!routeMatch && arrivalLastStopNormalized != null) + { + routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLastStopNormalized); + } + + return new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = routeMatch + }; + }) + .Where(x => x.RouteMatch) // Strict route matching + .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule) + .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit + .FirstOrDefault(); + + if (bestMatch is null) + { + _logger.LogInformation("Adding unmatched Vitrasa real-time arrival for line {Line} in {Minutes}m", + estimate.Line, estimate.Minutes); + + // Try to find a "template" arrival with the same line to copy colors from + var template = context.Arrivals + .FirstOrDefault(a => a.Route.ShortName.Trim() == estimate.Line.Trim()); + + newArrivals.Add(new Arrival + { + TripId = $"vitrasa:rtonly:{estimate.Line}:{estimate.Route}:{estimate.Minutes}", + Route = new RouteInfo + { + GtfsId = $"vitrasa:{estimate.Line}", + ShortName = estimate.Line, + Colour = template?.Route.Colour ?? "FFFFFF", + TextColour = template?.Route.TextColour ?? "000000", + }, + Headsign = new HeadsignInfo + { + Destination = estimate.Route + }, + Estimate = new ArrivalDetails + { + Minutes = estimate.Minutes, + Precision = ArrivalPrecision.Confident + } + }); + + continue; + } + + var arrival = bestMatch.Arrival; + + var scheduledMinutes = arrival.Estimate.Minutes; + arrival.Estimate.Minutes = estimate.Minutes; + + // Calculate delay badge + var delayMinutes = estimate.Minutes - scheduledMinutes; + arrival.Delay = new DelayBadge { Minutes = delayMinutes }; + + string scheduledHeadsign = arrival.Headsign.Destination; + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArr && !string.IsNullOrWhiteSpace(otpArr.Trip.TripHeadsign)) + { + scheduledHeadsign = otpArr.Trip.TripHeadsign; + } + + // Prefer real-time headsign UNLESS it's just the last stop name (which is less informative) + if (!string.IsNullOrWhiteSpace(estimate.Route)) + { + bool isJustLastStop = false; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) + { + var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); + if (lastStop != null) + { + var arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); + isJustLastStop = estimateRouteNormalized == arrivalLastStopNormalized; + } + } + + // Use real-time headsign unless it's just the final stop name + if (!isJustLastStop) + { + arrival.Headsign.Destination = estimate.Route; + } + } + + // Calculate position + if (stopLocation != null) + { + Position? currentPosition = null; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && + otpArrival.Trip.Geometry?.Points != null) + { + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + // Ensure meters is positive + var meters = Math.Max(0, estimate.Meters); + var result = _shapeService.GetBusPosition(shape, stopLocation, meters); + + currentPosition = result.BusPosition; + + // Populate Shape GeoJSON + if (!context.IsReduced && currentPosition != null) + { + List<object> features = [ + new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + } + ]; + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + } + else if (!context.IsNano && !context.IsReduced) // Full mode, no position means actually no position + { + // If we can't calculate a position, degrade precision to "Unsure" to indicate less confidence + arrival.Estimate.Precision = ArrivalPrecision.Unsure; + } + else + { + // In Nano/Reduced mode we don't have shape data, so we can't calculate position. Don't degrade precision since it's expected. + arrival.Estimate.Precision = ArrivalPrecision.Confident; + } + } + + usedTripIds.Add(arrival.TripId); + } + + context.Arrivals.AddRange(newArrivals); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); + } + + foreach (var arr in context.Arrivals) + { + if (arr.Estimate.Minutes < 1 && arr.Estimate.Precision == ArrivalPrecision.Scheduled) + { + arr.Delete = true; // Remove arrivals that are scheduled right now, since they are likely already departed + } + } + } + + private static bool IsRouteMatch(string a, string b) + { + return a == b || a.Contains(b) || b.Contains(a); + } +} |
