/* This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.opentripplanner.routing.impl; import java.io.Serializable; import java.util.Currency; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import org.onebusaway.gtfs.model.AgencyAndId; import org.onebusaway.gtfs.model.FareAttribute; import org.onebusaway.gtfs.model.Stop; import org.opentripplanner.routing.core.Fare; import org.opentripplanner.routing.core.FareRuleSet; import org.opentripplanner.routing.core.State; import org.opentripplanner.routing.core.WrappedCurrency; import org.opentripplanner.routing.core.Fare.FareType; import org.opentripplanner.routing.edgetype.HopEdge; import org.opentripplanner.routing.graph.Edge; import org.opentripplanner.routing.services.FareService; import org.opentripplanner.routing.spt.GraphPath; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** A set of edges on a single route, with associated information for calculating fares */ class Ride { AgencyAndId route; Set<String> zones; String startZone; String endZone; long startTime; long endTime; // in DefaultFareServiceImpl classifier is just the TraverseMode // it can be used differently in custom fare services public Object classifier; public Stop firstStop; public Stop lastStop; public Ride() { zones = new HashSet<String>(); } public String toString() { StringBuilder builder = new StringBuilder(); builder.append("Ride"); if (startZone != null) { builder.append("(from zone "); builder.append(startZone); } if (endZone != null) { builder.append(" to zone "); builder.append(endZone); } builder.append(" on route "); builder.append(route); if (zones.size() > 0) { builder.append(" through zones "); boolean first = true; for (String zone : zones) { if (first) { first = false; } else { builder.append(","); } builder.append(zone); } } builder.append(" at "); builder.append(startTime); if (classifier != null) { builder.append(", classified by "); builder.append(classifier.toString()); } builder.append(")"); return builder.toString(); } } /** * This fare service impl handles the cases that GTFS handles within a single feed. * It cannot necessarily handle multi-feed graphs, because a rule-less fare attribute * might be applied to rides on routes in another feed, for example. * For more interesting fare structures like New York's MTA, or cities with multiple * feeds and inter-feed transfer rules, you get to implement your own FareService. * See this thread on gtfs-changes explaining the proper interpretation of fares.txt: * http://groups.google.com/group/gtfs-changes/browse_thread /thread/8a4a48ae1e742517/4f81b826cb732f3b */ public class DefaultFareServiceImpl implements FareService, Serializable { private static final long serialVersionUID = 20120229L; private static final Logger LOG = LoggerFactory.getLogger(DefaultFareServiceImpl.class); protected HashMap<AgencyAndId, FareRuleSet> fareRules; protected HashMap<AgencyAndId, FareAttribute> fareAttributes; public DefaultFareServiceImpl(HashMap<AgencyAndId, FareRuleSet> fareRules, HashMap<AgencyAndId, FareAttribute> fareAttributes) { this.fareRules = fareRules; this.fareAttributes = fareAttributes; } public static List<Ride> createRides(GraphPath path) { List<Ride> rides = new LinkedList<Ride>(); Ride ride = null; for (State state : path.states) { Edge edge = state.getBackEdge(); if ( ! (edge instanceof HopEdge)) continue; HopEdge hEdge = (HopEdge) edge; if (ride == null || ! state.getRoute().equals(ride.route)) { ride = new Ride(); rides.add(ride); ride.startZone = hEdge.getStartStop().getZoneId(); ride.zones.add(ride.startZone); ride.route = state.getRoute(); ride.startTime = state.getBackState().getTimeSeconds(); ride.firstStop = hEdge.getStartStop(); } ride.lastStop = hEdge.getEndStop(); ride.endZone = ride.lastStop.getZoneId(); ride.zones.add(ride.endZone); ride.endTime = state.getTimeSeconds(); // in default fare service, classify rides by mode ride.classifier = state.getBackMode(); } return rides; } // TODO: Overridable classify method for rides / make rides from list<state> @Override public Fare getCost(GraphPath path) { List<Ride> rides = createRides(path); // If there are no rides, there's no fare. if (rides.size() == 0) { return null; } // pick up a random currency from fareAttributes, // we assume that all tickets use the same currency Currency currency = null; WrappedCurrency wrappedCurrency = null; if (fareAttributes.size() > 0) { currency = Currency.getInstance( fareAttributes.values().iterator().next().getCurrencyType()); wrappedCurrency = new WrappedCurrency(currency); } float lowestCost = getLowestCost(rides); if (lowestCost != Float.POSITIVE_INFINITY) { int fractionDigits = 2; if (currency != null) fractionDigits = currency.getDefaultFractionDigits(); int cents = (int) Math.round(lowestCost * Math.pow(10, fractionDigits)); Fare fare = new Fare(); fare.addFare(FareType.regular, wrappedCurrency, cents); return fare; } else { return null; } } public float getLowestCost(List<Ride> rides) { // Dynamic algorithm to calculate fare cost. // Cell [i,j] holds the best (lowest) cost for a trip from rides[i] to rides[j] float[][] resultTable = new float[rides.size()][rides.size()]; for (int i = 0; i < rides.size(); i++) { // each diagonal for (int j = 0; j < rides.size() - i; j++) { float cost = calculateCost(rides.subList(j, j + i + 1)); if (cost < 0) { LOG.error("negative cost for a ride sequence"); cost = Float.POSITIVE_INFINITY; } resultTable[j][j + i] = cost; for (int k = 0; k < i; k++) { float via = resultTable[j][j + k] + resultTable[j + k + 1][j + i]; if (resultTable[j][j + i] > via) resultTable[j][j + i] = via; } } } return resultTable[0][rides.size() - 1]; } protected float calculateCost(List<Ride> rides) { Set<String> zones = new HashSet<String>(); Set<AgencyAndId> routes = new HashSet<AgencyAndId>(); int transfersUsed = -1; Ride firstRide = rides.get(0); long startTime = firstRide.startTime; String startZone = firstRide.startZone; String endZone = firstRide.endZone; // stops don't really have an agency id, they have the per-feed default id String feedId = firstRide.firstStop.getId().getAgencyId(); long lastRideStartTime = firstRide.startTime; long lastRideEndTime = firstRide.endTime; for (Ride ride : rides) { if ( ! ride.firstStop.getId().getAgencyId().equals(feedId)) { LOG.debug("skipped multi-feed ride sequence {}", rides); return Float.POSITIVE_INFINITY; } lastRideStartTime = ride.startTime; lastRideEndTime = ride.endTime; endZone = ride.endZone; routes.add(ride.route); zones.addAll(ride.zones); transfersUsed += 1; } FareAttribute bestAttribute = null; float bestFare = Float.POSITIVE_INFINITY; long tripTime = lastRideStartTime - startTime; long journeyTime = lastRideEndTime - startTime; // find the best fare that matches this set of rides for (AgencyAndId fareId : fareAttributes.keySet()) { // fares also don't really have an agency id, they will have the per-feed default id if ( ! fareId.getAgencyId().equals(feedId)) continue; FareRuleSet ruleSet = fareRules.get(fareId); if (ruleSet == null || ruleSet.matches(startZone, endZone, zones, routes)) { FareAttribute attribute = fareAttributes.get(fareId); if (attribute.isTransfersSet() && attribute.getTransfers() < transfersUsed) { continue; } // assume transfers are evaluated at boarding time, // as trimet does if (attribute.isTransferDurationSet() && tripTime > attribute.getTransferDuration()) { continue; } if (attribute.isJourneyDurationSet() && journeyTime > attribute.getJourneyDuration()) { continue; } float newFare = attribute.getPrice(); if (newFare < bestFare) { bestAttribute = attribute; bestFare = newFare; } } } LOG.debug("{} best for {}", bestAttribute, rides); if (bestFare == Float.POSITIVE_INFINITY) { if (fareAttributes.isEmpty()) LOG.info("No fare for a ride sequence: {}", rides); else LOG.warn("No fare for a ride sequence: {}", rides); } return bestFare; } }