aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend
diff options
context:
space:
mode:
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
-rw-r--r--src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs3
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs2
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs369
-rw-r--r--src/Costasdev.Busurbano.Backend/Extensions/StopScheduleExtensions.cs58
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs4
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs8
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs76
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs304
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs2
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);