Skip to content

Commit

Permalink
Merge pull request #5826 from ibi-group/kitsap-free-transfers
Browse files Browse the repository at this point in the history
ORCA Fares: Add free cash transfers for Kitsap transit
  • Loading branch information
leonardehrenfried authored May 6, 2024
2 parents 5b3be61 + e33d710 commit f4f1e39
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 85 deletions.
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

0 comments on commit f4f1e39

Please sign in to comment.