aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend/Services
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-23 12:59:52 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-23 13:00:16 +0100
commit87417c313b455ba0dee19708528cc8d0b830a276 (patch)
tree34b7a2d6bb97157a1d35f57be85b8ff6532865d2 /src/Costasdev.Busurbano.Backend/Services
parentbed48c3d7e49b1736d50ce42d92bb6c18cf02504 (diff)
Reimplement real time for Vitrasa
Diffstat (limited to 'src/Costasdev.Busurbano.Backend/Services')
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs58
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FeedService.cs189
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs84
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs44
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs26
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs34
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs97
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs158
8 files changed, 690 insertions, 0 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs
new file mode 100644
index 0000000..8699a1e
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs
@@ -0,0 +1,58 @@
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public class ArrivalsContext
+{
+ /// <summary>
+ /// The full GTFS ID of the stop (e.g., "vitrasa:1400")
+ /// </summary>
+ public required string StopId { get; set; }
+
+ /// <summary>
+ /// The public code of the stop (e.g., "1400")
+ /// </summary>
+ public required string StopCode { get; set; }
+
+ /// <summary>
+ /// Whether to return a reduced number of arrivals (e.g., 4 instead of 10)
+ /// </summary>
+ public bool IsReduced { get; set; }
+
+ public required List<Arrival> Arrivals { get; set; }
+ public required DateTime NowLocal { get; set; }
+}
+
+public interface IArrivalsProcessor
+{
+ /// <summary>
+ /// Processes the arrivals in the context. Processors are executed in the order they are registered.
+ /// </summary>
+ Task ProcessAsync(ArrivalsContext context);
+}
+
+/// <summary>
+/// Orchestrates the enrichment of arrival data through a series of processors.
+/// This follows a pipeline pattern where each step (processor) adds or modifies data
+/// in the shared ArrivalsContext.
+/// </summary>
+public class ArrivalsPipeline
+{
+ private readonly IEnumerable<IArrivalsProcessor> _processors;
+
+ public ArrivalsPipeline(IEnumerable<IArrivalsProcessor> processors)
+ {
+ _processors = processors;
+ }
+
+ /// <summary>
+ /// Executes all registered processors sequentially.
+ /// </summary>
+ public async Task ExecuteAsync(ArrivalsContext context)
+ {
+ foreach (var processor in _processors)
+ {
+ await processor.ProcessAsync(context);
+ }
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs
new file mode 100644
index 0000000..48f9338
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs
@@ -0,0 +1,189 @@
+using System.Text.RegularExpressions;
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public class FeedService
+{
+ private static readonly Regex RemoveQuotationMarks = new(@"[""”]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex StreetNameRegex = new(@"^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Dictionary<string, string> NameReplacements = new(StringComparer.OrdinalIgnoreCase)
+ {
+ { "Rúa da Salguera Entrada", "Rúa da Salgueira" },
+ { "Rúa da Salgueira Entrada", "Rúa da Salgueira" },
+ { "Estrada de Miraflores", "Estrada Miraflores" },
+ { "FORA DE SERVIZO.G.B.", "" },
+ { "Praza de Fernando O Católico", "" },
+ { "Rúa da Travesía de Vigo", "Travesía de Vigo" },
+ { "Rúa de ", " " },
+ { "Rúa do ", " " },
+ { "Rúa da ", " " },
+ { "Rúa das ", " " },
+ { "Avda. de ", " " },
+ { "Avda. do ", " " },
+ { "Avda. da ", " " },
+ { "Avda. das ", " " },
+ { "Riós", "Ríos" },
+ { "Avda. Beiramar Porto Pesqueiro Berbés", "Berbés" },
+ { "Conde de Torrecedeira", "Torrecedeira" }
+ };
+
+ public (string Color, string TextColor) GetFallbackColourForFeed(string feed)
+ {
+ return feed switch
+ {
+ "vitrasa" => ("#95D516", "#000000"),
+ "santiago" => ("#508096", "#FFFFFF"),
+ "coruna" => ("#E61C29", "#FFFFFF"),
+ "xunta" => ("#007BC4", "#FFFFFF"),
+ "renfe" => ("#870164", "#FFFFFF"),
+ "feve" => ("#EE3D32", "#FFFFFF"),
+ _ => ("#000000", "#FFFFFF"),
+ };
+ }
+
+ public string NormalizeStopCode(string feedId, string code)
+ {
+ if (feedId == "vitrasa")
+ {
+ var digits = new string(code.Where(char.IsDigit).ToArray());
+ if (int.TryParse(digits, out int numericCode))
+ {
+ return numericCode.ToString();
+ }
+ }
+ return code;
+ }
+
+ public string NormalizeRouteShortName(string feedId, string shortName)
+ {
+ if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8)
+ {
+ // XG817014 -> 817.14
+ var contract = shortName.Substring(2, 3);
+ var lineStr = shortName.Substring(5);
+ if (int.TryParse(lineStr, out int line))
+ {
+ return $"{contract}.{line}";
+ }
+ }
+ return shortName;
+ }
+
+ public string NormalizeStopName(string feedId, string name)
+ {
+ if (feedId == "vitrasa")
+ {
+ return name
+ .Replace("\"", "")
+ .Replace(" ", ", ")
+ .Trim();
+ }
+
+ return name;
+ }
+
+ public string NormalizeRouteNameForMatching(string name)
+ {
+ var normalized = name.Trim().ToLowerInvariant();
+ // Remove diacritics/accents
+ normalized = Regex.Replace(normalized.Normalize(System.Text.NormalizationForm.FormD), @"\p{Mn}", "");
+ // Keep only alphanumeric
+ return Regex.Replace(normalized, @"[^a-z0-9]", "");
+ }
+
+ public string GetStreetName(string originalName)
+ {
+ var name = RemoveQuotationMarks.Replace(originalName, "").Trim();
+ var match = StreetNameRegex.Match(name);
+ var streetName = match.Success ? match.Groups[1].Value : name;
+
+ foreach (var replacement in NameReplacements)
+ {
+ if (streetName.Contains(replacement.Key, StringComparison.OrdinalIgnoreCase))
+ {
+ streetName = streetName.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase);
+ return streetName.Trim();
+ }
+ }
+
+ return streetName.Trim();
+ }
+
+ public string? GenerateMarquee(string feedId, List<string> nextStops)
+ {
+ if (nextStops.Count == 0) return null;
+
+ if (feedId == "vitrasa")
+ {
+ var streets = nextStops
+ .Select(GetStreetName)
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Distinct()
+ .ToList();
+
+ return string.Join(" - ", streets);
+ }
+
+ return feedId switch
+ {
+ "xunta" => string.Join(" > ", nextStops),
+ _ => string.Join(", ", nextStops.Take(4))
+ };
+ }
+
+ public bool IsStopHidden(string stopId)
+ {
+ return HiddenStops.Contains(stopId);
+ }
+
+ public ShiftBadge? GetShiftBadge(string feedId, string tripId)
+ {
+ if (feedId != "vitrasa") return null;
+
+ // Example: C1 04LN 02_001004_4
+ var parts = tripId.Split('_');
+ if (parts.Length < 2) return null;
+
+ var shiftGroup = parts[parts.Length - 2]; // 001004
+ var tripNumber = parts[parts.Length - 1]; // 4
+
+ if (shiftGroup.Length != 6) return null;
+
+ if (!int.TryParse(shiftGroup.Substring(0, 3), out var routeNum)) return null;
+ if (!int.TryParse(shiftGroup.Substring(3, 3), out var shiftNum)) return null;
+
+ var routeName = routeNum switch
+ {
+ 1 => "C1",
+ 3 => "C3",
+ 30 => "N1",
+ 33 => "N4",
+ 8 => "A",
+ 101 => "H",
+ 201 => "U1",
+ 202 => "U2",
+ 150 => "REF",
+ 500 => "TUR",
+ _ => $"L{routeNum}"
+ };
+
+ return new ShiftBadge
+ {
+ ShiftName = $"{routeName}-{shiftNum}",
+ ShiftTrip = tripNumber
+ };
+ }
+
+ private static readonly string[] HiddenStops =
+ [
+ "vitrasa:20223", // Castrelos (Pavillón - U1)
+ "vitrasa:20146", // García Barbón, 7 (A, 18A)
+ "vitrasa:20220", // COIA-SAMIL (15)
+ "vitrasa:20001", // Samil por Beiramar (15B)
+ "vitrasa:20002", // Samil por Torrecedeira (15C)
+ "vitrasa:20144", // Samil por Coia (C3d, C3i)
+ "vitrasa:20145" // Samil por Bouzs (C3d, C3i)
+ ];
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs
new file mode 100644
index 0000000..fde3e0a
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs
@@ -0,0 +1,84 @@
+using Costasdev.Busurbano.Backend.Helpers;
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class FeedConfigProcessor : IArrivalsProcessor
+{
+ private readonly FeedService _feedService;
+
+ public FeedConfigProcessor(FeedService feedService)
+ {
+ _feedService = feedService;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+ var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId);
+
+ foreach (var arrival in context.Arrivals)
+ {
+ arrival.Route.ShortName = _feedService.NormalizeRouteShortName(feedId, arrival.Route.ShortName);
+ arrival.Headsign.Destination = _feedService.NormalizeStopName(feedId, arrival.Headsign.Destination);
+
+ // Apply Vitrasa-specific line formatting
+ if (feedId == "vitrasa")
+ {
+ FormatVitrasaLine(arrival);
+ arrival.Shift = _feedService.GetShiftBadge(feedId, arrival.TripId);
+ }
+
+ if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF")
+ {
+ arrival.Route.Colour = fallbackColor;
+ arrival.Route.TextColour = fallbackTextColor;
+ }
+ else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000")
+ {
+ arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static void FormatVitrasaLine(Arrival arrival)
+ {
+ arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", "");
+
+ if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.")
+ {
+ arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)";
+ return;
+ }
+
+ switch (arrival.Route.ShortName)
+ {
+ case "A" when arrival.Headsign.Destination.StartsWith("\"1\""):
+ arrival.Route.ShortName = "A1";
+ arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"1\"", "");
+ break;
+ case "6":
+ arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", "");
+ break;
+ case "FUT":
+ if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO")
+ {
+ arrival.Route.ShortName = "MAR";
+ arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO";
+ }
+ else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA")
+ {
+ arrival.Route.ShortName = "RIO";
+ arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA";
+ }
+ else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO")
+ {
+ arrival.Route.ShortName = "GOL";
+ arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO";
+ }
+ break;
+ }
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs
new file mode 100644
index 0000000..c209db5
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs
@@ -0,0 +1,44 @@
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+/// <summary>
+/// Filters and sorts the arrivals based on the feed and the requested limit.
+/// This should run after real-time matching but before heavy enrichment (shapes, marquee).
+/// </summary>
+public class FilterAndSortProcessor : IArrivalsProcessor
+{
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+
+ // 1. Sort by minutes
+ var sorted = context.Arrivals
+ .OrderBy(a => a.Estimate.Minutes)
+ .ToList();
+
+ // 2. Filter based on feed rules
+ var filtered = sorted.Where(a =>
+ {
+ if (feedId == "vitrasa")
+ {
+ // For Vitrasa, we hide past arrivals because we have real-time
+ // If a past arrival was matched to a real-time estimate, its Minutes will be >= 0
+ return a.Estimate.Minutes >= 0;
+ }
+
+ // For others, show up to 10 minutes ago
+ return a.Estimate.Minutes >= -10;
+ }).ToList();
+
+ // 3. Limit results
+ var limit = context.IsReduced ? 4 : 10;
+ var limited = filtered.Take(limit).ToList();
+
+ // Update the context list in-place
+ context.Arrivals.Clear();
+ context.Arrivals.AddRange(limited);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs
new file mode 100644
index 0000000..ec65493
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs
@@ -0,0 +1,26 @@
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class MarqueeProcessor : IArrivalsProcessor
+{
+ private readonly FeedService _feedService;
+
+ public MarqueeProcessor(FeedService feedService)
+ {
+ _feedService = feedService;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+
+ foreach (var arrival in context.Arrivals)
+ {
+ if (string.IsNullOrEmpty(arrival.Headsign.Marquee))
+ {
+ arrival.Headsign.Marquee = _feedService.GenerateMarquee(feedId, arrival.NextStops);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs
new file mode 100644
index 0000000..6273e0d
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs
@@ -0,0 +1,34 @@
+using Costasdev.Busurbano.Backend.GraphClient.App;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class NextStopsProcessor : IArrivalsProcessor
+{
+ private readonly FeedService _feedService;
+
+ public NextStopsProcessor(FeedService feedService)
+ {
+ _feedService = feedService;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+
+ foreach (var arrival in context.Arrivals)
+ {
+ if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue;
+
+ // Filter stoptimes that are after the current stop's departure
+ var currentStopDeparture = otpArrival.ScheduledDepartureSeconds;
+
+ arrival.NextStops = otpArrival.Trip.Stoptimes
+ .Where(s => s.ScheduledDeparture > currentStopDeparture)
+ .OrderBy(s => s.ScheduledDeparture)
+ .Select(s => _feedService.NormalizeStopName(feedId, s.Stop.Name))
+ .ToList();
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs
new file mode 100644
index 0000000..300ce70
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs
@@ -0,0 +1,97 @@
+using Costasdev.Busurbano.Backend.GraphClient.App;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class ShapeProcessor : IArrivalsProcessor
+{
+ private readonly ILogger<ShapeProcessor> _logger;
+
+ public ShapeProcessor(ILogger<ShapeProcessor> logger)
+ {
+ _logger = logger;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ if (context.IsReduced)
+ {
+ return Task.CompletedTask;
+ }
+
+ foreach (var arrival in context.Arrivals)
+ {
+ if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue;
+
+ var encodedPoints = otpArrival.Trip.Geometry?.Points;
+ if (string.IsNullOrEmpty(encodedPoints))
+ {
+ _logger.LogDebug("No geometry found for trip {TripId}", arrival.TripId);
+ continue;
+ }
+
+ try
+ {
+ var points = Decode(encodedPoints);
+ if (points.Count == 0) continue;
+
+ arrival.Shape = new
+ {
+ type = "Feature",
+ geometry = new
+ {
+ type = "LineString",
+ coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList()
+ }
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error decoding shape for trip {TripId}", arrival.TripId);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static List<(double Lat, double Lon)> Decode(string encodedPoints)
+ {
+ var poly = new List<(double, double)>();
+ char[] polylineChars = encodedPoints.ToCharArray();
+ int index = 0;
+
+ int currentLat = 0;
+ int currentLng = 0;
+ int next5bits;
+ int sum;
+ int shifter;
+
+ while (index < polylineChars.Length)
+ {
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0));
+ }
+
+ return poly;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
new file mode 100644
index 0000000..7c98cfb
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
@@ -0,0 +1,158 @@
+using Costasdev.Busurbano.Backend.GraphClient.App;
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+using Costasdev.VigoTransitApi;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class VitrasaRealTimeProcessor : IArrivalsProcessor
+{
+ private readonly VigoTransitApiClient _api;
+ private readonly FeedService _feedService;
+ private readonly ILogger<VitrasaRealTimeProcessor> _logger;
+
+ public VitrasaRealTimeProcessor(HttpClient http, FeedService feedService, ILogger<VitrasaRealTimeProcessor> logger)
+ {
+ _api = new VigoTransitApiClient(http);
+ _feedService = feedService;
+ _logger = logger;
+ }
+
+ public 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
+ {
+ var realtime = await _api.GetStopEstimates(numericStopId);
+ var estimates = realtime.Estimates
+ .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*'))
+ .ToList();
+
+ 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 =>
+ {
+ var arrivalRouteNormalized = _feedService.NormalizeRouteNameForMatching(a.Headsign.Destination);
+ 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 != null)
+ {
+ var arrival = bestMatch.Arrival;
+ _logger.LogInformation("Matched Vitrasa real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)",
+ arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff);
+
+ 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 };
+ }
+
+ // Prefer real-time headsign if available and different
+ if (!string.IsNullOrWhiteSpace(estimate.Route))
+ {
+ arrival.Headsign.Destination = estimate.Route;
+ }
+
+ usedTripIds.Add(arrival.TripId);
+ }
+ else
+ {
+ _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:rt:{estimate.Line}:{estimate.Route}:{estimate.Minutes}",
+ Route = new RouteInfo
+ {
+ 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
+ }
+ });
+ }
+ }
+
+ context.Arrivals.AddRange(newArrivals);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Vitrasa 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);
+ }
+}