Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ORCA Fares: Add free cash transfers for Kitsap transit #5826

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,11 @@ void calculateFareThatExceedsTwoHourFreeTransferWindow() {
getLeg(KITSAP_TRANSIT_AGENCY_ID, 150)
);

var SIX_TIMES_DEFAULT = DEFAULT_TEST_RIDE_PRICE.times(6);
calculateFare(rides, regular, SIX_TIMES_DEFAULT);
calculateFare(rides, FareType.senior, SIX_TIMES_DEFAULT);
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(2));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(2));
calculateFare(rides, FareType.youth, ZERO_USD);
calculateFare(rides, FareType.electronicSpecial, TWO_DOLLARS);
calculateFare(
rides,
FareType.electronicRegular,
DEFAULT_TEST_RIDE_PRICE.plus(DEFAULT_TEST_RIDE_PRICE)
);
calculateFare(rides, FareType.electronicRegular, DEFAULT_TEST_RIDE_PRICE.times(2));
calculateFare(rides, FareType.electronicSenior, TWO_DOLLARS);
calculateFare(rides, FareType.electronicYouth, ZERO_USD);
}
Expand All @@ -228,11 +223,11 @@ void calculateFareThatIncludesNoFreeTransfers() {
getLeg(KITSAP_TRANSIT_AGENCY_ID, 121),
getLeg(WASHINGTON_STATE_FERRIES_AGENCY_ID, 150, "Fauntleroy-VashonIsland")
);
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(4).plus(FERRY_FARE));
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(3).plus(FERRY_FARE));
calculateFare(
rides,
FareType.senior,
DEFAULT_TEST_RIDE_PRICE.times(3).plus(usDollars(.50f)).plus(HALF_FERRY_FARE)
DEFAULT_TEST_RIDE_PRICE.times(2).plus(usDollars(.50f)).plus(HALF_FERRY_FARE)
);
calculateFare(rides, FareType.youth, Money.ZERO_USD);
// We don't get any fares for the skagit transit leg below here because they don't accept ORCA (electronic)
Expand Down Expand Up @@ -267,8 +262,8 @@ void calculateFareThatExceedsTwoHourFreeTransferWindowTwice() {
getLeg(KITSAP_TRANSIT_AGENCY_ID, 240),
getLeg(KITSAP_TRANSIT_AGENCY_ID, 270)
);
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(10));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(10));
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(3));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(3));
calculateFare(rides, FareType.youth, Money.ZERO_USD);
calculateFare(rides, FareType.electronicSpecial, usDollars(3));
calculateFare(rides, FareType.electronicRegular, DEFAULT_TEST_RIDE_PRICE.times(3));
Expand All @@ -290,8 +285,8 @@ void calculateFareThatStartsWithACashFare() {
getLeg(KITSAP_TRANSIT_AGENCY_ID, 120),
getLeg(KITSAP_TRANSIT_AGENCY_ID, 149)
);
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(6));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(6));
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(2));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(2));
calculateFare(rides, FareType.youth, Money.ZERO_USD);
calculateFare(rides, FareType.electronicSpecial, DEFAULT_TEST_RIDE_PRICE.plus(ONE_DOLLAR));
calculateFare(
Expand Down Expand Up @@ -424,16 +419,18 @@ void calculateSoundTransitBusFares() {
}

@Test
void calculateCashFreeTransferKCMetro() {
void calculateCashFreeTransferKCMetroAndKitsap() {
List<Leg> rides = List.of(
getLeg(KC_METRO_AGENCY_ID, 0),
getLeg(KC_METRO_AGENCY_ID, 20),
getLeg(COMM_TRANS_AGENCY_ID, 45),
getLeg(KC_METRO_AGENCY_ID, 60),
getLeg(KC_METRO_AGENCY_ID, 130)
getLeg(KC_METRO_AGENCY_ID, 130),
getLeg(KITSAP_TRANSIT_AGENCY_ID, 131),
getLeg(KITSAP_TRANSIT_AGENCY_ID, 132)
);
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(3));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(3));
calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(4));
calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE.times(4));
calculateFare(rides, FareType.youth, Money.ZERO_USD);
calculateFare(rides, FareType.electronicSpecial, usDollars(1.25f));
calculateFare(rides, FareType.electronicRegular, DEFAULT_TEST_RIDE_PRICE.times(2));
Expand Down
155 changes: 88 additions & 67 deletions src/ext/java/org/opentripplanner/ext/fares/impl/OrcaFareService.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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.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;
Expand All @@ -23,6 +25,7 @@
import org.opentripplanner.transit.model.basic.Money;
import org.opentripplanner.transit.model.framework.FeedScopedId;
import org.opentripplanner.transit.model.network.Route;
import org.opentripplanner.transit.model.organization.Agency;

