diff options
Diffstat (limited to 'src/Enmarcha.Backend')
20 files changed, 200 insertions, 454 deletions
diff --git a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs index e86ac39..ca99e3c 100644 --- a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs +++ b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs @@ -3,5 +3,6 @@ namespace Enmarcha.Backend.Configuration; public class AppConfiguration { public required string OpenTripPlannerBaseUrl { get; set; } + public required string GeoapifyApiKey { get; set; } public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org"; } diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index 7260fb4..a23c69c 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using Enmarcha.Sources.OpenTripPlannerGql; using Enmarcha.Sources.OpenTripPlannerGql.Queries; using Enmarcha.Backend.Configuration; @@ -87,11 +87,6 @@ public partial class ArrivalsController : ControllerBase continue; } - if (item.Trip.Geometry?.Points != null) - { - _logger.LogDebug("Trip {TripId} has geometry", item.Trip.GtfsId); - } - // Calculate departure time using the service day in the feed's timezone (Europe/Madrid) // This ensures we treat ScheduledDepartureSeconds as relative to the local midnight of the service day var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz); diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs index 7a03a24..89f6c59 100644 --- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs +++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs @@ -3,6 +3,7 @@ using Enmarcha.Sources.OpenTripPlannerGql; using Enmarcha.Sources.OpenTripPlannerGql.Queries; using Enmarcha.Backend.Configuration; using Enmarcha.Backend.Services; +using Enmarcha.Backend.Services.Geocoding; using Enmarcha.Backend.Types.Planner; using FuzzySharp; using Microsoft.AspNetCore.Mvc; @@ -57,7 +58,7 @@ public partial class RoutePlannerController : ControllerBase await Task.WhenAll(nominatimTask, stopsTask); - var nominatimResults = await nominatimTask; + var geocodingResults = await nominatimTask; var allStops = await stopsTask; // Fuzzy search stops @@ -65,14 +66,14 @@ public partial class RoutePlannerController : ControllerBase query, allStops.Select(s => s.Name ?? string.Empty), cutoff: 60 - ).Take(5).Select(r => allStops[r.Index]).ToList(); + ).Take(4).Select(r => allStops[r.Index]).ToList(); - // Merge results: stops first, then nominatim, deduplicating by coordinates (approx) - var finalResults = new List<PlannerSearchResult>(fuzzyResults); + // Merge results: geocoding first, then stops, deduplicating by coordinates (approx) + var finalResults = new List<PlannerSearchResult>(geocodingResults); - foreach (var res in nominatimResults) + foreach (var res in fuzzyResults) { - if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.0001 && Math.Abs(f.Lon - res.Lon) < 0.0001)) + if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.00001 && Math.Abs(f.Lon - res.Lon) < 0.00001)) { finalResults.Add(res); } diff --git a/src/Enmarcha.Backend/Controllers/TransitController.cs b/src/Enmarcha.Backend/Controllers/TransitController.cs index 62b3725..00e5fb7 100644 --- a/src/Enmarcha.Backend/Controllers/TransitController.cs +++ b/src/Enmarcha.Backend/Controllers/TransitController.cs @@ -62,7 +62,6 @@ public class TransitController : ControllerBase var routes = response.Data.Routes .Select(_otpService.MapRoute) - .Where(r => r.TripCount > 0) .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames)) .ToList(); diff --git a/src/Enmarcha.Backend/Controllers/VigoController.cs b/src/Enmarcha.Backend/Controllers/VigoController.cs deleted file mode 100644 index 4199251..0000000 --- a/src/Enmarcha.Backend/Controllers/VigoController.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Costasdev.VigoTransitApi; -using Enmarcha.Backend.Configuration; -using Enmarcha.Backend.Services; -using Enmarcha.Backend.Services.Providers; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace Enmarcha.Backend.Controllers; - -[ApiController] -[Route("api/vigo")] -public partial class VigoController : ControllerBase -{ - private readonly ILogger<VigoController> _logger; - private readonly VigoTransitApiClient _api; - private readonly AppConfiguration _configuration; - private readonly ShapeTraversalService _shapeService; - private readonly VitrasaTransitProvider _vitrasaProvider; - private readonly RenfeTransitProvider _renfeProvider; - - public VigoController( - HttpClient http, - IOptions<AppConfiguration> options, - ILogger<VigoController> logger, - ShapeTraversalService shapeService, - VitrasaTransitProvider vitrasaProvider, - RenfeTransitProvider renfeProvider) - { - _logger = logger; - _api = new VigoTransitApiClient(http); - _configuration = options.Value; - _shapeService = shapeService; - _vitrasaProvider = vitrasaProvider; - _renfeProvider = renfeProvider; - } - - [HttpGet("GetConsolidatedCirculations")] - public async Task<IActionResult> GetConsolidatedCirculations( - [FromQuery] string stopId - ) - { - // Use Europe/Madrid timezone consistently to avoid UTC/local skew - var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); - var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); - - ITransitProvider provider; - string effectiveStopId; - - if (stopId.StartsWith("renfe:")) - { - provider = _renfeProvider; - effectiveStopId = stopId.Substring("renfe:".Length); - } - else if (stopId.StartsWith("vitrasa:")) - { - provider = _vitrasaProvider; - effectiveStopId = stopId.Substring("vitrasa:".Length); - } - else - { - // Legacy/Default - provider = _vitrasaProvider; - effectiveStopId = stopId; - } - - var result = await provider.GetCirculationsAsync(effectiveStopId, nowLocal); - return Ok(result); - } -} diff --git a/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs b/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs index bbf0e4b..1ef7990 100644 --- a/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs +++ b/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs @@ -1,5 +1,4 @@ using Enmarcha.Backend.Types; -using static Enmarcha.Backend.Types.StopArrivals.Types; namespace Enmarcha.Backend.Extensions; diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index 4450da6..a13abec 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; using Enmarcha.Backend.Configuration; using Enmarcha.Backend.Services; +using Enmarcha.Backend.Services.Geocoding; using Enmarcha.Backend.Services.Processors; using Enmarcha.Backend.Services.Providers; -using Enmarcha.Sources.TranviasCoruna; var builder = WebApplication.CreateBuilder(args); @@ -37,10 +37,9 @@ builder.Services.AddScoped<IArrivalsProcessor, ShapeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, FeedConfigProcessor>(); builder.Services.AddScoped<ArrivalsPipeline>(); -builder.Services.AddHttpClient<IGeocodingService, NominatimGeocodingService>(); +// builder.Services.AddKeyedScoped<IGeocodingService, NominatimGeocodingService>("Nominatim"); +builder.Services.AddHttpClient<IGeocodingService, GeoapifyGeocodingService>(); builder.Services.AddHttpClient<OtpService>(); -builder.Services.AddScoped<VitrasaTransitProvider>(); -builder.Services.AddScoped<RenfeTransitProvider>(); var app = builder.Build(); diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs new file mode 100644 index 0000000..d6cf5f6 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types.Geoapify; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Geocoding; + +public class GeoapifyGeocodingService : IGeocodingService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger<GeoapifyGeocodingService> _logger; + private readonly AppConfiguration _config; + + private static readonly string[] ForbiddenResultTypes = ["city", "state", "county", "postcode"]; + + public GeoapifyGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<GeoapifyGeocodingService> logger, IOptions<AppConfiguration> config) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + _config = config.Value; + + // Geoapify requires a User-Agent + if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) + { + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + } + } + + public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null) + { + return cachedResults; + } + + var url = $"https://api.geoapify.com/v1/geocode/autocomplete?text={Uri.EscapeDataString(query)}&lang=gl&limit=5&filter=rect:-9.449497230816405,41.89720361654395,-6.581039728137625,43.92616367306067&format=json"; + + try + { + var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}"); + + + var results = response?.results + .Where(x => !ForbiddenResultTypes.Contains(x.result_type)) + .Select(MapToPlannerSearchResult) + .ToList() ?? []; + + _cache.Set(cacheKey, results, TimeSpan.FromMinutes(60)); + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Geoapify autocomplete results from {Url}", url); + 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; + } + + var url = + $"https://api.geoapify.com/v1/geocode/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=gl&format=json"; + try + { + var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}"); + + if (response == null) return null; + + var result = MapToPlannerSearchResult(response.results[0]); + + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Geoapify reverse geocode results from {Url}", url); + return null; + } + } + + private PlannerSearchResult MapToPlannerSearchResult(Result result) + { + var name = result.name ?? result.address_line1; + var label = $"{result.street} ({result.postcode} {result.city}, {result.county})"; + + return new PlannerSearchResult + { + Name = name, + Label = label, + Lat = result.lat, + Lon = result.lon, + Layer = result.result_type + }; + } +} diff --git a/src/Enmarcha.Backend/Services/IGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs index 5c1b19e..8619b0a 100644 --- a/src/Enmarcha.Backend/Services/IGeocodingService.cs +++ b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs @@ -1,6 +1,6 @@ using Enmarcha.Backend.Types.Planner; -namespace Enmarcha.Backend.Services; +namespace Enmarcha.Backend.Services.Geocoding; public interface IGeocodingService { diff --git a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs index e8eccb5..c38b1e6 100644 --- a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs +++ b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs @@ -5,7 +5,7 @@ using Enmarcha.Backend.Types.Planner; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -namespace Enmarcha.Backend.Services; +namespace Enmarcha.Backend.Services.Geocoding; public class NominatimGeocodingService : IGeocodingService { @@ -26,7 +26,7 @@ public class NominatimGeocodingService : IGeocodingService // Nominatim requires a User-Agent if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) { - _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app)"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); } } diff --git a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs index ad5465f..6a9d9dc 100644 --- a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -2,7 +2,6 @@ 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; diff --git a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs index 7df00fa..18ba2ac 100644 --- a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs @@ -1,5 +1,3 @@ -using Enmarcha.Backend.Types.Arrivals; - namespace Enmarcha.Backend.Services.Processors; /// <summary> diff --git a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs index d14cfa0..929e439 100644 --- a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs @@ -1,7 +1,4 @@ using Enmarcha.Backend.Helpers; -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; diff --git a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs deleted file mode 100644 index 77f6341..0000000 --- a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 036c9b1..0000000 --- a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 8a05fc6..0000000 --- a/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs +++ /dev/null @@ -1,281 +0,0 @@ -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 index 4bb60e2..1780c09 100644 --- a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs +++ b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs @@ -1,4 +1,4 @@ -using System.Collections.Frozen; +using System.Collections.Frozen; using System.Globalization; using CsvHelper; using CsvHelper.Configuration.Attributes; diff --git a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs index 221a975..1f77929 100644 --- a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs +++ b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs @@ -3,7 +3,6 @@ using Enmarcha.Backend.Types; using Microsoft.Extensions.Options; using ProjNet.CoordinateSystems; using ProjNet.CoordinateSystems.Transformations; -using SysFile = System.IO.File; namespace Enmarcha.Backend.Services; diff --git a/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs b/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs index 9d2ea1b..eab463d 100644 --- a/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs +++ b/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs @@ -1,6 +1,4 @@ -using System.Text.Json.Serialization; -using Enmarcha.Backend.Types; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Enmarcha.Backend.Types.Arrivals; diff --git a/src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs b/src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs new file mode 100644 index 0000000..ac692ca --- /dev/null +++ b/src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; + +namespace Enmarcha.Backend.Types.Geoapify; + +public class GeoapifyResult +{ + public Result[] results { get; set; } + public Query query { get; set; } +} + +public class Result +{ + public string country_code { get; set; } + public string? name { get; set; } + public string street { get; set; } + public string country { get; set; } + public Datasource datasource { get; set; } + public string postcode { get; set; } + public string state { get; set; } + public string state_code { get; set; } + public string district { get; set; } + public string city { get; set; } + public string county { get; set; } + public string county_code { get; set; } + public double lon { get; set; } + public double lat { get; set; } + public string result_type { get; set; } + public string NUTS_3 { get; set; } + public string formatted { get; set; } + public string address_line1 { get; set; } + public string address_line2 { get; set; } + public Timezone timezone { get; set; } + public string plus_code { get; set; } + public string iso3166_2 { get; set; } + public string place_id { get; set; } + public Other_names other_names { get; set; } + public string suburb { get; set; } + public string housenumber { get; set; } + public string iso3166_2_sublevel { get; set; } + public string category { get; set; } +} + +public class Datasource +{ + public string sourcename { get; set; } + public string attribution { get; set; } + public string license { get; set; } + public string url { get; set; } +} + +public class Timezone +{ + public string name { get; set; } + public string offset_STD { get; set; } + public int offset_STD_seconds { get; set; } + public string offset_DST { get; set; } + public int offset_DST_seconds { get; set; } + public string abbreviation_STD { get; set; } + public string abbreviation_DST { get; set; } +} + +public class Other_names +{ + public string name { get; set; } + public string name_gl { get; set; } + public string alt_name { get; set; } +} + +public class Query +{ + public string text { get; set; } +} + |
