
org.opentripplanner.ext.fares.impl.AtlantaFareService Maven / Gradle / Ivy
The newest version!
package org.opentripplanner.ext.fares.impl;
import static org.opentripplanner.transit.model.basic.Money.ZERO_USD;
import static org.opentripplanner.transit.model.basic.Money.usDollars;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import java.time.Duration;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Currency;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.opentripplanner.ext.fares.model.FareRuleSet;
import org.opentripplanner.model.fare.FareProduct;
import org.opentripplanner.model.fare.ItineraryFares;
import org.opentripplanner.model.plan.Leg;
import org.opentripplanner.routing.core.FareType;
import org.opentripplanner.transit.model.basic.Money;
import org.opentripplanner.transit.model.framework.FeedScopedId;
import org.opentripplanner.transit.model.network.Route;
public class AtlantaFareService extends DefaultFareService {
private static final ZoneId NEW_YORK_ZONE_ID = ZoneId.of("America/New_York");
public static final String COBB_AGENCY_ID = "2";
public static final String XPRESS_AGENCY_ID = "6";
public static final String MARTA_AGENCY_ID = "5";
public static final String GCT_AGENCY_ID = "4";
public static final Set COBB_FREE_RIDE_SHORT_NAMES = Set.of("blue", "green");
private static final String FEED_ID = "atlanta";
private enum TransferType {
END_TRANSFER, // Ends this transfer entirely.
NO_TRANSFER, // Effectively no transfer, but don't invalidate this transfer
FREE_TRANSFER, // Transfer is free
TRANSFER_WITH_UPCHARGE, // Transfer has a set upcharge
TRANSFER_PAY_DIFFERENCE, // Transfer pays difference between default fares
}
private enum RideType {
FREE_RIDE,
MARTA,
COBB_LOCAL,
COBB_EXPRESS("100", "101", "102"),
GCT_LOCAL,
GCT_EXPRESS_Z1("102", "103a", "110", "swpr"),
GCT_EXPRESS_Z2("101", "103"),
XPRESS_MORNING,
XPRESS_AFTERNOON,
STREETCAR("atlsc");
private final Set routeNames;
RideType(String... members) {
this.routeNames = Arrays.stream(members).map(String::toLowerCase).collect(Collectors.toSet());
}
public boolean routeNamesContains(@Nullable String s) {
if (s == null) {
return false;
}
return routeNames.contains(s.toLowerCase());
}
}
private static class ATLTransfer {
List legs = new ArrayList<>();
Multimap fares = ArrayListMultimap.create();
final FareType fareType;
final Currency currency;
Money lastFareWithTransfer;
int maxRides;
Duration transferWindow;
public ATLTransfer(Currency currency, FareType fareType) {
this.fareType = fareType;
this.currency = currency;
}
/**
* Adds a leg to this transfer.
* @param leg Ride to be added
* @param defaultFare Default fare to use for transfer calculations (usually from GTFS)
* @return Whether the added ride is valid or not. If invalid, then this transfer has ended and a new one is needed for the ride.
*/
public boolean addLeg(Leg leg, Money defaultFare) {
// A transfer will always contain at least one ride.
RideType toRideType = classify(leg);
if (legs.size() == 0) {
legs.add(leg);
fares.put(fareType, defaultFare);
lastFareWithTransfer = defaultFare;
maxRides = getMaxTransfers(toRideType);
transferWindow = getTransferWindow(toRideType);
return true;
}
Leg latestRide = legs.get(legs.size() - 1);
// TODO: Potential problem if the first trip of a transfer is a pay on exit?
var transferStartTime = legs.get(0).getStartTime();
RideType fromRideType = classify(latestRide);
TransferMeta transferClassification = classifyTransfer(
toRideType,
fromRideType,
this.fareType
);
var transferUseTime = transferClassification.payOnExit
? leg.getEndTime()
: leg.getStartTime();
// If transfer is NO_TRANSFER, it will not have a window or maxTransfers set,
// so we only check if it's valid if the transfer is going to be used.
if (!transferClassification.type.equals(TransferType.NO_TRANSFER)) {
// Consider the conditions under which this transfer will no longer be valid.
if (transferClassification.type.equals(TransferType.END_TRANSFER)) {
return false;
} else if (transferUseTime.isAfter(transferStartTime.plus(transferWindow))) {
return false;
} else if (legs.size() > maxRides) {
return false;
}
}
if (transferClassification.type.equals(TransferType.NO_TRANSFER)) {
fares.put(fareType, defaultFare);
// Full fare is charged, but transfer is still valid.
// Ride is not added to rides list since it doesn't count towards transfer limit.
// NOTE: Rides and fares list will not always be in sync because of this.
return true;
}
// All conditions below this point "use" the transfer, so we add the ride.
legs.add(leg);
if (transferClassification.type.equals(TransferType.FREE_TRANSFER)) {
fares.put(fareType, Money.ofFractionalAmount(currency, 0));
lastFareWithTransfer = defaultFare;
return true;
} else if (transferClassification.type.equals(TransferType.TRANSFER_PAY_DIFFERENCE)) {
Money newCost = Money.ZERO_USD;
if (defaultFare.greaterThan(lastFareWithTransfer)) {
newCost = defaultFare.minus(lastFareWithTransfer);
}
fares.put(fareType, newCost);
lastFareWithTransfer = defaultFare;
return true;
} else if (transferClassification.type.equals(TransferType.TRANSFER_WITH_UPCHARGE)) {
fares.put(fareType, transferClassification.upcharge);
lastFareWithTransfer = defaultFare;
return true;
}
return true;
}
public Money getTotal() {
return fares.get(fareType).stream().reduce(ZERO_USD, Money::plus);
}
}
/**
* Get the leg price for a single leg. If testing, this class is being called directly so the required agency cash
* values are not available therefore the default test price is used instead.
*/
protected Money getLegPrice(Leg leg, FareType fareType, Collection fareRules) {
return calculateCost(fareType, Lists.newArrayList(leg), fareRules).orElse(Money.ZERO_USD);
}
private static class TransferMeta {
public final TransferType type;
public final Money upcharge;
public final boolean payOnExit;
/**
* Create a TransferMeta
* @param type Type of transfer
* @param upcharge Upcharge for the transfer in cents
* @param payOnExit Whether the fare is charged at end of leg
*/
public TransferMeta(TransferType type, Money upcharge, boolean payOnExit) {
this.type = type;
this.upcharge = upcharge;
this.payOnExit = payOnExit;
}
public TransferMeta(TransferType type) {
this(type, Money.ZERO_USD, false);
}
}
private static RideType classify(Leg ride) {
Route getRoute = ride.getRoute();
String shortName = getRoute.getShortName();
if (shortName != null) {
shortName = shortName.toLowerCase();
}
switch (getRoute.getAgency().getId().getId()) {
case COBB_AGENCY_ID -> {
if (RideType.COBB_EXPRESS.routeNamesContains(shortName)) {
return RideType.COBB_EXPRESS;
} else if (COBB_FREE_RIDE_SHORT_NAMES.contains(shortName)) {
return RideType.FREE_RIDE;
}
return RideType.COBB_LOCAL;
}
case XPRESS_AGENCY_ID -> {
// Get hour of trip start
long hours = ride.getStartTime().withZoneSameInstant(NEW_YORK_ZONE_ID).getHour();
if (hours >= 12) {
return RideType.XPRESS_AFTERNOON;
} else {
return RideType.XPRESS_MORNING;
}
}
case GCT_AGENCY_ID -> {
if (RideType.GCT_EXPRESS_Z1.routeNamesContains(shortName)) {
return RideType.GCT_EXPRESS_Z1;
} else if (RideType.GCT_EXPRESS_Z2.routeNamesContains(shortName)) {
return RideType.GCT_EXPRESS_Z2;
}
return RideType.GCT_LOCAL;
}
// Also catches MARTA_AGENCY_ID
default -> {
// Streetcar GTFS published by MARTA
if (RideType.STREETCAR.routeNamesContains(shortName)) {
return RideType.STREETCAR;
}
return RideType.MARTA;
}
}
}
private static int getMaxTransfers(RideType rideType) {
return switch (rideType) {
// GCT only allows 3 transfers.
case GCT_EXPRESS_Z1, GCT_LOCAL, GCT_EXPRESS_Z2 -> 3;
default -> 4;
};
}
private static Duration getTransferWindow(RideType ignored) {
return Duration.ofHours(3);
}
private static TransferMeta classifyTransfer(
RideType toRideType,
RideType fromRideType,
FareType fareType
) {
switch (toRideType) {
case STREETCAR:
case FREE_RIDE:
return new TransferMeta(TransferType.NO_TRANSFER);
case COBB_LOCAL:
if (!isElectronicPayment(fareType)) {
if (fromRideType == RideType.COBB_LOCAL || fromRideType == RideType.COBB_EXPRESS) {
return new TransferMeta(TransferType.FREE_TRANSFER);
}
return new TransferMeta(TransferType.END_TRANSFER);
}
return switch (fromRideType) {
case COBB_LOCAL, COBB_EXPRESS, MARTA -> new TransferMeta(TransferType.FREE_TRANSFER);
default -> new TransferMeta(TransferType.END_TRANSFER);
};
case COBB_EXPRESS:
if (!isElectronicPayment(fareType)) {
return switch (fromRideType) {
case COBB_EXPRESS -> new TransferMeta(TransferType.FREE_TRANSFER);
case COBB_LOCAL -> new TransferMeta(TransferType.TRANSFER_PAY_DIFFERENCE);
default -> new TransferMeta(TransferType.END_TRANSFER);
};
}
// Electronic payment
return switch (fromRideType) {
case COBB_EXPRESS, MARTA -> new TransferMeta(TransferType.FREE_TRANSFER);
case COBB_LOCAL -> new TransferMeta(TransferType.TRANSFER_PAY_DIFFERENCE);
default -> new TransferMeta(TransferType.NO_TRANSFER);
};
case MARTA:
if (!isElectronicPayment(fareType)) return new TransferMeta(TransferType.END_TRANSFER);
return switch (fromRideType) {
case MARTA,
XPRESS_AFTERNOON,
XPRESS_MORNING,
COBB_LOCAL,
COBB_EXPRESS,
GCT_EXPRESS_Z1,
GCT_EXPRESS_Z2,
GCT_LOCAL -> new TransferMeta(TransferType.FREE_TRANSFER);
default -> new TransferMeta(TransferType.END_TRANSFER);
};
case XPRESS_MORNING:
case XPRESS_AFTERNOON:
boolean payOnExit = toRideType == RideType.XPRESS_AFTERNOON;
if (!isElectronicPayment(fareType)) return new TransferMeta(TransferType.END_TRANSFER);
return switch (fromRideType) {
case MARTA,
COBB_EXPRESS,
GCT_EXPRESS_Z1,
GCT_EXPRESS_Z2,
XPRESS_AFTERNOON,
XPRESS_MORNING -> new TransferMeta(
TransferType.FREE_TRANSFER,
Money.ZERO_USD,
payOnExit
);
case COBB_LOCAL -> new TransferMeta(
TransferType.TRANSFER_WITH_UPCHARGE,
usDollars(1.50f),
payOnExit
);
case GCT_LOCAL -> new TransferMeta(
TransferType.TRANSFER_WITH_UPCHARGE,
usDollars(1),
payOnExit
);
default -> new TransferMeta(TransferType.END_TRANSFER);
};
case GCT_LOCAL:
if (!isElectronicPayment(fareType)) return new TransferMeta(TransferType.END_TRANSFER);
return switch (fromRideType) {
case MARTA, GCT_LOCAL, GCT_EXPRESS_Z1, GCT_EXPRESS_Z2 -> new TransferMeta(
TransferType.FREE_TRANSFER
);
default -> new TransferMeta(TransferType.END_TRANSFER);
};
case GCT_EXPRESS_Z1:
case GCT_EXPRESS_Z2:
if (!isElectronicPayment(fareType)) return new TransferMeta(TransferType.END_TRANSFER);
return switch (fromRideType) {
case MARTA -> new TransferMeta(TransferType.FREE_TRANSFER);
case GCT_LOCAL, GCT_EXPRESS_Z1, GCT_EXPRESS_Z2 -> new TransferMeta(
TransferType.TRANSFER_PAY_DIFFERENCE
);
default -> new TransferMeta(TransferType.END_TRANSFER);
};
default:
return new TransferMeta(TransferType.END_TRANSFER);
}
}
private static boolean isElectronicPayment(FareType fareType) {
return (
fareType.equals(FareType.electronicRegular) ||
fareType.equals(FareType.electronicSenior) ||
fareType.equals(FareType.electronicSpecial) ||
fareType.equals(FareType.electronicYouth)
);
}
public AtlantaFareService(Collection regularFareRules) {
addFareRules(FareType.regular, regularFareRules);
addFareRules(FareType.senior, regularFareRules);
addFareRules(FareType.youth, regularFareRules);
addFareRules(FareType.electronicRegular, regularFareRules);
addFareRules(FareType.electronicYouth, regularFareRules);
addFareRules(FareType.electronicSpecial, regularFareRules);
addFareRules(FareType.electronicSenior, regularFareRules);
}
/**
* In the base class only the rules for a specific feed are selected and then passed to the
* fare engine, however here we want to explicitly compute fares across feed boundaries.
*/
@Nullable
@Override
protected Collection fareRulesForFeed(FareType fareType, String feedId) {
return fareRulesPerType.get(fareType);
}
@Override
public ItineraryFares calculateFaresForType(
Currency currency,
FareType fareType,
List legs,
Collection fareRules
) {
List transfers = new ArrayList<>();
for (var ride : legs) {
Money defaultFare = getLegPrice(ride, fareType, fareRules);
if (transfers.isEmpty()) {
transfers.add(new ATLTransfer(currency, fareType));
}
ATLTransfer latestTransfer = transfers.get(transfers.size() - 1);
if (!latestTransfer.addLeg(ride, defaultFare)) {
// Transfer is invalid, create a new one.
ATLTransfer newXfer = new ATLTransfer(currency, fareType);
newXfer.addLeg(ride, defaultFare);
transfers.add(newXfer);
}
}
Money cost = Money.ZERO_USD;
for (ATLTransfer transfer : transfers) {
cost = cost.plus(transfer.getTotal());
}
var fareProduct = new FareProduct(
new FeedScopedId(FEED_ID, fareType.name()),
fareType.name(),
cost,
null,
null,
null
);
var fare = ItineraryFares.empty();
fare.addItineraryProducts(List.of(fareProduct));
return fare;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy