From 817b29603e5b7f79bfe6489eebf73961e6ca93f2 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Thu, 6 Nov 2025 16:48:43 +0100 Subject: Move controllers, add config, read stop schedules from local directory --- Taskfile.yml | 5 + .../Configuration/AppConfiguration.cs | 6 + .../Controllers/SantiagoController.cs | 72 +++++++ .../Controllers/VigoController.cs | 185 ++++++++++++++++++ src/Costasdev.Busurbano.Backend/Program.cs | 4 + .../SantiagoController.cs | 72 ------- .../Types/VigoSchedules.cs | 43 ++--- src/Costasdev.Busurbano.Backend/VigoController.cs | 210 --------------------- src/gtfs_vigo_stops/pytest.ini | 4 - 9 files changed, 290 insertions(+), 311 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs create mode 100644 src/Costasdev.Busurbano.Backend/Controllers/SantiagoController.cs create mode 100644 src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs delete mode 100644 src/Costasdev.Busurbano.Backend/SantiagoController.cs delete mode 100644 src/Costasdev.Busurbano.Backend/VigoController.cs delete mode 100644 src/gtfs_vigo_stops/pytest.ini diff --git a/Taskfile.yml b/Taskfile.yml index 8a15306..84aab94 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -32,3 +32,8 @@ tasks: - npm run build --prefix src/frontend - mkdir dist/frontend - mv src/frontend/build/client/ dist/frontend/ + + gen-stop-report: + desc: Generate stop-based JSON reports for specified dates or date ranges. + cmds: + - uv --directory ./src/gtfs_vigo_stops run ./stop_report.py --output-dir ./output --feed-url https://datos.vigo.org/data/transporte/gtfs_vigo.zip diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs new file mode 100644 index 0000000..97296e5 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs @@ -0,0 +1,6 @@ +namespace Costasdev.Busurbano.Backend.Configuration; + +public class AppConfiguration +{ + public required string ScheduleBasePath { get; set; } +} diff --git a/src/Costasdev.Busurbano.Backend/Controllers/SantiagoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/SantiagoController.cs new file mode 100644 index 0000000..24ecab9 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Controllers/SantiagoController.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Costasdev.VigoTransitApi.Types; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Costasdev.Busurbano.Backend.Controllers; + +[ApiController] +[Route("api/santiago")] +public class SantiagoController : ControllerBase +{ + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public SantiagoController(HttpClient http, IMemoryCache cache) + { + _cache = cache; + _httpClient = http; + } + + [HttpGet("GetStopEstimates")] + public async Task Run() + { + var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); + if (!argumentAvailable) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); + } + + var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); + if (!argumentNumber) + { + return BadRequest("The provided stop id is not a valid number."); + } + + try + { + var obj = await _httpClient.GetFromJsonAsync( + $"https://app.tussa.org/tussa/api/paradas/{requestedStopId}"); + + if (obj is null) + { + return BadRequest("No response returned from the API, or whatever"); + } + + var root = obj.RootElement; + + List estimates = root + .GetProperty("lineas") + .EnumerateArray() + .Select(el => new StopEstimate( + el.GetProperty("sinoptico").GetString() ?? string.Empty, + el.GetProperty("nombre").GetString() ?? string.Empty, + el.GetProperty("minutosProximoPaso").GetInt32(), + 0 + )).ToList(); + + // Return only the estimates array, not the stop metadata + return new OkObjectResult(estimates); + } + catch (InvalidOperationException) + { + return BadRequest("Stop not found"); + } + } + + [HttpGet("GetStopTimetable")] + public async Task GetStopTimetable() + { + throw new NotImplementedException(); + } +} diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs new file mode 100644 index 0000000..e82baed --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Types; +using Costasdev.VigoTransitApi; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using SysFile = System.IO.File; + +namespace Costasdev.Busurbano.Backend.Controllers; + +[ApiController] +[Route("api/vigo")] +public class VigoController : ControllerBase +{ + private readonly ILogger _logger; + private readonly VigoTransitApiClient _api; + private readonly AppConfiguration _configuration; + + public VigoController(HttpClient http, IOptions options, ILogger logger) + { + _logger = logger; + _api = new VigoTransitApiClient(http); + _configuration = options.Value; + } + + [HttpGet("GetStopEstimates")] + public async Task Run( + [FromQuery] int id + ) + { + try + { + var response = await _api.GetStopEstimates(id); + // Return only the estimates array, not the stop metadata + return new OkObjectResult(response.Estimates); + } + catch (InvalidOperationException) + { + return BadRequest("Stop not found"); + } + } + + [HttpGet("GetStopTimetable")] + public async Task GetStopTimetable( + [FromQuery] int stopId, + [FromQuery] string date + ) + { + // Validate date format + if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, DateTimeStyles.None, out _)) + { + return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); + } + + try + { + var timetableData = await LoadTimetable(stopId.ToString(), date); + + return new OkObjectResult(timetableData); + } + catch (FileNotFoundException ex) + { + _logger.LogError(ex, "Stop data not found for stop {StopId} on date {Date}", stopId, date); + return StatusCode(404, $"Stop data not found for stop {stopId} on date {date}"); + } + catch(Exception ex) + { + _logger.LogError(ex, "Error loading stop data"); + return StatusCode(500, "Error loading timetable"); + } + } + + /*private StopEstimate[] LoadDebugEstimates() + { + var file = @"C:\Users\ariel\Desktop\GetStopEstimates.json"; + var contents = System.IO.File.ReadAllText(file); + return JsonSerializer.Deserialize(contents, JsonSerializerOptions.Web)!; + } + + private ScheduledStop[] LoadDebugTimetable() + { + var file = @"C:\Users\ariel\Desktop\GetStopTimetable.json"; + var contents = System.IO.File.ReadAllText(file); + return JsonSerializer.Deserialize(contents)!; + }*/ + + [HttpGet("GetStopArrivalsMerged")] + public async Task GetStopArrivalsMerged( + [FromQuery] int stopId + ) + { + StringBuilder outputBuffer = new(); + + var now = DateTime.Now.AddSeconds(60 - DateTime.Now.Second); + var realtimeTask = _api.GetStopEstimates(stopId); + var timetableTask = LoadTimetable(stopId.ToString(), now.ToString("yyyy-MM-dd")); + + await Task.WhenAll(realtimeTask, timetableTask); + + var realTimeEstimates = realtimeTask.Result.Estimates; + var timetable = timetableTask.Result; + + foreach (var estimate in realTimeEstimates) + { + outputBuffer.AppendLine($"Parsing estimate with line={estimate.Line}, route={estimate.Route} and minutes={estimate.Minutes} - Arrives at {now.AddMinutes(estimate.Minutes):HH:mm}"); + var fullArrivalTime = now.AddMinutes(estimate.Minutes); + + var possibleCirculations = timetable + .Where(c => c.Line.Trim() == estimate.Line.Trim() && c.Route.Trim() == estimate.Route.Trim()) + .OrderBy(c => c.CallingDateTime()) + .ToArray(); + + outputBuffer.AppendLine($"Found {possibleCirculations.Length} potential circulations"); + + ScheduledStop? closestCirculation = null; + int closestCirculationTime = int.MaxValue; + + foreach (var circulation in possibleCirculations) + { + var diffBetweenScheduleAndTrip = (int)Math.Round((fullArrivalTime - circulation.CallingDateTime()).TotalMinutes); + var diffBetweenNowAndSchedule = (int)(fullArrivalTime - now).TotalMinutes; + + var tolerance = Math.Max(2, diffBetweenNowAndSchedule * 0.15); // Positive amount of minutes + if (diffBetweenScheduleAndTrip <= -tolerance) + { + break; + } + + if (diffBetweenScheduleAndTrip < closestCirculationTime) + { + closestCirculation = circulation; + closestCirculationTime = diffBetweenScheduleAndTrip; + } + + } + + if (closestCirculation == null) + { + outputBuffer.AppendLine("**No circulation matched. List of all of them:**"); + foreach (var circulation in possibleCirculations) + { + // Circulation A 03LP000_008003_16 stopping at 05/11/2025 22:06:00 (diff: -03:29:59.2644092) + outputBuffer.AppendLine($"Circulation {circulation.TripId} stopping at {circulation.CallingDateTime()} (diff: {fullArrivalTime - circulation.CallingDateTime():HH:mm})"); + } + outputBuffer.AppendLine(); + + continue; + } + + if (closestCirculationTime > 0) + { + outputBuffer.Append($"Closest circulation is {closestCirculation.TripId} and arriving {closestCirculationTime} minutes LATE"); + } + else if (closestCirculationTime == 0) + { + outputBuffer.Append($"Closest circulation is {closestCirculation.TripId} and arriving ON TIME"); + } + else + { + outputBuffer.Append($"Closest circulation is {closestCirculation.TripId} and arriving {Math.Abs(closestCirculationTime)} minutes EARLY"); + } + + outputBuffer.AppendLine( + $" -- Circulation expected at {closestCirculation.CallingDateTime():HH:mm)}"); + + outputBuffer.AppendLine(); + } + + return Ok(outputBuffer.ToString()); + } + + private async Task> LoadTimetable(string stopId, string dateString) + { + var file = Path.Combine(_configuration.ScheduleBasePath, dateString, stopId + ".json"); + if (!SysFile.Exists(file)) + { + throw new FileNotFoundException(); + } + var contents = await SysFile.ReadAllTextAsync(file); + return JsonSerializer.Deserialize>(contents)!; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 68f84fb..7de4039 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -1,5 +1,9 @@ +using Costasdev.Busurbano.Backend.Configuration; + var builder = WebApplication.CreateBuilder(args); +builder.Services.Configure(builder.Configuration.GetSection("App")); + builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); diff --git a/src/Costasdev.Busurbano.Backend/SantiagoController.cs b/src/Costasdev.Busurbano.Backend/SantiagoController.cs deleted file mode 100644 index c86c74b..0000000 --- a/src/Costasdev.Busurbano.Backend/SantiagoController.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Text.Json; -using Costasdev.VigoTransitApi.Types; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; - -namespace Costasdev.Busurbano.Backend; - -[ApiController] -[Route("api/santiago")] -public class SantiagoController : ControllerBase -{ - private readonly IMemoryCache _cache; - private readonly HttpClient _httpClient; - - public SantiagoController(HttpClient http, IMemoryCache cache) - { - _cache = cache; - _httpClient = http; - } - - [HttpGet("GetStopEstimates")] - public async Task Run() - { - var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); - if (!argumentAvailable) - { - return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); - } - - var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); - if (!argumentNumber) - { - return BadRequest("The provided stop id is not a valid number."); - } - - try - { - var obj = await _httpClient.GetFromJsonAsync( - $"https://app.tussa.org/tussa/api/paradas/{requestedStopId}"); - - if (obj is null) - { - return BadRequest("No response returned from the API, or whatever"); - } - - var root = obj.RootElement; - - List estimates = root - .GetProperty("lineas") - .EnumerateArray() - .Select(el => new StopEstimate( - el.GetProperty("sinoptico").GetString() ?? string.Empty, - el.GetProperty("nombre").GetString() ?? string.Empty, - el.GetProperty("minutosProximoPaso").GetInt32(), - 0 - )).ToList(); - - // Return only the estimates array, not the stop metadata - return new OkObjectResult(estimates); - } - catch (InvalidOperationException) - { - return BadRequest("Stop not found"); - } - } - - [HttpGet("GetStopTimetable")] - public async Task GetStopTimetable() - { - throw new NotImplementedException(); - } -} diff --git a/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs b/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs index d6012e5..f8e5634 100644 --- a/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs +++ b/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs @@ -4,35 +4,28 @@ namespace Costasdev.Busurbano.Backend.Types; public class ScheduledStop { - [JsonPropertyName("line")] public required Line Line { get; set; } - [JsonPropertyName("trip")] public required Trip Trip { get; set; } - [JsonPropertyName("route_id")] public required string RouteId { get; set; } - [JsonPropertyName("departure_time")] public required string DepartureTime { get; set; } - - public DateTime DepartureDateTime() - { - var dt = DateTime.Today + TimeOnly.Parse(DepartureTime).ToTimeSpan(); - return dt.AddSeconds(60 - dt.Second); - } - + [JsonPropertyName("trip_id")] public required string TripId { get; set; } + [JsonPropertyName("service_id")] public required string ServiceId { get; set; } + [JsonPropertyName("line")] public required string Line { get; set; } + [JsonPropertyName("route")] public required string Route { get; set; } [JsonPropertyName("stop_sequence")] public required int StopSequence { get; set; } [JsonPropertyName("shape_dist_traveled")] - public required float ShapeDistTraveled { get; set; } + public required double ShapeDistTraveled { get; set; } [JsonPropertyName("next_streets")] public required string[] NextStreets { get; set; } -} - -public class Line -{ - [JsonPropertyName("name")] public required string Name { get; set; } - [JsonPropertyName("colour")] public required string Colour { get; set; } -} + [JsonPropertyName("starting_code")] public required string StartingCode { get; set; } + [JsonPropertyName("starting_name")] public required string StartingName { get; set; } + [JsonPropertyName("starting_time")] public required string StartingTime { get; set; } + [JsonPropertyName("calling_time")] public required string CallingTime { get; set; } + public DateTime CallingDateTime() + { + var dt = DateTime.Today + TimeOnly.Parse(CallingTime).ToTimeSpan(); + return dt.AddSeconds(60 - dt.Second); + } -public class Trip -{ - [JsonPropertyName("id")] public required string Id { get; set; } - [JsonPropertyName("service_id")] public required string ServiceId { get; set; } - [JsonPropertyName("headsign")] public required string Headsign { get; set; } - [JsonPropertyName("direction_id")] public required int DirectionId { get; set; } + [JsonPropertyName("calling_ssm")] public required int CallingSsm { get; set; } + [JsonPropertyName("terminus_code")] public required string TerminusCode { get; set; } + [JsonPropertyName("terminus_name")] public required string TerminusName { get; set; } + [JsonPropertyName("terminus_time")] public required string TerminusTime { get; set; } } diff --git a/src/Costasdev.Busurbano.Backend/VigoController.cs b/src/Costasdev.Busurbano.Backend/VigoController.cs deleted file mode 100644 index 320d898..0000000 --- a/src/Costasdev.Busurbano.Backend/VigoController.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System.Globalization; -using System.Text; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; -using Costasdev.VigoTransitApi; -using System.Text.Json; -using Costasdev.Busurbano.Backend.Types; -using Costasdev.VigoTransitApi.Types; - -namespace Costasdev.Busurbano.Backend; - -[ApiController] -[Route("api/vigo")] -public class VigoController : ControllerBase -{ - private readonly VigoTransitApiClient _api; - private readonly IMemoryCache _cache; - private readonly HttpClient _httpClient; - - public VigoController(HttpClient http, IMemoryCache cache) - { - _api = new VigoTransitApiClient(http); - _cache = cache; - _httpClient = http; - } - - [HttpGet("GetStopEstimates")] - public async Task Run( - [FromQuery] int id - ) - { - try - { - var response = await _api.GetStopEstimates(id); - // Return only the estimates array, not the stop metadata - return new OkObjectResult(response.Estimates); - } - catch (InvalidOperationException) - { - return BadRequest("Stop not found"); - } - } - - [HttpGet("GetStopTimetable")] - public async Task GetStopTimetable( - [FromQuery] int stopId, - [FromQuery] string date - ) - { - // Validate date format - if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, DateTimeStyles.None, out _)) - { - return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); - } - - // Create cache key - var cacheKey = $"timetable_{date}_{stopId}"; - - // Try to get from cache first - if (_cache.TryGetValue(cacheKey, out var cachedData)) - { - Response.Headers.Append("App-CacheUsage", "HIT"); - return new OkObjectResult(cachedData); - } - - try - { - var timetableData = await LoadTimetable(stopId.ToString(), date); - - // Cache the data for 12 hours - var cacheOptions = new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12), - SlidingExpiration = TimeSpan.FromHours(6), // Refresh cache if accessed within 6 hours of expiry - Priority = CacheItemPriority.Normal - }; - - _cache.Set(cacheKey, timetableData, cacheOptions); - - Response.Headers.Append("App-CacheUsage", "MISS"); - return new OkObjectResult(timetableData); - } - catch (HttpRequestException ex) - { - return StatusCode((int?)ex.StatusCode ?? 500, $"Error fetching timetable data: {ex.Message}"); - } - catch (JsonException ex) - { - return StatusCode(500, $"Error parsing timetable data: {ex.Message}"); - } - catch (Exception ex) - { - return StatusCode(500, $"Unexpected error: {ex.Message}"); - } - } - - /*private StopEstimate[] LoadDebugEstimates() - { - var file = @"C:\Users\ariel\Desktop\GetStopEstimates.json"; - var contents = System.IO.File.ReadAllText(file); - return JsonSerializer.Deserialize(contents, JsonSerializerOptions.Web)!; - } - - private ScheduledStop[] LoadDebugTimetable() - { - var file = @"C:\Users\ariel\Desktop\GetStopTimetable.json"; - var contents = System.IO.File.ReadAllText(file); - return JsonSerializer.Deserialize(contents)!; - }*/ - - [HttpGet("GetStopArrivalsMerged")] - public async Task GetStopArrivalsMerged( - [FromQuery] int stopId - ) - { - StringBuilder outputBuffer = new(); - - var now = DateTime.Now.AddSeconds(60 - DateTime.Now.Second); - var realtimeTask = _api.GetStopEstimates(stopId); - var timetableTask = LoadTimetable(stopId.ToString(), now.ToString("yyyy-MM-dd")); - - Task.WaitAll(realtimeTask, timetableTask); - - var realTimeEstimates = realtimeTask.Result.Estimates; - var timetable = timetableTask.Result; - - /*var now = DateTime.Today.AddHours(17).AddMinutes(59); - var realTimeEstimates = LoadDebugEstimates(); - var timetable = LoadDebugTimetable();*/ - - foreach (var estimate in realTimeEstimates) - { - outputBuffer.AppendLine($"Parsing estimate with line={estimate.Line}, route={estimate.Route} and minutes={estimate.Minutes} - Arrives at {now.AddMinutes(estimate.Minutes):HH:mm}"); - var fullArrivalTime = now.AddMinutes(estimate.Minutes); - - var possibleCirculations = timetable - .Where(c => c.Line.Name.Trim() == estimate.Line.Trim() && c.Trip.Headsign.Trim() == estimate.Route.Trim()) - .OrderBy(c => c.DepartureDateTime()) - .ToArray(); - - outputBuffer.AppendLine($"Found {possibleCirculations.Length} potential circulations"); - - ScheduledStop? closestCirculation = null; - int closestCirculationTime = int.MaxValue; - - foreach (var circulation in possibleCirculations) - { - var diffBetweenScheduleAndTrip = (int)Math.Round((fullArrivalTime - circulation.DepartureDateTime()).TotalMinutes); - var diffBetweenNowAndSchedule = (int)(fullArrivalTime - now).TotalMinutes; - - var tolerance = Math.Max(2, diffBetweenNowAndSchedule * 0.15); // Positive amount of minutes - if (diffBetweenScheduleAndTrip <= -tolerance) - { - break; - } - - if (diffBetweenScheduleAndTrip < closestCirculationTime) - { - closestCirculation = circulation; - closestCirculationTime = diffBetweenScheduleAndTrip; - } - - } - - if (closestCirculation == null) - { - outputBuffer.AppendLine("**No circulation matched. List of all of them:**"); - foreach (var circulation in possibleCirculations) - { - // Circulation A 03LP000_008003_16 stopping at 05/11/2025 22:06:00 (diff: -03:29:59.2644092) - outputBuffer.AppendLine($"Circulation {circulation.Trip.Id} stopping at {circulation.DepartureDateTime()} (diff: {fullArrivalTime - circulation.DepartureDateTime():HH:mm})"); - } - outputBuffer.AppendLine(); - - continue; - } - - if (closestCirculationTime > 0) - { - outputBuffer.Append($"Closest circulation is {closestCirculation.Trip.Id} and arriving {closestCirculationTime} minutes LATE"); - } - else if (closestCirculationTime == 0) - { - outputBuffer.Append($"Closest circulation is {closestCirculation.Trip.Id} and arriving ON TIME"); - } - else - { - outputBuffer.Append($"Closest circulation is {closestCirculation.Trip.Id} and arriving {Math.Abs(closestCirculationTime)} minutes EARLY"); - } - - outputBuffer.AppendLine( - $" -- Circulation expected at {closestCirculation.DepartureDateTime():HH:mm)}"); - - outputBuffer.AppendLine(); - } - - return Ok(outputBuffer.ToString()); - } - - private async Task> LoadTimetable(string stopId, string dateString) - { - var url = $"https://www.costas.dev/static-storage/vitrasa_svc/stops/{dateString}/{stopId}.json"; - var response = await _httpClient.GetAsync(url); - - response.EnsureSuccessStatusCode(); - - var jsonContent = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize>(jsonContent) ?? []; - } -} diff --git a/src/gtfs_vigo_stops/pytest.ini b/src/gtfs_vigo_stops/pytest.ini deleted file mode 100644 index e455bb4..0000000 --- a/src/gtfs_vigo_stops/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -minversion = 6.0 -testpaths = tests -python_files = test_*.py -- cgit v1.3