From a304c24b32c0327436bbd8c2853e60668e161b42 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 29 Dec 2025 00:41:52 +0100 Subject: Rename a lot of stuff, add Santiago real time --- .../Services/Processors/AbstractProcessor.cs | 56 +++++ .../Services/Processors/CorunaRealTimeProcessor.cs | 181 +++++++++++++++ .../Services/Processors/FeedConfigProcessor.cs | 84 +++++++ .../Services/Processors/FilterAndSortProcessor.cs | 44 ++++ .../Services/Processors/MarqueeProcessor.cs | 26 +++ .../Services/Processors/NextStopsProcessor.cs | 34 +++ .../Processors/SantiagoRealTimeProcessor.cs | 88 +++++++ .../Services/Processors/ShapeProcessor.cs | 132 +++++++++++ .../Processors/VitrasaRealTimeProcessor.cs | 254 +++++++++++++++++++++ 9 files changed, 899 insertions(+) create mode 100644 src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs (limited to 'src/Enmarcha.Backend/Services/Processors') diff --git a/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs b/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs new file mode 100644 index 0000000..d6b420f --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs @@ -0,0 +1,56 @@ +using Enmarcha.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/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs new file mode 100644 index 0000000..ca3f91d --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -0,0 +1,181 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Sources.TranviasCoruna; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors; + +public class CorunaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CorunaRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger _logger; + private readonly ShapeTraversalService _shapeService; + + public CorunaRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger logger, + ShapeTraversalService shapeService) + { + _realtime = new CorunaRealtimeEstimatesProvider(http); + _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); + + var usedTripIds = new HashSet(); + var newArrivals = new List(); + + 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(); + 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/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs new file mode 100644 index 0000000..2d5f5d9 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs @@ -0,0 +1,84 @@ +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs new file mode 100644 index 0000000..7df00fa --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs @@ -0,0 +1,44 @@ +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors; + +/// +/// 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). +/// +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/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs new file mode 100644 index 0000000..9e620c2 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs @@ -0,0 +1,26 @@ +namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs new file mode 100644 index 0000000..5d02066 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs @@ -0,0 +1,34 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; + +namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs new file mode 100644 index 0000000..28b38a9 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs @@ -0,0 +1,88 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Sources.TranviasCoruna; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Sources.Tussa; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors; + +public class SantiagoRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly SantiagoRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger _logger; + + public SantiagoRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger logger) + { + _realtime = new SantiagoRealtimeEstimatesProvider(http); + _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); + + var usedTripIds = new HashSet(); + var newArrivals = new List(); + + 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 }; + } + + 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); + } + } + +} diff --git a/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs new file mode 100644 index 0000000..f3af3a5 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs @@ -0,0 +1,132 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; + +namespace Enmarcha.Backend.Services.Processors; + +public class ShapeProcessor : IArrivalsProcessor +{ + private readonly ILogger _logger; + + public ShapeProcessor(ILogger 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(); + + // 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/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs new file mode 100644 index 0000000..5d44995 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -0,0 +1,254 @@ +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; + +public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly VigoTransitApiClient _api; + private readonly FeedService _feedService; + private readonly ILogger _logger; + private readonly ShapeTraversalService _shapeService; + private readonly AppConfiguration _configuration; + + public VitrasaRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger logger, + ShapeTraversalService shapeService, + IOptions 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(); + var newArrivals = new List(); + + 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(); + 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); + } +} -- cgit v1.3