diff options
Diffstat (limited to 'src/Enmarcha.Backend/Services')
21 files changed, 2552 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs new file mode 100644 index 0000000..57a46e1 --- /dev/null +++ b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs @@ -0,0 +1,61 @@ +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services; + +public class ArrivalsContext +{ + /// <summary> + /// The full GTFS ID of the stop (e.g., "vitrasa:1400") + /// </summary> + public required string StopId { get; set; } + + /// <summary> + /// The public code of the stop (e.g., "1400") + /// </summary> + public required string StopCode { get; set; } + + /// <summary> + /// Whether to return a reduced number of arrivals (e.g., 4 instead of 10) + /// </summary> + public bool IsReduced { get; set; } + + public Position? StopLocation { get; set; } + + public required List<Arrival> Arrivals { get; set; } + public required DateTime NowLocal { get; set; } +} + +public interface IArrivalsProcessor +{ + /// <summary> + /// Processes the arrivals in the context. Processors are executed in the order they are registered. + /// </summary> + Task ProcessAsync(ArrivalsContext context); +} + +/// <summary> +/// 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. +/// </summary> +public class ArrivalsPipeline +{ + private readonly IEnumerable<IArrivalsProcessor> _processors; + + public ArrivalsPipeline(IEnumerable<IArrivalsProcessor> processors) + { + _processors = processors; + } + + /// <summary> + /// Executes all registered processors sequentially. + /// </summary> + public async Task ExecuteAsync(ArrivalsContext context) + { + foreach (var processor in _processors) + { + await processor.ProcessAsync(context); + } + } +} diff --git a/src/Enmarcha.Backend/Services/FareService.cs b/src/Enmarcha.Backend/Services/FareService.cs new file mode 100644 index 0000000..bf85f03 --- /dev/null +++ b/src/Enmarcha.Backend/Services/FareService.cs @@ -0,0 +1,225 @@ +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Services.Providers; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Options; + +namespace Enmarcha.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<FareService> _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<AppConfiguration> config, + XuntaFareProvider xuntaFareProvider, + ILogger<FareService> logger + ) + { + _config = config.Value; + _xuntaFareProvider = xuntaFareProvider; + _logger = logger; + } + + public FareResult CalculateFare(IEnumerable<Leg> 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<Leg> legs) + { + decimal total = 0L; + bool allLegsProcessed = true; + + foreach (var leg in legs) + { + switch (leg.FeedId) + { + case "tussa": + total += SantiagoCashFare; + break; + case "tranvias": + 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<Leg> legs) + { + List<TicketPurchased> 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 "tranvias": + maxMinutes = 45; + maxUsages = 2; + initialFare = CorunaCardFare; + break; + case "tussa": + 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/Enmarcha.Backend/Services/FeedService.cs b/src/Enmarcha.Backend/Services/FeedService.cs new file mode 100644 index 0000000..8b0d3e7 --- /dev/null +++ b/src/Enmarcha.Backend/Services/FeedService.cs @@ -0,0 +1,213 @@ +using System.Text.RegularExpressions; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.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<string, string> 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"), + "tussa" => ("#508096", "#FFFFFF"), + "tranvias" => ("#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<string> nextStops) + { + if (nextStops.Count == 0) return null; + + if (feedId is "vitrasa" or "tranvias" or "tussa") + { + 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/Enmarcha.Backend/Services/IGeocodingService.cs b/src/Enmarcha.Backend/Services/IGeocodingService.cs new file mode 100644 index 0000000..5c1b19e --- /dev/null +++ b/src/Enmarcha.Backend/Services/IGeocodingService.cs @@ -0,0 +1,9 @@ +using Enmarcha.Backend.Types.Planner; + +namespace Enmarcha.Backend.Services; + +public interface IGeocodingService +{ + Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query); + Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon); +} diff --git a/src/Enmarcha.Backend/Services/LineFormatterService.cs b/src/Enmarcha.Backend/Services/LineFormatterService.cs new file mode 100644 index 0000000..d3b6109 --- /dev/null +++ b/src/Enmarcha.Backend/Services/LineFormatterService.cs @@ -0,0 +1,53 @@ +using Enmarcha.Backend.Types; + +namespace Enmarcha.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/Enmarcha.Backend/Services/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs new file mode 100644 index 0000000..8c4b8a5 --- /dev/null +++ b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types.Nominatim; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services; + +public class NominatimGeocodingService : IGeocodingService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger<NominatimGeocodingService> _logger; + private readonly AppConfiguration _config; + + private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7"; + + public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<NominatimGeocodingService> logger, IOptions<AppConfiguration> 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", "Enmarcha/0.1 testing only, will replace soon. Written 2025-12-28 (https://enmarcha.app)"); + } + } + + public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>(); + + var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? 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<List<NominatimSearchResult>>(url); + + var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List<PlannerSearchResult>(); + + _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<PlannerSearchResult>(); + } + } + + public async Task<PlannerSearchResult?> 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<NominatimSearchResult>(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/Enmarcha.Backend/Services/OtpService.cs b/src/Enmarcha.Backend/Services/OtpService.cs new file mode 100644 index 0000000..e4b4846 --- /dev/null +++ b/src/Enmarcha.Backend/Services/OtpService.cs @@ -0,0 +1,357 @@ +using System.Globalization; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Types.Otp; +using Enmarcha.Backend.Types.Planner; +using Enmarcha.Backend.Types.Transit; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services; + +public class OtpService +{ + private readonly HttpClient _httpClient; + private readonly AppConfiguration _config; + private readonly IMemoryCache _cache; + private readonly ILogger<OtpService> _logger; + private readonly FareService _fareService; + private readonly LineFormatterService _lineFormatter; + private readonly FeedService _feedService; + + public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> 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<PlannerPlace>().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<double> { 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/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs b/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs new file mode 100644 index 0000000..d6b420f --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs @@ -0,0 +1,56 @@ +using Enmarcha.Backend.Services; + +public abstract class AbstractRealTimeProcessor : IArrivalsProcessor +{ + public abstract Task ProcessAsync(ArrivalsContext context); + + protected static List<(double Lat, double Lon)> Decode(string encodedPoints) + { + if (string.IsNullOrEmpty(encodedPoints)) + return new List<(double, double)>(); + + var poly = new List<(double, double)>(); + char[] polylineChars = encodedPoints.ToCharArray(); + int index = 0; + + int currentLat = 0; + int currentLng = 0; + int next5bits; + int sum; + int shifter; + + while (index < polylineChars.Length) + { + // calculate next latitude + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + if (index >= polylineChars.Length) + break; + + currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + // calculate next longitude + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0)); + } + + return poly; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs new file mode 100644 index 0000000..ca3f91d --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -0,0 +1,181 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Sources.TranviasCoruna; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors; + +public class CorunaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CorunaRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger<CorunaRealTimeProcessor> _logger; + private readonly ShapeTraversalService _shapeService; + + public CorunaRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger<CorunaRealTimeProcessor> logger, + ShapeTraversalService shapeService) + { + _realtime = new CorunaRealtimeEstimatesProvider(http); + _feedService = feedService; + _logger = logger; + _shapeService = shapeService; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("tranvias:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("tranvias", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + + var usedTripIds = new HashSet<string>(); + var newArrivals = new List<Arrival>(); + + 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<object>(); + features.Add(new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + }); + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features = features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + arrival.StopShapeIndex = stopShapeIndex; + } + } + + usedTripIds.Add(arrival.TripId); + + } + + context.Arrivals.AddRange(newArrivals); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); + } + } + + private static bool IsRouteMatch(string a, string b) + { + return a == b || a.Contains(b) || b.Contains(a); + } + +} diff --git a/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs new file mode 100644 index 0000000..2d5f5d9 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs @@ -0,0 +1,84 @@ +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors; + +public class FeedConfigProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public FeedConfigProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + + foreach (var arrival in context.Arrivals) + { + arrival.Route.ShortName = _feedService.NormalizeRouteShortName(feedId, arrival.Route.ShortName); + arrival.Headsign.Destination = _feedService.NormalizeStopName(feedId, arrival.Headsign.Destination); + + // Apply Vitrasa-specific line formatting + if (feedId == "vitrasa") + { + FormatVitrasaLine(arrival); + arrival.Shift = _feedService.GetShiftBadge(feedId, arrival.TripId); + } + + if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF") + { + arrival.Route.Colour = fallbackColor; + arrival.Route.TextColour = fallbackTextColor; + } + else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000") + { + arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour); + } + } + + return Task.CompletedTask; + } + + private static void FormatVitrasaLine(Arrival arrival) + { + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", ""); + + if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.") + { + arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)"; + return; + } + + switch (arrival.Route.ShortName) + { + case "A" when arrival.Headsign.Destination.StartsWith("\"1\""): + arrival.Route.ShortName = "A1"; + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"1\"", ""); + break; + case "6": + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", ""); + break; + case "FUT": + if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") + { + arrival.Route.ShortName = "MAR"; + arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; + } + else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA") + { + arrival.Route.ShortName = "RIO"; + arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; + } + else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") + { + arrival.Route.ShortName = "GOL"; + arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; + } + break; + } + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs new file mode 100644 index 0000000..7df00fa --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs @@ -0,0 +1,44 @@ +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors; + +/// <summary> +/// 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). +/// </summary> +public class FilterAndSortProcessor : IArrivalsProcessor +{ + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + + // 1. Sort by minutes + var sorted = context.Arrivals + .OrderBy(a => a.Estimate.Minutes) + .ToList(); + + // 2. Filter based on feed rules + var filtered = sorted.Where(a => + { + if (feedId == "vitrasa") + { + // For Vitrasa, we hide past arrivals because we have real-time + // If a past arrival was matched to a real-time estimate, its Minutes will be >= 0 + return a.Estimate.Minutes >= 0; + } + + // For others, show up to 10 minutes ago + return a.Estimate.Minutes >= -10; + }).ToList(); + + // 3. Limit results + var limit = context.IsReduced ? 4 : 10; + var limited = filtered.Take(limit).ToList(); + + // Update the context list in-place + context.Arrivals.Clear(); + context.Arrivals.AddRange(limited); + + return Task.CompletedTask; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs new file mode 100644 index 0000000..9e620c2 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs @@ -0,0 +1,26 @@ +namespace Enmarcha.Backend.Services.Processors; + +public class MarqueeProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public MarqueeProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + + foreach (var arrival in context.Arrivals) + { + if (string.IsNullOrEmpty(arrival.Headsign.Marquee)) + { + arrival.Headsign.Marquee = _feedService.GenerateMarquee(feedId, arrival.NextStops); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs new file mode 100644 index 0000000..5d02066 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs @@ -0,0 +1,34 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; + +namespace Enmarcha.Backend.Services.Processors; + +public class NextStopsProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public NextStopsProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + + foreach (var arrival in context.Arrivals) + { + if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue; + + // Filter stoptimes that are after the current stop's departure + var currentStopDeparture = otpArrival.ScheduledDepartureSeconds; + + arrival.NextStops = otpArrival.Trip.Stoptimes + .Where(s => s.ScheduledDeparture > currentStopDeparture) + .OrderBy(s => s.ScheduledDeparture) + .Select(s => _feedService.NormalizeStopName(feedId, s.Stop.Name)) + .ToList(); + } + + return Task.CompletedTask; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs new file mode 100644 index 0000000..28b38a9 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs @@ -0,0 +1,88 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Sources.TranviasCoruna; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Sources.Tussa; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors; + +public class SantiagoRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly SantiagoRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger<SantiagoRealTimeProcessor> _logger; + + public SantiagoRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger<SantiagoRealTimeProcessor> logger) + { + _realtime = new SantiagoRealtimeEstimatesProvider(http); + _feedService = feedService; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("tussa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("tussa", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + + var usedTripIds = new HashSet<string>(); + var newArrivals = new List<Arrival>(); + + foreach (var estimate in realtime) + { + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.RouteId.Trim()) + .Select(a => + { + return new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true + }; + }) + .Where(x => x.RouteMatch) // Strict route matching + .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule) + .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit + .FirstOrDefault(); + + if (bestMatch == null) + { + continue; + } + + var arrival = bestMatch.Arrival; + + var scheduledMinutes = arrival.Estimate.Minutes; + arrival.Estimate.Minutes = estimate.Minutes; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimate.Minutes - scheduledMinutes; + if (delayMinutes != 0) + { + arrival.Delay = new DelayBadge { Minutes = delayMinutes }; + } + + usedTripIds.Add(arrival.TripId); + } + + context.Arrivals.AddRange(newArrivals); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); + } + } + +} diff --git a/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs new file mode 100644 index 0000000..f3af3a5 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs @@ -0,0 +1,132 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; + +namespace Enmarcha.Backend.Services.Processors; + +public class ShapeProcessor : IArrivalsProcessor +{ + private readonly ILogger<ShapeProcessor> _logger; + + public ShapeProcessor(ILogger<ShapeProcessor> 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<object>(); + + // Route LineString + features.Add(new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList() + }, + properties = new { type = "route" } + }); + + // Stops + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features = features + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error decoding shape for trip {TripId}", arrival.TripId); + } + } + + return Task.CompletedTask; + } + + private static List<(double Lat, double Lon)> Decode(string encodedPoints) + { + var poly = new List<(double, double)>(); + char[] polylineChars = encodedPoints.ToCharArray(); + int index = 0; + + int currentLat = 0; + int currentLng = 0; + int next5bits; + int sum; + int shifter; + + while (index < polylineChars.Length) + { + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0)); + } + + return poly; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs new file mode 100644 index 0000000..5d44995 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -0,0 +1,254 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Costasdev.VigoTransitApi; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Processors; + +public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly VigoTransitApiClient _api; + private readonly FeedService _feedService; + private readonly ILogger<VitrasaRealTimeProcessor> _logger; + private readonly ShapeTraversalService _shapeService; + private readonly AppConfiguration _configuration; + + public VitrasaRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger<VitrasaRealTimeProcessor> logger, + ShapeTraversalService shapeService, + IOptions<AppConfiguration> 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<string>(); + var newArrivals = new List<Arrival>(); + + 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<object>(); + 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/Enmarcha.Backend/Services/Providers/ITransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs new file mode 100644 index 0000000..77f6341 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs @@ -0,0 +1,8 @@ +using Enmarcha.Backend.Types; + +namespace Enmarcha.Backend.Services.Providers; + +public interface ITransitProvider +{ + Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime now); +} diff --git a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs new file mode 100644 index 0000000..036c9b1 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs @@ -0,0 +1,64 @@ +using Enmarcha.Backend.Extensions; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types; +using Microsoft.Extensions.Options; +using SysFile = System.IO.File; + +namespace Enmarcha.Backend.Services.Providers; + +[Obsolete] +public class RenfeTransitProvider : ITransitProvider +{ + private readonly AppConfiguration _configuration; + private readonly ILogger<RenfeTransitProvider> _logger; + + public RenfeTransitProvider(IOptions<AppConfiguration> options, ILogger<RenfeTransitProvider> logger) + { + _configuration = options.Value; + _logger = logger; + } + + public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal) + { + var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); + StopArrivals stopArrivals = null!; + + 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<ConsolidatedCirculation>(); + + foreach (var sched in scheduledWindow) + { + var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes; + + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = sched.Line, + Route = sched.Route, + Schedule = new ScheduleData + { + Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, + Minutes = minutes, + TripId = sched.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; + } +} diff --git a/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs new file mode 100644 index 0000000..8a05fc6 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs @@ -0,0 +1,281 @@ +using System.Globalization; +using System.Text; +using Enmarcha.Backend.Extensions; +using Costasdev.VigoTransitApi; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types; +using Microsoft.Extensions.Options; +using static Enmarcha.Backend.Types.StopArrivals.Types; +using SysFile = System.IO.File; + +namespace Enmarcha.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<VitrasaTransitProvider> _logger; + + public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger<VitrasaTransitProvider> logger) + { + _api = new VigoTransitApiClient(http); + _configuration = options.Value; + _shapeService = shapeService; + _lineFormatter = lineFormatter; + _logger = logger; + } + + public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal) + { + // Vitrasa stop IDs are integers, but we receive string "vitrasa:1234" or just "1234" if legacy + // The caller (Controller) should probably strip the prefix, but let's handle it here just in case or assume it's stripped. + // The user said: "Routing the request to one or tthe other will just work with the prefix. For example calling `/api/GetConsolidatedCirculations?stopId=vitrasa:1400` will call the vitrasa driver with stop 1400." + // So I should expect the ID part only here? Or the full ID? + // Usually providers take the ID they understand. I'll assume the controller strips the prefix. + + if (!int.TryParse(stopId, out var numericStopId)) + { + _logger.LogError("Invalid Vitrasa stop ID: {StopId}", stopId); + return []; + } + + var realtimeTask = _api.GetStopEstimates(numericStopId); + var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); + + // Load both today's and tomorrow's schedules to handle night services + var timetableTask = LoadStopArrivalsProto(stopId, todayDate); + + // Wait for real-time data and today's schedule (required) + await Task.WhenAll(realtimeTask, timetableTask); + + var realTimeEstimates = realtimeTask.Result.Estimates + .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<ConsolidatedCirculation> consolidatedCirculations = []; + var usedTripIds = new HashSet<string>(); + + foreach (var estimate in realTimeEstimates) + { + var estimatedArrivalTime = now.AddMinutes(estimate.Minutes); + + var possibleCirculations = timetable + .Where(c => + { + // Match by line number + if (c.Line.Trim() != estimate.Line.Trim()) + return false; + + // Match by route (destination) - compare with both Route field and Terminus stop name + // Normalize both sides: remove non-ASCII-alnum characters and lowercase + var estimateRoute = NormalizeRouteName(estimate.Route); + var scheduleRoute = NormalizeRouteName(c.Route); + var scheduleTerminus = NormalizeRouteName(c.TerminusName); + + // 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(); + + StopArrivals.Types.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; + + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = estimate.Line, + Route = estimate.Route == closestCirculation.TerminusName ? closestCirculation.Route : estimate.Route, + NextStreets = [.. closestCirculation.NextStreets], + Schedule = new ScheduleData + { + Running = isRunning, + Minutes = (int)(closestCirculation.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes, + TripId = closestCirculation.TripId, + ServiceId = closestCirculation.ServiceId, + ShapeId = closestCirculation.ShapeId, + }, + RealTime = new RealTimeData + { + Minutes = estimate.Minutes, + Distance = estimate.Meters + }, + CurrentPosition = currentPosition, + StopShapeIndex = stopShapeIndex, + IsPreviousTrip = usePreviousShape, + PreviousTripShapeId = usePreviousShape ? closestCirculation.PreviousTripShapeId : null + }); + + usedTripIds.Add(closestCirculation.TripId); + } + + // Add scheduled-only circulations between now and the last realtime arrival + if (scopeEnd > now) + { + var matchedTripIds = new HashSet<string>(usedTripIds); + + var scheduledWindow = timetable + .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd) + .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value); + + foreach (var sched in scheduledWindow) + { + if (matchedTripIds.Contains(sched.TripId)) + { + continue; // already represented via a matched realtime + } + + var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes; + if (minutes == 0) + { + continue; + } + + consolidatedCirculations.Add(new ConsolidatedCirculation + { + Line = sched.Line, + Route = sched.Route, + Schedule = new ScheduleData + { + Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, + Minutes = minutes, + TripId = sched.TripId, + ServiceId = sched.ServiceId, + ShapeId = sched.ShapeId, + }, + RealTime = null + }); + } + } + + // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes) + var sorted = consolidatedCirculations + .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes) + .Select(_lineFormatter.Format) + .ToList(); + + return sorted; + } + + private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString) + { + return new StopArrivals(); + // 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/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs new file mode 100644 index 0000000..4bb60e2 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs @@ -0,0 +1,57 @@ +using System.Collections.Frozen; +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Enmarcha.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<PriceRecord>() + .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/Enmarcha.Backend/Services/ShapeTraversalService.cs b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs new file mode 100644 index 0000000..221a975 --- /dev/null +++ b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs @@ -0,0 +1,224 @@ +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types; +using Microsoft.Extensions.Options; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Transformations; +using SysFile = System.IO.File; + +namespace Enmarcha.Backend.Services; + +/// <summary> +/// Service for loading shapes and calculating remaining path from a given stop point +/// </summary> +public class ShapeTraversalService +{ + private readonly AppConfiguration _configuration; + private readonly ILogger<ShapeTraversalService> _logger; + private readonly ICoordinateTransformation _transformation; + + public ShapeTraversalService(IOptions<AppConfiguration> options, ILogger<ShapeTraversalService> 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); + } + + public Shape CreateShapeFromWgs84(List<Position> 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] }; + } + + /// <summary> + /// Calculates the bus position by reverse-traversing the shape from the stop location + /// </summary> + /// <param name="shape">The shape points (in EPSG:25829 meters)</param> + /// <param name="stopLocation">The stop location (in EPSG:25829 meters)</param> + /// <param name="distanceMeters">Distance in meters from the stop to traverse backwards</param> + /// <returns>The lat/lng position of the bus and the stop index on the shape</returns> + 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); + } + + /// <summary> + /// Traverses backwards along the shape from a starting point by the specified distance + /// </summary> + 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); + } + + /// <summary> + /// Interpolates a point between two points at a given ratio + /// </summary> + 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 + }; + } + + /// <summary> + /// Finds the index of the closest point in the shape to the given location + /// </summary> + private int FindClosestPointIndex(IEnumerable<Epsg25829> 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; + } + + /// <summary> + /// Calculates Euclidean distance between two points in meters + /// </summary> + 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); + } + + /// <summary> + /// Calculates the total distance along the shape from the start to a given index + /// </summary> + 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; + } + + /// <summary> + /// Transforms a point from EPSG:25829 (meters) to EPSG:4326 (lat/lng) + /// </summary> + 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) + }; + } + +} |
