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/ArrivalsPipeline.cs | 61 ---- .../Services/FareService.cs | 225 ------------- .../Services/FeedService.cs | 213 ------------ .../Services/IGeocodingService.cs | 9 - .../Services/LineFormatterService.cs | 53 --- .../Services/NominatimGeocodingService.cs | 101 ------ .../Services/OtpService.cs | 357 --------------------- .../Services/Processors/AbstractProcessor.cs | 56 ---- .../Services/Processors/CorunaRealTimeProcessor.cs | 184 ----------- .../Services/Processors/FeedConfigProcessor.cs | 84 ----- .../Services/Processors/FilterAndSortProcessor.cs | 44 --- .../Services/Processors/MarqueeProcessor.cs | 26 -- .../Services/Processors/NextStopsProcessor.cs | 34 -- .../Services/Processors/ShapeProcessor.cs | 132 -------- .../Processors/VitrasaRealTimeProcessor.cs | 254 --------------- .../Services/Providers/ITransitProvider.cs | 8 - .../Services/Providers/RenfeTransitProvider.cs | 78 ----- .../Services/Providers/VitrasaTransitProvider.cs | 311 ------------------ .../Services/Providers/XuntaFareProvider.cs | 57 ---- .../Services/ShapeTraversalService.cs | 284 ---------------- 20 files changed, 2571 deletions(-) delete mode 100644 src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/FareService.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/FeedService.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/OtpService.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs (limited to 'src/Costasdev.Busurbano.Backend/Services') diff --git a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs deleted file mode 100644 index 3c9368c..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Costasdev.Busurbano.Backend.Types; -using Costasdev.Busurbano.Backend.Types.Arrivals; - -namespace Costasdev.Busurbano.Backend.Services; - -public class ArrivalsContext -{ - /// - /// The full GTFS ID of the stop (e.g., "vitrasa:1400") - /// - public required string StopId { get; set; } - - /// - /// The public code of the stop (e.g., "1400") - /// - public required string StopCode { get; set; } - - /// - /// Whether to return a reduced number of arrivals (e.g., 4 instead of 10) - /// - public bool IsReduced { get; set; } - - public Position? StopLocation { get; set; } - - public required List Arrivals { get; set; } - public required DateTime NowLocal { get; set; } -} - -public interface IArrivalsProcessor -{ - /// - /// Processes the arrivals in the context. Processors are executed in the order they are registered. - /// - Task ProcessAsync(ArrivalsContext context); -} - -/// -/// Orchestrates the enrichment of arrival data through a series of processors. -/// This follows a pipeline pattern where each step (processor) adds or modifies data -/// in the shared ArrivalsContext. -/// -public class ArrivalsPipeline -{ - private readonly IEnumerable _processors; - - public ArrivalsPipeline(IEnumerable processors) - { - _processors = processors; - } - - /// - /// Executes all registered processors sequentially. - /// - public async Task ExecuteAsync(ArrivalsContext context) - { - foreach (var processor in _processors) - { - await processor.ProcessAsync(context); - } - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs deleted file mode 100644 index c08d1d5..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/FareService.cs +++ /dev/null @@ -1,225 +0,0 @@ -using Costasdev.Busurbano.Backend.Configuration; -using Costasdev.Busurbano.Backend.Services.Providers; -using Costasdev.Busurbano.Backend.Types.Planner; -using Microsoft.Extensions.Options; - -namespace Costasdev.Busurbano.Backend.Services; - -public record FareResult(decimal CashFareEuro, bool CashFareIsTotal, decimal CardFareEuro, bool CardFareIsTotal); - -public class FareService -{ - private readonly AppConfiguration _config; - private readonly XuntaFareProvider _xuntaFareProvider; - private readonly ILogger _logger; - - private const decimal VitrasaCashFare = 1.63M; - private const decimal VitrasaCardFare = 0.67M; - - private const decimal CorunaCashFare = 1.30M; - private const decimal CorunaCardFare = 0.45M; - - private const decimal SantiagoCashFare = 1.00M; - private const decimal SantiagoCardFare = 0.36M; - - public FareService( - IOptions config, - XuntaFareProvider xuntaFareProvider, - ILogger logger - ) - { - _config = config.Value; - _xuntaFareProvider = xuntaFareProvider; - _logger = logger; - } - - public FareResult CalculateFare(IEnumerable legs) - { - var transitLegs = legs - .Where(l => l.Mode != null && !l.Mode.Equals("WALK", StringComparison.CurrentCultureIgnoreCase)) - .ToList(); - - if (!transitLegs.Any()) - { - return new FareResult(0, true, 0, true); - } - - var cashResult = CalculateCashTotal(transitLegs); - var cardResult = CalculateCardTotal(transitLegs); - - return new FareResult( - cashResult.Item1, cashResult.Item2, - cardResult.Item1, cardResult.Item2 - ); - } - - private (decimal, bool) CalculateCashTotal(IEnumerable legs) - { - decimal total = 0L; - bool allLegsProcessed = true; - - foreach (var leg in legs) - { - switch (leg.FeedId) - { - case "santiago": - total += SantiagoCashFare; - break; - case "coruna": - total += CorunaCashFare; - break; - case "vitrasa": - total += VitrasaCashFare; - break; - case "xunta": - // TODO: Handle potentiall blow-ups - if (leg.From is not { ZoneId: not null }) - { - _logger.LogInformation("Ignored fare calculation for leg without From ZoneId. {FromStop}", leg.From?.StopId); - } - - if (leg.To is not { ZoneId: not null }) - { - _logger.LogInformation("Ignored fare calculation for leg without To ZoneId. {ToStop}", leg.To?.StopId); - } - - total += _xuntaFareProvider.GetPrice(leg.From!.ZoneId!, leg.To!.ZoneId!)!.PriceCash; - break; - default: - allLegsProcessed = false; - _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); - break; - } - } - - return (total, allLegsProcessed); - } - - private (decimal, bool) CalculateCardTotal(IEnumerable legs) - { - List wallet = []; - decimal totalCost = 0; - - bool allLegsProcessed = true; - - foreach (var leg in legs) - { - int maxMinutes; - int maxUsages; - string? metroArea = null; - decimal initialFare = 0; - - switch (leg.FeedId) - { - case "vitrasa": - maxMinutes = 45; - maxUsages = 3; - initialFare = VitrasaCardFare; - break; - case "coruna": - maxMinutes = 45; - maxUsages = 2; - initialFare = CorunaCardFare; - break; - case "santiago": - maxMinutes = 60; - maxUsages = 2; - initialFare = SantiagoCardFare; - break; - case "xunta": - if (leg.From?.ZoneId == null || leg.To?.ZoneId == null) - { - _logger.LogWarning("Missing ZoneId for Xunta leg. From: {From}, To: {To}", leg.From?.StopId, leg.To?.StopId); - continue; - } - - var priceRecord = _xuntaFareProvider.GetPrice(leg.From.ZoneId, leg.To.ZoneId); - if (priceRecord == null) - { - _logger.LogWarning("No price record found for Xunta leg from {From} to {To}", leg.From.ZoneId, leg.To.ZoneId); - continue; - } - - metroArea = priceRecord.MetroArea; - initialFare = priceRecord.PriceCard; - maxMinutes = 60; - maxUsages = (metroArea != null && metroArea.StartsWith("ATM", StringComparison.OrdinalIgnoreCase)) ? 3 : 1; - break; - default: - _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); - allLegsProcessed = false; - continue; - } - - var validTicket = wallet.FirstOrDefault(t => t.FeedId == leg.FeedId && t.IsValid(leg.StartTime, maxMinutes, maxUsages)); - - if (validTicket != null) - { - if (leg.FeedId == "xunta" && maxUsages > 1) // ATM upgrade logic - { - var upgradeRecord = _xuntaFareProvider.GetPrice(validTicket.StartZone, leg.To!.ZoneId!); - if (upgradeRecord != null) - { - decimal upgradeCost = Math.Max(0, upgradeRecord.PriceCard - validTicket.TotalPaid); - totalCost += upgradeCost; - validTicket.TotalPaid += upgradeCost; - validTicket.UsedTimes++; - _logger.LogDebug("Xunta ATM upgrade: added {Cost}€, total paid for ticket: {TotalPaid}€", upgradeCost, validTicket.TotalPaid); - } - else - { - // Fallback: treat as new ticket if upgrade path not found - totalCost += initialFare; - wallet.Add(new TicketPurchased - { - FeedId = leg.FeedId, - PurchasedAt = leg.StartTime, - MetroArea = metroArea, - StartZone = leg.From!.ZoneId!, - TotalPaid = initialFare - }); - } - } - else - { - // Free transfer for city systems or non-ATM Xunta (though non-ATM Xunta has maxUsages=1) - validTicket.UsedTimes++; - _logger.LogDebug("Free transfer for {FeedId}", leg.FeedId); - } - } - else - { - // New ticket - totalCost += initialFare; - wallet.Add(new TicketPurchased - { - FeedId = leg.FeedId!, - PurchasedAt = leg.StartTime, - MetroArea = metroArea, - StartZone = leg.FeedId == "xunta" ? leg.From!.ZoneId! : string.Empty, - TotalPaid = initialFare - }); - _logger.LogDebug("New ticket for {FeedId}: {Cost}€", leg.FeedId, initialFare); - } - } - - return (totalCost, allLegsProcessed); - } -} - -public class TicketPurchased -{ - public required string FeedId { get; set; } - - public DateTime PurchasedAt { get; set; } - public string? MetroArea { get; set; } - public required string StartZone { get; set; } - - public int UsedTimes = 1; - public decimal TotalPaid { get; set; } - - public bool IsValid(DateTime startTime, int maxMinutes, int maxUsagesIncluded) - { - return (startTime - PurchasedAt).TotalMinutes <= maxMinutes && UsedTimes < maxUsagesIncluded; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs deleted file mode 100644 index 3ef079c..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Text.RegularExpressions; -using Costasdev.Busurbano.Backend.Types.Arrivals; - -namespace Costasdev.Busurbano.Backend.Services; - -public class FeedService -{ - private static readonly Regex RemoveQuotationMarks = new(@"[""”]", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex StreetNameRegex = new(@"^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly Dictionary NameReplacements = new(StringComparer.OrdinalIgnoreCase) - { - { "Rúa da Salguera Entrada", "Rúa da Salgueira" }, - { "Rúa da Salgueira Entrada", "Rúa da Salgueira" }, - { "Estrada de Miraflores", "Estrada Miraflores" }, - { "Avda. de Europa", "Avda. Europa" }, - { "Avda. de Galicia", "Avda. Galicia" }, - { "Avda. de Vigo", "Avda. Vigo" }, - { "FORA DE SERVIZO.G.B.", "" }, - { "Praza de Fernando O Católico", "" }, - { "Rúa da Travesía de Vigo", "Travesía de Vigo" }, - { "Rúa de ", " " }, - { "Rúa do ", " " }, - { "Rúa da ", " " }, - { "Rúa das ", " " }, - { "Avda. de ", " " }, - { "Avda. do ", " " }, - { "Avda. da ", " " }, - { "Avda. das ", " " }, - { "Riós", "Ríos" }, - { "Avda. Beiramar Porto Pesqueiro Berbés", "Berbés" }, - { "Conde de Torrecedeira", "Torrecedeira" }, - - }; - - public (string Color, string TextColor) GetFallbackColourForFeed(string feed) - { - return feed switch - { - "vitrasa" => ("#81D002", "#000000"), - "santiago" => ("#508096", "#FFFFFF"), - "coruna" => ("#E61C29", "#FFFFFF"), - "xunta" => ("#007BC4", "#FFFFFF"), - "renfe" => ("#870164", "#FFFFFF"), - "feve" => ("#EE3D32", "#FFFFFF"), - _ => ("#000000", "#FFFFFF"), - }; - } - - public string NormalizeStopCode(string feedId, string code) - { - if (feedId == "vitrasa") - { - var digits = new string(code.Where(char.IsDigit).ToArray()); - if (int.TryParse(digits, out int numericCode)) - { - return numericCode.ToString(); - } - } - return code; - } - - public string NormalizeRouteShortName(string feedId, string shortName) - { - if (feedId == "xunta" && shortName.StartsWith("XG")) - { - if (shortName.Length >= 8) - { - // XG817014 -> 817.14 - var contract = shortName.Substring(2, 3); - var lineStr = shortName.Substring(5); - if (int.TryParse(lineStr, out int line)) - { - return $"{contract}.{line:D2}"; - } - } - else if (shortName.Length > 2) - { - // XG883 -> 883 - return shortName.Substring(2); - } - } - return shortName; - } - - public string GetUniqueRouteShortName(string feedId, string shortName) - { - if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8) - { - var contract = shortName.Substring(2, 3); - return $"XG{contract}"; - } - - return NormalizeRouteShortName(feedId, shortName); - } - - public string NormalizeStopName(string feedId, string name) - { - if (feedId == "vitrasa") - { - return name - .Trim() - .Replace("\"", "") - .Replace(" ", ", ") - .Trim(); - } - - return name; - } - - public string NormalizeRouteNameForMatching(string name) - { - var normalized = name.Trim().ToLowerInvariant(); - // Remove diacritics/accents - normalized = Regex.Replace(normalized.Normalize(System.Text.NormalizationForm.FormD), @"\p{Mn}", ""); - // Keep only alphanumeric - return Regex.Replace(normalized, @"[^a-z0-9]", ""); - } - - public string GetStreetName(string originalName) - { - var name = RemoveQuotationMarks.Replace(originalName, "").Trim(); - var match = StreetNameRegex.Match(name); - var streetName = match.Success ? match.Groups[1].Value : name; - - foreach (var replacement in NameReplacements) - { - if (streetName.Contains(replacement.Key, StringComparison.OrdinalIgnoreCase)) - { - streetName = streetName.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase); - return streetName.Trim(); - } - } - - return streetName.Trim(); - } - - public string? GenerateMarquee(string feedId, List nextStops) - { - if (nextStops.Count == 0) return null; - - if (feedId == "vitrasa" || feedId == "coruna") - { - var streets = nextStops - .Select(GetStreetName) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .Distinct() - .ToList(); - - return string.Join(" - ", streets); - } - - return feedId switch - { - "xunta" => string.Join(" > ", nextStops), - _ => string.Join(", ", nextStops.Take(4)) - }; - } - - public bool IsStopHidden(string stopId) - { - return HiddenStops.Contains(stopId); - } - - public ShiftBadge? GetShiftBadge(string feedId, string tripId) - { - if (feedId != "vitrasa") return null; - - // Example: C1 04LN 02_001004_4 - var parts = tripId.Split('_'); - if (parts.Length < 2) return null; - - var shiftGroup = parts[parts.Length - 2]; // 001004 - var tripNumber = parts[parts.Length - 1]; // 4 - - if (shiftGroup.Length != 6) return null; - - if (!int.TryParse(shiftGroup.Substring(0, 3), out var routeNum)) return null; - if (!int.TryParse(shiftGroup.Substring(3, 3), out var shiftNum)) return null; - - var routeName = routeNum switch - { - 1 => "C1", - 3 => "C3", - 30 => "N1", - 33 => "N4", - 8 => "A", - 101 => "H", - 201 => "U1", - 202 => "U2", - 150 => "REF", - 500 => "TUR", - _ => $"L{routeNum}" - }; - - return new ShiftBadge - { - ShiftName = $"{routeName}-{shiftNum}", - ShiftTrip = tripNumber - }; - } - - private static readonly string[] HiddenStops = - [ - "vitrasa:20223", // Castrelos (Pavillón - U1) - "vitrasa:20146", // García Barbón, 7 (A, 18A) - "vitrasa:20220", // COIA-SAMIL (15) - "vitrasa:20001", // Samil por Beiramar (15B) - "vitrasa:20002", // Samil por Torrecedeira (15C) - "vitrasa:20144", // Samil por Coia (C3d, C3i) - "vitrasa:20145" // Samil por Bouzs (C3d, C3i) - ]; -} diff --git a/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs deleted file mode 100644 index 3ac29d6..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Costasdev.Busurbano.Backend.Types.Planner; - -namespace Costasdev.Busurbano.Backend.Services; - -public interface IGeocodingService -{ - Task> GetAutocompleteAsync(string query); - Task GetReverseGeocodeAsync(double lat, double lon); -} diff --git a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs deleted file mode 100644 index db9f2a5..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Costasdev.Busurbano.Backend.Types; - -namespace Costasdev.Busurbano.Backend.Services; - -public class LineFormatterService -{ - public ConsolidatedCirculation Format(ConsolidatedCirculation circulation) - { - circulation.Route = circulation.Route.Replace("*", ""); - - if (circulation.Route == "FORA DE SERVIZO.G.B.") - { - circulation.Route = "García Barbón, 7 (fora de servizo)"; - return circulation; - } - - switch (circulation.Line) - { - case "A" when circulation.Route.StartsWith("\"1\""): - circulation.Line = "A1"; - circulation.Route = circulation.Route.Replace("\"1\"", ""); - return circulation; - case "6": - circulation.Route = circulation.Route - .Replace("\"", ""); - return circulation; - case "FUT": - { - if (circulation.Route == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") - { - circulation.Line = "MAR"; - circulation.Route = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; - } - - if (circulation.Route == "P. ESPAÑA-T.VIGO-S.BADÍA") - { - circulation.Line = "RIO"; - circulation.Route = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; - } - - if (circulation.Route == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") - { - circulation.Line = "GOL"; - circulation.Route = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; - } - - return circulation; - } - default: - return circulation; - } - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs deleted file mode 100644 index 01e57f1..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Globalization; -using Costasdev.Busurbano.Backend.Configuration; -using Costasdev.Busurbano.Backend.Types.Nominatim; -using Costasdev.Busurbano.Backend.Types.Planner; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -namespace Costasdev.Busurbano.Backend.Services; - -public class NominatimGeocodingService : IGeocodingService -{ - private readonly HttpClient _httpClient; - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - private readonly AppConfiguration _config; - - private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7"; - - public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger logger, IOptions config) - { - _httpClient = httpClient; - _cache = cache; - _logger = logger; - _config = config.Value; - - // Nominatim requires a User-Agent - if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) - { - _httpClient.DefaultRequestHeaders.Add("User-Agent", "Busurbano/1.0 (https://github.com/arielcostas/Busurbano)"); - } - } - - public async Task> GetAutocompleteAsync(string query) - { - if (string.IsNullOrWhiteSpace(query)) return new List(); - - var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; - if (_cache.TryGetValue(cacheKey, out List? cachedResults) && cachedResults != null) - { - return cachedResults; - } - - try - { - var url = $"{_config.NominatimBaseUrl}/search?q={Uri.EscapeDataString(query)}&format=jsonv2&viewbox={GaliciaBounds}&bounded=1&countrycodes=es&addressdetails=1"; - var response = await _httpClient.GetFromJsonAsync>(url); - - var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List(); - - _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30)); - return results; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching Nominatim autocomplete results from {Url}", _config.NominatimBaseUrl); - return new List(); - } - } - - public async Task GetReverseGeocodeAsync(double lat, double lon) - { - var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}"; - if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null) - { - return cachedResult; - } - - try - { - var url = $"{_config.NominatimBaseUrl}/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&format=jsonv2&addressdetails=1"; - var response = await _httpClient.GetFromJsonAsync(url); - - if (response == null) return null; - - var result = MapToPlannerSearchResult(response); - - _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching Nominatim reverse geocode results from {Url}", _config.NominatimBaseUrl); - return null; - } - } - - private PlannerSearchResult MapToPlannerSearchResult(NominatimSearchResult result) - { - var name = result.Address?.Road ?? result.DisplayName?.Split(',').FirstOrDefault(); - var label = result.DisplayName; - - return new PlannerSearchResult - { - Name = name, - Label = label, - Lat = double.TryParse(result.Lat, CultureInfo.InvariantCulture, out var lat) ? lat : 0, - Lon = double.TryParse(result.Lon, CultureInfo.InvariantCulture, out var lon) ? lon : 0, - Layer = result.Type - }; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs deleted file mode 100644 index fb7413c..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ /dev/null @@ -1,357 +0,0 @@ -using System.Globalization; -using Costasdev.Busurbano.Backend.Configuration; -using Costasdev.Busurbano.Backend.Helpers; -using Costasdev.Busurbano.Backend.Types.Otp; -using Costasdev.Busurbano.Backend.Types.Planner; -using Costasdev.Busurbano.Backend.Types.Transit; -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -namespace Costasdev.Busurbano.Backend.Services; - -public class OtpService -{ - private readonly HttpClient _httpClient; - private readonly AppConfiguration _config; - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - private readonly FareService _fareService; - private readonly LineFormatterService _lineFormatter; - private readonly FeedService _feedService; - - public OtpService(HttpClient httpClient, IOptions config, IMemoryCache cache, ILogger logger, FareService fareService, LineFormatterService lineFormatter, FeedService feedService) - { - _httpClient = httpClient; - _config = config.Value; - _cache = cache; - _logger = logger; - _fareService = fareService; - _lineFormatter = lineFormatter; - _feedService = feedService; - } - - public RouteDto MapRoute(RoutesListResponse.RouteItem route) - { - var feedId = route.GtfsId.Split(':')[0]; - return new RouteDto - { - Id = route.GtfsId, - ShortName = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty), - LongName = route.LongName, - Color = route.Color, - TextColor = route.TextColor, - SortOrder = route.SortOrder, - AgencyName = route.Agency?.Name, - TripCount = route.Patterns.Sum(p => p.TripsForDate.Count) - }; - } - - public RouteDetailsDto MapRouteDetails(RouteDetailsResponse.RouteItem route) - { - var feedId = route.GtfsId?.Split(':')[0] ?? "unknown"; - return new RouteDetailsDto - { - ShortName = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty), - LongName = route.LongName, - Color = route.Color, - TextColor = route.TextColor, - AgencyName = route.Agency?.Name, - Patterns = route.Patterns.Select(p => MapPattern(p, feedId)).ToList() - }; - } - - private PatternDto MapPattern(RouteDetailsResponse.PatternItem pattern, string feedId) - { - return new PatternDto - { - Id = pattern.Id, - Name = pattern.Name, - Headsign = pattern.Headsign, - DirectionId = pattern.DirectionId, - Code = pattern.Code, - SemanticHash = pattern.SemanticHash, - TripCount = pattern.TripsForDate.Count, - Geometry = DecodePolyline(pattern.PatternGeometry?.Points)?.Coordinates, - Stops = pattern.Stops.Select((s, i) => new PatternStopDto - { - Id = s.GtfsId, - Code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty), - Name = _feedService.NormalizeStopName(feedId, s.Name), - Lat = s.Lat, - Lon = s.Lon, - ScheduledDepartures = pattern.TripsForDate - .Select(t => t.Stoptimes.ElementAtOrDefault(i)?.ScheduledDeparture ?? -1) - .Where(d => d != -1) - .OrderBy(d => d) - .ToList() - }).ToList() - }; - } - - private Leg MapLeg(OtpLeg otpLeg) - { - return new Leg - { - Mode = otpLeg.Mode, - RouteName = otpLeg.Route, - RouteShortName = otpLeg.RouteShortName, - RouteLongName = otpLeg.RouteLongName, - Headsign = otpLeg.Headsign, - AgencyName = otpLeg.AgencyName, - RouteColor = otpLeg.RouteColor, - RouteTextColor = otpLeg.RouteTextColor, - From = MapPlace(otpLeg.From), - To = MapPlace(otpLeg.To), - StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.StartTime).UtcDateTime, - EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.EndTime).UtcDateTime, - DistanceMeters = otpLeg.Distance, - Geometry = DecodePolyline(otpLeg.LegGeometry?.Points), - Steps = otpLeg.Steps.Select(MapStep).ToList(), - IntermediateStops = otpLeg.IntermediateStops.Select(MapPlace).Where(p => p != null).Cast().ToList() - }; - } - - private PlannerPlace? MapPlace(OtpPlace? otpPlace) - { - if (otpPlace == null) return null; - var feedId = otpPlace.StopId?.Split(':')[0] ?? "unknown"; - return new PlannerPlace - { - Name = _feedService.NormalizeStopName(feedId, otpPlace.Name), - Lat = otpPlace.Lat, - Lon = otpPlace.Lon, - StopId = otpPlace.StopId, // Use string directly - StopCode = _feedService.NormalizeStopCode(feedId, otpPlace.StopCode ?? string.Empty) - }; - } - - private Step MapStep(OtpWalkStep otpStep) - { - return new Step - { - DistanceMeters = otpStep.Distance, - RelativeDirection = otpStep.RelativeDirection, - AbsoluteDirection = otpStep.AbsoluteDirection, - StreetName = otpStep.StreetName, - Lat = otpStep.Lat, - Lon = otpStep.Lon - }; - } - - private PlannerGeometry? DecodePolyline(string? encodedPoints) - { - if (string.IsNullOrEmpty(encodedPoints)) return null; - - var coordinates = Decode(encodedPoints); - return new PlannerGeometry - { - Coordinates = coordinates.Select(c => new List { c.Lon, c.Lat }).ToList() - }; - } - - // Polyline decoding algorithm - private 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; - } - - public RoutePlan MapPlanResponse(PlanConnectionResponse response) - { - var itineraries = response.PlanConnection.Edges - .Select(e => MapItinerary(e.Node)) - .ToList(); - - return new RoutePlan - { - Itineraries = itineraries - }; - } - - private Itinerary MapItinerary(PlanConnectionResponse.Node node) - { - var legs = node.Legs.Select(MapLeg).ToList(); - var fares = _fareService.CalculateFare(legs); - - return new Itinerary - { - DurationSeconds = node.DurationSeconds, - StartTime = DateTime.Parse(node.Start8601, null, DateTimeStyles.RoundtripKind), - EndTime = DateTime.Parse(node.End8601, null, DateTimeStyles.RoundtripKind), - WalkDistanceMeters = node.WalkDistance, - WalkTimeSeconds = node.WalkSeconds, - TransitTimeSeconds = node.DurationSeconds - node.WalkSeconds - node.WaitingSeconds, - WaitingTimeSeconds = node.WaitingSeconds, - Legs = legs, - CashFare = fares.CashFareEuro, - CashFareIsTotal = fares.CashFareIsTotal, - CardFare = fares.CardFareEuro, - CardFareIsTotal = fares.CardFareIsTotal - }; - } - - private Leg MapLeg(PlanConnectionResponse.Leg leg) - { - var feedId = leg.From.Stop?.GtfsId?.Split(':')[0] ?? "unknown"; - var shortName = _feedService.NormalizeRouteShortName(feedId, leg.Route?.ShortName ?? string.Empty); - var headsign = leg.Headsign; - - if (feedId == "vitrasa") - { - headsign = headsign?.Replace("*", ""); - if (headsign == "FORA DE SERVIZO.G.B.") - { - headsign = "García Barbón, 7 (fora de servizo)"; - } - - switch (shortName) - { - case "A" when headsign != null && headsign.StartsWith("\"1\""): - shortName = "A1"; - headsign = headsign.Replace("\"1\"", ""); - break; - case "6": - headsign = headsign?.Replace("\"", ""); - break; - case "FUT": - if (headsign == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") - { - shortName = "MAR"; - headsign = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; - } - else if (headsign == "P. ESPAÑA-T.VIGO-S.BADÍA") - { - shortName = "RIO"; - headsign = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; - } - else if (headsign == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") - { - shortName = "GOL"; - headsign = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; - } - break; - } - } - - var color = leg.Route?.Color; - var textColor = leg.Route?.TextColor; - - if (string.IsNullOrEmpty(color) || color == "FFFFFF") - { - var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); - color = fallbackColor.Replace("#", ""); - textColor = fallbackTextColor.Replace("#", ""); - } - else if (string.IsNullOrEmpty(textColor) || textColor == "000000") - { - textColor = ContrastHelper.GetBestTextColour(color).Replace("#", ""); - } - - return new Leg - { - Mode = leg.Mode, - FeedId = feedId, - RouteId = leg.Route?.GtfsId, - RouteName = leg.Route?.LongName, - RouteShortName = shortName, - RouteLongName = leg.Route?.LongName, - Headsign = headsign, - AgencyName = leg.Route?.Agency?.Name, - RouteColor = color, - RouteTextColor = textColor, - From = MapPlace(leg.From), - To = MapPlace(leg.To), - StartTime = DateTime.Parse(leg.Start.ScheduledTime8601, null, DateTimeStyles.RoundtripKind), - EndTime = DateTime.Parse(leg.End.ScheduledTime8601, null, DateTimeStyles.RoundtripKind), - DistanceMeters = leg.Distance, - Geometry = DecodePolyline(leg.LegGeometry?.Points), - Steps = leg.Steps.Select(MapStep).ToList(), - IntermediateStops = leg.StopCalls.Select(sc => MapPlace(sc.StopLocation)).ToList() - }; - } - - private PlannerPlace MapPlace(PlanConnectionResponse.LegPosition pos) - { - var feedId = pos.Stop?.GtfsId?.Split(':')[0] ?? "unknown"; - return new PlannerPlace - { - Name = _feedService.NormalizeStopName(feedId, pos.Name), - Lat = pos.Latitude, - Lon = pos.Longitude, - StopId = pos.Stop?.GtfsId, - StopCode = _feedService.NormalizeStopCode(feedId, pos.Stop?.Code ?? string.Empty), - ZoneId = pos.Stop?.ZoneId - }; - } - - private PlannerPlace MapPlace(PlanConnectionResponse.StopLocation stop) - { - var feedId = stop.GtfsId?.Split(':')[0] ?? "unknown"; - return new PlannerPlace - { - Name = _feedService.NormalizeStopName(feedId, stop.Name), - Lat = stop.Latitude, - Lon = stop.Longitude, - StopId = stop.GtfsId, - StopCode = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty) - }; - } - - private Step MapStep(PlanConnectionResponse.Step step) - { - return new Step - { - DistanceMeters = step.Distance, - RelativeDirection = step.RelativeDirection, - AbsoluteDirection = step.AbsoluteDirection, - StreetName = step.StreetName, - Lat = step.Latitude, - Lon = step.Longitude - }; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs deleted file mode 100644 index 343f511..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Costasdev.Busurbano.Backend.Services; - -public abstract class AbstractRealTimeProcessor : IArrivalsProcessor -{ - public abstract Task ProcessAsync(ArrivalsContext context); - - protected static List<(double Lat, double Lon)> Decode(string encodedPoints) - { - if (string.IsNullOrEmpty(encodedPoints)) - return new List<(double, double)>(); - - var poly = new List<(double, double)>(); - char[] polylineChars = encodedPoints.ToCharArray(); - int index = 0; - - int currentLat = 0; - int currentLng = 0; - int next5bits; - int sum; - int shifter; - - while (index < polylineChars.Length) - { - // calculate next latitude - sum = 0; - shifter = 0; - do - { - next5bits = (int)polylineChars[index++] - 63; - sum |= (next5bits & 31) << shifter; - shifter += 5; - } while (next5bits >= 32 && index < polylineChars.Length); - - if (index >= polylineChars.Length) - break; - - currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); - - // calculate next longitude - sum = 0; - shifter = 0; - do - { - next5bits = (int)polylineChars[index++] - 63; - sum |= (next5bits & 31) << shifter; - shifter += 5; - } while (next5bits >= 32 && index < polylineChars.Length); - - currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); - - poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0)); - } - - return poly; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs deleted file mode 100644 index 587917e..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Costasdev.Busurbano.Backend.Types; -using Costasdev.Busurbano.Backend.Types.Arrivals; -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; -using Costasdev.Busurbano.Sources.TranviasCoruna; -using Arrival = Costasdev.Busurbano.Backend.Types.Arrivals.Arrival; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class CorunaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly CorunaRealtimeEstimatesProvider _realtime; - private readonly FeedService _feedService; - private readonly ILogger _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("coruna:")) return; - - var normalizedCode = _feedService.NormalizeStopCode("coruna", context.StopCode); - if (!int.TryParse(normalizedCode, out var numericStopId)) return; - - try - { - // Load schedule - var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); - - Epsg25829? stopLocation = null; - if (context.StopLocation != null) - { - stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); - } - - var realtime = await _realtime.GetEstimatesForStop(numericStopId); - - var usedTripIds = new HashSet(); - 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/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs deleted file mode 100644 index fde3e0a..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Costasdev.Busurbano.Backend.Helpers; -using Costasdev.Busurbano.Backend.Types.Arrivals; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class FeedConfigProcessor : IArrivalsProcessor -{ - private readonly FeedService _feedService; - - public FeedConfigProcessor(FeedService feedService) - { - _feedService = feedService; - } - - public Task ProcessAsync(ArrivalsContext context) - { - var feedId = context.StopId.Split(':')[0]; - var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); - - foreach (var arrival in context.Arrivals) - { - arrival.Route.ShortName = _feedService.NormalizeRouteShortName(feedId, arrival.Route.ShortName); - arrival.Headsign.Destination = _feedService.NormalizeStopName(feedId, arrival.Headsign.Destination); - - // Apply Vitrasa-specific line formatting - if (feedId == "vitrasa") - { - FormatVitrasaLine(arrival); - arrival.Shift = _feedService.GetShiftBadge(feedId, arrival.TripId); - } - - if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF") - { - arrival.Route.Colour = fallbackColor; - arrival.Route.TextColour = fallbackTextColor; - } - else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000") - { - arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour); - } - } - - return Task.CompletedTask; - } - - private static void FormatVitrasaLine(Arrival arrival) - { - arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", ""); - - if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.") - { - arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)"; - return; - } - - switch (arrival.Route.ShortName) - { - case "A" when arrival.Headsign.Destination.StartsWith("\"1\""): - arrival.Route.ShortName = "A1"; - arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"1\"", ""); - break; - case "6": - arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", ""); - break; - case "FUT": - if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") - { - arrival.Route.ShortName = "MAR"; - arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; - } - else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA") - { - arrival.Route.ShortName = "RIO"; - arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; - } - else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") - { - arrival.Route.ShortName = "GOL"; - arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; - } - break; - } - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs deleted file mode 100644 index c209db5..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Costasdev.Busurbano.Backend.Types.Arrivals; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -/// -/// 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/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs deleted file mode 100644 index ec65493..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class MarqueeProcessor : IArrivalsProcessor -{ - private readonly FeedService _feedService; - - public MarqueeProcessor(FeedService feedService) - { - _feedService = feedService; - } - - public Task ProcessAsync(ArrivalsContext context) - { - var feedId = context.StopId.Split(':')[0]; - - foreach (var arrival in context.Arrivals) - { - if (string.IsNullOrEmpty(arrival.Headsign.Marquee)) - { - arrival.Headsign.Marquee = _feedService.GenerateMarquee(feedId, arrival.NextStops); - } - } - - return Task.CompletedTask; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs deleted file mode 100644 index a00a68a..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class NextStopsProcessor : IArrivalsProcessor -{ - private readonly FeedService _feedService; - - public NextStopsProcessor(FeedService feedService) - { - _feedService = feedService; - } - - public Task ProcessAsync(ArrivalsContext context) - { - var feedId = context.StopId.Split(':')[0]; - - foreach (var arrival in context.Arrivals) - { - if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue; - - // Filter stoptimes that are after the current stop's departure - var currentStopDeparture = otpArrival.ScheduledDepartureSeconds; - - arrival.NextStops = otpArrival.Trip.Stoptimes - .Where(s => s.ScheduledDeparture > currentStopDeparture) - .OrderBy(s => s.ScheduledDeparture) - .Select(s => _feedService.NormalizeStopName(feedId, s.Stop.Name)) - .ToList(); - } - - return Task.CompletedTask; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs deleted file mode 100644 index 40bc508..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class ShapeProcessor : IArrivalsProcessor -{ - private readonly ILogger _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/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs deleted file mode 100644 index f3a8d91..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ /dev/null @@ -1,254 +0,0 @@ -using Costasdev.Busurbano.Backend.Configuration; -using Costasdev.Busurbano.Backend.Types; -using Costasdev.Busurbano.Backend.Types.Arrivals; -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; -using Costasdev.VigoTransitApi; -using Microsoft.Extensions.Options; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly VigoTransitApiClient _api; - private readonly FeedService _feedService; - private readonly ILogger _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); - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs deleted file mode 100644 index f0440e4..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Costasdev.Busurbano.Backend.Types; - -namespace Costasdev.Busurbano.Backend.Services.Providers; - -public interface ITransitProvider -{ - Task> 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 deleted file mode 100644 index 1793ada..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs +++ /dev/null @@ -1,78 +0,0 @@ -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; - -[Obsolete] -public class RenfeTransitProvider : ITransitProvider -{ - private readonly AppConfiguration _configuration; - private readonly ILogger _logger; - - public RenfeTransitProvider(IOptions options, ILogger logger) - { - _configuration = options.Value; - _logger = logger; - } - - public async Task> 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(8 * 60); - - 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(); - - 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.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)], - ServiceId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)], - ShapeId = sched.ShapeId, - }, - RealTime = null, - NextStreets = [.. sched.NextStreets] - }); - } - - return consolidatedCirculations; - } - - private async Task 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 deleted file mode 100644 index a736652..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs +++ /dev/null @@ -1,311 +0,0 @@ -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; - -[Obsolete] -public class VitrasaTransitProvider : ITransitProvider -{ - private readonly VigoTransitApiClient _api; - private readonly AppConfiguration _configuration; - private readonly ShapeTraversalService _shapeService; - private readonly LineFormatterService _lineFormatter; - private readonly ILogger _logger; - - public VitrasaTransitProvider(HttpClient http, IOptions options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger logger) - { - _api = new VigoTransitApiClient(http); - _configuration = options.Value; - _shapeService = shapeService; - _lineFormatter = lineFormatter; - _logger = logger; - } - - public async Task> 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 - .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) - .ToList(); - - // 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 consolidatedCirculations = []; - var usedTripIds = new HashSet(); - - 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); - - // TODO: Replace ñapa with fuzzy matching or better logic - return scheduleRoute == estimateRoute || scheduleTerminus == estimateRoute || - scheduleRoute.Contains(estimateRoute) || estimateRoute.Contains(scheduleRoute); - }) - .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 && x.TimeDiff >= -75) - .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(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(_lineFormatter.Format) - .ToList(); - - return sorted; - } - - private async Task 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/Providers/XuntaFareProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs deleted file mode 100644 index 7536c69..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Frozen; -using System.Globalization; -using CsvHelper; -using CsvHelper.Configuration.Attributes; - -namespace Costasdev.Busurbano.Backend.Services.Providers; - -public class PriceRecord -{ - [Name("conc_inicio")] public string Origin { get; set; } - [Name("conc_fin")] public string Destination { get; set; } - [Name("bonificacion")] public string? MetroArea { get; set; } - [Name("efectivo")] public decimal PriceCash { get; set; } - [Name("tpg")] public decimal PriceCard { get; set; } -} - -public class XuntaFareProvider -{ - private readonly FrozenDictionary<(string, string), PriceRecord> _priceMatrix; - - public XuntaFareProvider(IWebHostEnvironment env) - { - var filePath = Path.Combine(env.ContentRootPath, "Data", "xunta_fares.csv"); - - using var reader = new StreamReader(filePath); - using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); - - // We do GroupBy first to prevent duplicates from throwing an exception - _priceMatrix = csv.GetRecords() - .GroupBy(record => (record.Origin, record.Destination)) - .ToFrozenDictionary( - group => group.Key, - group => group.First() - ); - } - - public PriceRecord? GetPrice(string origin, string destination) - { - var originMunicipality = origin[..5]; - var destinationMunicipality = destination[..5]; - - var valueOrDefault = _priceMatrix.GetValueOrDefault((originMunicipality, destinationMunicipality)); - - /* This happens in cases where traffic is forbidden (like inside municipalities with urban transit */ - if (valueOrDefault?.PriceCash == 0.0M) - { - valueOrDefault.PriceCash = 100; - } - - if (valueOrDefault?.PriceCard == 0.0M) - { - valueOrDefault.PriceCard = 100; - } - - return valueOrDefault; - } -} diff --git a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs deleted file mode 100644 index c3c66f4..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs +++ /dev/null @@ -1,284 +0,0 @@ -using Costasdev.Busurbano.Backend.Configuration; -using Costasdev.Busurbano.Backend.Types; -using Microsoft.Extensions.Options; -using ProjNet.CoordinateSystems; -using ProjNet.CoordinateSystems.Transformations; -using SysFile = System.IO.File; - -namespace Costasdev.Busurbano.Backend.Services; - -/// -/// Service for loading shapes and calculating remaining path from a given stop point -/// -public class ShapeTraversalService -{ - private readonly AppConfiguration _configuration; - private readonly ILogger _logger; - private readonly ICoordinateTransformation _transformation; - - public ShapeTraversalService(IOptions options, ILogger logger) - { - _configuration = options.Value; - _logger = logger; - - // Set up coordinate transformation from EPSG:25829 (meters) to EPSG:4326 (lat/lng) - var ctFactory = new CoordinateTransformationFactory(); - var csFactory = new CoordinateSystemFactory(); - - // EPSG:25829 - ETRS89 / UTM zone 29N - var source = csFactory.CreateFromWkt( - "PROJCS[\"ETRS89 / UTM zone 29N\",GEOGCS[\"ETRS89\",DATUM[\"European_Terrestrial_Reference_System_1989\",SPHEROID[\"GRS 1980\",6378137,298.257222101,AUTHORITY[\"EPSG\",\"7019\"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY[\"EPSG\",\"6258\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4258\"]],PROJECTION[\"Transverse_Mercator\"],PARAMETER[\"latitude_of_origin\",0],PARAMETER[\"central_meridian\",-9],PARAMETER[\"scale_factor\",0.9996],PARAMETER[\"false_easting\",500000],PARAMETER[\"false_northing\",0],UNIT[\"metre\",1,AUTHORITY[\"EPSG\",\"9001\"]],AXIS[\"Easting\",EAST],AXIS[\"Northing\",NORTH],AUTHORITY[\"EPSG\",\"25829\"]]"); - - // EPSG:4326 - WGS84 - var target = GeographicCoordinateSystem.WGS84; - - _transformation = ctFactory.CreateFromCoordinateSystems(source, target); - } - - /// - /// Loads a shape from disk - /// - public async Task LoadShapeAsync(string shapeId) - { - var file = Path.Combine(_configuration.VitrasaScheduleBasePath, "shapes", shapeId + ".pb"); - if (!SysFile.Exists(file)) - { - _logger.LogWarning("Shape file not found: {ShapeId}", shapeId); - return null; - } - - try - { - var contents = await SysFile.ReadAllBytesAsync(file); - var shape = Shape.Parser.ParseFrom(contents); - return shape; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error loading shape {ShapeId}", shapeId); - return null; - } - } - - public async Task?> GetShapePathAsync(string shapeId, int startIndex) - { - var shape = await LoadShapeAsync(shapeId); - if (shape == null) return null; - - var result = new List(); - // Ensure startIndex is within bounds - if (startIndex < 0) startIndex = 0; - // If startIndex is beyond the end, return empty list - if (startIndex >= shape.Points.Count) return result; - - for (int i = startIndex; i < shape.Points.Count; i++) - { - var pos = TransformToLatLng(shape.Points[i]); - pos.ShapeIndex = i; - result.Add(pos); - } - return result; - } - - public async Task FindClosestPointIndexAsync(string shapeId, double lat, double lon) - { - var shape = await LoadShapeAsync(shapeId); - if (shape == null) return null; - - // Transform input WGS84 to EPSG:25829 - // Input is [Longitude, Latitude] - var inverseTransform = _transformation.MathTransform.Inverse(); - var transformed = inverseTransform.Transform(new[] { lon, lat }); - - var location = new Epsg25829 { X = transformed[0], Y = transformed[1] }; - - return FindClosestPointIndex(shape.Points, location); - } - - public Shape CreateShapeFromWgs84(List points) - { - var shape = new Shape(); - var inverseTransform = _transformation.MathTransform.Inverse(); - - foreach (var point in points) - { - var transformed = inverseTransform.Transform(new[] { point.Longitude, point.Latitude }); - shape.Points.Add(new Epsg25829 { X = transformed[0], Y = transformed[1] }); - } - return shape; - } - - public Epsg25829 TransformToEpsg25829(double lat, double lon) - { - var inverseTransform = _transformation.MathTransform.Inverse(); - var transformed = inverseTransform.Transform(new[] { lon, lat }); - return new Epsg25829 { X = transformed[0], Y = transformed[1] }; - } - - /// - /// Calculates the bus position by reverse-traversing the shape from the stop location - /// - /// The shape points (in EPSG:25829 meters) - /// The stop location (in EPSG:25829 meters) - /// Distance in meters from the stop to traverse backwards - /// The lat/lng position of the bus and the stop index on the shape - public (Position? BusPosition, int StopIndex) GetBusPosition(Shape shape, Epsg25829 stopLocation, int distanceMeters) - { - if (shape.Points.Count == 0 || distanceMeters <= 0) - { - return (null, -1); - } - - // Find the closest point on the shape to the stop - int closestPointIndex = FindClosestPointIndex(shape.Points, stopLocation); - - // Calculate the total distance from the start of the shape to the stop - double totalDistanceToStop = CalculateTotalDistance(shape.Points.ToArray(), closestPointIndex); - - // If the reported distance exceeds the total distance to the stop, the bus is likely - // on a previous trip whose shape we don't have. Don't provide position information. - if (distanceMeters > totalDistanceToStop) - { - _logger.LogDebug("Distance {Distance}m exceeds total shape distance to stop {Total}m - bus likely on previous trip", distanceMeters, totalDistanceToStop); - return (null, closestPointIndex); - } - - // Traverse backwards from the closest point to find the position at the given distance - var (busPoint, forwardIndex) = TraverseBackwards(shape.Points.ToArray(), closestPointIndex, distanceMeters); - - if (busPoint == null) - { - return (null, closestPointIndex); - } - - var forwardPoint = shape.Points[forwardIndex]; - - // Compute orientation in EPSG:25829 (meters): 0°=North, 90°=East (azimuth) - var dx = forwardPoint.X - busPoint.X; // Easting difference - var dy = forwardPoint.Y - busPoint.Y; // Northing difference - var bearing = Math.Atan2(dx, dy) * 180.0 / Math.PI; // swap for 0° north - if (bearing < 0) bearing += 360.0; - - // Transform from EPSG:25829 (meters) to EPSG:4326 (lat/lng) - var pos = TransformToLatLng(busPoint); - pos.OrientationDegrees = (int)Math.Round(bearing); - pos.ShapeIndex = forwardIndex; - return (pos, closestPointIndex); - } - - /// - /// Traverses backwards along the shape from a starting point by the specified distance - /// - private (Epsg25829 point, int forwardIndex) TraverseBackwards(Epsg25829[] shapePoints, int startIndex, double distanceMeters) - { - if (startIndex <= 0) - { - // Already at the beginning, return the first point - var forwardIdx = Math.Min(1, shapePoints.Length - 1); - return (shapePoints[0], forwardIdx); - } - - double remainingDistance = distanceMeters; - int currentIndex = startIndex; - - while (currentIndex > 0 && remainingDistance > 0) - { - var segmentDistance = CalculateDistance(shapePoints[currentIndex], shapePoints[currentIndex - 1]); - - if (segmentDistance >= remainingDistance) - { - // The bus position is somewhere along this segment - // Interpolate between the two points - var ratio = remainingDistance / segmentDistance; - var interpolated = InterpolatePoint(shapePoints[currentIndex], shapePoints[currentIndex - 1], ratio); - // Forward direction is towards the stop (increasing index direction) - return (interpolated, currentIndex); - } - - remainingDistance -= segmentDistance; - currentIndex--; - } - - // We've reached the beginning of the shape - var fwd = Math.Min(1, shapePoints.Length - 1); - return (shapePoints[0], fwd); - } - - /// - /// Interpolates a point between two points at a given ratio - /// - private Epsg25829 InterpolatePoint(Epsg25829 from, Epsg25829 to, double ratio) - { - return new Epsg25829 - { - X = from.X + (to.X - from.X) * ratio, - Y = from.Y + (to.Y - from.Y) * ratio - }; - } - - /// - /// Finds the index of the closest point in the shape to the given location - /// - private int FindClosestPointIndex(IEnumerable shapePoints, Epsg25829 location) - { - var pointsArray = shapePoints.ToArray(); - var minDistance = double.MaxValue; - var closestIndex = 0; - - for (int i = 0; i < pointsArray.Length; i++) - { - var distance = CalculateDistance(pointsArray[i], location); - if (distance < minDistance) - { - minDistance = distance; - closestIndex = i; - } - } - - return closestIndex; - } - - /// - /// Calculates Euclidean distance between two points in meters - /// - private double CalculateDistance(Epsg25829 p1, Epsg25829 p2) - { - var dx = p1.X - p2.X; - var dy = p1.Y - p2.Y; - return Math.Sqrt(dx * dx + dy * dy); - } - - /// - /// Calculates the total distance along the shape from the start to a given index - /// - private double CalculateTotalDistance(Epsg25829[] shapePoints, int endIndex) - { - if (endIndex <= 0 || shapePoints.Length == 0) - { - return 0; - } - - double totalDistance = 0; - for (int i = 1; i <= endIndex && i < shapePoints.Length; i++) - { - totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); - } - - return totalDistance; - } - - /// - /// Transforms a point from EPSG:25829 (meters) to EPSG:4326 (lat/lng) - /// - private Position TransformToLatLng(Epsg25829 point) - { - var transformed = _transformation.MathTransform.Transform(new[] { point.X, point.Y }); - return new Position - { - // Round to 6 decimals (~0.1m precision) - Longitude = Math.Round(transformed[0], 6), - Latitude = Math.Round(transformed[1], 6) - }; - } - -} -- cgit v1.3