diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 00:17:11 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 00:17:11 +0100 |
| commit | 1fd17d4d07d25a810816e4e38ddc31ae72b8c91a (patch) | |
| tree | acd8e54ac1abea4fa9ba23430f4a80aba743aa11 /src/Costasdev.Busurbano.Backend/Services | |
| parent | fbd2c1aa2dd25dd61483553d114c484060f71bd6 (diff) | |
Basic fare calculations for Galicia (Xunta)
Diffstat (limited to 'src/Costasdev.Busurbano.Backend/Services')
5 files changed, 242 insertions, 156 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs index 9a11a56..d0423e6 100644 --- a/src/Costasdev.Busurbano.Backend/Services/FareService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/FareService.cs @@ -1,65 +1,215 @@ using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Services.Providers; using Costasdev.Busurbano.Backend.Types.Planner; using Microsoft.Extensions.Options; namespace Costasdev.Busurbano.Backend.Services; -public record FareResult(double CashFareEuro, double CardFareEuro); +public record FareResult(decimal CashFareEuro, decimal CardFareEuro); public class FareService { private readonly AppConfiguration _config; + private readonly XuntaFareProvider _xuntaFareProvider; + private readonly ILogger<FareService> _logger; - public FareService(IOptions<AppConfiguration> config) + 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 busLegs = legs.Where(l => l.Mode != null && l.Mode.ToUpper() != "WALK").ToList(); + var transitLegs = legs + .Where(l => l.Mode != null && !l.Mode.Equals("WALK", StringComparison.CurrentCultureIgnoreCase)) + .ToList(); - // Cash fare logic - double cashFare = 0; - foreach (var leg in busLegs) + if (!transitLegs.Any()) { - // TODO: In the future, this should depend on the operator/feed - if (leg.FeedId == "vitrasa") - { - cashFare += 1.63; - } - else + return new FareResult(0, 0); + } + + return new FareResult( + CalculateCashTotal(transitLegs), + CalculateCardTotal(transitLegs) + ); + } + + private decimal CalculateCashTotal(IEnumerable<Leg> legs) + { + decimal total = 0L; + foreach (var leg in legs) + { + switch (leg.FeedId) { - cashFare += 1.63; // Default fallback + case "santiago": + total += SantiagoCashFare; + break; + case "coruna": + total += CorunaCashFare; + break; + case "vitrasa": + total += VitrasaCashFare; + break; + case "xunta": + // TODO: Handle potentiall blow-ups + if (leg.From is not { ZoneId: not null }) + { + _logger.LogInformation("Ignored fare calculation for leg without From ZoneId. {FromStop}", leg.From?.StopId); + } + + if (leg.To is not { ZoneId: not null }) + { + _logger.LogInformation("Ignored fare calculation for leg without To ZoneId. {ToStop}", leg.To?.StopId); + } + + total += _xuntaFareProvider.GetPrice(leg.From!.ZoneId!, leg.To!.ZoneId!)!.PriceCash; + break; } } - // Card fare logic (45-min transfer window) - int cardTicketsRequired = 0; - DateTime? lastTicketPurchased = null; - int tripsPaidWithTicket = 0; - string? lastFeedId = null; + return total; + } + + private decimal CalculateCardTotal(IEnumerable<Leg> legs) + { + List<TicketPurchased> wallet = []; + decimal totalCost = 0; - foreach (var leg in busLegs) + foreach (var leg in legs) { - // If no ticket purchased, ticket expired (no free transfers after 45 mins), or max trips with ticket reached - // Also check if we changed operator (assuming no free transfers between different operators for now) - if (lastTicketPurchased == null || - (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || - tripsPaidWithTicket >= 3 || - leg.FeedId != lastFeedId) + _logger.LogDebug("Processing leg {leg}", leg); + + int maxMinutes; + int maxUsages; + string? metroArea = null; + decimal initialFare = 0; + + switch (leg.FeedId) + { + case "vitrasa": + maxMinutes = 45; + maxUsages = 3; + initialFare = VitrasaCardFare; + break; + case "coruna": + maxMinutes = 45; + maxUsages = 2; + initialFare = CorunaCardFare; + break; + case "santiago": + maxMinutes = 60; + maxUsages = 2; + initialFare = SantiagoCardFare; + break; + case "xunta": + if (leg.From?.ZoneId == null || leg.To?.ZoneId == null) + { + _logger.LogWarning("Missing ZoneId for Xunta leg. From: {From}, To: {To}", leg.From?.StopId, leg.To?.StopId); + continue; + } + + var priceRecord = _xuntaFareProvider.GetPrice(leg.From.ZoneId, leg.To.ZoneId); + if (priceRecord == null) + { + _logger.LogWarning("No price record found for Xunta leg from {From} to {To}", leg.From.ZoneId, leg.To.ZoneId); + continue; + } + + metroArea = priceRecord.MetroArea; + initialFare = priceRecord.PriceCard; + maxMinutes = 60; + maxUsages = (metroArea != null && metroArea.StartsWith("ATM", StringComparison.OrdinalIgnoreCase)) ? 3 : 1; + break; + default: + _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); + continue; + } + + var validTicket = wallet.FirstOrDefault(t => t.FeedId == leg.FeedId && t.IsValid(leg.StartTime, maxMinutes, maxUsages)); + + if (validTicket != null) { - cardTicketsRequired++; - lastTicketPurchased = leg.StartTime; - tripsPaidWithTicket = 1; - lastFeedId = leg.FeedId; + 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 { - tripsPaidWithTicket++; + // 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 new FareResult(cashFare, cardTicketsRequired * 0.67); + return totalCost; + } +} + +public class TicketPurchased +{ + public string FeedId { get; set; } + + public DateTime PurchasedAt { get; set; } + public string? MetroArea { get; set; } + public string StartZone { get; set; } + + public int UsedTimes = 1; + public decimal TotalPaid { get; set; } + + public bool IsValid(DateTime startTime, int maxMinutes, int maxUsagesIncluded) + { + return (startTime - PurchasedAt).TotalMinutes <= maxMinutes && UsedTimes < maxUsagesIncluded; } } diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index 8d47225..b7e2d3f 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -101,130 +101,6 @@ public class OtpService } } - public async Task<RoutePlan> GetRoutePlanAsync(double fromLat, double fromLon, double toLat, double toLon, DateTime? time = null, bool arriveBy = false) - { - try - { - // Convert the provided time to Europe/Madrid local time and pass explicit offset to OTP - var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); - DateTime utcReference; - if (time.HasValue) - { - var t = time.Value; - if (t.Kind == DateTimeKind.Unspecified) - t = DateTime.SpecifyKind(t, DateTimeKind.Utc); - utcReference = t.Kind == DateTimeKind.Utc ? t : t.ToUniversalTime(); - } - else - { - utcReference = DateTime.UtcNow; - } - - var localMadrid = TimeZoneInfo.ConvertTimeFromUtc(utcReference, tz); - var offsetSeconds = (int)tz.GetUtcOffset(localMadrid).TotalSeconds; - - var dateStr = localMadrid.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture); - var timeStr = localMadrid.ToString("HH:mm", CultureInfo.InvariantCulture); - - var queryParams = new Dictionary<string, string> - { - { "fromPlace", $"{fromLat.ToString(CultureInfo.InvariantCulture)},{fromLon.ToString(CultureInfo.InvariantCulture)}" }, - { "toPlace", $"{toLat.ToString(CultureInfo.InvariantCulture)},{toLon.ToString(CultureInfo.InvariantCulture)}" }, - { "arriveBy", arriveBy.ToString().ToLower() }, - { "date", dateStr }, - { "time", timeStr }, - { "locale", "es" }, - { "showIntermediateStops", "true" }, - { "mode", "TRANSIT,WALK" }, - { "numItineraries", _config.NumItineraries.ToString() }, - { "walkSpeed", _config.WalkSpeed.ToString(CultureInfo.InvariantCulture) }, - { "maxWalkDistance", _config.MaxWalkDistance.ToString() }, // Note: OTP might ignore this if it's too small - { "optimize", "QUICK" }, - { "wheelchair", "false" }, - { "timeZoneOffset", offsetSeconds.ToString(CultureInfo.InvariantCulture) } - }; - - // Add slack/comfort parameters - queryParams["transferSlack"] = _config.TransferSlackSeconds.ToString(); - queryParams["minTransferTime"] = _config.MinTransferTimeSeconds.ToString(); - queryParams["walkReluctance"] = _config.WalkReluctance.ToString(CultureInfo.InvariantCulture); - - var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); - var url = $"{_config.OtpPlannerBaseUrl}/plan?{queryString}"; - - var response = await _httpClient.GetFromJsonAsync<OtpResponse>(url); - - if (response?.Plan == null) - { - return new RoutePlan(); - } - - return MapToRoutePlan(response.Plan); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching route plan"); - throw; - } - } - - private RoutePlan MapToRoutePlan(OtpPlan otpPlan) - { - // OTP times are already correct when requested with explicit offset - var timeOffsetSeconds = 0L; - - return new RoutePlan - { - Itineraries = otpPlan.Itineraries.Select(MapItinerary).OrderBy(i => i.DurationSeconds).ToList(), - TimeOffsetSeconds = timeOffsetSeconds - }; - } - - private Itinerary MapItinerary(OtpItinerary otpItinerary) - { - var legs = otpItinerary.Legs.Select(MapLeg).ToList(); - var busLegs = legs.Where(leg => leg.Mode != null && leg.Mode.ToUpper() != "WALK"); - - var cashFareEuro = busLegs.Count() * _config.FareCashPerBus; - - int cardTicketsRequired = 0; - DateTime? lastTicketPurchased = null; - int tripsPaidWithTicket = 0; - - foreach (var leg in busLegs) - { - // If no ticket purchased, ticket expired (no free transfers after 45 mins), or max trips with ticket reached - if ( - lastTicketPurchased == null || - (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || - tripsPaidWithTicket >= 3 - ) - { - cardTicketsRequired++; - lastTicketPurchased = leg.StartTime; - tripsPaidWithTicket = 1; - } - else - { - tripsPaidWithTicket++; - } - } - - return new Itinerary - { - DurationSeconds = otpItinerary.Duration, - StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpItinerary.StartTime).UtcDateTime, - EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpItinerary.EndTime).UtcDateTime, - WalkDistanceMeters = otpItinerary.WalkDistance, - WalkTimeSeconds = otpItinerary.WalkTime, - TransitTimeSeconds = otpItinerary.TransitTime, - WaitingTimeSeconds = otpItinerary.WaitingTime, - Legs = legs, - CashFareEuro = cashFareEuro, - CardFareEuro = cardTicketsRequired * _config.FareCardPerBus - }; - } - private Leg MapLeg(OtpLeg otpLeg) { return new Leg @@ -458,7 +334,8 @@ public class OtpService Lat = pos.Latitude, Lon = pos.Longitude, StopId = pos.Stop?.GtfsId, - StopCode = _feedService.NormalizeStopCode(feedId, pos.Stop?.Code ?? string.Empty) + StopCode = _feedService.NormalizeStopCode(feedId, pos.Stop?.Code ?? string.Empty), + ZoneId = pos.Stop?.ZoneId }; } diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs index f114ec3..1793ada 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs @@ -6,6 +6,7 @@ using SysFile = System.IO.File; namespace Costasdev.Busurbano.Backend.Services.Providers; +[Obsolete] public class RenfeTransitProvider : ITransitProvider { private readonly AppConfiguration _configuration; diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs index e54b66e..a736652 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs @@ -10,6 +10,7 @@ using SysFile = System.IO.File; namespace Costasdev.Busurbano.Backend.Services.Providers; +[Obsolete] public class VitrasaTransitProvider : ITransitProvider { private readonly VigoTransitApiClient _api; diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs new file mode 100644 index 0000000..7536c69 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs @@ -0,0 +1,57 @@ +using System.Collections.Frozen; +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Costasdev.Busurbano.Backend.Services.Providers; + +public class PriceRecord +{ + [Name("conc_inicio")] public string Origin { get; set; } + [Name("conc_fin")] public string Destination { get; set; } + [Name("bonificacion")] public string? MetroArea { get; set; } + [Name("efectivo")] public decimal PriceCash { get; set; } + [Name("tpg")] public decimal PriceCard { get; set; } +} + +public class XuntaFareProvider +{ + private readonly FrozenDictionary<(string, string), PriceRecord> _priceMatrix; + + public XuntaFareProvider(IWebHostEnvironment env) + { + var filePath = Path.Combine(env.ContentRootPath, "Data", "xunta_fares.csv"); + + using var reader = new StreamReader(filePath); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); + + // We do GroupBy first to prevent duplicates from throwing an exception + _priceMatrix = csv.GetRecords<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; + } +} |
