diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-07 23:33:10 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-07 23:37:38 +0100 |
| commit | a1d589c1a0d5a5010e5fe4e8a1ec403ffafb289f (patch) | |
| tree | 870366d9ce178530b836086e432331f78ec4a07e /src/Costasdev.Busurbano.Backend | |
| parent | 5fa8d1ffeb4a3a0c5c6846de3986ec779a4fe564 (diff) | |
Implement Renfe data source
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
9 files changed, 481 insertions, 345 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs index 9fa5e75..49c001f 100644 --- a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs +++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs @@ -2,5 +2,6 @@ namespace Costasdev.Busurbano.Backend.Configuration; public class AppConfiguration { - public required string ScheduleBasePath { get; set; } + public required string VitrasaScheduleBasePath { get; set; } + public required string RenfeScheduleBasePath { get; set; } } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs index 3bb9930..d006e38 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs @@ -53,7 +53,7 @@ public partial class VigoController : ControllerBase try { - var file = Path.Combine(_configuration.ScheduleBasePath, effectiveDate, stopId + ".json"); + var file = Path.Combine(_configuration.VitrasaScheduleBasePath, effectiveDate, stopId + ".json"); if (!SysFile.Exists(file)) { throw new FileNotFoundException(); diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs index 288cc98..642cccb 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -1,13 +1,9 @@ -using System.Globalization; -using System.Text; using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; -using Costasdev.Busurbano.Backend.Types; +using Costasdev.Busurbano.Backend.Services.Providers; using Costasdev.VigoTransitApi; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using static Costasdev.Busurbano.Backend.Types.StopArrivals.Types; -using SysFile = System.IO.File; namespace Costasdev.Busurbano.Backend.Controllers; @@ -19,13 +15,23 @@ public partial class VigoController : ControllerBase private readonly VigoTransitApiClient _api; private readonly AppConfiguration _configuration; private readonly ShapeTraversalService _shapeService; + private readonly VitrasaTransitProvider _vitrasaProvider; + private readonly RenfeTransitProvider _renfeProvider; - public VigoController(HttpClient http, IOptions<AppConfiguration> options, ILogger<VigoController> logger, ShapeTraversalService shapeService) + public VigoController( + HttpClient http, + IOptions<AppConfiguration> options, + ILogger<VigoController> logger, + ShapeTraversalService shapeService, + VitrasaTransitProvider vitrasaProvider, + RenfeTransitProvider renfeProvider) { _logger = logger; _api = new VigoTransitApiClient(http); _configuration = options.Value; _shapeService = shapeService; + _vitrasaProvider = vitrasaProvider; + _renfeProvider = renfeProvider; } [HttpGet("GetShape")] @@ -108,355 +114,34 @@ public partial class VigoController : ControllerBase [HttpGet("GetConsolidatedCirculations")] public async Task<IActionResult> GetConsolidatedCirculations( - [FromQuery] int stopId + [FromQuery] string stopId ) { // Use Europe/Madrid timezone consistently to avoid UTC/local skew var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); - var realtimeTask = _api.GetStopEstimates(stopId); - var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); + ITransitProvider provider; + string effectiveStopId; - // Load both today's and tomorrow's schedules to handle night services - var timetableTask = LoadStopArrivalsProto(stopId.ToString(), todayDate); - - // Wait for real-time data and today's schedule (required) - await Task.WhenAll(realtimeTask, timetableTask); - - var realTimeEstimates = realtimeTask.Result.Estimates; - - // Handle case where schedule file doesn't exist - return realtime-only data - if (timetableTask.Result == null) - { - _logger.LogWarning("No schedule data available for stop {StopId} on {Date}, returning realtime-only data", stopId, todayDate); - - var realtimeOnlyCirculations = realTimeEstimates.Select(estimate => new ConsolidatedCirculation - { - Line = estimate.Line, - Route = estimate.Route, - Schedule = null, - RealTime = new RealTimeData - { - Minutes = estimate.Minutes, - Distance = estimate.Meters - } - }).OrderBy(c => c.RealTime!.Minutes).ToList(); - - return Ok(realtimeOnlyCirculations); - } - - var timetable = timetableTask.Result.Arrivals - .Where(c => c.StartingDateTime(nowLocal.Date) != null && c.CallingDateTime(nowLocal.Date) != null) - .ToList(); - - var stopLocation = timetableTask.Result.Location; - - var now = nowLocal.AddSeconds(60 - nowLocal.Second); - // Define the scope end as the time of the last realtime arrival (no extra buffer) - var scopeEnd = realTimeEstimates.Count > 0 - ? now.AddMinutes(Math.Min(realTimeEstimates.Max(e => e.Minutes) + 5, 75)) - : now.AddMinutes(60); // If no estimates, show next hour of scheduled only - - List<ConsolidatedCirculation> consolidatedCirculations = []; - var usedTripIds = new HashSet<string>(); - - foreach (var estimate in realTimeEstimates) - { - var estimatedArrivalTime = now.AddMinutes(estimate.Minutes); - - var possibleCirculations = timetable - .Where(c => - { - // Match by line number - if (c.Line.Trim() != estimate.Line.Trim()) - return false; - - // Match by route (destination) - compare with both Route field and Terminus stop name - // Normalize both sides: remove non-ASCII-alnum characters and lowercase - var estimateRoute = NormalizeRouteName(estimate.Route); - var scheduleRoute = NormalizeRouteName(c.Route); - var scheduleTerminus = NormalizeRouteName(c.TerminusName); - - return scheduleRoute == estimateRoute || scheduleTerminus == estimateRoute; - }) - .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value) - .ToArray(); - - ScheduledArrival? closestCirculation = null; - - // Matching strategy: - // 1) Filter trips that are not "too early" (TimeDiff <= 7). - // TimeDiff = Schedule - Realtime. - // If TimeDiff > 7, bus is > 7 mins early. Reject. - // 2) From the valid trips, pick the one with smallest Abs(TimeDiff). - // This handles "as late as it gets" (large negative TimeDiff) by preferring smaller delays if available, - // but accepting large delays if that's the only option (and better than an invalid early trip). - const int maxEarlyArrivalMinutes = 7; - - var bestMatch = possibleCirculations - .Select(c => new - { - Circulation = c, - TimeDiff = (c.CallingDateTime(nowLocal.Date)!.Value - estimatedArrivalTime).TotalMinutes - }) - .Where(x => x.TimeDiff <= maxEarlyArrivalMinutes) - .OrderBy(x => Math.Abs(x.TimeDiff)) - .FirstOrDefault(); - - if (bestMatch != null) - { - closestCirculation = bestMatch.Circulation; - } - - if (closestCirculation == null) - { - // No scheduled match: include realtime-only entry - _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes (tried matching {NormalizedRoute})", estimate.Line, estimate.Route, estimate.Minutes, NormalizeRouteName(estimate.Route)); - consolidatedCirculations.Add(new ConsolidatedCirculation - { - Line = estimate.Line, - Route = estimate.Route, - Schedule = null, - RealTime = new RealTimeData - { - Minutes = estimate.Minutes, - Distance = estimate.Meters - } - }); - - continue; - } - - // Ensure each scheduled trip is only matched once to a realtime estimate - if (usedTripIds.Contains(closestCirculation.TripId)) - { - _logger.LogInformation("Skipping duplicate realtime match for TripId {TripId}", closestCirculation.TripId); - continue; - } - - var isRunning = closestCirculation.StartingDateTime(nowLocal.Date)!.Value <= now; - Position? currentPosition = null; - int? stopShapeIndex = null; - bool usePreviousShape = false; - - // Calculate bus position for realtime trips - if (!string.IsNullOrEmpty(closestCirculation.ShapeId)) - { - // Check if we are likely on the previous trip - // If the bus is further away than the distance from the start of the trip to the stop, - // it implies the bus is on the previous trip (or earlier). - double distOnPrevTrip = estimate.Meters - closestCirculation.ShapeDistTraveled; - usePreviousShape = !isRunning && - !string.IsNullOrEmpty(closestCirculation.PreviousTripShapeId) && - distOnPrevTrip > 0; - - if (usePreviousShape) - { - var prevShape = await _shapeService.LoadShapeAsync(closestCirculation.PreviousTripShapeId); - if (prevShape != null && prevShape.Points.Count > 0) - { - // The bus is on the previous trip. - // We treat the end of the previous shape as the "stop" for the purpose of calculation. - // The distance to traverse backwards from the end of the previous shape is 'distOnPrevTrip'. - var lastPoint = prevShape.Points[prevShape.Points.Count - 1]; - var result = _shapeService.GetBusPosition(prevShape, lastPoint, (int)distOnPrevTrip); - currentPosition = result.BusPosition; - stopShapeIndex = result.StopIndex; - } - } - else - { - // Normal case: bus is on the current trip shape - var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId); - if (shape != null && stopLocation != null) - { - var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); - currentPosition = result.BusPosition; - stopShapeIndex = result.StopIndex; - } - } - } - - consolidatedCirculations.Add(new ConsolidatedCirculation - { - Line = estimate.Line, - Route = estimate.Route == closestCirculation.TerminusName ? closestCirculation.Route : estimate.Route, - NextStreets = [.. closestCirculation.NextStreets], - Schedule = new ScheduleData - { - Running = isRunning, - Minutes = (int)(closestCirculation.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes, - TripId = closestCirculation.TripId, - ServiceId = closestCirculation.ServiceId, - ShapeId = closestCirculation.ShapeId, - }, - RealTime = new RealTimeData - { - Minutes = estimate.Minutes, - Distance = estimate.Meters - }, - CurrentPosition = currentPosition, - StopShapeIndex = stopShapeIndex, - IsPreviousTrip = usePreviousShape, - PreviousTripShapeId = usePreviousShape ? closestCirculation.PreviousTripShapeId : null - }); - - usedTripIds.Add(closestCirculation.TripId); - } - - // Add scheduled-only circulations between now and the last realtime arrival - if (scopeEnd > now) - { - var matchedTripIds = new HashSet<string>(usedTripIds); - - var scheduledWindow = timetable - .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd) - .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value); - - foreach (var sched in scheduledWindow) - { - if (matchedTripIds.Contains(sched.TripId)) - { - continue; // already represented via a matched realtime - } - - var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes; - if (minutes == 0) - { - continue; - } - - consolidatedCirculations.Add(new ConsolidatedCirculation - { - Line = sched.Line, - Route = sched.Route, - Schedule = new ScheduleData - { - Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, - Minutes = minutes, - TripId = sched.TripId, - ServiceId = sched.ServiceId, - ShapeId = sched.ShapeId, - }, - RealTime = null - }); - } - } - - // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes) - var sorted = consolidatedCirculations - .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes) - .Select(LineFormatterService.Format) - .ToList(); - - return Ok(sorted); - } - - private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString) - { - var file = Path.Combine(_configuration.ScheduleBasePath, dateString, stopId + ".pb"); - if (!SysFile.Exists(file)) - { - _logger.LogWarning("Stop arrivals proto file not found: {File}", file); - return null; - } - - var contents = await SysFile.ReadAllBytesAsync(file); - var stopArrivals = StopArrivals.Parser.ParseFrom(contents); - return stopArrivals; - } - - private async Task<Shape> LoadShapeProto(string shapeId) - { - var file = Path.Combine(_configuration.ScheduleBasePath, shapeId + ".pb"); - if (!SysFile.Exists(file)) - { - throw new FileNotFoundException(); - } - - var contents = await SysFile.ReadAllBytesAsync(file); - var shape = Shape.Parser.ParseFrom(contents); - return shape; - } - - private static string NormalizeRouteName(string route) - { - var normalized = route.Trim().ToLowerInvariant(); - // Remove diacritics/accents first, then filter to alphanumeric - normalized = RemoveDiacritics(normalized); - return new string(normalized.Where(char.IsLetterOrDigit).ToArray()); - } - - private static string RemoveDiacritics(string text) - { - var normalizedString = text.Normalize(NormalizationForm.FormD); - var stringBuilder = new StringBuilder(); - - foreach (var c in normalizedString) + if (stopId.StartsWith("renfe:")) { - var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); - if (unicodeCategory != UnicodeCategory.NonSpacingMark) - { - stringBuilder.Append(c); - } + provider = _renfeProvider; + effectiveStopId = stopId.Substring("renfe:".Length); } - - return stringBuilder.ToString().Normalize(NormalizationForm.FormC); - } -} - -public static class StopScheduleExtensions -{ - public static DateTime? StartingDateTime(this ScheduledArrival stop, DateTime baseDate) - { - return ParseGtfsTime(stop.StartingTime, baseDate); - } - - public static DateTime? CallingDateTime(this ScheduledArrival stop, DateTime baseDate) - { - return ParseGtfsTime(stop.CallingTime, baseDate); - } - - /// <summary> - /// Parse GTFS time format (HH:MM:SS) which can have hours >= 24 for services past midnight - /// </summary> - private static DateTime? ParseGtfsTime(string timeStr, DateTime baseDate) - { - if (string.IsNullOrWhiteSpace(timeStr)) - { - return null; - } - - var parts = timeStr.Split(':'); - if (parts.Length != 3) + else if (stopId.StartsWith("vitrasa:")) { - return null; + provider = _vitrasaProvider; + effectiveStopId = stopId.Substring("vitrasa:".Length); } - - if (!int.TryParse(parts[0], out var hours) || - !int.TryParse(parts[1], out var minutes) || - !int.TryParse(parts[2], out var seconds)) + else { - return null; + // Legacy/Default + provider = _vitrasaProvider; + effectiveStopId = stopId; } - // Handle GTFS times that exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day) - var days = hours / 24; - var normalizedHours = hours % 24; - - try - { - var dt = baseDate - .AddDays(days) - .AddHours(normalizedHours) - .AddMinutes(minutes) - .AddSeconds(seconds); - return dt.AddSeconds(60 - dt.Second); - } - catch - { - return null; - } + var result = await provider.GetCirculationsAsync(effectiveStopId, nowLocal); + return Ok(result); } } diff --git a/src/Costasdev.Busurbano.Backend/Extensions/StopScheduleExtensions.cs b/src/Costasdev.Busurbano.Backend/Extensions/StopScheduleExtensions.cs new file mode 100644 index 0000000..b435158 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Extensions/StopScheduleExtensions.cs @@ -0,0 +1,58 @@ +using static Costasdev.Busurbano.Backend.Types.StopArrivals.Types; + +namespace Costasdev.Busurbano.Backend.Extensions; + +public static class StopScheduleExtensions +{ + public static DateTime? StartingDateTime(this ScheduledArrival stop, DateTime baseDate) + { + return ParseGtfsTime(stop.StartingTime, baseDate); + } + + public static DateTime? CallingDateTime(this ScheduledArrival stop, DateTime baseDate) + { + return ParseGtfsTime(stop.CallingTime, baseDate); + } + + /// <summary> + /// Parse GTFS time format (HH:MM:SS) which can have hours >= 24 for services past midnight + /// </summary> + private static DateTime? ParseGtfsTime(string timeStr, DateTime baseDate) + { + if (string.IsNullOrWhiteSpace(timeStr)) + { + return null; + } + + var parts = timeStr.Split(':'); + if (parts.Length != 3) + { + return null; + } + + if (!int.TryParse(parts[0], out var hours) || + !int.TryParse(parts[1], out var minutes) || + !int.TryParse(parts[2], out var seconds)) + { + return null; + } + + // Handle GTFS times that exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day) + var days = hours / 24; + var normalizedHours = hours % 24; + + try + { + var dt = baseDate + .AddDays(days) + .AddHours(normalizedHours) + .AddMinutes(minutes) + .AddSeconds(seconds); + return dt.AddSeconds(60 - dt.Second); + } + catch + { + return null; + } + } +} diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 46f2595..959e114 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -1,5 +1,6 @@ using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; +using Costasdev.Busurbano.Backend.Services.Providers; var builder = WebApplication.CreateBuilder(args); @@ -10,6 +11,9 @@ builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); builder.Services.AddSingleton<ShapeTraversalService>(); +builder.Services.AddScoped<VitrasaTransitProvider>(); +builder.Services.AddScoped<RenfeTransitProvider>(); + var app = builder.Build(); app.MapControllers(); diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs new file mode 100644 index 0000000..f0440e4 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs @@ -0,0 +1,8 @@ +using Costasdev.Busurbano.Backend.Types; + +namespace Costasdev.Busurbano.Backend.Services.Providers; + +public interface ITransitProvider +{ + Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime now); +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs new file mode 100644 index 0000000..55e880f --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs @@ -0,0 +1,76 @@ +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Extensions; +using Costasdev.Busurbano.Backend.Types; +using Microsoft.Extensions.Options; +using SysFile = System.IO.File; + +namespace Costasdev.Busurbano.Backend.Services.Providers; + +public class RenfeTransitProvider : ITransitProvider +{ + private readonly AppConfiguration _configuration; + private readonly ILogger<RenfeTransitProvider> _logger; + + public RenfeTransitProvider(IOptions<AppConfiguration> options, ILogger<RenfeTransitProvider> logger) + { + _configuration = options.Value; + _logger = logger; + } + + public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal) + { + var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); + var stopArrivals = await LoadStopArrivalsProto(stopId, todayDate); + + if (stopArrivals == null) + { + return []; + } + + var now = nowLocal.AddSeconds(60 - nowLocal.Second); + var scopeEnd = now.AddMinutes(300); + + var scheduledWindow = stopArrivals.Arrivals + .Where(c => c.CallingDateTime(nowLocal.Date) != null) + .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd) + .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value); + + var consolidatedCirculations = new List<ConsolidatedCirculation>(); + + foreach (var sched in scheduledWindow) + { + var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes; + + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = sched.Line, + Route = sched.Route, + Schedule = new ScheduleData + { + Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, + Minutes = minutes, + TripId = sched.TripId, + ServiceId = sched.ServiceId, + ShapeId = sched.ShapeId, + }, + RealTime = null + }); + } + + return consolidatedCirculations; + } + + private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString) + { + var file = Path.Combine(_configuration.RenfeScheduleBasePath, dateString, stopId + ".pb"); + if (!SysFile.Exists(file)) + { + _logger.LogWarning("Stop arrivals proto file not found: {File}", file); + return null; + } + + var contents = await SysFile.ReadAllBytesAsync(file); + var stopArrivals = StopArrivals.Parser.ParseFrom(contents); + return stopArrivals; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs new file mode 100644 index 0000000..079d510 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs @@ -0,0 +1,304 @@ +using System.Globalization; +using System.Text; +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Extensions; +using Costasdev.Busurbano.Backend.Types; +using Costasdev.VigoTransitApi; +using Microsoft.Extensions.Options; +using static Costasdev.Busurbano.Backend.Types.StopArrivals.Types; +using SysFile = System.IO.File; + +namespace Costasdev.Busurbano.Backend.Services.Providers; + +public class VitrasaTransitProvider : ITransitProvider +{ + private readonly VigoTransitApiClient _api; + private readonly AppConfiguration _configuration; + private readonly ShapeTraversalService _shapeService; + private readonly ILogger<VitrasaTransitProvider> _logger; + + public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, ILogger<VitrasaTransitProvider> logger) + { + _api = new VigoTransitApiClient(http); + _configuration = options.Value; + _shapeService = shapeService; + _logger = logger; + } + + public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal) + { + // Vitrasa stop IDs are integers, but we receive string "vitrasa:1234" or just "1234" if legacy + // The caller (Controller) should probably strip the prefix, but let's handle it here just in case or assume it's stripped. + // The user said: "Routing the request to one or tthe other will just work with the prefix. For example calling `/api/GetConsolidatedCirculations?stopId=vitrasa:1400` will call the vitrasa driver with stop 1400." + // So I should expect the ID part only here? Or the full ID? + // Usually providers take the ID they understand. I'll assume the controller strips the prefix. + + if (!int.TryParse(stopId, out var numericStopId)) + { + _logger.LogError("Invalid Vitrasa stop ID: {StopId}", stopId); + return []; + } + + var realtimeTask = _api.GetStopEstimates(numericStopId); + var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); + + // Load both today's and tomorrow's schedules to handle night services + var timetableTask = LoadStopArrivalsProto(stopId, todayDate); + + // Wait for real-time data and today's schedule (required) + await Task.WhenAll(realtimeTask, timetableTask); + + var realTimeEstimates = realtimeTask.Result.Estimates; + + // Handle case where schedule file doesn't exist - return realtime-only data + if (timetableTask.Result == null) + { + _logger.LogWarning("No schedule data available for stop {StopId} on {Date}, returning realtime-only data", stopId, todayDate); + + var realtimeOnlyCirculations = realTimeEstimates.Select(estimate => new ConsolidatedCirculation + { + Line = estimate.Line, + Route = estimate.Route, + Schedule = null, + RealTime = new RealTimeData + { + Minutes = estimate.Minutes, + Distance = estimate.Meters + } + }).OrderBy(c => c.RealTime!.Minutes).ToList(); + + return realtimeOnlyCirculations; + } + + var timetable = timetableTask.Result.Arrivals + .Where(c => c.StartingDateTime(nowLocal.Date) != null && c.CallingDateTime(nowLocal.Date) != null) + .ToList(); + + var stopLocation = timetableTask.Result.Location; + + var now = nowLocal.AddSeconds(60 - nowLocal.Second); + // Define the scope end as the time of the last realtime arrival (no extra buffer) + var scopeEnd = realTimeEstimates.Count > 0 + ? now.AddMinutes(Math.Min(realTimeEstimates.Max(e => e.Minutes) + 5, 75)) + : now.AddMinutes(60); // If no estimates, show next hour of scheduled only + + List<ConsolidatedCirculation> consolidatedCirculations = []; + var usedTripIds = new HashSet<string>(); + + foreach (var estimate in realTimeEstimates) + { + var estimatedArrivalTime = now.AddMinutes(estimate.Minutes); + + var possibleCirculations = timetable + .Where(c => + { + // Match by line number + if (c.Line.Trim() != estimate.Line.Trim()) + return false; + + // Match by route (destination) - compare with both Route field and Terminus stop name + // Normalize both sides: remove non-ASCII-alnum characters and lowercase + var estimateRoute = NormalizeRouteName(estimate.Route); + var scheduleRoute = NormalizeRouteName(c.Route); + var scheduleTerminus = NormalizeRouteName(c.TerminusName); + + return scheduleRoute == estimateRoute || scheduleTerminus == estimateRoute; + }) + .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value) + .ToArray(); + + ScheduledArrival? closestCirculation = null; + + const int maxEarlyArrivalMinutes = 7; + + var bestMatch = possibleCirculations + .Select(c => new + { + Circulation = c, + TimeDiff = (c.CallingDateTime(nowLocal.Date)!.Value - estimatedArrivalTime).TotalMinutes + }) + .Where(x => x.TimeDiff <= maxEarlyArrivalMinutes) + .OrderBy(x => Math.Abs(x.TimeDiff)) + .FirstOrDefault(); + + if (bestMatch != null) + { + closestCirculation = bestMatch.Circulation; + } + + if (closestCirculation == null) + { + // No scheduled match: include realtime-only entry + _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes (tried matching {NormalizedRoute})", estimate.Line, estimate.Route, estimate.Minutes, NormalizeRouteName(estimate.Route)); + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = estimate.Line, + Route = estimate.Route, + Schedule = null, + RealTime = new RealTimeData + { + Minutes = estimate.Minutes, + Distance = estimate.Meters + } + }); + + continue; + } + + // Ensure each scheduled trip is only matched once to a realtime estimate + if (usedTripIds.Contains(closestCirculation.TripId)) + { + _logger.LogInformation("Skipping duplicate realtime match for TripId {TripId}", closestCirculation.TripId); + continue; + } + + var isRunning = closestCirculation.StartingDateTime(nowLocal.Date)!.Value <= now; + Position? currentPosition = null; + int? stopShapeIndex = null; + bool usePreviousShape = false; + + // Calculate bus position for realtime trips + if (!string.IsNullOrEmpty(closestCirculation.ShapeId)) + { + double distOnPrevTrip = estimate.Meters - closestCirculation.ShapeDistTraveled; + usePreviousShape = !isRunning && + !string.IsNullOrEmpty(closestCirculation.PreviousTripShapeId) && + distOnPrevTrip > 0; + + if (usePreviousShape) + { + var prevShape = await _shapeService.LoadShapeAsync(closestCirculation.PreviousTripShapeId); + if (prevShape != null && prevShape.Points.Count > 0) + { + var lastPoint = prevShape.Points[prevShape.Points.Count - 1]; + var result = _shapeService.GetBusPosition(prevShape, lastPoint, (int)distOnPrevTrip); + currentPosition = result.BusPosition; + stopShapeIndex = result.StopIndex; + } + } + else + { + var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId); + if (shape != null && stopLocation != null) + { + var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); + currentPosition = result.BusPosition; + stopShapeIndex = result.StopIndex; + } + } + } + + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = estimate.Line, + Route = estimate.Route == closestCirculation.TerminusName ? closestCirculation.Route : estimate.Route, + NextStreets = [.. closestCirculation.NextStreets], + Schedule = new ScheduleData + { + Running = isRunning, + Minutes = (int)(closestCirculation.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes, + TripId = closestCirculation.TripId, + ServiceId = closestCirculation.ServiceId, + ShapeId = closestCirculation.ShapeId, + }, + RealTime = new RealTimeData + { + Minutes = estimate.Minutes, + Distance = estimate.Meters + }, + CurrentPosition = currentPosition, + StopShapeIndex = stopShapeIndex, + IsPreviousTrip = usePreviousShape, + PreviousTripShapeId = usePreviousShape ? closestCirculation.PreviousTripShapeId : null + }); + + usedTripIds.Add(closestCirculation.TripId); + } + + // Add scheduled-only circulations between now and the last realtime arrival + if (scopeEnd > now) + { + var matchedTripIds = new HashSet<string>(usedTripIds); + + var scheduledWindow = timetable + .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd) + .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value); + + foreach (var sched in scheduledWindow) + { + if (matchedTripIds.Contains(sched.TripId)) + { + continue; // already represented via a matched realtime + } + + var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes; + if (minutes == 0) + { + continue; + } + + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = sched.Line, + Route = sched.Route, + Schedule = new ScheduleData + { + Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, + Minutes = minutes, + TripId = sched.TripId, + ServiceId = sched.ServiceId, + ShapeId = sched.ShapeId, + }, + RealTime = null + }); + } + } + + // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes) + var sorted = consolidatedCirculations + .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes) + .Select(LineFormatterService.Format) + .ToList(); + + return sorted; + } + + private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString) + { + var file = Path.Combine(_configuration.VitrasaScheduleBasePath, dateString, stopId + ".pb"); + if (!SysFile.Exists(file)) + { + _logger.LogWarning("Stop arrivals proto file not found: {File}", file); + return null; + } + + var contents = await SysFile.ReadAllBytesAsync(file); + var stopArrivals = StopArrivals.Parser.ParseFrom(contents); + return stopArrivals; + } + + private static string NormalizeRouteName(string route) + { + var normalized = route.Trim().ToLowerInvariant(); + // Remove diacritics/accents first, then filter to alphanumeric + normalized = RemoveDiacritics(normalized); + return new string(normalized.Where(char.IsLetterOrDigit).ToArray()); + } + + private static string RemoveDiacritics(string text) + { + var normalizedString = text.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs index 928c173..63f4a2e 100644 --- a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs @@ -40,7 +40,7 @@ public class ShapeTraversalService /// </summary> public async Task<Shape?> LoadShapeAsync(string shapeId) { - var file = Path.Combine(_configuration.ScheduleBasePath, "shapes", shapeId + ".pb"); + var file = Path.Combine(_configuration.VitrasaScheduleBasePath, "shapes", shapeId + ".pb"); if (!SysFile.Exists(file)) { _logger.LogWarning("Shape file not found: {ShapeId}", shapeId); |
