diff --git a/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceFactory.java b/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceFactory.java index 975c4a2759d..f8a10759177 100644 --- a/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceFactory.java +++ b/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceFactory.java @@ -35,9 +35,9 @@ the License, or (props, at your option) any later version. /** * Implements the default GTFS fare rules as described in * http://groups.google.com/group/gtfs-changes/msg/4f81b826cb732f3b - * + * * @author novalis - * + * */ public class DefaultFareServiceFactory implements FareServiceFactory { @@ -111,9 +111,9 @@ public void configure(JsonNode config) { /** * Build a specific FareServiceFactory given the config node, or fallback to the default if none * specified. - * + * * Accept different formats. Examples: - * + * *
      * { fares : "seattle" }
      * --------------------------
@@ -172,6 +172,9 @@ public static FareServiceFactory fromConfig(JsonNode config) {
         case "bike-rental-time-based":
             retval = new TimeBasedBikeRentalFareServiceFactory();
             break;
+        case "dutch":
+            retval = new DutchFareServiceFactory();
+            break;
         case "san-francisco":
             retval = new SFBayFareServiceFactory();
             break;
diff --git a/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceImpl.java b/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceImpl.java
index 7e08c9da149..11e648e1281 100644
--- a/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceImpl.java
+++ b/src/main/java/org/opentripplanner/routing/impl/DefaultFareServiceImpl.java
@@ -215,15 +215,10 @@ public Fare getCost(GraphPath path) {
         for (Map.Entry> kv : fareRulesPerType.entrySet()) {
             FareType fareType = kv.getKey();
             Collection fareRules = kv.getValue();
-
-            // pick up a random currency from fareAttributes,
-            // we assume that all tickets use the same currency
+            // Get the currency from the first fareAttribute, assuming that all tickets use the same currency.
             Currency currency = null;
-            WrappedCurrency wrappedCurrency = null;
             if (fareRules.size() > 0) {
-                currency = Currency.getInstance(fareRules.iterator().next().getFareAttribute()
-                        .getCurrencyType());
-                wrappedCurrency = new WrappedCurrency(currency);
+                currency = Currency.getInstance(fareRules.iterator().next().getFareAttribute().getCurrencyType());
             }
             hasFare = populateFare(fare, currency, fareType, rides, fareRules);
         }
diff --git a/src/main/java/org/opentripplanner/routing/impl/DutchFareServiceFactory.java b/src/main/java/org/opentripplanner/routing/impl/DutchFareServiceFactory.java
new file mode 100644
index 00000000000..f7f2d815fef
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/impl/DutchFareServiceFactory.java
@@ -0,0 +1,23 @@
+/* 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 . */
+
+package org.opentripplanner.routing.impl;
+
+import org.opentripplanner.routing.services.FareService;
+
+public class DutchFareServiceFactory extends DefaultFareServiceFactory {
+    @Override
+    public FareService makeFareService() { 
+        return new DutchFareServiceImpl(regularFareRules.values());
+    }
+}
diff --git a/src/main/java/org/opentripplanner/routing/impl/DutchFareServiceImpl.java b/src/main/java/org/opentripplanner/routing/impl/DutchFareServiceImpl.java
new file mode 100644
index 00000000000..0ee199fdd3a
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/impl/DutchFareServiceImpl.java
@@ -0,0 +1,309 @@
+/* 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 . */
+
+package org.opentripplanner.routing.impl;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Currency;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.geotools.xml.xsi.XSISimpleTypes;
+import org.onebusaway.gtfs.model.AgencyAndId;
+import org.opentripplanner.common.model.P2;
+import org.opentripplanner.routing.core.FareRuleSet;
+import org.opentripplanner.routing.core.Fare;
+import org.opentripplanner.routing.core.Fare.FareType;
+import org.opentripplanner.routing.core.Money;
+import org.opentripplanner.routing.spt.GraphPath;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DutchFareServiceImpl extends DefaultFareServiceImpl {
+
+    public DutchFareServiceImpl(Collection regularFareRules) {
+        addFareRules(FareType.regular, regularFareRules);
+    }
+
+    private static final long serialVersionUID = 1L;
+    @SuppressWarnings("unused")
+    private static final Logger LOG = LoggerFactory.getLogger(DutchFareServiceImpl.class);
+
+    public static final int TRANSFER_DURATION = 60 * 35; /* tranfers within 35 min won't require a new base fare */
+    
+    @Override
+    protected boolean populateFare(Fare fare, Currency currency, FareType fareType, List rides,
+                                   Collection fareRules) {
+        float lowestCost = getLowestCost(fareType, rides, fareRules);
+        if(lowestCost != Float.POSITIVE_INFINITY) {
+            fare.addFare(fareType, getMoney(currency, lowestCost));
+            return true;
+        }
+        return false;
+    }
+ 
+    /* The Netherlands has an almost uniform system for electronic ticketing using a NFC-card, branded as OV-chipkaart.
+     *
+     * To travel through all modes in The Netherlands a uses has two products on their card:
+     * 1) Easy Trip, to travel with all operators except Dutch Railways
+     * 2) Reizen op Saldo, for Dutch Railways which should be explicitly
+     *    loaded on a card and requires the user to select a first or second class.
+     *
+     * Check-in and check-out is done using validators. For our calculation on transerfer time it matters if this
+     * validator is inside the vehicle - we have to wait for the validator to arrive - or we can already check in
+     * on a validator present on the stop, and wait for the vehicle to arrive.
+     *
+     * Reizen op Saldo is limited to Dutch Railways, and always allows to validate inside the stations.
+     * Additionally in the following cases validators are also on the platform or stations.
+     *  - Metro of Amsterdam operated by GVB
+     *  - Metro of Rotterdam operated by RET
+     *  - Lightrail of Utrecht operated by Qbuzz
+     *  - All heavy rail services operated by Arriva, Breng, Connexxion, NS, Syntus and Veolia.
+     *
+     * Leaving the platform or stations implies that the traveler must check-out. Thus a transfer will play a role.
+     *
+     * All other modes by these operators have validators inside the vehicle.
+     *
+     * TODO: It is an optimisation to be able to check-in early. And most likely only be visible by a trip which is
+     * artificially created to test for this implementation.
+     *
+     * Long-Distance-Discount for trains
+     * Between train operators in The Netherlands long distance discount applies under the following condition:
+     *  - a trip between two train operators takes places within 35 minutes
+     *
+     * First the route is maximised per operator.
+     *
+     * The price of the next operator consists of:
+     *     globally traveled units = 100 previous operator(s)
+     *     locally  traveled units = 10
+     *
+     *     (DutchRailwaysPrice(0 + 100) - DutchRailwaysPrice(0)) + (ArrivaPrice(100 + 10) - ArrivaPrice(100))
+     */
+
+    private class UnitsFareZone {
+        public int units;
+        public String fareZone;
+
+        public UnitsFareZone(int units, String fareZone) {
+            this.units = units;
+            this.fareZone = fareZone;
+        }
+    }
+
+    private UnitsFareZone getUnitsByZones(String agencyId, String startZone, String endZone, Collection fareRules) {
+        P2 od = new P2(startZone, endZone);
+
+        LOG.warn("Search " + startZone + " and " + endZone);
+
+        String fareIdStartsWith = agencyId + "::";
+
+        for (FareRuleSet ruleSet : fareRules) {
+            if (ruleSet.getFareAttribute().getId().getId().startsWith(fareIdStartsWith) &&
+                ruleSet.getOriginDestinations().contains(od)) {
+                String fareId = ruleSet.getFareAttribute().getId().getId();
+                String[] parts = fareId.split("::");
+                String fareZone = parts[1];
+
+                LOG.warn("Between " + startZone + " and " + endZone + ": " + (int) ruleSet.getFareAttribute().getPrice() + " (" + fareZone + ")");
+                return new UnitsFareZone((int) ruleSet.getFareAttribute().getPrice(), fareZone);
+            }
+        }
+
+        LOG.warn("Can't find units between " + startZone + " and " + endZone);
+
+        /* TODO: Raise Exception */
+
+        return null;
+    }
+    
+    private float getCostByUnits(String fareZone, int units, int prevSumUnits, Collection fareRules) {
+        if (units == 0) {
+            return 0f;
+        }
+
+        /* Train-units cannot exceed 250 units; http://wiki.ovinnederland.nl/wiki/Tariefeenheid#Tarieven_NS */
+        if (units > 250) {
+            units = 250;
+        }
+
+        float cost = 0f;
+
+        String fareId = fareZone + ":" + (units + prevSumUnits);
+        for (FareRuleSet ruleSet : fareRules) {
+            if (ruleSet.getFareAttribute().getId().getId().equals(fareId)) {
+                cost = ruleSet.getFareAttribute().getPrice();
+                break;
+            }
+        }
+
+        if (cost == 0f) {
+            LOG.warn("Can't find price for " + fareZone + " with " + units + " units");
+
+        } else if (prevSumUnits > 0) {
+
+            fareId = fareZone + ":" + prevSumUnits;
+            for (FareRuleSet ruleSet : fareRules) {
+                if (ruleSet.getFareAttribute().getId().getId().equals(fareId)) {
+                    cost -= ruleSet.getFareAttribute().getPrice();
+                    return cost;
+                }
+            }
+
+            LOG.warn("Can't find price for " + fareZone + " with " + prevSumUnits + " units");
+
+            return Float.POSITIVE_INFINITY;
+        }
+
+        return cost;
+    }
+
+    private float getEasyTripFareByLineFromTo(String route, String firstStop, String lastStop,
+                                              boolean entranceFee, Collection fareRules) {
+
+        float cost = Float.POSITIVE_INFINITY;
+
+        String fareId = route + ":" + firstStop + "-" + lastStop;
+
+        for (FareRuleSet ruleSet : fareRules) {
+            if (ruleSet.getFareAttribute().getId().getId().equals(fareId)) {
+                cost = ruleSet.getFareAttribute().getPrice();
+                break;
+            }
+        }
+
+        if (cost == Float.POSITIVE_INFINITY) {
+            LOG.warn("Can't find price for " + firstStop + " to " + lastStop + " operated on " + route);
+
+            return cost;
+        }
+
+        if (entranceFee) cost += 89f; /* TODO: Configurable? */
+
+        return cost;
+    }
+
+    @Override
+    public Fare getCost(GraphPath path) {
+        Currency euros = Currency.getInstance("EUR");
+        // Use the usual process from the default fare service, but force the currency to Euros.
+        // The default process assumes there is only one currency per set of fare rules and looks at any old rule to
+        // guess what the currency is. This doesn't work on the Dutch data which has distances mixed in with Euros to
+        // account for distance-derived fares.
+        Fare fare = super.getCost(path);
+        if (fare != null) {
+            for (Money money : fare.fare.values()) {
+                money.setCurrency(euros);
+            }
+        }
+        return fare;
+    }
+
+    @Override
+    protected float getLowestCost(FareType fareType, List rides, Collection fareRules) {
+
+        float cost = 0f;
+
+	    int units = 0;
+        int prevSumUnits = 0;
+
+        boolean mustHaveCheckedOut = false;
+        String startTariefEenheden = null;
+        String endTariefEenheden = null;
+        String lastAgencyId = null;
+        String lastFareZone = null;
+
+        long alightedEasyTrip = 0;
+        long alightedTariefEenheden = 0;
+
+        for (Ride ride : rides) {
+            LOG.warn(String.format("%s %s %s %s %s %s", ride.startZone, ride.endZone, ride.firstStop, ride.lastStop, ride.route, ride.agency));
+
+            if (ride.agency.startsWith("IFF:")) {
+                LOG.warn("1. Trains");
+		        /* In Reizen op Saldo we will try to fares as long as possible. */
+
+                /* If our previous agency isn't this agency, then we must have checked out */
+                mustHaveCheckedOut |= !ride.agency.equals(lastAgencyId);
+
+                /* When a user has checked out, we first calculate the units made until then. */
+                if (mustHaveCheckedOut && lastAgencyId != null) {
+                    LOG.warn("2. Must have checked out from a station");
+                    UnitsFareZone unitsFareZone = getUnitsByZones(lastAgencyId, startTariefEenheden, endTariefEenheden, fareRules);
+                    if (unitsFareZone == null) return Float.POSITIVE_INFINITY;
+                    lastFareZone = unitsFareZone.fareZone;
+                    units += unitsFareZone.units;
+                    startTariefEenheden = ride.startZone;
+                    mustHaveCheckedOut = false;
+                }
+
+        		/* The entrance Fee applies if the transfer time ends before the new trip starts. */
+                if ((alightedTariefEenheden + TRANSFER_DURATION) < ride.startTime) {
+                    LOG.warn("3. Exceeded Transfer Time");
+                    cost += getCostByUnits(lastFareZone, units, prevSumUnits, fareRules);
+                    if (cost == Float.POSITIVE_INFINITY) return cost;
+
+                    startTariefEenheden = ride.startZone;
+                    units = 0;
+                    prevSumUnits = 0;
+                    mustHaveCheckedOut = false;
+
+                } else if (!ride.agency.equals(lastAgencyId)) {
+                    LOG.warn("4. Swiched Rail Agency");
+
+                    cost += getCostByUnits(lastFareZone, units, prevSumUnits, fareRules);
+                    if (cost == Float.POSITIVE_INFINITY) return cost;
+
+                    prevSumUnits += units;
+                    units = 0;
+                    startTariefEenheden = ride.startZone;
+                }
+
+                alightedTariefEenheden = ride.endTime;
+                endTariefEenheden = ride.endZone;
+                lastAgencyId = ride.agency;
+
+            } else {
+                LOG.warn("5. Easy Trip");
+
+                /* We are now on Easy Trip, so we must have checked-out from Reizen op Saldo, if we were on it */
+                mustHaveCheckedOut = (startTariefEenheden != null);
+
+                /* The entranceFee applies if the transfer time ends before the new trip starts. */
+                boolean entranceFee = ((alightedEasyTrip + TRANSFER_DURATION) < ride.startTime);
+
+                /* EasyTrip will always calculate its price per leg */
+                cost += getEasyTripFareByLineFromTo(ride.route.getId(), ride.startZone, ride.endZone, entranceFee, fareRules);
+                if (cost == Float.POSITIVE_INFINITY) return cost;
+
+                alightedEasyTrip = ride.endTime;
+            }
+        }
+
+        LOG.warn("6. Final");
+        if (lastAgencyId != null) {
+            UnitsFareZone unitsFareZone = getUnitsByZones(lastAgencyId, startTariefEenheden, endTariefEenheden, fareRules);
+            if (unitsFareZone == null) return Float.POSITIVE_INFINITY;
+
+            lastFareZone = unitsFareZone.fareZone;
+            units += unitsFareZone.units;
+            cost += getCostByUnits(lastFareZone, units, prevSumUnits, fareRules);
+        }
+
+        if (cost == Float.POSITIVE_INFINITY) return cost;
+
+        return cost / 100f;
+    }
+}