public class OrcaFareService extends DefaultFareService {

Expand Down Expand Up @@ -50,6 +53,12 @@ public class OrcaFareService extends DefaultFareService {
"cash"
);

protected enum TransferType {
ORCA_INTERAGENCY_TRANSFER,
SAME_AGENCY_TRANSFER,
NO_TRANSFER,
}

protected enum RideType {
COMM_TRANS_LOCAL_SWIFT,
COMM_TRANS_COMMUTER_EXPRESS,
Expand All @@ -75,12 +84,25 @@ protected enum RideType {
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 -> false;
case WASHINGTON_STATE_FERRIES,
SKAGIT_TRANSIT,
WHATCOM_LOCAL,
WHATCOM_CROSS_COUNTY,
SKAGIT_CROSS_COUNTY -> false;
default -> true;
};
}
Expand All @@ -93,6 +115,38 @@ public boolean agencyAcceptsOrca() {
}
}

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.
Expand Down Expand Up @@ -403,17 +457,13 @@ public ItineraryFares calculateFaresForType(
Collection<FareRuleSet> fareRules
) {
var fare = ItineraryFares.empty();
ZonedDateTime freeTransferStartTime = null;
Money cost = Money.ZERO_USD;
Money orcaFareDiscount = Money.ZERO_USD;
var orcaFareDiscount = new TransferData();
HashMap<String, TransferData> perAgencyTransferDiscount = new HashMap<>();

for (Leg leg : legs) {
RideType rideType = getRideType(leg);
assert rideType != null;
boolean ridePermitsFreeTransfers = rideType.permitsFreeTransfers();
if (freeTransferStartTime == null && ridePermitsFreeTransfers) {
// The start of a free transfer must be with a transit agency that permits it!
freeTransferStartTime = leg.getStartTime();
}
Optional<Money> singleLegPrice = getRidePrice(leg, FareType.regular, fareRules);
Optional<Money> optionalLegFare = singleLegPrice.flatMap(slp ->
getLegFare(fareType, rideType, slp, leg)
Expand All @@ -424,61 +474,43 @@ public ItineraryFares calculateFaresForType(
}
Money legFare = optionalLegFare.get();

boolean inFreeTransferWindow = inFreeTransferWindow(
freeTransferStartTime,
leg.getStartTime()
);
if (hasFreeTransfers(fareType, rideType) && inFreeTransferWindow) {
// If using Orca (free transfers), the total fare should be equivalent to the
// most expensive leg of the journey.
// If the new fare is more than the current ORCA amount, the transfer is extended.
if (legFare.greaterThan(orcaFareDiscount)) {
freeTransferStartTime = leg.getStartTime();
// Note: on first leg, discount will be 0 meaning no transfer was applied.
addLegFareProduct(leg, fare, fareType, legFare.minus(orcaFareDiscount), orcaFareDiscount);
orcaFareDiscount = legFare;
} else {
// Ride is free, counts as a transfer if legFare is NOT free
addLegFareProduct(
leg,
fare,
fareType,
Money.ZERO_USD,
legFare.isPositive() ? orcaFareDiscount : Money.ZERO_USD
);
}
} else if (usesOrca(fareType) && !inFreeTransferWindow) {
// If using Orca and outside of the free transfer window, add the cumulative Orca fare (the maximum leg
// fare encountered within the free transfer window).
cost = cost.plus(orcaFareDiscount);

// Reset the free transfer start time and next Orca fare as needed.
if (ridePermitsFreeTransfers) {
// The leg is using a ride type that permits free transfers.
// The next free transfer window begins at the start time of this leg.
freeTransferStartTime = leg.getStartTime();
// Reset the Orca fare to be the fare of this leg.
orcaFareDiscount = legFare;
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 {
// The leg is not using a ride type that permits free transfers.
// Since there are no free transfers for this leg, increase the total cost by the fare for this leg.
cost = cost.plus(legFare);
// The current free transfer window has expired and won't start again until another leg is
// encountered that does have free transfers.
freeTransferStartTime = null;
// The previous Orca fare has been applied to the total cost. Also, the non-free transfer cost has
// also been applied to the total cost. Therefore, the next Orca cost for the next free-transfer
// window needs to be reset to 0 so that it is not applied after looping through all rides.
orcaFareDiscount = Money.ZERO_USD;
transferData = new TransferData();
perAgencyTransferDiscount.put(leg.getAgency().getName(), transferData);
}
addLegFareProduct(leg, fare, fareType, legFare, Money.ZERO_USD);
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);
}
}
cost = cost.plus(orcaFareDiscount);
if (cost.fractionalAmount().floatValue() < Float.MAX_VALUE) {
var fp = FareProduct
.of(new FeedScopedId(FEED_ID, fareType.name()), fareType.name(), cost)
Expand Down Expand Up @@ -552,7 +584,7 @@ protected Map<String, List<Leg>> fareLegsByFeed(List<Leg> fareLegs) {
/**
* Check if trip falls within the transfer time window.
*/
private boolean inFreeTransferWindow(
private static boolean inFreeTransferWindow(
ZonedDateTime freeTransferStartTime,
ZonedDateTime currentLegStartTime
) {
Expand All @@ -562,17 +594,6 @@ private boolean inFreeTransferWindow(
return duration.compareTo(MAX_TRANSFER_DISCOUNT_DURATION) < 0;
}

/**
* A free transfer can be applied if using Orca and the transit agency permits free transfers.
*/
private boolean hasFreeTransfers(FareType fareType, RideType rideType) {
// King County Metro allows transfers on cash fare
return (
(rideType.permitsFreeTransfers() && usesOrca(fareType)) ||
(rideType == RideType.KC_METRO && !usesOrca(fareType))
);
}

/**
* Define Orca fare types.
*/
Expand Down
Loading