
org.opentripplanner.ext.fares.impl.OrcaFareService Maven / Gradle / Ivy
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.Lists;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nullable;
import org.opentripplanner.ext.fares.model.FareRuleSet;
import org.opentripplanner.framework.i18n.I18NString;
import org.opentripplanner.model.fare.FareMedium;
import org.opentripplanner.model.fare.FareProduct;
import org.opentripplanner.model.fare.ItineraryFares;
import org.opentripplanner.model.fare.RiderCategory;
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 OrcaFareService extends DefaultFareService {
private static final Duration MAX_TRANSFER_DISCOUNT_DURATION = Duration.ofHours(2);
public static final String COMM_TRANS_AGENCY_ID = "29";
public static final String COMM_TRANS_FLEX_AGENCY_ID = "4969";
public static final String KC_METRO_AGENCY_ID = "1";
public static final String SOUND_TRANSIT_AGENCY_ID = "40";
public static final String T_LINK_AGENCY_ID = "F1";
public static final String EVERETT_TRANSIT_AGENCY_ID = "97";
public static final String PIERCE_COUNTY_TRANSIT_AGENCY_ID = "3";
public static final String SKAGIT_TRANSIT_AGENCY_ID = "e0e4541a-2714-487b-b30c-f5c6cb4a310f";
public static final String SEATTLE_STREET_CAR_AGENCY_ID = "23";
public static final String WASHINGTON_STATE_FERRIES_AGENCY_ID = "95";
public static final String KITSAP_TRANSIT_AGENCY_ID = "kt";
public static final String WHATCOM_AGENCY_ID = "14";
public static final int ROUTE_TYPE_FERRY = 4;
public static final String FEED_ID = "orca";
private static final FareMedium ELECTRONIC_MEDIUM = new FareMedium(
new FeedScopedId(FEED_ID, "electronic"),
"electronic"
);
private static final FareMedium CASH_MEDIUM = new FareMedium(
new FeedScopedId(FEED_ID, "cash"),
"cash"
);
// TODO: Remove after mar 1
private static final LocalDate CT_FARE_CHANGE_DATE = LocalDate.of(2025, 3, 1);
protected enum TransferType {
ORCA_INTERAGENCY_TRANSFER,
SAME_AGENCY_TRANSFER,
NO_TRANSFER,
}
protected enum RideType {
COMM_TRANS_LOCAL_SWIFT,
EVERETT_TRANSIT,
KC_WATER_TAXI_VASHON_ISLAND,
KC_WATER_TAXI_WEST_SEATTLE,
KC_METRO,
KITSAP_TRANSIT,
KITSAP_TRANSIT_FAST_FERRY_EASTBOUND,
KITSAP_TRANSIT_FAST_FERRY_WESTBOUND,
PIERCE_COUNTY_TRANSIT,
SKAGIT_TRANSIT,
SEATTLE_STREET_CAR,
SOUND_TRANSIT,
SOUND_TRANSIT_BUS,
SOUND_TRANSIT_SOUNDER,
SOUND_TRANSIT_T_LINK,
SOUND_TRANSIT_LINK,
WASHINGTON_STATE_FERRIES,
WHATCOM_LOCAL,
WHATCOM_CROSS_COUNTY,
SKAGIT_LOCAL,
SKAGIT_CROSS_COUNTY,
UNKNOWN;
public TransferType getTransferType(FareType fareType) {
if (usesOrca(fareType) && this.permitsFreeTransfers()) {
return TransferType.ORCA_INTERAGENCY_TRANSFER;
} else if (this == KC_METRO || this == KITSAP_TRANSIT) {
return TransferType.SAME_AGENCY_TRANSFER;
}
return TransferType.NO_TRANSFER;
}
/**
* All transit agencies permit free transfers, apart from these.
*/
public boolean permitsFreeTransfers() {
return switch (this) {
case WASHINGTON_STATE_FERRIES,
SKAGIT_TRANSIT,
WHATCOM_LOCAL,
WHATCOM_CROSS_COUNTY,
SKAGIT_CROSS_COUNTY -> false;
default -> true;
};
}
public boolean agencyAcceptsOrca() {
return switch (this) {
case WHATCOM_LOCAL, WHATCOM_CROSS_COUNTY, SKAGIT_CROSS_COUNTY, SKAGIT_LOCAL -> false;
default -> true;
};
}
}
static class TransferData {
private Money transferDiscount;
private ZonedDateTime transferStartTime;
public Money getTransferDiscount() {
if (this.transferDiscount == null) {
return ZERO_USD;
}
return this.transferDiscount;
}
public Money getDiscountedLegPrice(Leg leg, Money legPrice) {
if (transferStartTime != null) {
var inFreeTransferWindow = inFreeTransferWindow(transferStartTime, leg.getStartTime());
if (inFreeTransferWindow) {
if (legPrice.greaterThan(transferDiscount)) {
this.transferStartTime = leg.getStartTime();
var discountedLegFare = legPrice.minus(this.transferDiscount);
this.transferDiscount = legPrice;
return discountedLegFare;
}
return Money.ZERO_USD;
}
}
// Start a new transfer
this.transferDiscount = legPrice;
this.transferStartTime = leg.getStartTime();
return legPrice;
}
}
/**
* Categorizes a leg based on various parameters.
* The classifications determine the various rules and fares applied to the leg.
* @param leg Leg to be classified.
* @return RideType classification
*/
static RideType getRideType(Leg leg) {
var agencyId = leg.getAgency().getId().getId();
var route = leg.getRoute();
var tripId = leg.getTrip().getId().getId();
return switch (agencyId) {
case COMM_TRANS_AGENCY_ID, COMM_TRANS_FLEX_AGENCY_ID -> {
try {
int routeId = Integer.parseInt(route.getShortName());
if (routeId >= 500 && routeId < 600) {
yield RideType.SOUND_TRANSIT_BUS; // CommTrans operates some ST routes.
}
yield RideType.COMM_TRANS_LOCAL_SWIFT;
} catch (NumberFormatException e) {
yield RideType.COMM_TRANS_LOCAL_SWIFT;
}
}
case KC_METRO_AGENCY_ID -> {
try {
int routeId = Integer.parseInt(route.getShortName());
if (routeId >= 500 && routeId < 600) {
yield RideType.SOUND_TRANSIT_BUS;
}
} catch (NumberFormatException ignored) {
// Lettered routes exist, are not an error.
}
if ("973".equals(route.getShortName())) {
yield RideType.KC_WATER_TAXI_WEST_SEATTLE;
} else if ("975".equals(route.getShortName())) {
yield RideType.KC_WATER_TAXI_VASHON_ISLAND;
}
yield RideType.KC_METRO;
}
case PIERCE_COUNTY_TRANSIT_AGENCY_ID -> {
try {
int routeId = Integer.parseInt(route.getShortName());
if (routeId >= 520 && routeId < 600) {
// PierceTransit operates some ST routes. But 500 and 501 are PT routes.
yield RideType.SOUND_TRANSIT_BUS;
}
yield RideType.PIERCE_COUNTY_TRANSIT;
} catch (NumberFormatException e) {
yield RideType.PIERCE_COUNTY_TRANSIT;
}
}
case SOUND_TRANSIT_AGENCY_ID -> RideType.SOUND_TRANSIT;
case EVERETT_TRANSIT_AGENCY_ID -> RideType.EVERETT_TRANSIT;
case SKAGIT_TRANSIT_AGENCY_ID -> Set.of("80X", "90X").contains(route.getShortName())
? RideType.SKAGIT_CROSS_COUNTY
: RideType.SKAGIT_LOCAL;
case SEATTLE_STREET_CAR_AGENCY_ID -> RideType.SEATTLE_STREET_CAR;
case WASHINGTON_STATE_FERRIES_AGENCY_ID -> RideType.WASHINGTON_STATE_FERRIES;
case T_LINK_AGENCY_ID -> RideType.SOUND_TRANSIT_T_LINK;
case KITSAP_TRANSIT_AGENCY_ID -> {
if (route.getGtfsType() == ROUTE_TYPE_FERRY) {
// Additional trip id checks are required to distinguish Kitsap fast ferry routes.
if (tripId.contains("east")) {
yield RideType.KITSAP_TRANSIT_FAST_FERRY_EASTBOUND;
} else if (tripId.contains("west")) {
yield RideType.KITSAP_TRANSIT_FAST_FERRY_WESTBOUND;
}
}
yield RideType.KITSAP_TRANSIT;
}
case WHATCOM_AGENCY_ID -> "80X".equals(route.getShortName())
? RideType.WHATCOM_CROSS_COUNTY
: RideType.WHATCOM_LOCAL;
default -> RideType.UNKNOWN;
};
}
public OrcaFareService(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);
}
/**
* Define which discount fare should be applied based on the fare type. If the ride type is
* unknown the discount fare can not be applied, use the default fare.
*/
private Optional getLegFare(
FareType fareType,
RideType rideType,
Money defaultFare,
Leg leg
) {
if (rideType == null) {
return Optional.of(defaultFare);
}
// Filter out agencies that don't accept ORCA from the electronic fare type
if (usesOrca(fareType) && !rideType.agencyAcceptsOrca()) {
return Optional.empty();
}
return switch (fareType) {
case youth, electronicYouth -> Optional.of(getYouthFare());
case electronicSpecial -> getLiftFare(rideType, defaultFare, leg);
case electronicSenior, senior -> getSeniorFare(fareType, rideType, defaultFare, leg);
case regular, electronicRegular -> getRegularFare(fareType, rideType, defaultFare, leg);
default -> Optional.of(defaultFare);
};
}
private static Optional optionalUSD(float amount) {
return Optional.of(usDollars(amount));
}
private static Optional getCTLocalReducedFare(Leg leg) {
if (
leg.getStartTime().isBefore(CT_FARE_CHANGE_DATE.atStartOfDay(leg.getStartTime().getZone()))
) {
return optionalUSD(1.25f);
} else {
return optionalUSD(1.00f);
}
}
/**
* Apply regular discount fares. If the ride type cannot be matched the default fare is used.
*/
private Optional getRegularFare(
FareType fareType,
RideType rideType,
Money defaultFare,
Leg leg
) {
Route route = leg.getRoute();
if (route == null) {
return Optional.of(defaultFare);
}
return switch (rideType) {
case KC_WATER_TAXI_VASHON_ISLAND -> usesOrca(fareType)
? optionalUSD(5.75f)
: optionalUSD(6.75f);
case KC_WATER_TAXI_WEST_SEATTLE -> usesOrca(fareType) ? optionalUSD(5f) : optionalUSD(5.75f);
case KITSAP_TRANSIT_FAST_FERRY_EASTBOUND -> optionalUSD(2f);
case KITSAP_TRANSIT_FAST_FERRY_WESTBOUND -> optionalUSD(10f);
case WASHINGTON_STATE_FERRIES -> Optional.of(
getWashingtonStateFerriesFare(route.getLongName(), fareType, defaultFare)
);
case SOUND_TRANSIT_BUS -> optionalUSD(3.25f);
case WHATCOM_LOCAL,
WHATCOM_CROSS_COUNTY,
SKAGIT_LOCAL,
SKAGIT_CROSS_COUNTY -> fareType.equals(FareType.electronicRegular)
? Optional.empty()
: Optional.of(defaultFare);
default -> Optional.of(defaultFare);
};
}
/**
* Apply Orca lift discount fares based on the ride type.
*/
private Optional getLiftFare(RideType rideType, Money defaultFare, Leg leg) {
var route = leg.getRoute();
if (route == null) {
return Optional.of(defaultFare);
}
return switch (rideType) {
case COMM_TRANS_LOCAL_SWIFT -> getCTLocalReducedFare(leg);
case KC_WATER_TAXI_VASHON_ISLAND -> optionalUSD(4.5f);
case KC_WATER_TAXI_WEST_SEATTLE -> optionalUSD(3.75f);
case KC_METRO,
SOUND_TRANSIT,
SOUND_TRANSIT_BUS,
SOUND_TRANSIT_LINK,
SOUND_TRANSIT_SOUNDER,
SOUND_TRANSIT_T_LINK,
KITSAP_TRANSIT,
EVERETT_TRANSIT,
PIERCE_COUNTY_TRANSIT,
SEATTLE_STREET_CAR -> optionalUSD(1.00f);
case WASHINGTON_STATE_FERRIES -> Optional.of(
getWashingtonStateFerriesFare(route.getLongName(), FareType.electronicSpecial, defaultFare)
);
case KITSAP_TRANSIT_FAST_FERRY_EASTBOUND -> optionalUSD((1f));
case KITSAP_TRANSIT_FAST_FERRY_WESTBOUND -> optionalUSD((5f));
case SKAGIT_LOCAL,
SKAGIT_CROSS_COUNTY,
WHATCOM_CROSS_COUNTY,
WHATCOM_LOCAL -> Optional.empty();
default -> Optional.of(defaultFare);
};
}
/**
* Apply senior discount fares based on the fare and ride types.
*/
private Optional getSeniorFare(
FareType fareType,
RideType rideType,
Money defaultFare,
Leg leg
) {
var route = leg.getRoute();
if (route == null) {
return Optional.of(defaultFare);
}
// Many agencies only provide senior discount if using ORCA
return switch (rideType) {
case COMM_TRANS_LOCAL_SWIFT -> getCTLocalReducedFare(leg);
case SKAGIT_TRANSIT, WHATCOM_LOCAL, SKAGIT_LOCAL -> optionalUSD(0.5f);
case EVERETT_TRANSIT -> optionalUSD(0.5f);
case KITSAP_TRANSIT_FAST_FERRY_EASTBOUND,
SOUND_TRANSIT,
SOUND_TRANSIT_BUS,
SOUND_TRANSIT_LINK,
SOUND_TRANSIT_SOUNDER,
SOUND_TRANSIT_T_LINK,
KC_METRO,
PIERCE_COUNTY_TRANSIT,
SEATTLE_STREET_CAR,
KITSAP_TRANSIT -> optionalUSD(1f);
case KC_WATER_TAXI_VASHON_ISLAND -> optionalUSD(3f);
case KC_WATER_TAXI_WEST_SEATTLE -> optionalUSD(2.5f);
case KITSAP_TRANSIT_FAST_FERRY_WESTBOUND -> optionalUSD(5f);
// Discount specific to Skagit transit and not Orca.
case WASHINGTON_STATE_FERRIES -> Optional.of(
getWashingtonStateFerriesFare(route.getLongName(), fareType, defaultFare)
);
case WHATCOM_CROSS_COUNTY, SKAGIT_CROSS_COUNTY -> Optional.of(defaultFare.half());
default -> Optional.of(defaultFare);
};
}
/**
* Apply youth discount fares based on the ride type. Youth ride free in Washington.
*/
private Money getYouthFare() {
return Money.ZERO_USD;
}
/**
* Get the washington state ferries fare matching the route long name and fare type. If no match
* is found, return the default fare.
*/
private Money getWashingtonStateFerriesFare(
I18NString routeLongName,
FareType fareType,
Money defaultFare
) {
if (routeLongName == null || routeLongName.toString().isEmpty()) {
return defaultFare;
}
return switch (fareType) {
case youth, electronicYouth -> Money.ZERO_USD;
case regular, electronicRegular, electronicSpecial -> defaultFare;
case senior, electronicSenior -> defaultFare.half().roundDownToNearestFiveMinorUnits();
};
}
/**
* Get the ride 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 Optional getRidePrice(
Leg leg,
FareType fareType,
Collection fareRules
) {
return calculateCost(fareType, Lists.newArrayList(leg), fareRules);
}
/**
* Calculate the cost of a journey. Where free transfers are not permitted the cash price is used.
* If free transfers are applicable, the most expensive discount fare across all legs is added to
* the final cumulative price.
*
* The computed fare for Orca card users takes into account real-time trip updates where available,
* so that, for instance, when a leg on a long itinerary is delayed to begin after the initial two
* hour window has expired, the calculated fare for that trip will be two one-way fares instead of
* one.
*/
@Override
public ItineraryFares calculateFaresForType(
Currency currency,
FareType fareType,
List legs,
Collection fareRules
) {
var fare = ItineraryFares.empty();
Money cost = Money.ZERO_USD;
var orcaFareDiscount = new TransferData();
HashMap perAgencyTransferDiscount = new HashMap<>();
for (Leg leg : legs) {
RideType rideType = getRideType(leg);
assert rideType != null;
Optional singleLegPrice = getRidePrice(leg, FareType.regular, fareRules);
Optional optionalLegFare = singleLegPrice.flatMap(slp ->
getLegFare(fareType, rideType, slp, leg)
);
if (optionalLegFare.isEmpty()) {
// If there is no fare for this leg then skip the rest of the logic.
continue;
}
Money legFare = optionalLegFare.get();
var transferType = rideType.getTransferType(fareType);
if (transferType == TransferType.ORCA_INTERAGENCY_TRANSFER) {
// Important to get transfer discount before calculating next leg price
var transferDiscount = orcaFareDiscount.getTransferDiscount();
var discountedFare = orcaFareDiscount.getDiscountedLegPrice(leg, legFare);
addLegFareProduct(
leg,
fare,
fareType,
discountedFare,
legFare.greaterThan(ZERO_USD) ? transferDiscount : ZERO_USD
);
cost = cost.plus(discountedFare);
} else if (transferType == TransferType.SAME_AGENCY_TRANSFER) {
TransferData transferData;
if (perAgencyTransferDiscount.containsKey(leg.getAgency().getName())) {
transferData = perAgencyTransferDiscount.get(leg.getAgency().getName());
} else {
transferData = new TransferData();
perAgencyTransferDiscount.put(leg.getAgency().getName(), transferData);
}
var transferDiscount = transferData.getTransferDiscount();
var discountedFare = transferData.getDiscountedLegPrice(leg, legFare);
addLegFareProduct(
leg,
fare,
fareType,
discountedFare,
legFare.greaterThan(ZERO_USD) ? transferDiscount : ZERO_USD
);
cost = cost.plus(discountedFare);
} else {
// If not using Orca, add the agency's default price for this leg.
addLegFareProduct(leg, fare, fareType, legFare, Money.ZERO_USD);
cost = cost.plus(legFare);
}
}
if (cost.fractionalAmount().floatValue() < Float.MAX_VALUE) {
var fp = FareProduct.of(
new FeedScopedId(FEED_ID, fareType.name()),
fareType.name(),
cost
).build();
fare.addItineraryProducts(List.of(fp));
}
return fare;
}
/**
* Adds a leg fare product to the given itinerary fares object
*
* @param leg The leg to create a fareproduct for
* @param itineraryFares The itinerary fares to store the fare product in
* @param fareType Fare type (split into container and rider category)
* @param totalFare Total fare paid after transfer
* @param transferDiscount Transfer discount applied
*/
private static void addLegFareProduct(
Leg leg,
ItineraryFares itineraryFares,
FareType fareType,
Money totalFare,
Money transferDiscount
) {
var id = new FeedScopedId(FEED_ID, "farePayment");
var riderCategory = getRiderCategory(fareType);
FareMedium medium;
if (usesOrca(fareType)) {
medium = ELECTRONIC_MEDIUM;
} else {
medium = CASH_MEDIUM;
}
var duration = Duration.ZERO;
var fareProduct = new FareProduct(id, "rideCost", totalFare, duration, riderCategory, medium);
itineraryFares.addFareProduct(leg, fareProduct);
// If a transfer was used, then also add a transfer fare product.
if (transferDiscount.isPositive()) {
var transferFareProduct = new FareProduct(
id,
"transfer",
transferDiscount,
duration,
riderCategory,
medium
);
itineraryFares.addFareProduct(leg, transferFareProduct);
}
}
/**
* 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);
}
/**
* Disables functionality grouping legs by their feed.
* This ensures we can calculate transfers between agencies/feeds.
*/
@Override
protected Map> fareLegsByFeed(List fareLegs) {
return Map.of(FEED_ID, fareLegs);
}
/**
* Check if trip falls within the transfer time window.
*/
private static boolean inFreeTransferWindow(
ZonedDateTime freeTransferStartTime,
ZonedDateTime currentLegStartTime
) {
// If there is no free transfer, then return false.
if (freeTransferStartTime == null) return false;
Duration duration = Duration.between(freeTransferStartTime, currentLegStartTime);
return duration.compareTo(MAX_TRANSFER_DISCOUNT_DURATION) < 0;
}
/**
* Define Orca fare types.
*/
private static boolean usesOrca(FareType fareType) {
return (
fareType.equals(FareType.electronicSpecial) ||
fareType.equals(FareType.electronicSenior) ||
fareType.equals(FareType.electronicRegular) ||
fareType.equals(FareType.electronicYouth)
);
}
private static RiderCategory getRiderCategory(FareType fareType) {
var splitFareType = fareType.toString().split("electronic");
String name;
if (splitFareType.length > 1) {
name = splitFareType[1].toLowerCase();
} else {
name = fareType.toString();
}
return new RiderCategory(new FeedScopedId(FEED_ID, name), name, null);
}
}