From 033df2ee521fb8b4a1e091a0ccdc82e142488685 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 14 Nov 2025 17:33:58 +0100 Subject: Add ShapeTraversalService for shape loading and bus position calculation --- .../Controllers/VigoController.cs | 38 +++- .../Costasdev.Busurbano.Backend.csproj | 1 + src/Costasdev.Busurbano.Backend/Program.cs | 2 + .../Services/ShapeTraversalService.cs | 196 +++++++++++++++++++++ .../Types/ConsolidatedCirculation.cs | 8 + 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs (limited to 'src') diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs index 84b0461..05a3025 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text; using System.Text.Json; using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Types; using Costasdev.VigoTransitApi; using Microsoft.AspNetCore.Mvc; @@ -18,12 +19,14 @@ public class VigoController : ControllerBase private readonly ILogger _logger; private readonly VigoTransitApiClient _api; private readonly AppConfiguration _configuration; + private readonly ShapeTraversalService _shapeService; - public VigoController(HttpClient http, IOptions options, ILogger logger) + public VigoController(HttpClient http, IOptions options, ILogger logger, ShapeTraversalService shapeService) { _logger = logger; _api = new VigoTransitApiClient(http); _configuration = options.Value; + _shapeService = shapeService; } [HttpGet("GetStopEstimates")] @@ -93,6 +96,8 @@ public class VigoController : ControllerBase .Where(c => c.StartingDateTime() != null && c.CallingDateTime() != 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 lastEstimateArrivalMinutes = realTimeEstimates.Max(e => e.Minutes); @@ -206,13 +211,26 @@ public class VigoController : ControllerBase continue; } + var isRunning = closestCirculation.StartingDateTime()!.Value <= now; + Position? currentPosition = null; + + // Calculate bus position only for realtime trips that have already departed + if (isRunning && !string.IsNullOrEmpty(closestCirculation.ShapeId)) + { + var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId); + if (shape != null && stopLocation != null) + { + currentPosition = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); + } + } + consolidatedCirculations.Add(new ConsolidatedCirculation { Line = estimate.Line, Route = estimate.Route, Schedule = new ScheduleData { - Running = closestCirculation.StartingDateTime()!.Value <= now, + Running = isRunning, Minutes = (int)(closestCirculation.CallingDateTime()!.Value - now).TotalMinutes, TripId = closestCirculation.TripId, ServiceId = closestCirculation.ServiceId, @@ -221,7 +239,8 @@ public class VigoController : ControllerBase { Minutes = estimate.Minutes, Distance = estimate.Meters - } + }, + CurrentPosition = currentPosition }); usedTripIds.Add(closestCirculation.TripId); @@ -280,6 +299,19 @@ public class VigoController : ControllerBase return stopArrivals; } + private async Task LoadShapeProto(string shapeId) + { + var file = Path.Combine(_configuration.ScheduleBasePath, shapeId + ".pb"); + if (!SysFile.Exists(file)) + { + throw new FileNotFoundException(); + } + + var contents = await SysFile.ReadAllBytesAsync(file); + var shape = Shape.Parser.ParseFrom(contents); + return shape; + } + private async Task> LoadTimetable(string stopId, string dateString) { var file = Path.Combine(_configuration.ScheduleBasePath, dateString, stopId + ".json"); diff --git a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj index d25a59e..0ec51df 100644 --- a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj +++ b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 7de4039..46f2595 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -1,4 +1,5 @@ using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Services; var builder = WebApplication.CreateBuilder(args); @@ -7,6 +8,7 @@ builder.Services.Configure(builder.Configuration.GetSection("A builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); +builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs new file mode 100644 index 0000000..bded78b --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs @@ -0,0 +1,196 @@ +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Types; +using Microsoft.Extensions.Options; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Transformations; +using SysFile = System.IO.File; + +namespace Costasdev.Busurbano.Backend.Services; + +/// +/// Service for loading shapes and calculating remaining path from a given stop point +/// +public class ShapeTraversalService +{ + private readonly AppConfiguration _configuration; + private readonly ILogger _logger; + private readonly ICoordinateTransformation _transformation; + + public ShapeTraversalService(IOptions options, ILogger logger) + { + _configuration = options.Value; + _logger = logger; + + // Set up coordinate transformation from EPSG:25829 (meters) to EPSG:4326 (lat/lng) + var ctFactory = new CoordinateTransformationFactory(); + var csFactory = new CoordinateSystemFactory(); + + // EPSG:25829 - ETRS89 / UTM zone 29N + var source = csFactory.CreateFromWkt( + "PROJCS[\"ETRS89 / UTM zone 29N\",GEOGCS[\"ETRS89\",DATUM[\"European_Terrestrial_Reference_System_1989\",SPHEROID[\"GRS 1980\",6378137,298.257222101,AUTHORITY[\"EPSG\",\"7019\"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY[\"EPSG\",\"6258\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4258\"]],PROJECTION[\"Transverse_Mercator\"],PARAMETER[\"latitude_of_origin\",0],PARAMETER[\"central_meridian\",-9],PARAMETER[\"scale_factor\",0.9996],PARAMETER[\"false_easting\",500000],PARAMETER[\"false_northing\",0],UNIT[\"metre\",1,AUTHORITY[\"EPSG\",\"9001\"]],AXIS[\"Easting\",EAST],AXIS[\"Northing\",NORTH],AUTHORITY[\"EPSG\",\"25829\"]]"); + + // EPSG:4326 - WGS84 + var target = GeographicCoordinateSystem.WGS84; + + _transformation = ctFactory.CreateFromCoordinateSystems(source, target); + } + + /// + /// Loads a shape from disk + /// + public async Task LoadShapeAsync(string shapeId) + { + var file = Path.Combine(_configuration.ScheduleBasePath, "shapes", shapeId + ".pb"); + if (!SysFile.Exists(file)) + { + _logger.LogWarning("Shape file not found: {ShapeId}", shapeId); + return null; + } + + try + { + var contents = await SysFile.ReadAllBytesAsync(file); + var shape = Shape.Parser.ParseFrom(contents); + return shape; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading shape {ShapeId}", shapeId); + return null; + } + } + + /// + /// Calculates the bus position by reverse-traversing the shape from the stop location + /// + /// The shape points (in EPSG:25829 meters) + /// The stop location (in EPSG:25829 meters) + /// Distance in meters from the stop to traverse backwards + /// The lat/lng position of the bus, or null if not calculable + public Position? GetBusPosition(Shape shape, Epsg25829 stopLocation, int distanceMeters) + { + if (shape.Points.Count == 0 || distanceMeters <= 0) + { + return null; + } + + // Find the closest point on the shape to the stop + int closestPointIndex = FindClosestPointIndex(shape.Points, stopLocation); + + // Traverse backwards from the closest point to find the position at the given distance + var (busPoint, forwardPoint) = TraverseBackwards(shape.Points.ToArray(), closestPointIndex, distanceMeters); + + if (busPoint == null) + { + return null; + } + + // 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); + return pos; + } + + /// + /// Traverses backwards along the shape from a starting point by the specified distance + /// + private (Epsg25829 point, Epsg25829 forward) 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], shapePoints[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, shapePoints[currentIndex]); + } + + remainingDistance -= segmentDistance; + currentIndex--; + } + + // We've reached the beginning of the shape + var fwd = shapePoints[Math.Min(1, shapePoints.Length - 1)]; + return (shapePoints[0], fwd); + } + + /// + /// Interpolates a point between two points at a given ratio + /// + private Epsg25829 InterpolatePoint(Epsg25829 from, Epsg25829 to, double ratio) + { + return new Epsg25829 + { + X = from.X + (to.X - from.X) * ratio, + Y = from.Y + (to.Y - from.Y) * ratio + }; + } + + /// + /// Finds the index of the closest point in the shape to the given location + /// + private int FindClosestPointIndex(IEnumerable shapePoints, Epsg25829 location) + { + var pointsArray = shapePoints.ToArray(); + var minDistance = double.MaxValue; + var closestIndex = 0; + + for (int i = 0; i < pointsArray.Length; i++) + { + var distance = CalculateDistance(pointsArray[i], location); + if (distance < minDistance) + { + minDistance = distance; + closestIndex = i; + } + } + + return closestIndex; + } + + /// + /// Calculates Euclidean distance between two points in meters + /// + private double CalculateDistance(Epsg25829 p1, Epsg25829 p2) + { + var dx = p1.X - p2.X; + var dy = p1.Y - p2.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + /// + /// Transforms a point from EPSG:25829 (meters) to EPSG:4326 (lat/lng) + /// + private Position TransformToLatLng(Epsg25829 point) + { + var transformed = _transformation.MathTransform.Transform(new[] { point.X, point.Y }); + return new Position + { + // Round to 6 decimals (~0.1m precision) + Longitude = Math.Round(transformed[0], 6), + Latitude = Math.Round(transformed[1], 6) + }; + } + +} diff --git a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs index 7cc79c0..3806241 100644 --- a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs +++ b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs @@ -7,6 +7,7 @@ public class ConsolidatedCirculation public ScheduleData? Schedule { get; set; } public RealTimeData? RealTime { get; set; } + public Position? CurrentPosition { get; set; } } public class RealTimeData @@ -22,3 +23,10 @@ public class ScheduleData public required string ServiceId { get; set; } public required string TripId { get; set; } } + +public class Position +{ + public required double Latitude { get; set; } + public required double Longitude { get; set; } + public int OrientationDegrees { get; set; } +} -- cgit v1.3