aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend/Services
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-28 00:17:11 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-28 00:17:11 +0100
commit1fd17d4d07d25a810816e4e38ddc31ae72b8c91a (patch)
treeacd8e54ac1abea4fa9ba23430f4a80aba743aa11 /src/Costasdev.Busurbano.Backend/Services
parentfbd2c1aa2dd25dd61483553d114c484060f71bd6 (diff)
Basic fare calculations for Galicia (Xunta)
Diffstat (limited to 'src/Costasdev.Busurbano.Backend/Services')
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FareService.cs212
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs127
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs57
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;
+ }
+}