aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend/Services/Processors/RealTime
diff options
context:
space:
mode:
Diffstat (limited to 'src/Enmarcha.Backend/Services/Processors/RealTime')
-rw-r--r--src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs231
-rw-r--r--src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs307
-rw-r--r--src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs84
-rw-r--r--src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs94
-rw-r--r--src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs295
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);
+ }
+}