aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend/Services/Processors
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-29 00:41:52 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-29 00:41:52 +0100
commita304c24b32c0327436bbd8c2853e60668e161b42 (patch)
tree08f65c05daca134cf4d2e4f779bd15d98fd66370 /src/Costasdev.Busurbano.Backend/Services/Processors
parent120a3c6bddd0fb8d9fa05df4763596956554c025 (diff)
Rename a lot of stuff, add Santiago real time
Diffstat (limited to 'src/Costasdev.Busurbano.Backend/Services/Processors')
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs56
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs184
-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.cs132
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs254
8 files changed, 0 insertions, 814 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs
deleted file mode 100644
index 343f511..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using Costasdev.Busurbano.Backend.Services;
-
-public abstract class AbstractRealTimeProcessor : IArrivalsProcessor
-{
- public abstract Task ProcessAsync(ArrivalsContext context);
-
- protected static List<(double Lat, double Lon)> Decode(string encodedPoints)
- {
- if (string.IsNullOrEmpty(encodedPoints))
- return new List<(double, double)>();
-
- 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)
- {
- // calculate next latitude
- sum = 0;
- shifter = 0;
- do
- {
- next5bits = (int)polylineChars[index++] - 63;
- sum |= (next5bits & 31) << shifter;
- shifter += 5;
- } while (next5bits >= 32 && index < polylineChars.Length);
-
- if (index >= polylineChars.Length)
- break;
-
- currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
-
- // calculate next longitude
- 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/CorunaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs
deleted file mode 100644
index 587917e..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs
+++ /dev/null
@@ -1,184 +0,0 @@
-using Costasdev.Busurbano.Backend.Types;
-using Costasdev.Busurbano.Backend.Types.Arrivals;
-using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
-using Costasdev.Busurbano.Sources.TranviasCoruna;
-using Arrival = Costasdev.Busurbano.Backend.Types.Arrivals.Arrival;
-
-namespace Costasdev.Busurbano.Backend.Services.Processors;
-
-public class CorunaRealTimeProcessor : AbstractRealTimeProcessor
-{
- private readonly CorunaRealtimeEstimatesProvider _realtime;
- private readonly FeedService _feedService;
- private readonly ILogger<CorunaRealTimeProcessor> _logger;
- private readonly ShapeTraversalService _shapeService;
-
- public CorunaRealTimeProcessor(
- HttpClient http,
- FeedService feedService,
- ILogger<CorunaRealTimeProcessor> logger,
- ShapeTraversalService shapeService)
- {
- _realtime = new CorunaRealtimeEstimatesProvider(http);
- _feedService = feedService;
- _logger = logger;
- _shapeService = shapeService;
- }
-
- public override async Task ProcessAsync(ArrivalsContext context)
- {
- if (!context.StopId.StartsWith("coruna:")) return;
-
- var normalizedCode = _feedService.NormalizeStopCode("coruna", 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 _realtime.GetEstimatesForStop(numericStopId);
-
- var usedTripIds = new HashSet<string>();
- var newArrivals = new List<Arrival>();
-
- 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 =>
- {
- return new
- {
- Arrival = a,
- TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule
- RouteMatch = true
- };
- })
- .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)
- {
- 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 };
- }
-
- // Calculate position
- if (stopLocation != null)
- {
- Position? currentPosition = null;
- int? stopShapeIndex = 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;
- stopShapeIndex = result.StopIndex;
-
- 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 = features
- };
- }
- }
-
- if (currentPosition != null)
- {
- arrival.CurrentPosition = currentPosition;
- arrival.StopShapeIndex = stopShapeIndex;
- }
- }
-
- 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);
- }
- }
-
- private static bool IsRouteMatch(string a, string b)
- {
- return a == b || a.Contains(b) || b.Contains(a);
- }
-
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs
deleted file mode 100644
index fde3e0a..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-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
deleted file mode 100644
index c209db5..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-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
deleted file mode 100644
index ec65493..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index a00a68a..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
-
-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
deleted file mode 100644
index 40bc508..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
-
-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 shape is already populated (e.g. by VitrasaRealTimeProcessor), skip
- if (arrival.Shape != null) continue;
-
- 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;
-
- var features = new List<object>();
-
- // Route LineString
- features.Add(new
- {
- type = "Feature",
- geometry = new
- {
- type = "LineString",
- coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList()
- },
- properties = new { type = "route" }
- });
-
- // Stops
- 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 = features
- };
- }
- 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
deleted file mode 100644
index f3a8d91..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
+++ /dev/null
@@ -1,254 +0,0 @@
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Types;
-using Costasdev.Busurbano.Backend.Types.Arrivals;
-using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
-using Costasdev.VigoTransitApi;
-using Microsoft.Extensions.Options;
-
-namespace Costasdev.Busurbano.Backend.Services.Processors;
-
-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(
- HttpClient http,
- FeedService feedService,
- ILogger<VitrasaRealTimeProcessor> logger,
- ShapeTraversalService shapeService,
- IOptions<AppConfiguration> options)
- {
- _api = new VigoTransitApiClient(http);
- _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();
-
- 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;
-
- var scheduledMinutes = arrival.Estimate.Minutes;
- arrival.Estimate.Minutes = estimate.Minutes;
- arrival.Estimate.Precision = ArrivalPrecision.Confident;
-
- // Calculate delay badge
- var delayMinutes = estimate.Minutes - scheduledMinutes;
- arrival.Delay = new DelayBadge { Minutes = delayMinutes };
-
- // Prefer real-time headsign if available and different
- if (!string.IsNullOrWhiteSpace(estimate.Route))
- {
- arrival.Headsign.Destination = estimate.Route;
- }
-
- // Calculate position
- if (stopLocation != null)
- {
- Position? currentPosition = null;
- int? stopShapeIndex = 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;
- stopShapeIndex = result.StopIndex;
-
- 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 = features
- };
- }
- }
-
- if (currentPosition != null)
- {
- arrival.CurrentPosition = currentPosition;
- arrival.StopShapeIndex = stopShapeIndex;
- }
- }
-
- 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
- {
- 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
- }
- });
- }
- }
-
- 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);
- }
-}