Skip to content

Commit

Permalink
add fare zone based pt pricing (#3382)
Browse files Browse the repository at this point in the history
* first try to implement fare zone based pt pricing, deutschlandtarif numbers still missing

* put deutschlandtarif price linear functions into code

* unit test for FareZoneBasedPtFareHandler
  • Loading branch information
simei94 authored Aug 8, 2024
1 parent 12b08e1 commit ea1f72c
Show file tree
Hide file tree
Showing 10 changed files with 310 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class DistanceBasedPtFareParams extends ReflectiveConfigGroup {
public static final String LONG_DISTANCE_TRIP_THRESHOLD = "longDistanceTripThreshold";
public static final String LONG_DISTANCE_TRIP_SLOPE = "longDistanceTripSlope";
public static final String LONG_DISTANCE_TRIP_INTERCEPT = "longDistanceTripIntercept";
public static final String FARE_ZONE_SHP = "fareZoneShp";

@PositiveOrZero
private double minFare = 2.0;
Expand All @@ -35,6 +36,7 @@ public class DistanceBasedPtFareParams extends ReflectiveConfigGroup {
private double longDistanceTripIntercept = 30.0;
@PositiveOrZero
private double longDistanceTripSlope = 0.00025;
private String fareZoneShp;

public DistanceBasedPtFareParams() {
super(SET_NAME);
Expand All @@ -52,6 +54,7 @@ public Map<String, String> getComments() {
map.put(LONG_DISTANCE_TRIP_THRESHOLD, "Threshold of the long trips in meters. Below this value, " +
"the trips are considered as normal trips. Above this value, the trips are considered as " +
"inter-city trips");
map.put(FARE_ZONE_SHP, "Shp file with fare zone(s). This parameter is only used for PtFareCalculationModel 'fareZoneBased'.");
return map;
}

Expand Down Expand Up @@ -114,4 +117,14 @@ public double getLongDistanceTripThreshold() {
public void setLongDistanceTripThreshold(double longDistanceTripThreshold) {
this.longDistanceTripThreshold = longDistanceTripThreshold;
}

@StringGetter(FARE_ZONE_SHP)
public String getFareZoneShp() {
return fareZoneShp;
}

@StringSetter(FARE_ZONE_SHP)
public void setFareZoneShp(String fareZoneShp) {
this.fareZoneShp = fareZoneShp;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package playground.vsp.pt.fare;

import com.google.inject.Inject;
import org.apache.commons.math.stat.regression.SimpleRegression;
import org.geotools.api.feature.simple.SimpleFeature;
import org.locationtech.jts.geom.Geometry;
import org.matsim.api.core.v01.Coord;
import org.matsim.api.core.v01.Id;
import org.matsim.api.core.v01.events.ActivityStartEvent;
import org.matsim.api.core.v01.events.PersonMoneyEvent;
import org.matsim.api.core.v01.events.handler.ActivityStartEventHandler;
import org.matsim.api.core.v01.population.Person;
import org.matsim.application.options.ShpOptions;
import org.matsim.core.api.experimental.events.EventsManager;
import org.matsim.core.router.StageActivityTypeIdentifier;
import org.matsim.core.utils.geometry.CoordUtils;
import org.matsim.core.utils.geometry.geotools.MGC;
import org.matsim.pt.PtConstants;

import java.util.AbstractMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class FareZoneBasedPtFareHandler implements ActivityStartEventHandler {
@Inject
private EventsManager events;

public static final String FARE = "fare";
public static final String PT_FARE_ZONE_BASED = "fare zone based pt fare";
public static final String PT_GERMANWIDE_FARE_BASED = "german-wide fare based pt fare";

private final ShpOptions shp;

private final Map<Id<Person>, Coord> personDepartureCoordMap = new HashMap<>();
private final Map<Id<Person>, Coord> personArrivalCoordMap = new HashMap<>();

public FareZoneBasedPtFareHandler(DistanceBasedPtFareParams params) {
this.shp = new ShpOptions(params.getFareZoneShp(), null, null);
}

@Override
public void handleEvent(ActivityStartEvent event) {

if (event.getActType().equals(PtConstants.TRANSIT_ACTIVITY_TYPE)) {
personDepartureCoordMap.computeIfAbsent(event.getPersonId(), c -> event.getCoord()); // The departure place is fixed to the place of first pt interaction an agent has in the whole leg
personArrivalCoordMap.put(event.getPersonId(), event.getCoord()); // The arrival stop will keep updating until the agent start a real activity (i.e. finish the leg)
}

if (!StageActivityTypeIdentifier.isStageActivity(event.getActType())) {
Id<Person> personId = event.getPersonId();
if (personDepartureCoordMap.containsKey(personId)) {
double distance = CoordUtils.calcEuclideanDistance
(personDepartureCoordMap.get(personId), personArrivalCoordMap.get(personId));

SimpleFeature departureZone = determineFareZone(personDepartureCoordMap.get(personId), shp.readFeatures());
SimpleFeature arrivalZone = determineFareZone(personArrivalCoordMap.get(personId), shp.readFeatures());

Map.Entry<String, Double> fareEntry = computeFare(distance, departureZone, arrivalZone);
// charge fare to the person
events.processEvent(
new PersonMoneyEvent(event.getTime(), event.getPersonId(), -fareEntry.getValue(),
PtFareConfigGroup.PT_FARE, fareEntry.getKey(), event.getPersonId().toString()));

personDepartureCoordMap.remove(personId);
personArrivalCoordMap.remove(personId);
}
}
}

public static Map.Entry<String, Double> computeFare(double distance, SimpleFeature departureZone, SimpleFeature arrivalZone) {

if (departureZone != null && arrivalZone != null) {
// if both zones are not null -> departure and arrival point are inside of one of the tarifzonen
if (departureZone.getID().equals(arrivalZone.getID())) {
return new AbstractMap.SimpleEntry<>(PT_FARE_ZONE_BASED ,(double) departureZone.getAttribute(FARE));
}
}
// in every other case return german wide fare / Deutschlandtarif
return getGermanWideFare(distance);
}

private static Map.Entry<String, Double> getGermanWideFare(double distance) {

SimpleRegression regression = new SimpleRegression();

// in Deutschlandtarif, the linear function for the prices above 100km seem to have a different steepness
// hence the following difference in data points
// prices taken from https://deutschlandtarifverbund.de/wp-content/uploads/2024/07/20231201_TBDT_J_10_Preisliste_V07.pdf
if (distance / 1000 <= 100.) {
regression.addData(1, 1.70);
regression.addData(2,1.90);
regression.addData(3,2.00);
regression.addData(4,2.10);
regression.addData(5,2.20);
regression.addData(6,3.20);
regression.addData(7,3.70);
regression.addData(8,3.80);
regression.addData(9,3.90);
regression.addData(10,4.10);
regression.addData(11,5.00);
regression.addData(12,5.40);
regression.addData(13,5.60);
regression.addData(14,5.80);
regression.addData(15,5.90);
regression.addData(16,6.40);
regression.addData(17,6.50);
regression.addData(18,6.60);
regression.addData(19,6.70);
regression.addData(20,6.90);
regression.addData(30,9.90);
regression.addData(40,13.70);
regression.addData(50,16.30);
regression.addData(60,18.10);
regression.addData(70,20.10);
regression.addData(80,23.20);
regression.addData(90,26.20);
regression.addData(100,28.10);
} else {
regression.addData(100,28.10);
regression.addData(200,47.20);
regression.addData(300,59.70);
regression.addData(400,71.70);
regression.addData(500,83.00);
regression.addData(600,94.60);
regression.addData(700,106.30);
regression.addData(800,118.20);
regression.addData(900,130.10);
regression.addData(1000,141.00);
regression.addData(1100,148.60);
regression.addData(1200,158.10);
regression.addData(1300,169.20);
regression.addData(1400,179.80);
regression.addData(1500,190.10);
regression.addData(1600,201.50);
regression.addData(1700,212.80);
regression.addData(1800,223.30);
regression.addData(1900,233.90);
regression.addData(2000,244.00);
}
return new AbstractMap.SimpleEntry<>(PT_GERMANWIDE_FARE_BASED, regression.getSlope() * distance / 1000 + regression.getIntercept());
}

static SimpleFeature determineFareZone(Coord coord, List<SimpleFeature> features) {
SimpleFeature zone = null;

for (SimpleFeature ft : features) {
Geometry geom = (Geometry) ft.getDefaultGeometry();

if (MGC.coord2Point(coord).within(geom)) {
zone = ft;
break;
}
}
return zone;
}

@Override
public void reset(int iteration) {
personArrivalCoordMap.clear();
personDepartureCoordMap.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class PtFareConfigGroup extends ReflectiveConfigGroup {
public static final String APPLY_UPPER_BOUND = "applyUpperBound";
public static final String UPPER_BOUND_FACTOR = "upperBoundFactor";

public enum PtFareCalculationModels {distanceBased} // More to come (e.g. zone based, hybrid...)
public enum PtFareCalculationModels {distanceBased, fareZoneBased} // More to come (e.g. zone based, hybrid...)

private static final String PT_FARE_CALCULATION_CMT = "PT fare calculation scheme. Current implementation: distanceBased (more to come...)";
public static final String UPPER_BOUND_FACTOR_CMT = "When upper bound is applied, upperBound = upperBoundFactor * max Fare of the day. " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ public void install() {
getConfig().scoring().getModes().get(TransportMode.pt).setDailyMonetaryConstant(0);
getConfig().scoring().getModes().get(TransportMode.pt).setMarginalUtilityOfDistance(0);
PtFareConfigGroup ptFareConfigGroup = ConfigUtils.addOrGetModule(this.getConfig(), PtFareConfigGroup.class);
DistanceBasedPtFareParams distanceBasedPtFareParams = ConfigUtils.addOrGetModule(this.getConfig(), DistanceBasedPtFareParams.class);

if (ptFareConfigGroup.getPtFareCalculation() == PtFareConfigGroup.PtFareCalculationModels.distanceBased) {
DistanceBasedPtFareParams distanceBasedPtFareParams = ConfigUtils.addOrGetModule(this.getConfig(), DistanceBasedPtFareParams.class);
addEventHandlerBinding().toInstance(new DistanceBasedPtFareHandler(distanceBasedPtFareParams));
} else {
addEventHandlerBinding().toInstance(new DistanceBasedPtFareHandler(distanceBasedPtFareParams));
} else if (ptFareConfigGroup.getPtFareCalculation() == PtFareConfigGroup.PtFareCalculationModels.fareZoneBased) {
addEventHandlerBinding().toInstance(new FareZoneBasedPtFareHandler(distanceBasedPtFareParams));
} else {
throw new RuntimeException("Please choose from the following fare Calculation method: [" +
PtFareConfigGroup.PtFareCalculationModels.distanceBased + "]");
PtFareConfigGroup.PtFareCalculationModels.distanceBased + ", " + PtFareConfigGroup.PtFareCalculationModels.fareZoneBased + "]");
}

if (ptFareConfigGroup.getApplyUpperBound()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package playground.vsp.pt.fare;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.matsim.analysis.personMoney.PersonMoneyEventsAnalysisModule;
import org.matsim.api.core.v01.Coord;
import org.matsim.api.core.v01.Id;
import org.matsim.api.core.v01.TransportMode;
import org.matsim.api.core.v01.population.*;
import org.matsim.core.config.Config;
import org.matsim.core.config.ConfigUtils;
import org.matsim.core.config.groups.ScoringConfigGroup;
import org.matsim.core.controler.AbstractModule;
import org.matsim.core.controler.Controler;
import org.matsim.core.controler.OutputDirectoryHierarchy;
import org.matsim.core.scenario.MutableScenario;
import org.matsim.core.scenario.ScenarioUtils;
import org.matsim.core.utils.io.IOUtils;
import org.matsim.examples.ExamplesUtils;
import org.matsim.testcases.MatsimTestUtils;

import java.io.BufferedReader;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

import static org.matsim.application.ApplicationUtils.globFile;

public class FareZoneBasedPtFareHandlerTest {

@RegisterExtension
private MatsimTestUtils utils = new MatsimTestUtils();

@Test
void testFareZoneBasedPtFareHandler() {

URL context = ExamplesUtils.getTestScenarioURL("kelheim");
Config config = ConfigUtils.loadConfig(IOUtils.extendUrl(context, "config.xml"));
config.controller().setOverwriteFileSetting(OutputDirectoryHierarchy.OverwriteFileSetting.deleteDirectoryIfExists);
config.controller().setOutputDirectory(utils.getOutputDirectory());
config.controller().setLastIteration(0);

PtFareConfigGroup ptFareConfigGroup = ConfigUtils.addOrGetModule(config, PtFareConfigGroup.class);
ptFareConfigGroup.setPtFareCalculationModel(PtFareConfigGroup.PtFareCalculationModels.fareZoneBased);

DistanceBasedPtFareParams fareParams = ConfigUtils.addOrGetModule(config, DistanceBasedPtFareParams.class);
fareParams.setFareZoneShp(IOUtils.extendUrl(context, "ptTestArea/pt-area.shp").toString());


ScoringConfigGroup scoring = ConfigUtils.addOrGetModule(config, ScoringConfigGroup.class);

ScoringConfigGroup.ActivityParams homeParams = new ScoringConfigGroup.ActivityParams("home");
ScoringConfigGroup.ActivityParams workParams = new ScoringConfigGroup.ActivityParams("work");
homeParams.setTypicalDuration(8 * 3600.);
workParams.setTypicalDuration(8 * 3600.);
scoring.addActivityParams(homeParams);
scoring.addActivityParams(workParams);


MutableScenario scenario = (MutableScenario) ScenarioUtils.loadScenario(config);

Population population = ScenarioUtils.createScenario(ConfigUtils.createConfig()).getPopulation();
PopulationFactory fac = population.getFactory();

Person person = fac.createPerson(Id.createPersonId("fareTestPerson"));
Plan plan = fac.createPlan();

Activity home = fac.createActivityFromCoord("home", new Coord(710300.624,5422165.737));
// bus to Saal (Donau) work location departs at 09:14
home.setEndTime(9 * 3600.);
Activity work = fac.createActivityFromCoord("work", new Coord(714940.65,5420707.78));
// rb17 to regensburg 2nd home location departs at 13:59
work.setEndTime(13 * 3600. + 45 * 60);
Activity home2 = fac.createActivityFromCoord("home", new Coord(726634.40,5433508.07));

Leg leg = fac.createLeg(TransportMode.pt);

plan.addActivity(home);
plan.addLeg(leg);
plan.addActivity(work);
plan.addLeg(leg);
plan.addActivity(home2);

person.addPlan(plan);
population.addPerson(person);
scenario.setPopulation(population);

Controler controler = new Controler(scenario);
controler.addOverridingModule(new AbstractModule() {
@Override
public void install() {
install(new PtFareModule());
install(new PersonMoneyEventsAnalysisModule());
}
});
controler.run();

Assertions.assertTrue(Files.exists(Path.of(utils.getOutputDirectory())));

// read personMoneyEvents.tsv and check if both fare entries do have the correct fare type
String filePath = globFile(Path.of(utils.getOutputDirectory()), "*output_personMoneyEvents.tsv*").toString();
String line;
List<String[]> events = new ArrayList<>();

try (BufferedReader br = IOUtils.getBufferedReader(filePath)) {
// skip header
br.readLine();

while ((line = br.readLine()) != null) {
events.add(line.split(";"));
}
} catch (IOException e) {
e.printStackTrace();
}

Assertions.assertEquals(2, events.size());
Assertions.assertEquals(FareZoneBasedPtFareHandler.PT_FARE_ZONE_BASED, events.get(0)[4]);
Assertions.assertEquals(FareZoneBasedPtFareHandler.PT_GERMANWIDE_FARE_BASED, events.get(1)[4]);
}
}
1 change: 1 addition & 0 deletions examples/scenarios/kelheim/ptTestArea/pt-area.cpg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
UTF-8
Binary file added examples/scenarios/kelheim/ptTestArea/pt-area.dbf
Binary file not shown.
1 change: 1 addition & 0 deletions examples/scenarios/kelheim/ptTestArea/pt-area.prj
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PROJCS["ETRS_1989_UTM_Zone_32N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",9.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]]
Binary file added examples/scenarios/kelheim/ptTestArea/pt-area.shp
Binary file not shown.
Binary file added examples/scenarios/kelheim/ptTestArea/pt-area.shx
Binary file not shown.

0 comments on commit ea1f72c

Please sign in to comment.