diff --git a/src/main/java/com/conveyal/r5/analyst/fare/ChicagoInRoutingFareCalculator.java b/src/main/java/com/conveyal/r5/analyst/fare/ChicagoCTAInRoutingFareCalculator.java similarity index 95% rename from src/main/java/com/conveyal/r5/analyst/fare/ChicagoInRoutingFareCalculator.java rename to src/main/java/com/conveyal/r5/analyst/fare/ChicagoCTAInRoutingFareCalculator.java index ce4e2f2fe..5d93ef02a 100644 --- a/src/main/java/com/conveyal/r5/analyst/fare/ChicagoInRoutingFareCalculator.java +++ b/src/main/java/com/conveyal/r5/analyst/fare/ChicagoCTAInRoutingFareCalculator.java @@ -15,11 +15,11 @@ * Greedy fare calculator for the Chicago Transit Authority. * Just looks at rail and bus, not at Metra, PACE, etc., and does not handle out-of-system rail transfers. */ -public class ChicagoInRoutingFareCalculator extends InRoutingFareCalculator { +public class ChicagoCTAInRoutingFareCalculator extends InRoutingFareCalculator { public static final int L_FARE = 225; public static final int BUS_FARE = 200; public static final int TRANSFER_FARE = 25; - private static final Logger LOG = LoggerFactory.getLogger(ChicagoInRoutingFareCalculator.class); + private static final Logger LOG = LoggerFactory.getLogger(ChicagoCTAInRoutingFareCalculator.class); @Override public FareBounds calculateFare(McRaptorSuboptimalPathProfileRouter.McRaptorState state, int maxClockTime) { diff --git a/src/main/java/com/conveyal/r5/analyst/fare/ChicagoRTAInRoutingFareCalculator.java b/src/main/java/com/conveyal/r5/analyst/fare/ChicagoRTAInRoutingFareCalculator.java new file mode 100644 index 000000000..0590251dc --- /dev/null +++ b/src/main/java/com/conveyal/r5/analyst/fare/ChicagoRTAInRoutingFareCalculator.java @@ -0,0 +1,250 @@ +package com.conveyal.r5.analyst.fare; + +import com.conveyal.gtfs.model.Fare; +import com.conveyal.r5.profile.McRaptorSuboptimalPathProfileRouter; +import com.conveyal.r5.transit.RouteInfo; +import com.conveyal.r5.transit.TransitLayer; +import gnu.trove.list.TIntList; +import gnu.trove.list.array.TIntArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * Fare calculator for the Chicago regional service (CTA, Metra, PACE) + * Assumes use of Ventra, and purchase of 1-Day Pass when it's the best option + */ +public class ChicagoRTAInRoutingFareCalculator extends InRoutingFareCalculator { + public static final int CTA_L_FARE = 250; + public static final int CTA_BUS_FARE = 225; + public static final int PACE_REGULAR_FARE = 200; + public static final int PACE_PREMIUM_FARE = 450; + + // Boarding a Pace Premium route with a CTA-Pace day pass has a surcharge + public static final int PACE_PREMIUM_TRANSFER = 225; + + // Transfers are pay-the-difference, up to two additional boardings within two hours of first boarding + // TODO check if the CTA imposes restrictions on transfers; for example, does subway -> bus -> subway qualify? + + public static final int SUBSEQUENT_RIDES = 2; + public static final int TRANSFER_DURATION_SECONDS = 2 * 60 * 60; + + // Day pass provides unlimited rides, except on Pace Premium routes ($2.25 per boarding upcharge) and Metra + public static final int CTA_PACE_DAY_PASS = 500; + + private static final Set> stationsConnected = new HashSet<>(Arrays.asList( + new HashSet<>(Arrays.asList("41660", "40260", "40370")), // Lake, State/Lake, Washington + new HashSet<>(Arrays.asList("40070", "40560", "40850")) // Jackson (Blue), Jackson (Red), HW Library + )); + + private static boolean platformsConnected(int fromStopIndex, String fromStation, int toStopIndex, String toStation){ + return (fromStopIndex == toStopIndex || // same platform + // different platforms, same station, in stations with behind-gate transfers between platforms + (fromStation != null && fromStation.equals(toStation)) || // TODO check stationsWithoutBehindGateTransfers + // different stations connected with a virtual transfer + stationsConnected.contains(new HashSet<>(Arrays.asList(fromStation, toStation)))); + } + + public static class CTAPaceTransferAllowance extends TransferAllowance { + private final boolean unlimited; + + private CTAPaceTransferAllowance (int value, int number, int expirationTime) { + super(value, number, expirationTime); + this.unlimited = false; + } + + private CTAPaceTransferAllowance (boolean unlimited) { + this.unlimited = unlimited; + } + + private CTAPaceTransferAllowance redeem (int fareToBoard) { + assert this.value + fareToBoard < CTA_PACE_DAY_PASS; + return new CTAPaceTransferAllowance(Math.max(fareToBoard, this.value), this.number - 1, this.expirationTime); + } + + } + + // For now, there are no transfer allowances to/from Metra + + private static final Logger LOG = LoggerFactory.getLogger(ChicagoRTAInRoutingFareCalculator.class); + + private static final WeakHashMap fareSystemCache = new WeakHashMap<>(); + + private RouteBasedFareRules fares; + + // Pace free + private static final Set paceFreeRoutes = new HashSet<>(Arrays.asList("410", "412", "475", "811", "905", + "926")); + private static final Set pacePremiumRoutes = new HashSet<>(Arrays.asList("236", "282", "284", "755", "768", "769", "770", "771", "772", "773", "774", "775", "776", "779", "850", "851", "855")); + + private enum Agency {CTA, METRA, PACE} + + private static int priceToInt(double price) {return (int) (price * 100);} // usd to cents + + private static int payFullFare(Fare fare) {return priceToInt(fare.fare_attribute.price);} + + private static Agency getAgency (RouteInfo route) { + switch (route.agency_id) { + case "PACE": + return Agency.PACE; + case "METRA": + return Agency.METRA; + default: + // CTA GTFS does not include agency_id. + return Agency.CTA; + } + } + + @Override + public FareBounds calculateFare(McRaptorSuboptimalPathProfileRouter.McRaptorState state, int maxClockTime) { + // First, load fare data from GTFS + if (fares == null) { + synchronized (this) { + if (fares == null) { + synchronized (fareSystemCache) { + FareSystemWrapper fareSystem = fareSystemCache.computeIfAbsent(this.transitLayer, + ChicagoRTAInRoutingFareCalculator::loadFaresFromGTFS); + this.fares = fareSystem.fares; + } + } + } + } + + + // Initialize: haven't boarded, paid a fare, or received a transfer allowance + int cumulativeFarePaid = 0; + CTAPaceTransferAllowance transferAllowance = new CTAPaceTransferAllowance(false); + + // Extract relevant data about rides + TIntList patterns = new TIntArrayList(); + TIntList boardStops = new TIntArrayList(); + TIntList alightStops = new TIntArrayList(); + TIntList boardTimes = new TIntArrayList(); + + McRaptorSuboptimalPathProfileRouter.McRaptorState stateForTraversal = state; + while (stateForTraversal != null) { + if (stateForTraversal.pattern == -1) { + stateForTraversal = stateForTraversal.back; + continue; // on the street, not on transit + } + patterns.add(stateForTraversal.pattern); + alightStops.add(stateForTraversal.stop); + boardStops.add(transitLayer.tripPatterns.get(stateForTraversal.pattern).stops[stateForTraversal.boardStopPosition]); + boardTimes.add(stateForTraversal.boardTime); + stateForTraversal = stateForTraversal.back; + } + + // reverse data about the rides so that we can step forward through them + patterns.reverse(); + alightStops.reverse(); + boardStops.reverse(); + boardTimes.reverse(); + + int alightStopIndex; + + // Loop over rides to get to the state in forward-chronological order + for (int ride = 0; ride < patterns.size(); ride++) { + int pattern = patterns.get(ride); + RouteInfo route = transitLayer.routes.get(transitLayer.tripPatterns.get(pattern).routeIndex); + Agency agency = getAgency(route); + + // board stop for this ride + int boardStopIndex = boardStops.get(ride); + String boardStation = transitLayer.parentStationIdForStop.get(boardStopIndex); + String boardStopZoneId = transitLayer.fareZoneForStop.get(boardStopIndex); + int boardClockTime = boardTimes.get(ride); + + // alight stop for this ride + alightStopIndex = alightStops.get(ride); + String alightStopZoneId = transitLayer.fareZoneForStop.get(alightStopIndex); + + if (agency == Agency.METRA) { + // Pay the Metra fare, but don't touch the CTA-Pace transfer allowance + Fare fare = fares.getFareOrDefault(null, boardStopZoneId, alightStopZoneId); + cumulativeFarePaid += payFullFare(fare); + } else { + if (transferAllowance.unlimited) continue; + int fareToPay = 0; + if (agency == Agency.PACE) { + String shortenedRouteId = route.route_id.split("-")[0]; + if (paceFreeRoutes.contains(shortenedRouteId)) continue; + if (pacePremiumRoutes.contains(shortenedRouteId)) fareToPay = PACE_PREMIUM_FARE; + else fareToPay = PACE_REGULAR_FARE; + } if (agency == Agency.CTA) { + if (route.route_type == 1) { + // Boarding metro (CTA "L" service) + if (boardStation.equals("40890")) { // Boarding at O'Hare; buy a day pass to cover the surcharge + cumulativeFarePaid += CTA_PACE_DAY_PASS - transferAllowance.value; + transferAllowance = new CTAPaceTransferAllowance(true); + } + + if (ride > 0) { + // If we have already taken a ride, check whether we can do an in-system (behind fare gate) + // transfer + int fromStopIndex = alightStops.get(ride - 1); + String fromStation = transitLayer.parentStationIdForStop.get(fromStopIndex); + if (platformsConnected(fromStopIndex, fromStation, boardStopIndex, boardStation)) { + // Transfer behind gates, no Ventra tap or change in transfer allowance + continue; + } + } + else { + fareToPay = CTA_L_FARE; + } + } + else fareToPay = CTA_BUS_FARE; + } + if (transferAllowance.number > 0) { + // We have a transfer to redeem + if (fareToPay <= transferAllowance.value) { + // No additional fare required to board + // TODO handle special case of PACE_PREMIUM_TRANSFER from day pass + transferAllowance = transferAllowance.redeem(fareToPay); + } else { + // Additional fare required (transferring to a more expensive service than previously ridden) + if (fareToPay + transferAllowance.value < CTA_PACE_DAY_PASS) { + cumulativeFarePaid += transferAllowance.payDifference(fareToPay); + transferAllowance = transferAllowance.redeem(fareToPay); + } else { + // Should have bought a day pass instead. We'll allow it retroactively. + cumulativeFarePaid += CTA_PACE_DAY_PASS - transferAllowance.value; + transferAllowance = new CTAPaceTransferAllowance(true); + } + } + } else { + // No transfer to redeem; pay full fare and get a fresh transfer allowance. + cumulativeFarePaid += fareToPay; + transferAllowance = new CTAPaceTransferAllowance(fareToPay, SUBSEQUENT_RIDES, + boardClockTime + TRANSFER_DURATION_SECONDS); + } + } + } + return new FareBounds(cumulativeFarePaid, transferAllowance.tightenExpiration(maxClockTime)); + } + + private static class FareSystemWrapper{ + public RouteBasedFareRules fares; + + private FareSystemWrapper(RouteBasedFareRules fares) { + this.fares = fares; + } + } + + private static FareSystemWrapper loadFaresFromGTFS(TransitLayer transitLayer){ + RouteBasedFareRules fares = new RouteBasedFareRules(); + // iterate through fares to record rules + for (Fare fare : transitLayer.fares.values()){ + fares.addFareRules(fare); + } + return new FareSystemWrapper(fares); + } + @Override + public String getType() { + return "chicago-rta"; + } +} diff --git a/src/main/java/com/conveyal/r5/analyst/fare/InRoutingFareCalculator.java b/src/main/java/com/conveyal/r5/analyst/fare/InRoutingFareCalculator.java index db9c03443..57250fd8c 100644 --- a/src/main/java/com/conveyal/r5/analyst/fare/InRoutingFareCalculator.java +++ b/src/main/java/com/conveyal/r5/analyst/fare/InRoutingFareCalculator.java @@ -25,7 +25,8 @@ @JsonSubTypes({ @JsonSubTypes.Type(name = "boston", value = BostonInRoutingFareCalculator.class), @JsonSubTypes.Type(name = "bogota", value = BogotaInRoutingFareCalculator.class), - @JsonSubTypes.Type(name = "chicago", value = ChicagoInRoutingFareCalculator.class), + @JsonSubTypes.Type(name = "chicago-cta", value = ChicagoCTAInRoutingFareCalculator.class), + @JsonSubTypes.Type(name = "chicago-rta", value = ChicagoRTAInRoutingFareCalculator.class), @JsonSubTypes.Type(name = "simple", value = SimpleInRoutingFareCalculator.class), @JsonSubTypes.Type(name = "bogota-mixed", value = BogotaMixedInRoutingFareCalculator.class), @JsonSubTypes.Type(name = "nyc", value = NYCInRoutingFareCalculator.class),