diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-02-18 12:23:04 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-02-18 12:23:04 +0100 |
| commit | 191d36dbbea5fab6141d9a144f154c98757e284f (patch) | |
| tree | 4d18e83c14c7f48ccc0eb1f57d3516f94ed305d8 | |
| parent | 3adc4abbe506aeb3a11afdd731e571b387423d42 (diff) | |
feat: Add CtagShuttle support with real-time WRONG estimate and good position
| -rw-r--r-- | Enmarcha.Sources.CtagShuttle/CtagShuttleRealtimeEstimatesProvider.cs | 30 | ||||
| -rw-r--r-- | Enmarcha.Sources.CtagShuttle/Enmarcha.Sources.CtagShuttle.csproj | 9 | ||||
| -rw-r--r-- | Enmarcha.Sources.CtagShuttle/Status.cs | 46 | ||||
| -rw-r--r-- | Enmarcha.slnx | 1 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Enmarcha.Backend.csproj | 1 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Program.cs | 2 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs | 308 |
7 files changed, 397 insertions, 0 deletions
diff --git a/Enmarcha.Sources.CtagShuttle/CtagShuttleRealtimeEstimatesProvider.cs b/Enmarcha.Sources.CtagShuttle/CtagShuttleRealtimeEstimatesProvider.cs new file mode 100644 index 0000000..3ec0c6f --- /dev/null +++ b/Enmarcha.Sources.CtagShuttle/CtagShuttleRealtimeEstimatesProvider.cs @@ -0,0 +1,30 @@ +using System.Net.Http.Json; + +namespace Enmarcha.Sources.CtagShuttle; + +public class CtagShuttleRealtimeEstimatesProvider +{ + private HttpClient _http; + + public CtagShuttleRealtimeEstimatesProvider(HttpClient http) + { + _http = http; + } + + public async Task<CtagShuttleStatus> GetShuttleStatus() + { + const string url = "https://shuttle.brain4mobility.com/status"; + + var response = await _http.GetAsync(url); + var status = await response.Content.ReadFromJsonAsync<CtagShuttleStatus>(); + + if (status is null) + { + throw new InvalidOperationException("Failed to retrieve shuttle status"); + } + + return status; + } + +} + diff --git a/Enmarcha.Sources.CtagShuttle/Enmarcha.Sources.CtagShuttle.csproj b/Enmarcha.Sources.CtagShuttle/Enmarcha.Sources.CtagShuttle.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/Enmarcha.Sources.CtagShuttle/Enmarcha.Sources.CtagShuttle.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + +</Project> diff --git a/Enmarcha.Sources.CtagShuttle/Status.cs b/Enmarcha.Sources.CtagShuttle/Status.cs new file mode 100644 index 0000000..ecf2da1 --- /dev/null +++ b/Enmarcha.Sources.CtagShuttle/Status.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Enmarcha.Sources.CtagShuttle; + +public class CtagShuttleStatus +{ + [JsonPropertyName("status")] public required string StatusValue { get; set; } + [JsonPropertyName("lat")] public double Latitude { get; set; } + [JsonPropertyName("lng")] public double Longitude { get; set; } + [JsonPropertyName("last_position_at")] public required string LastPositionAtValue { get; set; } + [JsonPropertyName("free_seats")] public int FreeSeats { get; set; } + + [JsonPropertyName("last_occupancy_at")] + public required string LastOccupancyAtValue { get; set; } + + [JsonIgnore] + public Status Status => Status.Parse(StatusValue); + + [JsonIgnore] + public DateTime LastPositionAt => DateTime.Parse(LastPositionAtValue); + + [JsonIgnore] + public DateTime LastOccupancyAt => DateTime.Parse(LastOccupancyAtValue); +} + +public enum Status +{ + Idle, + Operating +} + +public static class StatusExtensions +{ + extension(Status) + { + public static Status Parse(string value) + { + return value switch + { + "idle" => Status.Idle, + "operating" => Status.Operating, + _ => throw new ArgumentException($"Invalid status value: {value}") + }; + } + } +} diff --git a/Enmarcha.slnx b/Enmarcha.slnx index fbe16a7..63ed763 100644 --- a/Enmarcha.slnx +++ b/Enmarcha.slnx @@ -1,4 +1,5 @@ <Solution> + <Project Path="Enmarcha.Sources.CtagShuttle/Enmarcha.Sources.CtagShuttle.csproj" /> <Project Path="src/Enmarcha.Backend/Enmarcha.Backend.csproj" /> <Project Path="src/Enmarcha.Experimental.ServiceViewer/Enmarcha.Experimental.ServiceViewer.csproj" /> <Project Path="src/Enmarcha.Sources.OpenTripPlannerGql/Enmarcha.Sources.OpenTripPlannerGql.csproj" /> diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj index de6489e..a353b4d 100644 --- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj +++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj @@ -29,6 +29,7 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\..\Enmarcha.Sources.CtagShuttle\Enmarcha.Sources.CtagShuttle.csproj" /> <ProjectReference Include="..\Enmarcha.Sources.OpenTripPlannerGql\Enmarcha.Sources.OpenTripPlannerGql.csproj" /> <ProjectReference Include="..\Enmarcha.Sources.TranviasCoruna\Enmarcha.Sources.TranviasCoruna.csproj" /> <ProjectReference Include="..\Enmarcha.Sources.Tussa\Enmarcha.Sources.Tussa.csproj" /> diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index f4b39ff..025a0b3 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -128,6 +128,7 @@ builder.Services.AddSingleton<LineFormatterService>(); builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, SantiagoRealTimeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, CtagShuttleRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, VigoUsageProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, FilterAndSortProcessor>(); @@ -142,6 +143,7 @@ builder.Services.AddHttpClient<IGeocodingService, GeoapifyGeocodingService>(); builder.Services.AddHttpClient<OtpService>(); builder.Services.AddHttpClient<Enmarcha.Sources.TranviasCoruna.CorunaRealtimeEstimatesProvider>(); builder.Services.AddHttpClient<Enmarcha.Sources.Tussa.SantiagoRealtimeEstimatesProvider>(); +builder.Services.AddHttpClient<Enmarcha.Sources.CtagShuttle.CtagShuttleRealtimeEstimatesProvider>(); builder.Services.AddHttpClient<Costasdev.VigoTransitApi.VigoTransitApiClient>(); var app = builder.Build(); diff --git a/src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs new file mode 100644 index 0000000..593ac86 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs @@ -0,0 +1,308 @@ +using Enmarcha.Sources.CtagShuttle; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors; + +public class CtagShuttleRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CtagShuttleRealtimeEstimatesProvider _shuttleProvider; + private readonly ShapeTraversalService _shapeService; + private readonly ILogger<CtagShuttleRealTimeProcessor> _logger; + + // Maximum distance (in meters) a GPS coordinate can be from the route shape to be considered valid + private const double MaxDistanceFromShape = 100.0; + + // Maximum age (in minutes) for position data to be considered fresh + private const double MaxPositionAgeMinutes = 3.0; + + public CtagShuttleRealTimeProcessor( + CtagShuttleRealtimeEstimatesProvider shuttleProvider, + ShapeTraversalService shapeService, + ILogger<CtagShuttleRealTimeProcessor> logger) + { + _shuttleProvider = shuttleProvider; + _shapeService = shapeService; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + // Only process shuttle stops + if (!context.StopId.StartsWith("shuttle:")) + { + return; + } + + try + { + // Fetch current shuttle status + var status = await _shuttleProvider.GetShuttleStatus(); + System.Diagnostics.Activity.Current?.SetTag("shuttle.status", status.StatusValue); + + // Validate position timestamp - skip if data is stale (>3 minutes old) + var positionAge = (context.NowLocal - status.LastPositionAt).TotalMinutes; + if (positionAge > MaxPositionAgeMinutes) + { + _logger.LogInformation( + "Shuttle position is stale ({Age:F1} minutes old), skipping real-time update", + positionAge); + return; + } + + // Skip processing if shuttle is idle + if (status.StatusValue.Equals("idle", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Shuttle is idle, skipping real-time update"); + return; + } + + // No arrivals to process + if (context.Arrivals.Count == 0) + { + _logger.LogWarning("No scheduled arrivals found for shuttle stop {StopId}", context.StopId); + return; + } + + // Transform shuttle GPS position to EPSG:25829 (meters) + var shuttlePosition = _shapeService.TransformToEpsg25829(status.Latitude, status.Longitude); + _logger.LogDebug("Shuttle position: Lat={Lat}, Lon={Lon} -> X={X}, Y={Y}", + status.Latitude, status.Longitude, shuttlePosition.X, shuttlePosition.Y); + + // Get the shape from the first arrival (assuming single circular route) + var firstArrival = context.Arrivals.First(); + if (firstArrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival || + otpArrival.Trip.Geometry?.Points == null) + { + _logger.LogWarning("No shape geometry available for shuttle trip"); + return; + } + + // Decode polyline and create shape + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + if (shape.Points.Count == 0) + { + _logger.LogWarning("Shape has no points"); + return; + } + + // Find closest point on shape to shuttle's current position + var (closestPointIndex, distanceToShape) = FindClosestPointOnShape(shape.Points.ToList(), shuttlePosition); + + // Validate that shuttle is reasonably close to the route + if (distanceToShape > MaxDistanceFromShape) + { + _logger.LogWarning( + "Shuttle position is {Distance:F1}m from route (threshold: {Threshold}m), skipping update", + distanceToShape, MaxDistanceFromShape); + return; + } + + // Calculate distance from shape start to shuttle's current position + var shuttleDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), closestPointIndex); + _logger.LogDebug("Shuttle is {Distance:F1}m along the shape", shuttleDistanceAlongShape); + + // Calculate total shape length + var totalShapeLength = CalculateTotalShapeLength(shape.Points.ToArray()); + + if (context.StopLocation == null) + { + _logger.LogWarning("Stop location not available for shuttle stop {StopId}", context.StopId); + return; + } + + // Transform stop location to EPSG:25829 + var stopLocation = _shapeService.TransformToEpsg25829( + context.StopLocation.Latitude, + context.StopLocation.Longitude); + + // Find closest point on shape to this stop + var (stopPointIndex, _) = FindClosestPointOnShape(shape.Points.ToList(), stopLocation); + var stopDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), stopPointIndex); + + // Calculate remaining distance from shuttle to stop + var remainingDistance = stopDistanceAlongShape - shuttleDistanceAlongShape; + + // Handle circular route wraparound (if shuttle is past the stop on the loop) + if (remainingDistance < 0) + { + remainingDistance += totalShapeLength; + } + + _logger.LogDebug("Remaining distance to stop: {Distance:F1}m", remainingDistance); + + // Calculate estimated minutes based on distance and reasonable shuttle speed + // Assume average urban shuttle speed of 20 km/h = 333 meters/minute + const double metersPerMinute = 333.0; + int estimatedMinutesForActive; + + if (remainingDistance < 50) // Within 50 meters + { + estimatedMinutesForActive = 0; + } + else + { + // Calculate time based on distance + var minutesFromDistance = remainingDistance / metersPerMinute; + estimatedMinutesForActive = (int)Math.Ceiling(minutesFromDistance); + } + + _logger.LogDebug("Calculated ETA: {Minutes} min for {Distance:F1}m", estimatedMinutesForActive, remainingDistance); + + // Find the active trip - should be one where: + // 1. Scheduled time is in the future (or very recent past, max -2 min for "arriving now" scenarios) + // 2. Scheduled time is reasonably close to our calculated ETA + var activeArrival = context.Arrivals + .Where(a => a.Estimate.Minutes >= -2) // Only consider upcoming or very recent arrivals + .Select(a => new + { + Arrival = a, + TimeDiff = Math.Abs(a.Estimate.Minutes - estimatedMinutesForActive) + }) + .Where(x => x.TimeDiff < 45) // Only consider if within 45 minutes difference from our estimate + .OrderBy(x => x.TimeDiff) + .FirstOrDefault()?.Arrival; + + // Fallback: if no good match, use the next upcoming arrival + if (activeArrival == null) + { + activeArrival = context.Arrivals + .Where(a => a.Estimate.Minutes >= 0) + .OrderBy(a => a.Estimate.Minutes) + .FirstOrDefault(); + + _logger.LogDebug("No matching arrival found, using next upcoming trip"); + } + + // If we found an active trip, update it with real-time data + if (activeArrival != null) + { + var scheduledMinutes = activeArrival.Estimate.Minutes; + activeArrival.Estimate.Minutes = estimatedMinutesForActive; + activeArrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimatedMinutesForActive - scheduledMinutes; + if (delayMinutes != 0) + { + activeArrival.Delay = new DelayBadge { Minutes = delayMinutes }; + } + + // Set current position for visualization + var shuttleWgs84 = new Position + { + Latitude = status.Latitude, + Longitude = status.Longitude + }; + + // Calculate bearing from shuttle to next point on shape + if (closestPointIndex < shape.Points.Count - 1) + { + var currentPoint = shape.Points[closestPointIndex]; + var nextPoint = shape.Points[closestPointIndex + 1]; + var dx = nextPoint.X - currentPoint.X; + var dy = nextPoint.Y - currentPoint.Y; + var bearing = Math.Atan2(dx, dy) * 180.0 / Math.PI; + if (bearing < 0) bearing += 360.0; + shuttleWgs84.OrientationDegrees = (int)Math.Round(bearing); + } + + activeArrival.CurrentPosition = shuttleWgs84; + activeArrival.StopShapeIndex = stopPointIndex; + + _logger.LogInformation( + "Updated active trip {TripId}: {Minutes} min (was {Scheduled} min, delay: {Delay} min, distance: {Distance:F1}m)", + activeArrival.TripId, estimatedMinutesForActive, scheduledMinutes, delayMinutes, remainingDistance); + + _logger.LogInformation( + "Shuttle position set: Lat={Lat}, Lon={Lon}, Bearing={Bearing}°", + shuttleWgs84.Latitude, shuttleWgs84.Longitude, shuttleWgs84.OrientationDegrees); + } + else + { + _logger.LogWarning("Could not determine active trip for shuttle"); + } + + System.Diagnostics.Activity.Current?.SetTag("shuttle.active_trip_updated", activeArrival != null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing shuttle real-time data for stop {StopId}", context.StopId); + // Don't throw - allow scheduled data to be returned + } + } + + /// <summary> + /// Finds the closest point on the shape to the given location and returns the index and distance + /// </summary> + private (int Index, double Distance) FindClosestPointOnShape(List<Epsg25829> shapePoints, Epsg25829 location) + { + var minDistance = double.MaxValue; + var closestIndex = 0; + + for (int i = 0; i < shapePoints.Count; i++) + { + var distance = CalculateDistance(shapePoints[i], location); + if (distance < minDistance) + { + minDistance = distance; + closestIndex = i; + } + } + + return (closestIndex, minDistance); + } + + /// <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 CalculateTotalDistanceToPoint(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> + /// Calculates the total length of the entire shape + /// </summary> + private double CalculateTotalShapeLength(Epsg25829[] shapePoints) + { + if (shapePoints.Length <= 1) + { + return 0; + } + + double totalDistance = 0; + for (int i = 1; i < shapePoints.Length; i++) + { + totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); + } + + return totalDistance; + } +} |
