diff --git a/doc-templates/Emissions.md b/doc-templates/Emissions.md new file mode 100644 index 00000000000..81ea0d968e2 --- /dev/null +++ b/doc-templates/Emissions.md @@ -0,0 +1,54 @@ +# CO₂ Emissions calculation + +## Contact Info + +- Digitransit Team + +## Documentation + +Graph build import of CO₂ Emissions from GTFS data sets (through custom emissions.txt extension) +and the ability to attach them to itineraries by Digitransit team. +The emissions are represented in grams per kilometer (g/Km) unit. + +Emissions data is located in an emissions.txt file within a gtfs package and has the following columns: + +`route_id`: route id + +`avg_co2_per_vehicle_per_km`: Average carbon dioxide equivalent value for the vehicles used on the route at grams/Km units. + +`avg_passenger_count`: Average passenger count for the vehicles on the route. + +For example: +```csv +route_id,avg_co2_per_vehicle_per_km,avg_passenger_count +1234,123,20 +2345,0,0 +3456,12.3,20.0 +``` + +Emissions data is loaded from the gtfs package and embedded into the graph during the build process. + + +### Configuration +To enable this functionality, you need to enable the "Co2Emissions" feature in the +`otp-config.json` file. + +```JSON +//otp-config.json +{ + "Co2Emissions": true +} + +``` +Include the `emissions` object in the +`build-config.json` file. The `emissions` object should contain parameters called +`carAvgCo2PerKm` and `carAvgOccupancy`. The `carAvgCo2PerKm` provides the average emissions value for a car in g/km and +the `carAvgOccupancy` provides the average number of passengers in a car. + + + +## Changelog + +### OTP 2.5 + +- Initial implementation of the emissions calculation. diff --git a/docs/BuildConfiguration.md b/docs/BuildConfiguration.md index d382d07f673..f66decf4fad 100644 --- a/docs/BuildConfiguration.md +++ b/docs/BuildConfiguration.md @@ -55,6 +55,7 @@ Sections follow that describe particular settings in more depth. | demDefaults | `object` | Default properties for DEM extracts. | *Optional* | | 2.3 | |    [elevationUnitMultiplier](#demDefaults_elevationUnitMultiplier) | `double` | Specify a multiplier to convert elevation units from source to meters. | *Optional* | `1.0` | 2.3 | | [elevationBucket](#elevationBucket) | `object` | Used to download NED elevation tiles from the given AWS S3 bucket. | *Optional* | | na | +| [emissions](sandbox/Emissions.md) | `object` | Emissions configuration. | *Optional* | | na | | [fares](sandbox/Fares.md) | `object` | Fare configuration. | *Optional* | | 2.0 | | gtfsDefaults | `object` | The gtfsDefaults section allows you to specify default properties for GTFS files. | *Optional* | | 2.3 | |    blockBasedInterlining | `boolean` | Whether to create stay-seated transfers in between two trips with the same block id. | *Optional* | `true` | 2.3 | @@ -1163,7 +1164,11 @@ case where this is not the case. "enabled" : true } } - ] + ], + "emissions" : { + "carAvgCo2PerKm" : 170, + "carAvgOccupancy" : 1.3 + } } ``` diff --git a/docs/Changelog.md b/docs/Changelog.md index 238caddcf4b..13157fc1180 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -33,6 +33,7 @@ based on merged pull requests. Search GitHub issues and pull requests for smalle - Ignore negative travel-times in Raptor [#5443](https://github.com/opentripplanner/OpenTripPlanner/pull/5443) - Fix sort order bug in optimized transfers [#5446](https://github.com/opentripplanner/OpenTripPlanner/pull/5446) - Siri file loader [#5460](https://github.com/opentripplanner/OpenTripPlanner/pull/5460) +- Calculate CO₂ emissions of itineraries [#5278](https://github.com/opentripplanner/OpenTripPlanner/pull/5278) [](AUTOMATIC_CHANGELOG_PLACEHOLDER_DO_NOT_REMOVE) ## 2.4.0 (2023-09-13) diff --git a/docs/Configuration.md b/docs/Configuration.md index 5692b5f87d0..bfd168d5955 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -236,6 +236,7 @@ Here is a list of all features which can be toggled on/off and their default val | `TransferConstraints` | Enforce transfers to happen according to the _transfers.txt_(GTFS) and Interchanges(NeTEx). Turing this _off_ will increase the routing performance a little. | ✓️ | | | `ActuatorAPI` | Endpoint for actuators (service health status). | | ✓️ | | `AsyncGraphQLFetchers` | Whether the @async annotation in the GraphQL schema should lead to the fetch being executed asynchronously. This allows batch or alias queries to run in parallel at the cost of consuming extra threads. | | | +| `Co2Emissions` | Enable the emissions sandbox module. | | ✓️ | | `DataOverlay` | Enable usage of data overlay when calculating costs for the street network. | | ✓️ | | `FaresV2` | Enable import of GTFS-Fares v2 data. | | ✓️ | | `FlexRouting` | Enable FLEX routing. | | ✓️ | diff --git a/docs/sandbox/Emissions.md b/docs/sandbox/Emissions.md new file mode 100644 index 00000000000..280d4aeacdb --- /dev/null +++ b/docs/sandbox/Emissions.md @@ -0,0 +1,79 @@ +# CO₂ Emissions calculation + +## Contact Info + +- Digitransit Team + +## Documentation + +Graph build import of CO₂ Emissions from GTFS data sets (through custom emissions.txt extension) +and the ability to attach them to itineraries by Digitransit team. +The emissions are represented in grams per kilometer (g/Km) unit. + +Emissions data is located in an emissions.txt file within a gtfs package and has the following columns: + +`route_id`: route id + +`avg_co2_per_vehicle_per_km`: Average carbon dioxide equivalent value for the vehicles used on the route at grams/Km units. + +`avg_passenger_count`: Average passenger count for the vehicles on the route. + +For example: +```csv +route_id,avg_co2_per_vehicle_per_km,avg_passenger_count +1234,123,20 +2345,0,0 +3456,12.3,20.0 +``` + +Emissions data is loaded from the gtfs package and embedded into the graph during the build process. + + +### Configuration +To enable this functionality, you need to enable the "Co2Emissions" feature in the +`otp-config.json` file. + +```JSON +//otp-config.json +{ + "Co2Emissions": true +} + +``` +Include the `emissions` object in the +`build-config.json` file. The `emissions` object should contain parameters called +`carAvgCo2PerKm` and `carAvgOccupancy`. The `carAvgCo2PerKm` provides the average emissions value for a car in g/km and +the `carAvgOccupancy` provides the average number of passengers in a car. + + + + +### Example configuration + +```JSON +// build-config.json +{ + "emissions" : { + "carAvgCo2PerKm" : 170, + "carAvgOccupancy" : 1.3 + } +} +``` +### Overview + +| Config Parameter | Type | Summary | Req./Opt. | Default Value | Since | +|------------------|:---------:|------------------------------------------------------------|:----------:|---------------|:-----:| +| carAvgCo2PerKm | `integer` | The average CO₂ emissions of a car in grams per kilometer. | *Optional* | `170` | na | +| carAvgOccupancy | `double` | The average number of passengers in a car. | *Optional* | `1.3` | na | + + +### Details + + + + +## Changelog + +### OTP 2.5 + +- Initial implementation of the emissions calculation. diff --git a/pom.xml b/pom.xml index a4a9c9832af..e1e047d7556 100644 --- a/pom.xml +++ b/pom.xml @@ -56,13 +56,13 @@ - 123 + 124 30.0 2.48.1 2.15.3 3.1.3 - 5.10.0 + 5.10.1 1.11.5 5.5.3 1.4.11 @@ -247,7 +247,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.2.1 + 3.2.2 me.fabriciorby @@ -579,7 +579,7 @@ com.google.cloud libraries-bom - 26.24.0 + 26.26.0 pom import @@ -721,7 +721,7 @@ org.entur.gbfs gbfs-java-model - 3.0.11 + 3.0.13 @@ -740,13 +740,13 @@ com.tngtech.archunit archunit - 1.1.0 + 1.2.0 test org.mockito mockito-core - 5.6.0 + 5.7.0 test @@ -772,7 +772,7 @@ com.google.guava guava - 32.1.2-jre + 32.1.3-jre diff --git a/renovate.json5 b/renovate.json5 index b600b9593d6..836ba065662 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -49,7 +49,8 @@ "@graphql-codegen/java-resolvers", "graphql", "io.micrometer:micrometer-registry-prometheus", - "io.micrometer:micrometer-registry-influx" + "io.micrometer:micrometer-registry-influx", + "org.entur.gbfs:gbfs-java-model" ], // we don't use the 'monthly' preset because that only fires on the first day of the month // when there might already other PRs open diff --git a/src/ext-test/java/org/opentripplanner/ext/emissions/Co2EmissionsDataReaderTest.java b/src/ext-test/java/org/opentripplanner/ext/emissions/Co2EmissionsDataReaderTest.java new file mode 100644 index 00000000000..235badd9f48 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/emissions/Co2EmissionsDataReaderTest.java @@ -0,0 +1,38 @@ +package org.opentripplanner.ext.emissions; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; +import org.junit.jupiter.api.Test; +import org.opentripplanner.graph_builder.issue.service.DefaultDataImportIssueStore; +import org.opentripplanner.test.support.ResourceLoader; + +public class Co2EmissionsDataReaderTest { + + private static final ResourceLoader RES = ResourceLoader.of(Co2EmissionsDataReaderTest.class); + private static final File CO2_GTFS_ZIP = RES.file("emissions-test-gtfs.zip"); + private static final File CO2_GTFS = RES.file("emissions-test-gtfs/"); + private static final File INVALID_CO2_GTFS = RES.file("emissions-invalid-test-gtfs/"); + + private Co2EmissionsDataReader co2EmissionsDataReader = new Co2EmissionsDataReader( + new DefaultDataImportIssueStore() + ); + + @Test + void testCo2EmissionsZipDataReading() { + var emissions = co2EmissionsDataReader.readGtfsZip(CO2_GTFS_ZIP); + assertEquals(6, emissions.size()); + } + + @Test + void testCo2EmissionsDataReading() { + var emissions = co2EmissionsDataReader.readGtfs(CO2_GTFS); + assertEquals(6, emissions.size()); + } + + @Test + void testInvalidCo2EmissionsDataReading() { + var emissions = co2EmissionsDataReader.readGtfs(INVALID_CO2_GTFS); + assertEquals(0, emissions.size()); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/emissions/EmissionsTest.java b/src/ext-test/java/org/opentripplanner/ext/emissions/EmissionsTest.java new file mode 100644 index 00000000000..ee8f148faa6 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/emissions/EmissionsTest.java @@ -0,0 +1,173 @@ +package org.opentripplanner.ext.emissions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.transit.model._data.TransitModelForTest.id; + +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner._support.time.ZoneIds; +import org.opentripplanner.framework.model.Grams; +import org.opentripplanner.model.StopTime; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.ScheduledTransitLeg; +import org.opentripplanner.model.plan.ScheduledTransitLegBuilder; +import org.opentripplanner.model.plan.StreetLeg; +import org.opentripplanner.street.search.TraverseMode; +import org.opentripplanner.transit.model._data.TransitModelForTest; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.framework.Deduplicator; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.organization.Agency; +import org.opentripplanner.transit.model.timetable.Trip; +import org.opentripplanner.transit.model.timetable.TripTimes; + +class EmissionsTest { + + private DefaultEmissionsService eService; + private EmissionsFilter emissionsFilter; + + static final ZonedDateTime TIME = OffsetDateTime + .parse("2023-07-20T17:49:06+03:00") + .toZonedDateTime(); + + private static final Agency subject = Agency + .of(TransitModelForTest.id("F:1")) + .withName("Foo_CO") + .withTimezone("Europe/Helsinki") + .build(); + + @BeforeEach + void SetUp() { + Map emissions = new HashMap<>(); + emissions.put(new FeedScopedId("F", "1"), (0.12 / 12)); + emissions.put(new FeedScopedId("F", "2"), 0.0); + EmissionsDataModel emissionsDataModel = new EmissionsDataModel(emissions, 0.131); + this.eService = new DefaultEmissionsService(emissionsDataModel); + this.emissionsFilter = new EmissionsFilter(eService); + } + + @Test + void testGetEmissionsForItinerary() { + var stopOne = TransitModelForTest.stopForTest("1:stop1", 60, 25); + var stopTwo = TransitModelForTest.stopForTest("1:stop1", 61, 25); + var stopThree = TransitModelForTest.stopForTest("1:stop1", 62, 25); + var stopPattern = TransitModelForTest.stopPattern(stopOne, stopTwo, stopThree); + var route = TransitModelForTest.route(id("1")).build(); + var pattern = TransitModelForTest.tripPattern("1", route).withStopPattern(stopPattern).build(); + var stoptime = new StopTime(); + var stoptimes = new ArrayList(); + stoptimes.add(stoptime); + var trip = Trip + .of(FeedScopedId.parse("FOO:BAR")) + .withMode(TransitMode.BUS) + .withRoute(route) + .build(); + var leg = new ScheduledTransitLegBuilder<>() + .withTripTimes(new TripTimes(trip, stoptimes, new Deduplicator())) + .withTripPattern(pattern) + .withBoardStopIndexInPattern(0) + .withAlightStopIndexInPattern(2) + .withStartTime(TIME) + .withEndTime(TIME.plusMinutes(10)) + .withServiceDate(TIME.toLocalDate()) + .withZoneId(ZoneIds.BERLIN) + .build(); + Itinerary i = new Itinerary(List.of(leg)); + assertEquals( + new Grams(2223.902), + emissionsFilter.filter(List.of(i)).get(0).getEmissionsPerPerson().getCo2() + ); + } + + @Test + void testGetEmissionsForCarRoute() { + var leg = StreetLeg + .create() + .withMode(TraverseMode.CAR) + .withDistanceMeters(214.4) + .withStartTime(TIME) + .withEndTime(TIME.plus(1, ChronoUnit.HOURS)) + .build(); + Itinerary i = new Itinerary(List.of(leg)); + assertEquals( + new Grams(28.0864), + emissionsFilter.filter(List.of(i)).get(0).getEmissionsPerPerson().getCo2() + ); + } + + @Test + void testNoEmissionsForFeedWithoutEmissionsConfigured() { + Map emissions = new HashMap<>(); + emissions.put(new FeedScopedId("G", "1"), (0.12 / 12)); + EmissionsDataModel emissionsDataModel = new EmissionsDataModel(emissions, 0.131); + + this.eService = new DefaultEmissionsService(emissionsDataModel); + this.emissionsFilter = new EmissionsFilter(this.eService); + + var route = TransitModelForTest.route(id("1")).withAgency(subject).build(); + var pattern = TransitModelForTest + .tripPattern("1", route) + .withStopPattern(TransitModelForTest.stopPattern(3)) + .build(); + var stoptime = new StopTime(); + var stoptimes = new ArrayList(); + stoptimes.add(stoptime); + var trip = Trip + .of(FeedScopedId.parse("FOO:BAR")) + .withMode(TransitMode.BUS) + .withRoute(route) + .build(); + var leg = new ScheduledTransitLegBuilder<>() + .withTripTimes(new TripTimes(trip, stoptimes, new Deduplicator())) + .withTripPattern(pattern) + .withBoardStopIndexInPattern(0) + .withAlightStopIndexInPattern(2) + .withStartTime(TIME) + .withEndTime(TIME.plusMinutes(10)) + .withServiceDate(TIME.toLocalDate()) + .withZoneId(ZoneIds.BERLIN) + .build(); + Itinerary i = new Itinerary(List.of(leg)); + assertEquals(null, emissionsFilter.filter(List.of(i)).get(0).getEmissionsPerPerson()); + } + + @Test + void testZeroEmissionsForItineraryWithZeroEmissions() { + var stopOne = TransitModelForTest.stopForTest("1:stop1", 60, 25); + var stopTwo = TransitModelForTest.stopForTest("1:stop1", 61, 25); + var stopThree = TransitModelForTest.stopForTest("1:stop1", 62, 25); + var stopPattern = TransitModelForTest.stopPattern(stopOne, stopTwo, stopThree); + var route = TransitModelForTest.route(id("2")).build(); + var pattern = TransitModelForTest.tripPattern("1", route).withStopPattern(stopPattern).build(); + var stoptime = new StopTime(); + var stoptimes = new ArrayList(); + stoptimes.add(stoptime); + var trip = Trip + .of(FeedScopedId.parse("FOO:BAR")) + .withMode(TransitMode.BUS) + .withRoute(route) + .build(); + var leg = new ScheduledTransitLegBuilder<>() + .withTripTimes(new TripTimes(trip, stoptimes, new Deduplicator())) + .withTripPattern(pattern) + .withBoardStopIndexInPattern(0) + .withAlightStopIndexInPattern(2) + .withStartTime(TIME) + .withEndTime(TIME.plusMinutes(10)) + .withServiceDate(TIME.toLocalDate()) + .withZoneId(ZoneIds.BERLIN) + .build(); + Itinerary i = new Itinerary(List.of(leg)); + assertEquals( + new Grams(0.0), + emissionsFilter.filter(List.of(i)).get(0).getEmissionsPerPerson().getCo2() + ); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/fares/impl/OrcaFareServiceTest.java b/src/ext-test/java/org/opentripplanner/ext/fares/impl/OrcaFareServiceTest.java index a08b50b083d..22dfdd38303 100644 --- a/src/ext-test/java/org/opentripplanner/ext/fares/impl/OrcaFareServiceTest.java +++ b/src/ext-test/java/org/opentripplanner/ext/fares/impl/OrcaFareServiceTest.java @@ -10,6 +10,7 @@ import static org.opentripplanner.ext.fares.impl.OrcaFareService.SKAGIT_TRANSIT_AGENCY_ID; import static org.opentripplanner.ext.fares.impl.OrcaFareService.SOUND_TRANSIT_AGENCY_ID; import static org.opentripplanner.ext.fares.impl.OrcaFareService.WASHINGTON_STATE_FERRIES_AGENCY_ID; +import static org.opentripplanner.ext.fares.impl.OrcaFareService.WHATCOM_AGENCY_ID; import static org.opentripplanner.model.plan.PlanTestConstants.T11_00; import static org.opentripplanner.model.plan.PlanTestConstants.T11_12; import static org.opentripplanner.model.plan.TestItineraryBuilder.newItinerary; @@ -55,6 +56,8 @@ public class OrcaFareServiceTest { private static final Money FERRY_FARE = usDollars(6.50f); private static final Money HALF_FERRY_FARE = usDollars(3.25f); private static final Money ORCA_SPECIAL_FARE = usDollars(1.00f); + public static final Money VASHON_WATER_TAXI_CASH_FARE = usDollars(6.75f); + public static final Money WEST_SEATTLE_WATER_TAXI_CASH_FARE = usDollars(5.75f); private static final String FEED_ID = "A"; private static TestOrcaFareService orcaFareService; public static final Money DEFAULT_TEST_RIDE_PRICE = usDollars(3.49f); @@ -340,12 +343,13 @@ void calculateFareForWSFPtToTahlequah() { * Single trip with Link Light Rail to ensure distance fare is calculated correctly. */ @Test - void calculateFareForLightRailLeg() { + void calculateFareForSTRail() { List rides = List.of( - getLeg(SOUND_TRANSIT_AGENCY_ID, "1-Line", 0, "Roosevelt Station", "Int'l Dist/Chinatown") + getLeg(SOUND_TRANSIT_AGENCY_ID, "1-Line", 0, "Roosevelt Station", "Int'l Dist/Chinatown"), + getLeg(SOUND_TRANSIT_AGENCY_ID, "S Line", 100, "King Street Station", "Auburn Station") ); - calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE); - calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE); + 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, ORCA_SPECIAL_FARE); calculateFare(rides, FareType.electronicRegular, DEFAULT_TEST_RIDE_PRICE); @@ -353,17 +357,28 @@ void calculateFareForLightRailLeg() { calculateFare(rides, FareType.electronicYouth, Money.ZERO_USD); } + /** + * Test King County Water Taxis + */ @Test - void calculateFareForSounderLeg() { - List rides = List.of( - getLeg(SOUND_TRANSIT_AGENCY_ID, "S Line", 0, "King Street Station", "Auburn Station") - ); - calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE); - calculateFare(rides, FareType.senior, DEFAULT_TEST_RIDE_PRICE); + void calculateWaterTaxiFares() { + List rides = List.of(getLeg(KC_METRO_AGENCY_ID, "973", 1)); + calculateFare(rides, regular, WEST_SEATTLE_WATER_TAXI_CASH_FARE); + calculateFare(rides, FareType.senior, WEST_SEATTLE_WATER_TAXI_CASH_FARE); calculateFare(rides, FareType.youth, Money.ZERO_USD); - calculateFare(rides, FareType.electronicSpecial, ORCA_SPECIAL_FARE); - calculateFare(rides, FareType.electronicRegular, DEFAULT_TEST_RIDE_PRICE); - calculateFare(rides, FareType.electronicSenior, ONE_DOLLAR); + calculateFare(rides, FareType.electronicSpecial, usDollars(3.75f)); + calculateFare(rides, FareType.electronicRegular, usDollars(5f)); + calculateFare(rides, FareType.electronicSenior, usDollars(2.50f)); + calculateFare(rides, FareType.electronicYouth, Money.ZERO_USD); + + rides = List.of(getLeg(KC_METRO_AGENCY_ID, "975", 1)); + + calculateFare(rides, regular, VASHON_WATER_TAXI_CASH_FARE); + calculateFare(rides, FareType.senior, VASHON_WATER_TAXI_CASH_FARE); + calculateFare(rides, FareType.youth, Money.ZERO_USD); + calculateFare(rides, FareType.electronicSpecial, usDollars(4.50f)); + calculateFare(rides, FareType.electronicRegular, usDollars(5.75f)); + calculateFare(rides, FareType.electronicSenior, usDollars(3f)); calculateFare(rides, FareType.electronicYouth, Money.ZERO_USD); } @@ -438,6 +453,24 @@ void calculateTransferExtension() { calculateFare(rides, FareType.electronicYouth, Money.ZERO_USD); } + /** + * Tests fares from non ORCA accepting agencies + */ + @Test + void testNonOrcaAgencies() { + List rides = List.of( + getLeg(SKAGIT_TRANSIT_AGENCY_ID, 0, "80X"), + getLeg(WHATCOM_AGENCY_ID, 0, "80X") + ); + + calculateFare(rides, regular, DEFAULT_TEST_RIDE_PRICE.times(2)); + calculateFare(rides, FareType.senior, usDollars(0.50f).times(2)); + // TODO: Check that these are undefined, not zero + calculateFare(rides, FareType.youth, ZERO_USD); + calculateFare(rides, FareType.electronicSpecial, ZERO_USD); + calculateFare(rides, FareType.electronicRegular, ZERO_USD); + } + static Stream allTypes() { return Arrays.stream(FareType.values()).map(Arguments::of); } diff --git a/src/ext-test/java/org/opentripplanner/ext/transmodelapi/mapping/TripRequestMapperTest.java b/src/ext-test/java/org/opentripplanner/ext/transmodelapi/mapping/TripRequestMapperTest.java index 0160b58140a..cf4e5877245 100644 --- a/src/ext-test/java/org/opentripplanner/ext/transmodelapi/mapping/TripRequestMapperTest.java +++ b/src/ext-test/java/org/opentripplanner/ext/transmodelapi/mapping/TripRequestMapperTest.java @@ -24,6 +24,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.opentripplanner._support.time.ZoneIds; +import org.opentripplanner.ext.emissions.DefaultEmissionsService; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.ext.transmodelapi.TransmodelRequestContext; import org.opentripplanner.graph_builder.issue.api.DataImportIssueStore; import org.opentripplanner.model.calendar.CalendarServiceData; @@ -125,6 +127,7 @@ public class TripRequestMapperTest implements PlanTestConstants { new DefaultWorldEnvelopeService(new DefaultWorldEnvelopeRepository()), new DefaultRealtimeVehicleService(transitService), new DefaultVehicleRentalService(), + new DefaultEmissionsService(new EmissionsDataModel()), RouterConfig.DEFAULT.flexConfig(), List.of(), null diff --git a/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-invalid-test-gtfs/emissions.txt b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-invalid-test-gtfs/emissions.txt new file mode 100644 index 00000000000..ca313d4bab8 --- /dev/null +++ b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-invalid-test-gtfs/emissions.txt @@ -0,0 +1,4 @@ +route_id,avg_co2_per_vehicle_per_km,avg_passenger_count +1001,1,0 +1001,-1,1 +1001,1,-1 diff --git a/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-invalid-test-gtfs/feed_info.txt b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-invalid-test-gtfs/feed_info.txt new file mode 100644 index 00000000000..612fde11bf5 --- /dev/null +++ b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-invalid-test-gtfs/feed_info.txt @@ -0,0 +1,2 @@ +feed_publisher_name,feed_publisher_url,feed_lang,feed_start_date,feed_end_date,feed_version,feed_id +emissionstest,http://www.emissionstest.fi/,fi,20230623,20230806,2023-06-23 22:42:09,emissionstest diff --git a/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs.zip b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs.zip new file mode 100644 index 00000000000..146ecfd2320 Binary files /dev/null and b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs.zip differ diff --git a/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs/emissions.txt b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs/emissions.txt new file mode 100644 index 00000000000..18fcb3e64de --- /dev/null +++ b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs/emissions.txt @@ -0,0 +1,7 @@ +route_id,avg_co2_per_vehicle_per_km,avg_passenger_count +1001,12.0,2.0 +1002,123,3 +1003,0,0 +1004,0.0,0.0 +1005,0,-1 +1006,0,1 diff --git a/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs/feed_info.txt b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs/feed_info.txt new file mode 100644 index 00000000000..612fde11bf5 --- /dev/null +++ b/src/ext-test/resources/org/opentripplanner/ext/emissions/emissions-test-gtfs/feed_info.txt @@ -0,0 +1,2 @@ +feed_publisher_name,feed_publisher_url,feed_lang,feed_start_date,feed_end_date,feed_version,feed_id +emissionstest,http://www.emissionstest.fi/,fi,20230623,20230806,2023-06-23 22:42:09,emissionstest diff --git a/src/ext/java/org/opentripplanner/ext/emissions/Co2EmissionsDataReader.java b/src/ext/java/org/opentripplanner/ext/emissions/Co2EmissionsDataReader.java new file mode 100644 index 00000000000..6c733ee0e6a --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/Co2EmissionsDataReader.java @@ -0,0 +1,149 @@ +package org.opentripplanner.ext.emissions; + +import com.csvreader.CsvReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.zip.ZipFile; +import org.opentripplanner.framework.lang.Sandbox; +import org.opentripplanner.graph_builder.issue.api.DataImportIssueStore; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class handles reading the CO₂ emissions data from the files in the GTFS package + * and saving it in a map. + */ +@Sandbox +public class Co2EmissionsDataReader { + + private static final Logger LOG = LoggerFactory.getLogger(Co2EmissionsDataReader.class); + + private final DataImportIssueStore issueStore; + + public Co2EmissionsDataReader(DataImportIssueStore issueStore) { + this.issueStore = issueStore; + } + + /** + * Read files in a GTFS directory. + * @param directory + * @return emissions data + */ + public Map readGtfs(File directory) { + String feedId = ""; + Map emissionsData = new HashMap<>(); + try (InputStream feedInfoStream = new FileInputStream(directory + "/feed_info.txt")) { + feedId = readFeedId(feedInfoStream); + } catch (IOException e) { + issueStore.add("InvalidEmissionData", "Reading feed_info.txt failed."); + LOG.error("InvalidEmissionData: reading feed_info.txt failed.", e); + } + try (InputStream stream = new FileInputStream(directory + "/emissions.txt")) { + emissionsData = readEmissions(stream, feedId); + } catch (IOException e) { + issueStore.add("InvalidEmissionData", "Reading emissions.txt failed."); + LOG.error("InvalidEmissionData: reading emissions.txt failed.", e); + } + return emissionsData; + } + + /** + * Read files in a GTFS zip file. + * @param file + * @return emissions data + */ + public Map readGtfsZip(File file) { + try { + ZipFile zipFile = new ZipFile(file, ZipFile.OPEN_READ); + String feedId = readFeedId(zipFile.getInputStream(zipFile.getEntry("feed_info.txt"))); + InputStream stream = zipFile.getInputStream(zipFile.getEntry("emissions.txt")); + Map emissionsData = readEmissions(stream, feedId); + zipFile.close(); + return emissionsData; + } catch (IOException e) { + issueStore.add("InvalidEmissionData", "Reading emissions data failed."); + LOG.error("InvalidEmissionData: Reading emissions data failed.", e); + } + return null; + } + + private Map readEmissions(InputStream stream, String feedId) + throws IOException { + Map emissionsData = new HashMap<>(); + CsvReader reader = new CsvReader(stream, StandardCharsets.UTF_8); + reader.readHeaders(); + + while (reader.readRecord()) { + String routeId = reader.get("route_id"); + String avgCo2PerVehiclePerKmString = reader.get("avg_co2_per_vehicle_per_km"); + String avgPassengerCountString = reader.get("avg_passenger_count"); + + if (avgCo2PerVehiclePerKmString.isEmpty()) { + issueStore.add( + "InvalidEmissionData", + "Value for avg_co2_per_vehicle_per_km is missing in the Emissions.txt for route %s", + routeId + ); + } + if (avgPassengerCountString.isEmpty()) { + issueStore.add( + "InvalidEmissionData", + "Value for avg_passenger_count is missing in the Emissions.txt for route %s", + routeId + ); + } + + Double avgCo2PerVehiclePerMeter = Double.parseDouble(avgCo2PerVehiclePerKmString) / 1000; + Double avgPassengerCount = Double.parseDouble(reader.get("avg_passenger_count")); + Optional emissions = calculateEmissionsPerPassengerPerMeter( + routeId, + avgCo2PerVehiclePerMeter, + avgPassengerCount + ); + if (emissions.isPresent()) { + emissionsData.put(new FeedScopedId(feedId, routeId), emissions.get()); + } + } + return emissionsData; + } + + private String readFeedId(InputStream stream) { + try { + CsvReader reader = new CsvReader(stream, StandardCharsets.UTF_8); + reader.readHeaders(); + reader.readRecord(); + return reader.get("feed_id"); + } catch (IOException e) { + issueStore.add("InvalidEmissionData", "Reading feed_info.txt failed."); + LOG.error("InvalidEmissionData: reading feed_info.txt failed.", e); + throw new RuntimeException(e); + } + } + + private Optional calculateEmissionsPerPassengerPerMeter( + String routeId, + double avgCo2PerVehiclePerMeter, + double avgPassengerCount + ) { + if (avgCo2PerVehiclePerMeter == 0) { + // Passenger number is irrelevant when emissions is 0. + return Optional.of(avgCo2PerVehiclePerMeter); + } + if (avgPassengerCount <= 0 || avgCo2PerVehiclePerMeter < 0) { + issueStore.add( + "InvalidEmissionData", + "avgPassengerCount is 0 or less, but avgCo2PerVehiclePerMeter is nonzero or avgCo2PerVehiclePerMeter is negative for route %s", + routeId + ); + return Optional.empty(); + } + return Optional.of(avgCo2PerVehiclePerMeter / avgPassengerCount); + } +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/DefaultEmissionsService.java b/src/ext/java/org/opentripplanner/ext/emissions/DefaultEmissionsService.java new file mode 100644 index 00000000000..5df2ca17f26 --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/DefaultEmissionsService.java @@ -0,0 +1,35 @@ +package org.opentripplanner.ext.emissions; + +import jakarta.inject.Inject; +import java.util.Optional; +import org.opentripplanner.framework.lang.Sandbox; +import org.opentripplanner.framework.model.Grams; +import org.opentripplanner.model.plan.Emissions; +import org.opentripplanner.transit.model.framework.FeedScopedId; + +@Sandbox +public class DefaultEmissionsService implements EmissionsService { + + private final EmissionsDataModel emissionsDataModel; + + @Inject + public DefaultEmissionsService(EmissionsDataModel emissionsDataModel) { + this.emissionsDataModel = emissionsDataModel; + } + + @Override + public Optional getEmissionsPerMeterForRoute(FeedScopedId feedScopedRouteId) { + Optional co2Emissions = this.emissionsDataModel.getCO2EmissionsById(feedScopedRouteId); + return co2Emissions.isPresent() + ? Optional.of(new Emissions(new Grams(co2Emissions.get()))) + : Optional.empty(); + } + + @Override + public Optional getEmissionsPerMeterForCar() { + Optional co2Emissions = this.emissionsDataModel.getCarAvgCo2PerMeter(); + return co2Emissions.isPresent() + ? Optional.of(new Emissions(new Grams(co2Emissions.get()))) + : Optional.empty(); + } +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/EmissionsConfig.java b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsConfig.java new file mode 100644 index 00000000000..ed95efa0d4c --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsConfig.java @@ -0,0 +1,43 @@ +package org.opentripplanner.ext.emissions; + +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; + +/** + * This class is responsible for mapping emissions configuration into emissions parameters. + */ +public class EmissionsConfig { + + private int carAvgCo2PerKm; + private double carAvgOccupancy; + + public EmissionsConfig(String parameterName, NodeAdapter root) { + var c = root + .of(parameterName) + .summary("Emissions configuration.") + .description( + """ + By specifying the average CO₂ emissions of a car in grams per kilometer as well as + the average number of passengers in a car the program is able to to perform emission + calculations for car travel. + """ + ) + .asObject(); + + this.carAvgCo2PerKm = + c + .of("carAvgCo2PerKm") + .summary("The average CO₂ emissions of a car in grams per kilometer.") + .asInt(170); + + this.carAvgOccupancy = + c.of("carAvgOccupancy").summary("The average number of passengers in a car.").asDouble(1.3); + } + + public int getCarAvgCo2PerKm() { + return carAvgCo2PerKm; + } + + public double getCarAvgOccupancy() { + return carAvgOccupancy; + } +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/EmissionsDataModel.java b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsDataModel.java new file mode 100644 index 00000000000..fa4180380bd --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsDataModel.java @@ -0,0 +1,40 @@ +package org.opentripplanner.ext.emissions; + +import jakarta.inject.Inject; +import java.io.Serializable; +import java.util.Map; +import java.util.Optional; +import org.opentripplanner.transit.model.framework.FeedScopedId; + +/** + * Container for emissions data. + */ +public class EmissionsDataModel implements Serializable { + + private Map co2Emissions; + private Double carAvgCo2PerMeter; + + @Inject + public EmissionsDataModel() {} + + public EmissionsDataModel(Map co2Emissions, double carAvgCo2PerMeter) { + this.co2Emissions = co2Emissions; + this.carAvgCo2PerMeter = carAvgCo2PerMeter; + } + + public void setCo2Emissions(Map co2Emissions) { + this.co2Emissions = co2Emissions; + } + + public void setCarAvgCo2PerMeter(double carAvgCo2PerMeter) { + this.carAvgCo2PerMeter = carAvgCo2PerMeter; + } + + public Optional getCarAvgCo2PerMeter() { + return Optional.ofNullable(this.carAvgCo2PerMeter); + } + + public Optional getCO2EmissionsById(FeedScopedId feedScopedRouteId) { + return Optional.ofNullable(this.co2Emissions.get(feedScopedRouteId)); + } +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/EmissionsFilter.java b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsFilter.java new file mode 100644 index 00000000000..845bb42204d --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsFilter.java @@ -0,0 +1,93 @@ +package org.opentripplanner.ext.emissions; + +import java.util.List; +import java.util.Optional; +import org.opentripplanner.ext.flex.FlexibleTransitLeg; +import org.opentripplanner.framework.lang.Sandbox; +import org.opentripplanner.framework.model.Grams; +import org.opentripplanner.model.plan.Emissions; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.ScheduledTransitLeg; +import org.opentripplanner.model.plan.StreetLeg; +import org.opentripplanner.model.plan.TransitLeg; +import org.opentripplanner.routing.algorithm.filterchain.ItineraryListFilter; +import org.opentripplanner.street.search.TraverseMode; +import org.opentripplanner.transit.model.framework.FeedScopedId; + +/** + * Calculates the emissions for the itineraries and adds them. + * @param emissionsService + */ +@Sandbox +public record EmissionsFilter(EmissionsService emissionsService) implements ItineraryListFilter { + @Override + public List filter(List itineraries) { + for (Itinerary itinerary : itineraries) { + List transitLegs = itinerary + .getLegs() + .stream() + .filter(l -> l instanceof ScheduledTransitLeg || l instanceof FlexibleTransitLeg) + .map(TransitLeg.class::cast) + .toList(); + + calculateCo2EmissionsForTransit(transitLegs) + .ifPresent(co2 -> { + itinerary.setEmissionsPerPerson(new Emissions(co2)); + }); + + List carLegs = itinerary + .getLegs() + .stream() + .filter(l -> l instanceof StreetLeg) + .map(StreetLeg.class::cast) + .filter(leg -> leg.getMode() == TraverseMode.CAR) + .toList(); + + calculateCo2EmissionsForCar(carLegs) + .ifPresent(co2 -> { + itinerary.setEmissionsPerPerson(new Emissions(co2)); + }); + } + return itineraries; + } + + private Optional calculateCo2EmissionsForTransit(List transitLegs) { + if (transitLegs.isEmpty()) { + return Optional.empty(); + } + Grams co2Emissions = new Grams(0.0); + for (TransitLeg leg : transitLegs) { + FeedScopedId feedScopedRouteId = new FeedScopedId( + leg.getAgency().getId().getFeedId(), + leg.getRoute().getId().getId() + ); + Optional co2EmissionsForRoute = emissionsService.getEmissionsPerMeterForRoute( + feedScopedRouteId + ); + if (co2EmissionsForRoute.isPresent()) { + co2Emissions = + co2Emissions.plus(co2EmissionsForRoute.get().getCo2().multiply(leg.getDistanceMeters())); + } else { + // Partial results would not give an accurate representation of the emissions. + return Optional.empty(); + } + } + return Optional.ofNullable(co2Emissions); + } + + private Optional calculateCo2EmissionsForCar(List carLegs) { + if (carLegs.isEmpty()) { + return Optional.empty(); + } + return emissionsService + .getEmissionsPerMeterForCar() + .map(emissions -> + new Grams( + carLegs + .stream() + .mapToDouble(leg -> emissions.getCo2().multiply(leg.getDistanceMeters()).asDouble()) + .sum() + ) + ); + } +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/EmissionsModule.java b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsModule.java new file mode 100644 index 00000000000..841c5252937 --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsModule.java @@ -0,0 +1,63 @@ +package org.opentripplanner.ext.emissions; + +import dagger.Module; +import jakarta.inject.Inject; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import org.opentripplanner.graph_builder.ConfiguredDataSource; +import org.opentripplanner.graph_builder.GraphBuilderDataSources; +import org.opentripplanner.graph_builder.issue.api.DataImportIssueStore; +import org.opentripplanner.graph_builder.model.GraphBuilderModule; +import org.opentripplanner.gtfs.graphbuilder.GtfsFeedParameters; +import org.opentripplanner.standalone.config.BuildConfig; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class allows updating the graph with emissions data from external emissions data files. + */ +@Module +public class EmissionsModule implements GraphBuilderModule { + + private static final Logger LOG = LoggerFactory.getLogger(EmissionsModule.class); + private BuildConfig config; + private EmissionsDataModel emissionsDataModel; + private GraphBuilderDataSources dataSources; + private Map emissionsData = new HashMap<>(); + private final DataImportIssueStore issueStore; + + @Inject + public EmissionsModule( + GraphBuilderDataSources dataSources, + BuildConfig config, + EmissionsDataModel emissionsDataModel, + DataImportIssueStore issueStore + ) { + this.dataSources = dataSources; + this.config = config; + this.emissionsDataModel = emissionsDataModel; + this.issueStore = issueStore; + } + + public void buildGraph() { + if (config.emissions != null) { + LOG.info("Start emissions building"); + Co2EmissionsDataReader co2EmissionsDataReader = new Co2EmissionsDataReader(issueStore); + double carAvgCo2PerKm = config.emissions.getCarAvgCo2PerKm(); + double carAvgOccupancy = config.emissions.getCarAvgOccupancy(); + double carAvgEmissionsPerMeter = carAvgCo2PerKm / 1000 / carAvgOccupancy; + + for (ConfiguredDataSource gtfsData : dataSources.getGtfsConfiguredDatasource()) { + if (gtfsData.dataSource().name().contains(".zip")) { + emissionsData = co2EmissionsDataReader.readGtfsZip(new File(gtfsData.dataSource().uri())); + } else { + emissionsData = co2EmissionsDataReader.readGtfs(new File(gtfsData.dataSource().uri())); + } + } + this.emissionsDataModel.setCo2Emissions(this.emissionsData); + this.emissionsDataModel.setCarAvgCo2PerMeter(carAvgEmissionsPerMeter); + } + } +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/EmissionsService.java b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsService.java new file mode 100644 index 00000000000..6f69ac60d06 --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsService.java @@ -0,0 +1,26 @@ +package org.opentripplanner.ext.emissions; + +import java.util.Optional; +import org.opentripplanner.framework.lang.Sandbox; +import org.opentripplanner.model.plan.Emissions; +import org.opentripplanner.transit.model.framework.FeedScopedId; + +/** + * A service for getting emissions information for routes. + */ +@Sandbox +public interface EmissionsService { + /** + * Get all emissions per meter for a specific route. + * + * @return Emissions per meter + */ + Optional getEmissionsPerMeterForRoute(FeedScopedId feedScopedRouteId); + + /** + * Get all emissions per meter for a car. + * + * @return Emissions per meter + */ + Optional getEmissionsPerMeterForCar(); +} diff --git a/src/ext/java/org/opentripplanner/ext/emissions/EmissionsServiceModule.java b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsServiceModule.java new file mode 100644 index 00000000000..47ee043e1b5 --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/emissions/EmissionsServiceModule.java @@ -0,0 +1,19 @@ +package org.opentripplanner.ext.emissions; + +import dagger.Module; +import dagger.Provides; +import jakarta.inject.Singleton; + +/** + * The service is used during application serve phase, not loading, so we need to provide + * a module for the service without the repository, which is injected from the loading phase. + */ +@Module +public class EmissionsServiceModule { + + @Provides + @Singleton + public EmissionsService provideEmissionsService(EmissionsDataModel emissionsDataModel) { + return new DefaultEmissionsService(emissionsDataModel); + } +} diff --git a/src/ext/java/org/opentripplanner/ext/fares/impl/OrcaFareService.java b/src/ext/java/org/opentripplanner/ext/fares/impl/OrcaFareService.java index 3f9fe293f3d..b8c2de89a4e 100644 --- a/src/ext/java/org/opentripplanner/ext/fares/impl/OrcaFareService.java +++ b/src/ext/java/org/opentripplanner/ext/fares/impl/OrcaFareService.java @@ -133,15 +133,9 @@ static RideType getRideType(Leg leg) { // Lettered routes exist, are not an error. } - if ( - route.getGtfsType() == ROUTE_TYPE_FERRY && - routeLongNameFallBack(route).contains("Water Taxi: West Seattle") - ) { + if ("973".equals(route.getShortName())) { yield RideType.KC_WATER_TAXI_WEST_SEATTLE; - } else if ( - route.getGtfsType() == ROUTE_TYPE_FERRY && - route.getDescription().contains("Water Taxi: Vashon Island") - ) { + } else if ("975".equals(route.getShortName())) { yield RideType.KC_WATER_TAXI_VASHON_ISLAND; } yield RideType.KC_METRO; @@ -245,8 +239,10 @@ private Optional getRegularFare( ) { Route route = leg.getRoute(); return switch (rideType) { - case KC_WATER_TAXI_VASHON_ISLAND -> optionalUSD(5.75f); - case KC_WATER_TAXI_WEST_SEATTLE -> optionalUSD(5f); + case KC_WATER_TAXI_VASHON_ISLAND -> usesOrca(fareType) + ? optionalUSD(5.75f) + : optionalUSD(6.75f); + case KC_WATER_TAXI_WEST_SEATTLE -> usesOrca(fareType) ? optionalUSD(5f) : optionalUSD(5.75f); case KITSAP_TRANSIT_FAST_FERRY_EASTBOUND -> optionalUSD(2f); case KITSAP_TRANSIT_FAST_FERRY_WESTBOUND -> optionalUSD(10f); case WASHINGTON_STATE_FERRIES -> Optional.of( @@ -287,6 +283,10 @@ private Optional getLiftFare(RideType rideType, Money defaultFare, Route ); case KITSAP_TRANSIT_FAST_FERRY_EASTBOUND -> optionalUSD((1f)); case KITSAP_TRANSIT_FAST_FERRY_WESTBOUND -> optionalUSD((5f)); + case SKAGIT_LOCAL, + SKAGIT_CROSS_COUNTY, + WHATCOM_CROSS_COUNTY, + WHATCOM_LOCAL -> Optional.empty(); default -> Optional.of(defaultFare); }; } @@ -308,8 +308,10 @@ private Optional getSeniorFare( case KITSAP_TRANSIT_FAST_FERRY_EASTBOUND -> fareType.equals(FareType.electronicSenior) // Kitsap only provide discounted senior fare for orca. ? optionalUSD(1f) : optionalUSD(2f); - case KC_WATER_TAXI_VASHON_ISLAND -> optionalUSD(3f); - case KC_WATER_TAXI_WEST_SEATTLE -> optionalUSD(2.5f); + case KC_WATER_TAXI_VASHON_ISLAND -> usesOrca(fareType) ? optionalUSD(3f) : optionalUSD(6.75f); + case KC_WATER_TAXI_WEST_SEATTLE -> usesOrca(fareType) + ? optionalUSD(2.5f) + : optionalUSD(5.75f); case SOUND_TRANSIT, SOUND_TRANSIT_BUS, SOUND_TRANSIT_LINK, @@ -328,7 +330,7 @@ private Optional getSeniorFare( case WASHINGTON_STATE_FERRIES -> Optional.of( getWashingtonStateFerriesFare(route.getLongName(), fareType, defaultFare) ); - case WHATCOM_CROSS_COUNTY, SKAGIT_CROSS_COUNTY -> optionalUSD(1f); + case WHATCOM_CROSS_COUNTY, SKAGIT_CROSS_COUNTY -> Optional.of(defaultFare.half()); default -> Optional.of(defaultFare); }; } diff --git a/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdater.java b/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdater.java index ef138264999..921657bb8f1 100644 --- a/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdater.java +++ b/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdater.java @@ -1,5 +1,7 @@ package org.opentripplanner.ext.siri.updater.azure; +import static net.logstash.logback.argument.StructuredArguments.keyValue; + import com.azure.messaging.servicebus.ServiceBusErrorContext; import com.azure.messaging.servicebus.ServiceBusReceivedMessageContext; import jakarta.xml.bind.JAXBException; @@ -14,11 +16,14 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.stream.Collectors; import javax.xml.stream.XMLStreamException; import org.apache.hc.core5.net.URIBuilder; import org.opentripplanner.ext.siri.SiriTimetableSnapshotSource; import org.opentripplanner.framework.time.DurationUtils; import org.opentripplanner.transit.service.TransitModel; +import org.opentripplanner.updater.spi.ResultLogger; +import org.opentripplanner.updater.spi.UpdateError; import org.opentripplanner.updater.spi.UpdateResult; import org.opentripplanner.updater.trip.metrics.TripUpdateMetrics; import org.rutebanken.siri20.util.SiriXml; @@ -106,13 +111,15 @@ private void processMessage(String message, String id) { } super.saveResultOnGraph.execute((graph, transitModel) -> { - snapshotSource.applyEstimatedTimetable( + var result = snapshotSource.applyEstimatedTimetable( fuzzyTripMatcher(), entityResolver(), feedId, false, updates ); + ResultLogger.logUpdateResultErrors(feedId, "siri-et", result); + recordMetrics.accept(result); }); } catch (JAXBException | XMLStreamException e) { LOG.error(e.getLocalizedMessage(), e); @@ -138,6 +145,7 @@ private void processHistory(String message, String id) { false, updates ); + ResultLogger.logUpdateResultErrors(feedId, "siri-et", result); recordMetrics.accept(result); setPrimed(true); diff --git a/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdaterParameters.java b/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdaterParameters.java index a1ee8c5a79e..e63615dd893 100644 --- a/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdaterParameters.java +++ b/src/ext/java/org/opentripplanner/ext/siri/updater/azure/SiriAzureETUpdaterParameters.java @@ -1,5 +1,6 @@ package org.opentripplanner.ext.siri.updater.azure; +import com.azure.core.amqp.implementation.ConnectionStringProperties; import java.time.LocalDate; import org.opentripplanner.updater.trip.UrlUpdaterParameters; @@ -23,6 +24,11 @@ public void setFromDateTime(LocalDate fromDateTime) { @Override public String url() { - return getDataInitializationUrl(); + var url = getServiceBusUrl(); + try { + return new ConnectionStringProperties(url).getEndpoint().toString(); + } catch (IllegalArgumentException e) { + return url; + } } } diff --git a/src/main/java/org/opentripplanner/api/mapping/ItineraryMapper.java b/src/main/java/org/opentripplanner/api/mapping/ItineraryMapper.java index e65700b2f08..cf14eda73dd 100644 --- a/src/main/java/org/opentripplanner/api/mapping/ItineraryMapper.java +++ b/src/main/java/org/opentripplanner/api/mapping/ItineraryMapper.java @@ -30,7 +30,6 @@ public ApiItinerary mapItinerary(Itinerary domain) { return null; } ApiItinerary api = new ApiItinerary(); - api.duration = domain.getDuration().toSeconds(); api.startTime = GregorianCalendar.from(domain.startTime()); api.endTime = GregorianCalendar.from(domain.endTime()); diff --git a/src/main/java/org/opentripplanner/apis/gtfs/GraphQLScalars.java b/src/main/java/org/opentripplanner/apis/gtfs/GraphQLScalars.java index 07292cfca08..6280ac28ac2 100644 --- a/src/main/java/org/opentripplanner/apis/gtfs/GraphQLScalars.java +++ b/src/main/java/org/opentripplanner/apis/gtfs/GraphQLScalars.java @@ -13,6 +13,7 @@ import org.locationtech.jts.geom.Geometry; import org.opentripplanner.framework.geometry.GeometryUtils; import org.opentripplanner.framework.graphql.scalar.DurationScalarFactory; +import org.opentripplanner.framework.model.Grams; public class GraphQLScalars { @@ -116,4 +117,39 @@ public Relay.ResolvedGlobalId parseLiteral(Object input) } ) .build(); + + public static GraphQLScalarType gramsScalar = GraphQLScalarType + .newScalar() + .name("Grams") + .coercing( + new Coercing() { + @Override + public Double serialize(Object dataFetcherResult) throws CoercingSerializeException { + if (dataFetcherResult instanceof Grams) { + var grams = (Grams) dataFetcherResult; + return Double.valueOf(grams.asDouble()); + } + return null; + } + + @Override + public Grams parseValue(Object input) throws CoercingParseValueException { + if (input instanceof Double) { + var grams = (Double) input; + return new Grams(grams); + } + return null; + } + + @Override + public Grams parseLiteral(Object input) throws CoercingParseLiteralException { + if (input instanceof Double) { + var grams = (Double) input; + return new Grams(grams); + } + return null; + } + } + ) + .build(); } diff --git a/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java b/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java index 9c6b50dbb4c..c70836a581f 100644 --- a/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java +++ b/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java @@ -112,6 +112,7 @@ protected static GraphQLSchema buildSchema() { .scalar(GraphQLScalars.polylineScalar) .scalar(GraphQLScalars.geoJsonScalar) .scalar(GraphQLScalars.graphQLIDScalar) + .scalar(GraphQLScalars.gramsScalar) .scalar(ExtendedScalars.GraphQLLong) .type("Node", type -> type.typeResolver(new NodeTypeResolver())) .type("PlaceInterface", type -> type.typeResolver(new PlaceInterfaceTypeResolver())) diff --git a/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/ItineraryImpl.java b/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/ItineraryImpl.java index afc78479de1..e446be9202c 100644 --- a/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/ItineraryImpl.java +++ b/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/ItineraryImpl.java @@ -9,6 +9,7 @@ import org.opentripplanner.apis.gtfs.mapping.NumberMapper; import org.opentripplanner.model.SystemNotice; import org.opentripplanner.model.fare.ItineraryFares; +import org.opentripplanner.model.plan.Emissions; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.model.plan.Leg; @@ -100,6 +101,11 @@ public DataFetcher accessibilityScore() { return environment -> NumberMapper.toDouble(getSource(environment).getAccessibilityScore()); } + @Override + public DataFetcher emissionsPerPerson() { + return environment -> getSource(environment).getEmissionsPerPerson(); + } + private Itinerary getSource(DataFetchingEnvironment environment) { return environment.getSource(); } diff --git a/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java b/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java index b31dac0ea8e..7f081b4faf6 100644 --- a/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java +++ b/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java @@ -33,6 +33,7 @@ import org.opentripplanner.model.fare.FareProduct; import org.opentripplanner.model.fare.FareProductUse; import org.opentripplanner.model.fare.RiderCategory; +import org.opentripplanner.model.plan.Emissions; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.model.plan.Leg; import org.opentripplanner.model.plan.StopArrival; @@ -335,6 +336,10 @@ public interface GraphQLDepartureRow { public DataFetcher> stoptimes(); } + public interface GraphQLEmissions { + public DataFetcher co2(); + } + /** A 'medium' that a fare product applies to, for example cash, 'Oyster Card' or 'DB Navigator App'. */ public interface GraphQLFareMedium { public DataFetcher id(); @@ -394,6 +399,8 @@ public interface GraphQLItinerary { public DataFetcher elevationLost(); + public DataFetcher emissionsPerPerson(); + public DataFetcher endTime(); public DataFetcher>> fares(); @@ -755,12 +762,24 @@ public interface GraphQLRentalVehicle { public DataFetcher vehicleType(); } + public interface GraphQLRentalVehicleEntityCounts { + public DataFetcher> byType(); + + public DataFetcher total(); + } + public interface GraphQLRentalVehicleType { public DataFetcher formFactor(); public DataFetcher propulsionType(); } + public interface GraphQLRentalVehicleTypeCount { + public DataFetcher count(); + + public DataFetcher vehicleType(); + } + /** An estimate for a ride on a hailed vehicle, like an Uber car. */ public interface GraphQLRideHailingEstimate { public DataFetcher arrival(); @@ -1160,6 +1179,10 @@ public interface GraphQLVehicleRentalStation { public DataFetcher allowPickupNow(); + public DataFetcher availableSpaces(); + + public DataFetcher availableVehicles(); + public DataFetcher capacity(); public DataFetcher id(); @@ -1183,10 +1206,6 @@ public interface GraphQLVehicleRentalStation { public DataFetcher stationId(); public DataFetcher vehiclesAvailable(); - - public DataFetcher availableVehicles(); - - public DataFetcher availableSpaces(); } public interface GraphQLVehicleRentalUris { diff --git a/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml b/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml index ed5726b9bb3..68901bdccef 100644 --- a/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml +++ b/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml @@ -25,6 +25,7 @@ config: Long: Long Polyline: String GeoJson: org.locationtech.jts.geom.Geometry + Grams: org.opentripplanner.framework.model.Grams Duration: java.time.Duration mappers: AbsoluteDirection: org.opentripplanner.apis.gtfs.generated.GraphQLTypes.GraphQLAbsoluteDirection#GraphQLAbsoluteDirection @@ -52,6 +53,7 @@ config: debugOutput: org.opentripplanner.api.resource.DebugOutput#DebugOutput DepartureRow: org.opentripplanner.routing.graphfinder.PatternAtStop#PatternAtStop elevationProfileComponent: org.opentripplanner.model.plan.ElevationProfile.Step + Emissions: org.opentripplanner.model.plan.Emissions#Emissions fare: java.util.Map#Map fareComponent: org.opentripplanner.routing.core.FareComponent#FareComponent Feed: String @@ -111,3 +113,4 @@ config: FareMedium: org.opentripplanner.model.fare.FareMedium#FareMedium RiderCategory: org.opentripplanner.model.fare.RiderCategory#RiderCategory StopPosition: org.opentripplanner.apis.gtfs.model.StopPosition#StopPosition + diff --git a/src/main/java/org/opentripplanner/framework/application/OTPFeature.java b/src/main/java/org/opentripplanner/framework/application/OTPFeature.java index a80bd26bad1..9c730b94216 100644 --- a/src/main/java/org/opentripplanner/framework/application/OTPFeature.java +++ b/src/main/java/org/opentripplanner/framework/application/OTPFeature.java @@ -72,7 +72,7 @@ public enum OTPFeature { false, "Whether the @async annotation in the GraphQL schema should lead to the fetch being executed asynchronously. This allows batch or alias queries to run in parallel at the cost of consuming extra threads." ), - + Co2Emissions(false, true, "Enable the emissions sandbox module."), DataOverlay( false, true, diff --git a/src/main/java/org/opentripplanner/framework/model/Grams.java b/src/main/java/org/opentripplanner/framework/model/Grams.java new file mode 100644 index 00000000000..86e8adce5b1 --- /dev/null +++ b/src/main/java/org/opentripplanner/framework/model/Grams.java @@ -0,0 +1,55 @@ +package org.opentripplanner.framework.model; + +import static java.lang.Double.compare; + +import java.io.Serializable; + +/** + * A representation of the weight of something in grams. + */ +public final class Grams implements Serializable, Comparable { + + private final double value; + + public Grams(double value) { + this.value = value; + } + + public Grams plus(Grams g) { + return new Grams(this.value + g.value); + } + + public Grams multiply(int factor) { + return new Grams(this.value * factor); + } + + public Grams multiply(double factor) { + return new Grams(this.value * factor); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + var that = (Grams) o; + return value == that.value; + } + + @Override + public int compareTo(Grams o) { + return compare(value, o.value); + } + + @Override + public String toString() { + return this.value + "g"; + } + + public double asDouble() { + return this.value; + } +} diff --git a/src/main/java/org/opentripplanner/graph_builder/GraphBuilder.java b/src/main/java/org/opentripplanner/graph_builder/GraphBuilder.java index ace5fa2768e..5b322f735bf 100644 --- a/src/main/java/org/opentripplanner/graph_builder/GraphBuilder.java +++ b/src/main/java/org/opentripplanner/graph_builder/GraphBuilder.java @@ -9,6 +9,8 @@ import java.util.ArrayList; import java.util.List; import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.framework.application.OtpAppException; import org.opentripplanner.framework.lang.OtpNumberFormat; @@ -60,6 +62,7 @@ public static GraphBuilder create( Graph graph, TransitModel transitModel, WorldEnvelopeRepository worldEnvelopeRepository, + @Nullable EmissionsDataModel emissionsDataModel, boolean loadStreetGraph, boolean saveStreetGraph ) { @@ -71,15 +74,20 @@ public static GraphBuilder create( transitModel.initTimeZone(config.transitModelTimeZone); - var factory = DaggerGraphBuilderFactory + var builder = DaggerGraphBuilderFactory .builder() .config(config) .graph(graph) .transitModel(transitModel) .worldEnvelopeRepository(worldEnvelopeRepository) .dataSources(dataSources) - .timeZoneId(transitModel.getTimeZone()) - .build(); + .timeZoneId(transitModel.getTimeZone()); + + if (OTPFeature.Co2Emissions.isOn()) { + builder.emissionsDataModel(emissionsDataModel); + } + + var factory = builder.build(); var graphBuilder = factory.graphBuilder(); @@ -156,6 +164,10 @@ public static GraphBuilder create( graphBuilder.addModuleOptional(factory.dataOverlayFactory()); } + if (OTPFeature.Co2Emissions.isOn()) { + graphBuilder.addModule(factory.emissionsModule()); + } + graphBuilder.addModule(factory.calculateWorldEnvelopeModule()); return graphBuilder; diff --git a/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderFactory.java b/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderFactory.java index a67876f7b78..0b2a46e165e 100644 --- a/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderFactory.java +++ b/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderFactory.java @@ -7,6 +7,8 @@ import java.util.List; import javax.annotation.Nullable; import org.opentripplanner.ext.dataoverlay.EdgeUpdaterModule; +import org.opentripplanner.ext.emissions.EmissionsDataModel; +import org.opentripplanner.ext.emissions.EmissionsModule; import org.opentripplanner.ext.flex.AreaStopsToVerticesMapper; import org.opentripplanner.ext.transferanalyzer.DirectTransferAnalyzer; import org.opentripplanner.graph_builder.GraphBuilder; @@ -37,6 +39,7 @@ public interface GraphBuilderFactory { GraphBuilder graphBuilder(); OsmModule osmModule(); GtfsModule gtfsModule(); + EmissionsModule emissionsModule(); NetexModule netexModule(); TimeZoneAdjusterModule timeZoneAdjusterModule(); TripPatternNamer tripPatternNamer(); @@ -74,5 +77,8 @@ interface Builder { Builder timeZoneId(@Nullable ZoneId zoneId); GraphBuilderFactory build(); + + @BindsInstance + Builder emissionsDataModel(@Nullable EmissionsDataModel emissionsDataModel); } } diff --git a/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java b/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java index 98ec013aea0..755b7725941 100644 --- a/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java +++ b/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java @@ -8,9 +8,12 @@ import java.io.File; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; import org.opentripplanner.datastore.api.DataSource; import org.opentripplanner.ext.dataoverlay.EdgeUpdaterModule; import org.opentripplanner.ext.dataoverlay.configure.DataOverlayFactory; +import org.opentripplanner.ext.emissions.EmissionsDataModel; +import org.opentripplanner.ext.emissions.EmissionsModule; import org.opentripplanner.ext.transferanalyzer.DirectTransferAnalyzer; import org.opentripplanner.graph_builder.ConfiguredDataSource; import org.opentripplanner.graph_builder.GraphBuilderDataSources; @@ -106,6 +109,17 @@ static GtfsModule provideGtfsModule( ); } + @Provides + @Singleton + static EmissionsModule provideEmissionsModule( + GraphBuilderDataSources dataSources, + BuildConfig config, + @Nullable EmissionsDataModel emissionsDataModel, + DataImportIssueStore issueStore + ) { + return new EmissionsModule(dataSources, config, emissionsDataModel, issueStore); + } + @Provides @Singleton static NetexModule provideNetexModule( diff --git a/src/main/java/org/opentripplanner/model/plan/Emissions.java b/src/main/java/org/opentripplanner/model/plan/Emissions.java new file mode 100644 index 00000000000..e19ce3a914a --- /dev/null +++ b/src/main/java/org/opentripplanner/model/plan/Emissions.java @@ -0,0 +1,23 @@ +package org.opentripplanner.model.plan; + +import org.opentripplanner.framework.lang.Sandbox; +import org.opentripplanner.framework.model.Grams; + +/** + * Represents the emissions of a journey. Each type of emissions has its own field and unit. + */ +@Sandbox +public class Emissions { + + private Grams co2; + + public Emissions(Grams co2) { + if (co2 != null) { + this.co2 = co2; + } + } + + public Grams getCo2() { + return co2; + } +} diff --git a/src/main/java/org/opentripplanner/model/plan/Itinerary.java b/src/main/java/org/opentripplanner/model/plan/Itinerary.java index 09a9ce7d126..4fa4707f590 100644 --- a/src/main/java/org/opentripplanner/model/plan/Itinerary.java +++ b/src/main/java/org/opentripplanner/model/plan/Itinerary.java @@ -52,6 +52,8 @@ public class Itinerary implements ItinerarySortKey { /* Sandbox experimental properties */ private Float accessibilityScore; + private Emissions emissionsPerPerson; + /* other properties */ private final List systemNotices = new ArrayList<>(); @@ -264,6 +266,7 @@ public String toString() { .addNum("elevationGained", elevationGained, 0.0) .addCol("legs", legs) .addObj("fare", fare) + .addObj("emissionsPerPerson", emissionsPerPerson) .toString(); } @@ -600,4 +603,16 @@ public List getScheduledTransitLegs() { .map(ScheduledTransitLeg.class::cast) .toList(); } + + /** + * The emissions of this itinerary. + */ + public void setEmissionsPerPerson(Emissions emissionsPerPerson) { + this.emissionsPerPerson = emissionsPerPerson; + } + + @Nullable + public Emissions getEmissionsPerPerson() { + return this.emissionsPerPerson; + } } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainBuilder.java b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainBuilder.java index f66a713f9b1..39835f0fccf 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainBuilder.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainBuilder.java @@ -77,6 +77,9 @@ public class ItineraryListFilterChainBuilder { private double minBikeParkingDistance; private boolean removeTransitIfWalkingIsBetter = true; + @Sandbox + private ItineraryListFilter emissionsFilter; + @Sandbox private ItineraryListFilter rideHailingFilter; @@ -283,6 +286,11 @@ public ItineraryListFilterChainBuilder withFares(FareService fareService) { return this; } + public ItineraryListFilterChainBuilder withEmissions(ItineraryListFilter emissionsFilter) { + this.emissionsFilter = emissionsFilter; + return this; + } + public ItineraryListFilterChainBuilder withMinBikeParkingDistance(double distance) { this.minBikeParkingDistance = distance; return this; @@ -329,6 +337,10 @@ public ItineraryListFilterChain build() { filters.add(new FaresFilter(faresService)); } + if (this.emissionsFilter != null) { + filters.add(this.emissionsFilter); + } + if (transitAlertService != null) { filters.add(new TransitAlertFilter(transitAlertService, getMultiModalStation)); } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/mapping/RouteRequestToFilterChainMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/mapping/RouteRequestToFilterChainMapper.java index a7ca4989203..f5029e456f3 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/mapping/RouteRequestToFilterChainMapper.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/mapping/RouteRequestToFilterChainMapper.java @@ -3,7 +3,9 @@ import java.time.Instant; import java.util.List; import java.util.function.Consumer; +import org.opentripplanner.ext.emissions.EmissionsFilter; import org.opentripplanner.ext.ridehailing.RideHailingFilter; +import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.routing.algorithm.filterchain.GroupBySimilarity; import org.opentripplanner.routing.algorithm.filterchain.ItineraryListFilterChain; import org.opentripplanner.routing.algorithm.filterchain.ItineraryListFilterChainBuilder; @@ -94,6 +96,10 @@ public static ItineraryListFilterChain createFilterChain( ); } + if (OTPFeature.Co2Emissions.isOn() && context.emissionsService() != null) { + builder.withEmissions(new EmissionsFilter(context.emissionsService())); + } + return builder.build(); } diff --git a/src/main/java/org/opentripplanner/routing/graph/SerializedGraphObject.java b/src/main/java/org/opentripplanner/routing/graph/SerializedGraphObject.java index 6541c082f33..8b066b1d1d2 100644 --- a/src/main/java/org/opentripplanner/routing/graph/SerializedGraphObject.java +++ b/src/main/java/org/opentripplanner/routing/graph/SerializedGraphObject.java @@ -17,6 +17,7 @@ import java.util.List; import javax.annotation.Nullable; import org.opentripplanner.datastore.api.DataSource; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.framework.application.OtpAppException; import org.opentripplanner.framework.geometry.CompactElevationProfile; import org.opentripplanner.framework.lang.OtpNumberFormat; @@ -75,6 +76,7 @@ public class SerializedGraphObject implements Serializable { public final DataImportIssueSummary issueSummary; private final int stopLocationCounter; private final int routingTripPatternCounter; + public final EmissionsDataModel emissionsDataModel; public SerializedGraphObject( Graph graph, @@ -82,7 +84,8 @@ public SerializedGraphObject( WorldEnvelopeRepository worldEnvelopeRepository, BuildConfig buildConfig, RouterConfig routerConfig, - DataImportIssueSummary issueSummary + DataImportIssueSummary issueSummary, + EmissionsDataModel emissionsDataModel ) { this.graph = graph; this.edges = graph.getEdges(); @@ -91,6 +94,7 @@ public SerializedGraphObject( this.buildConfig = buildConfig; this.routerConfig = routerConfig; this.issueSummary = issueSummary; + this.emissionsDataModel = emissionsDataModel; this.allTransitSubModes = SubMode.listAllCachedSubModes(); this.stopLocationCounter = StopLocation.indexCounter(); this.routingTripPatternCounter = RoutingTripPattern.indexCounter(); diff --git a/src/main/java/org/opentripplanner/standalone/OTPMain.java b/src/main/java/org/opentripplanner/standalone/OTPMain.java index 0d868e43eec..65b40dc3630 100644 --- a/src/main/java/org/opentripplanner/standalone/OTPMain.java +++ b/src/main/java/org/opentripplanner/standalone/OTPMain.java @@ -151,7 +151,8 @@ private static void startOTPServer(CommandLineParameters cli) { app.worldEnvelopeRepository(), config.buildConfig(), config.routerConfig(), - DataImportIssueSummary.combine(graphBuilder.issueSummary(), app.dataImportIssueSummary()) + DataImportIssueSummary.combine(graphBuilder.issueSummary(), app.dataImportIssueSummary()), + app.emissionsDataModel() ) .save(app.graphOutputDataSource()); // Log size info for the deduplicator diff --git a/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java b/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java index 5c4b6ad0c1e..db105338736 100644 --- a/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java +++ b/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java @@ -3,8 +3,10 @@ import io.micrometer.core.instrument.MeterRegistry; import java.util.List; import java.util.Locale; +import javax.annotation.Nullable; import org.opentripplanner.astar.spi.TraverseVisitor; import org.opentripplanner.ext.dataoverlay.routing.DataOverlayContext; +import org.opentripplanner.ext.emissions.EmissionsService; import org.opentripplanner.ext.ridehailing.RideHailingService; import org.opentripplanner.ext.vectortiles.VectorTilesResource; import org.opentripplanner.framework.application.OTPFeature; @@ -94,6 +96,9 @@ public interface OtpServerRequestContext { MeterRegistry meterRegistry(); + @Nullable + EmissionsService emissionsService(); + /** Inspector/debug services */ TileRendererManager tileRendererManager(); diff --git a/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java b/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java index 65d1a412331..060d224b487 100644 --- a/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java +++ b/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java @@ -18,6 +18,7 @@ import javax.annotation.Nonnull; import org.opentripplanner.datastore.api.OtpDataStoreConfig; import org.opentripplanner.ext.dataoverlay.configuration.DataOverlayConfig; +import org.opentripplanner.ext.emissions.EmissionsConfig; import org.opentripplanner.ext.fares.FaresConfiguration; import org.opentripplanner.framework.geometry.CompactElevationProfile; import org.opentripplanner.framework.lang.ObjectUtils; @@ -164,6 +165,7 @@ public class BuildConfig implements OtpDataStoreConfig { public final Set boardingLocationTags; public final DemExtractParametersList dem; public final OsmExtractParametersList osm; + public final EmissionsConfig emissions; public final TransitFeeds transitFeeds; public boolean staticParkAndRide; public boolean staticBikeParkAndRide; @@ -611,6 +613,8 @@ that we support remote input files (cloud storage or arbitrary URLs) not all dat osm = OsmConfig.mapOsmConfig(root, "osm", osmDefaults); demDefaults = DemConfig.mapDemDefaultsConfig(root, "demDefaults"); dem = DemConfig.mapDemConfig(root, "dem", demDefaults); + emissions = new EmissionsConfig("emissions", root); + netexDefaults = NetexConfig.mapNetexDefaultParameters(root, "netexDefaults"); gtfsDefaults = GtfsConfig.mapGtfsDefaultParameters(root, "gtfsDefaults"); transitFeeds = diff --git a/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java b/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java index e60fd159332..faf1952c10d 100644 --- a/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java +++ b/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java @@ -3,6 +3,7 @@ import jakarta.ws.rs.core.Application; import javax.annotation.Nullable; import org.opentripplanner.datastore.api.DataSource; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.ext.geocoder.LuceneIndex; import org.opentripplanner.ext.transmodelapi.TransmodelAPI; import org.opentripplanner.framework.application.LogMDCSupport; @@ -70,7 +71,8 @@ public class ConstructApplication { WorldEnvelopeRepository worldEnvelopeRepository, ConfigModel config, GraphBuilderDataSources graphBuilderDataSources, - DataImportIssueSummary issueSummary + DataImportIssueSummary issueSummary, + EmissionsDataModel emissionsDataModel ) { this.cli = cli; this.graphBuilderDataSources = graphBuilderDataSources; @@ -87,6 +89,7 @@ public class ConstructApplication { .transitModel(transitModel) .graphVisualizer(graphVisualizer) .worldEnvelopeRepository(worldEnvelopeRepository) + .emissionsDataModel(emissionsDataModel) .dataImportIssueSummary(issueSummary) .build(); } @@ -118,6 +121,7 @@ public GraphBuilder createGraphBuilder() { graph(), transitModel(), factory.worldEnvelopeRepository(), + factory.emissionsDataModel(), cli.doLoadStreetGraph(), cli.doSaveStreetGraph() ); @@ -288,4 +292,8 @@ private void enableRequestTraceLogging() { private void createMetricsLogging() { factory.metricsLogging(); } + + public EmissionsDataModel emissionsDataModel() { + return factory.emissionsDataModel(); + } } diff --git a/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java b/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java index b776df1ae5d..eb286ea5843 100644 --- a/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java +++ b/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java @@ -4,6 +4,8 @@ import dagger.Component; import jakarta.inject.Singleton; import javax.annotation.Nullable; +import org.opentripplanner.ext.emissions.EmissionsDataModel; +import org.opentripplanner.ext.emissions.EmissionsServiceModule; import org.opentripplanner.ext.ridehailing.configure.RideHailingServicesModule; import org.opentripplanner.graph_builder.issue.api.DataImportIssueSummary; import org.opentripplanner.raptor.configure.RaptorConfig; @@ -44,6 +46,7 @@ VehicleRentalRepositoryModule.class, ConstructApplicationModule.class, RideHailingServicesModule.class, + EmissionsServiceModule.class, } ) public interface ConstructApplicationFactory { @@ -59,6 +62,9 @@ public interface ConstructApplicationFactory { VehicleRentalService vehicleRentalService(); DataImportIssueSummary dataImportIssueSummary(); + @Nullable + EmissionsDataModel emissionsDataModel(); + @Nullable GraphVisualizer graphVisualizer(); @@ -87,6 +93,9 @@ interface Builder { @BindsInstance Builder dataImportIssueSummary(DataImportIssueSummary issueSummary); + @BindsInstance + Builder emissionsDataModel(EmissionsDataModel emissionsDataModel); + ConstructApplicationFactory build(); } } diff --git a/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java b/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java index 3b43644e24a..8f936d48522 100644 --- a/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java +++ b/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java @@ -6,6 +6,7 @@ import java.util.List; import javax.annotation.Nullable; import org.opentripplanner.astar.spi.TraverseVisitor; +import org.opentripplanner.ext.emissions.EmissionsService; import org.opentripplanner.ext.ridehailing.RideHailingService; import org.opentripplanner.raptor.configure.RaptorConfig; import org.opentripplanner.routing.algorithm.raptoradapter.transit.TripSchedule; @@ -32,7 +33,8 @@ OtpServerRequestContext providesServerContext( RealtimeVehicleService realtimeVehicleService, VehicleRentalService vehicleRentalService, List rideHailingServices, - @Nullable TraverseVisitor traverseVisitor + @Nullable TraverseVisitor traverseVisitor, + EmissionsService emissionsService ) { return DefaultServerRequestContext.create( routerConfig.transitTuningConfig(), @@ -45,6 +47,7 @@ OtpServerRequestContext providesServerContext( worldEnvelopeService, realtimeVehicleService, vehicleRentalService, + emissionsService, routerConfig.flexConfig(), rideHailingServices, traverseVisitor diff --git a/src/main/java/org/opentripplanner/standalone/configure/LoadApplication.java b/src/main/java/org/opentripplanner/standalone/configure/LoadApplication.java index 9b29d8827e8..c8936ab3054 100644 --- a/src/main/java/org/opentripplanner/standalone/configure/LoadApplication.java +++ b/src/main/java/org/opentripplanner/standalone/configure/LoadApplication.java @@ -1,6 +1,7 @@ package org.opentripplanner.standalone.configure; import org.opentripplanner.datastore.api.DataSource; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.graph_builder.GraphBuilderDataSources; import org.opentripplanner.graph_builder.issue.api.DataImportIssueSummary; import org.opentripplanner.routing.graph.Graph; @@ -52,7 +53,8 @@ public ConstructApplication appConstruction(SerializedGraphObject obj) { obj.graph, obj.transitModel, obj.worldEnvelopeRepository, - obj.issueSummary + obj.issueSummary, + obj.emissionsDataModel ); } @@ -62,7 +64,8 @@ public ConstructApplication appConstruction() { factory.emptyGraph(), factory.emptyTransitModel(), factory.emptyWorldEnvelopeRepository(), - DataImportIssueSummary.empty() + DataImportIssueSummary.empty(), + factory.emptyEmissionsDataModel() ); } @@ -81,7 +84,8 @@ private ConstructApplication createAppConstruction( Graph graph, TransitModel transitModel, WorldEnvelopeRepository worldEnvelopeRepository, - DataImportIssueSummary issueSummary + DataImportIssueSummary issueSummary, + EmissionsDataModel emissionsDataModel ) { return new ConstructApplication( cli, @@ -90,7 +94,8 @@ private ConstructApplication createAppConstruction( worldEnvelopeRepository, config(), graphBuilderDataSources(), - issueSummary + issueSummary, + emissionsDataModel ); } } diff --git a/src/main/java/org/opentripplanner/standalone/configure/LoadApplicationFactory.java b/src/main/java/org/opentripplanner/standalone/configure/LoadApplicationFactory.java index 5839f4f5b69..fbd5ad2de51 100644 --- a/src/main/java/org/opentripplanner/standalone/configure/LoadApplicationFactory.java +++ b/src/main/java/org/opentripplanner/standalone/configure/LoadApplicationFactory.java @@ -6,6 +6,7 @@ import org.opentripplanner.datastore.OtpDataStore; import org.opentripplanner.datastore.configure.DataStoreModule; import org.opentripplanner.ext.datastore.gs.GsDataSourceModule; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.graph_builder.GraphBuilderDataSources; import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.service.worldenvelope.WorldEnvelopeRepository; @@ -44,6 +45,9 @@ public interface LoadApplicationFactory { @Singleton GraphBuilderDataSources graphBuilderDataSources(); + @Singleton + EmissionsDataModel emptyEmissionsDataModel(); + @Component.Builder interface Builder { @BindsInstance diff --git a/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java b/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java index 62b848fd5e9..66bb4f5cbc3 100644 --- a/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java +++ b/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java @@ -5,6 +5,7 @@ import java.util.Locale; import javax.annotation.Nullable; import org.opentripplanner.astar.spi.TraverseVisitor; +import org.opentripplanner.ext.emissions.EmissionsService; import org.opentripplanner.ext.ridehailing.RideHailingService; import org.opentripplanner.ext.vectortiles.VectorTilesResource; import org.opentripplanner.inspector.raster.TileRendererManager; @@ -43,6 +44,7 @@ public class DefaultServerRequestContext implements OtpServerRequestContext { private final WorldEnvelopeService worldEnvelopeService; private final RealtimeVehicleService realtimeVehicleService; private final VehicleRentalService vehicleRentalService; + private final EmissionsService emissionsService; /** * Make sure all mutable components are copied/cloned before calling this constructor. @@ -59,6 +61,7 @@ private DefaultServerRequestContext( WorldEnvelopeService worldEnvelopeService, RealtimeVehicleService realtimeVehicleService, VehicleRentalService vehicleRentalService, + EmissionsService emissionsService, List rideHailingServices, TraverseVisitor traverseVisitor, FlexConfig flexConfig @@ -77,6 +80,7 @@ private DefaultServerRequestContext( this.worldEnvelopeService = worldEnvelopeService; this.realtimeVehicleService = realtimeVehicleService; this.rideHailingServices = rideHailingServices; + this.emissionsService = emissionsService; } /** @@ -93,6 +97,7 @@ public static DefaultServerRequestContext create( WorldEnvelopeService worldEnvelopeService, RealtimeVehicleService realtimeVehicleService, VehicleRentalService vehicleRentalService, + @Nullable EmissionsService emissionsService, FlexConfig flexConfig, List rideHailingServices, @Nullable TraverseVisitor traverseVisitor @@ -109,6 +114,7 @@ public static DefaultServerRequestContext create( worldEnvelopeService, realtimeVehicleService, vehicleRentalService, + emissionsService, rideHailingServices, traverseVisitor, flexConfig @@ -206,4 +212,9 @@ public FlexConfig flexConfig() { public VectorTilesResource.LayersParameters vectorTileLayers() { return vectorTileLayers; } + + @Override + public EmissionsService emissionsService() { + return emissionsService; + } } diff --git a/src/main/java/org/opentripplanner/transit/model/basic/Money.java b/src/main/java/org/opentripplanner/transit/model/basic/Money.java index e6cb8f53887..e44994d1105 100644 --- a/src/main/java/org/opentripplanner/transit/model/basic/Money.java +++ b/src/main/java/org/opentripplanner/transit/model/basic/Money.java @@ -140,6 +140,14 @@ public Money plus(Money other) { return op(other, o -> new Money(currency, amount + o.amount)); } + /** + * Returns half this instance's amount + * Amounts in minor currency unit is rounded to nearest integer, so $0.99/2 becomes $0.50 + */ + public Money half() { + return new Money(currency, IntUtils.round(amount / 2f)); + } + /** * Multiplies the amount with the multiplicator. */ diff --git a/src/main/java/org/opentripplanner/updater/spi/ResultLogger.java b/src/main/java/org/opentripplanner/updater/spi/ResultLogger.java index 431f630c578..97af8d650bb 100644 --- a/src/main/java/org/opentripplanner/updater/spi/ResultLogger.java +++ b/src/main/java/org/opentripplanner/updater/spi/ResultLogger.java @@ -27,24 +27,30 @@ public static void logUpdateResult(String feedId, String type, UpdateResult upda DoubleUtils.roundTo2Decimals((double) updateResult.successful() / totalUpdates * 100) ); - var errorIndex = updateResult.failures(); - - errorIndex - .keySet() - .forEach(key -> { - var value = errorIndex.get(key); - var tripIds = value.stream().map(UpdateError::debugId).collect(Collectors.toSet()); - LOG.warn( - "[{} {}] {} failures of {}: {}", - keyValue("feedId", feedId), - keyValue("type", type), - value.size(), - keyValue("errorType", key), - tripIds - ); - }); + logUpdateResultErrors(feedId, type, updateResult); } else { LOG.info("[feedId={}, type={}] Feed did not contain any updates", feedId, type); } } + + public static void logUpdateResultErrors(String feedId, String type, UpdateResult updateResult) { + if (updateResult.failed() == 0) { + return; + } + var errorIndex = updateResult.failures(); + errorIndex + .keySet() + .forEach(key -> { + var value = errorIndex.get(key); + var tripIds = value.stream().map(UpdateError::debugId).collect(Collectors.toSet()); + LOG.warn( + "[{} {}] {} failures of {}: {}", + keyValue("feedId", feedId), + keyValue("type", type), + value.size(), + keyValue("errorType", key), + tripIds + ); + }); + } } diff --git a/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls b/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls index 82d297abe96..52815f42938 100644 --- a/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls +++ b/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls @@ -926,6 +926,13 @@ type elevationProfileComponent { elevation: Float } +type Emissions { + """ + CO₂ emissions in grams. + """ + co2: Grams +} + type fare { type: String @deprecated @@ -1027,6 +1034,8 @@ type Geometry { scalar GeoJson +scalar Grams + type StopGeometries { """Representation of the stop geometries as GeoJSON (https://geojson.org/)""" geoJson: GeoJson, @@ -1493,6 +1502,9 @@ type Itinerary { """How far the user has to walk, in meters.""" walkDistance: Float + """Emissions of this itinerary per traveler.""" + emissionsPerPerson: Emissions + """ A list of Legs. Each Leg is either a walking (cycling, car) portion of the itinerary, or a transit leg on a particular vehicle. So a itinerary where the diff --git a/src/test/java/org/opentripplanner/TestServerContext.java b/src/test/java/org/opentripplanner/TestServerContext.java index 8e68fc7c3a0..a62aba32759 100644 --- a/src/test/java/org/opentripplanner/TestServerContext.java +++ b/src/test/java/org/opentripplanner/TestServerContext.java @@ -4,6 +4,9 @@ import io.micrometer.core.instrument.Metrics; import java.util.List; +import org.opentripplanner.ext.emissions.DefaultEmissionsService; +import org.opentripplanner.ext.emissions.EmissionsDataModel; +import org.opentripplanner.ext.emissions.EmissionsService; import org.opentripplanner.raptor.configure.RaptorConfig; import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.service.realtimevehicles.RealtimeVehicleService; @@ -43,6 +46,7 @@ public static OtpServerRequestContext createServerContext( createWorldEnvelopeService(), createRealtimeVehicleService(transitService), createVehicleRentalService(), + createEmissionsService(), routerConfig.flexConfig(), List.of(), null @@ -63,4 +67,8 @@ public static RealtimeVehicleService createRealtimeVehicleService(TransitService public static VehicleRentalService createVehicleRentalService() { return new DefaultVehicleRentalService(); } + + public static EmissionsService createEmissionsService() { + return new DefaultEmissionsService(new EmissionsDataModel()); + } } diff --git a/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java b/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java index 010fdc1adc6..2a0792f4dae 100644 --- a/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java +++ b/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java @@ -46,10 +46,12 @@ import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.framework.i18n.NonLocalizedString; +import org.opentripplanner.framework.model.Grams; import org.opentripplanner.model.fare.FareMedium; import org.opentripplanner.model.fare.FareProduct; import org.opentripplanner.model.fare.ItineraryFares; import org.opentripplanner.model.fare.RiderCategory; +import org.opentripplanner.model.plan.Emissions; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.model.plan.PlanTestConstants; import org.opentripplanner.model.plan.RelativeDirection; @@ -204,6 +206,9 @@ static void setup() { railLeg.addAlert(alert); + var emissions = new Emissions(new Grams(123.0)); + i1.setEmissionsPerPerson(emissions); + var transitService = new DefaultTransitService(transitModel) { private final TransitAlertService alertService = new TransitAlertServiceImpl(transitModel); diff --git a/src/test/java/org/opentripplanner/generate/doc/BuildConfigurationDocTest.java b/src/test/java/org/opentripplanner/generate/doc/BuildConfigurationDocTest.java index 231ce70407a..287b0145292 100644 --- a/src/test/java/org/opentripplanner/generate/doc/BuildConfigurationDocTest.java +++ b/src/test/java/org/opentripplanner/generate/doc/BuildConfigurationDocTest.java @@ -34,6 +34,7 @@ public class BuildConfigurationDocTest { .skip("dataOverlay", "sandbox/DataOverlay.md") .skip("fares", "sandbox/Fares.md") .skip("transferRequests", "RouteRequest.md") + .skip("emissions", "sandbox/Emissions.md") .build(); /** diff --git a/src/test/java/org/opentripplanner/generate/doc/EmissionsConfigurationDocTest.java b/src/test/java/org/opentripplanner/generate/doc/EmissionsConfigurationDocTest.java new file mode 100644 index 00000000000..68caa043e26 --- /dev/null +++ b/src/test/java/org/opentripplanner/generate/doc/EmissionsConfigurationDocTest.java @@ -0,0 +1,76 @@ +package org.opentripplanner.generate.doc; + +import static org.opentripplanner.framework.io.FileUtils.assertFileEquals; +import static org.opentripplanner.framework.io.FileUtils.readFile; +import static org.opentripplanner.framework.io.FileUtils.writeFile; +import static org.opentripplanner.framework.text.MarkdownFormatter.HEADER_4; +import static org.opentripplanner.generate.doc.framework.DocsTestConstants.DOCS_ROOT; +import static org.opentripplanner.generate.doc.framework.DocsTestConstants.TEMPLATE_ROOT; +import static org.opentripplanner.generate.doc.framework.TemplateUtil.replaceSection; +import static org.opentripplanner.standalone.config.framework.json.JsonSupport.jsonNodeFromResource; + +import java.io.File; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.application.OtpFileNames; +import org.opentripplanner.generate.doc.framework.DocBuilder; +import org.opentripplanner.generate.doc.framework.GeneratesDocumentation; +import org.opentripplanner.generate.doc.framework.ParameterDetailsList; +import org.opentripplanner.generate.doc.framework.ParameterSummaryTable; +import org.opentripplanner.generate.doc.framework.SkipNodes; +import org.opentripplanner.generate.doc.framework.TemplateUtil; +import org.opentripplanner.standalone.config.BuildConfig; +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; + +@GeneratesDocumentation +public class EmissionsConfigurationDocTest { + + private static final File TEMPLATE = new File(TEMPLATE_ROOT, "Emissions.md"); + private static final File OUT_FILE = new File(DOCS_ROOT + "/sandbox", "Emissions.md"); + private static final String CONFIG_JSON = OtpFileNames.BUILD_CONFIG_FILENAME; + private static final String CONFIG_PATH = "standalone/config/" + CONFIG_JSON; + private static final SkipNodes SKIP_NODES = SkipNodes.of().build(); + + @Test + public void updateEmissionsDoc() { + NodeAdapter node = readEmissionsConfig(); + + String template = readFile(TEMPLATE); + String original = readFile(OUT_FILE); + + template = replaceSection(template, "config", updaterDoc(node)); + + writeFile(OUT_FILE, template); + assertFileEquals(original, OUT_FILE); + } + + private NodeAdapter readEmissionsConfig() { + var json = jsonNodeFromResource(CONFIG_PATH); + var conf = new BuildConfig(json, CONFIG_PATH, false); + return conf.asNodeAdapter().child("emissions"); + } + + private String updaterDoc(NodeAdapter node) { + DocBuilder buf = new DocBuilder(); + addExample(buf, node); + addParameterSummaryTable(buf, node); + addDetailsSection(buf, node); + return buf.toString(); + } + + private void addParameterSummaryTable(DocBuilder buf, NodeAdapter node) { + buf + .header(3, "Overview", null) + .addSection(new ParameterSummaryTable(SKIP_NODES).createTable(node).toMarkdownTable()); + } + + private void addDetailsSection(DocBuilder buf, NodeAdapter node) { + buf + .header(3, "Details", null) + .addSection(ParameterDetailsList.listParametersWithDetails(node, SKIP_NODES, HEADER_4)); + } + + private void addExample(DocBuilder buf, NodeAdapter node) { + var root = TemplateUtil.jsonExampleBuilder(node.rawNode()).wrapInObject("emissions").build(); + buf.header(3, "Example configuration", null).addExample("build-config.json", root); + } +} diff --git a/src/test/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainTest.java b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainTest.java index 2bbb5a25e3b..a5c8ba92be3 100644 --- a/src/test/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainTest.java +++ b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/ItineraryListFilterChainTest.java @@ -13,11 +13,17 @@ import java.time.Duration; import java.time.Instant; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.opentripplanner.ext.emissions.DefaultEmissionsService; +import org.opentripplanner.ext.emissions.EmissionsDataModel; +import org.opentripplanner.ext.emissions.EmissionsFilter; +import org.opentripplanner.ext.emissions.EmissionsService; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.model.plan.PlanTestConstants; import org.opentripplanner.model.plan.TestItineraryBuilder; @@ -26,6 +32,7 @@ import org.opentripplanner.routing.api.response.RoutingError; import org.opentripplanner.routing.api.response.RoutingErrorCode; import org.opentripplanner.routing.services.TransitAlertService; +import org.opentripplanner.transit.model.framework.FeedScopedId; /** * This class test the whole filter chain with a few test cases. Each filter should be tested with a @@ -38,6 +45,7 @@ public class ItineraryListFilterChainTest implements PlanTestConstants { private Itinerary i1; private Itinerary i2; private Itinerary i3; + private Itinerary i4; @BeforeEach public void setUpItineraries() { @@ -50,6 +58,9 @@ public void setUpItineraries() { // Not optimal, departure is very late i3 = newItinerary(A).bus(20, I3_LATE_START_TIME, I3_LATE_START_TIME + D1m, E).build(); + + // car itinerary for emissions test + i4 = newItinerary(A).drive(T11_30, PlanTestConstants.T11_50, B).build(); } @Test @@ -322,4 +333,29 @@ public void removeTransitWithHigherCostThanBestOnStreetOnlyEnabled() { assertEquals(toStr(List.of(walk)), toStr(chain.filter(List.of(walk, bus)))); } } + + @Nested + class AddEmissionsToItineraryTest { + + Itinerary bus; + Itinerary car; + ItineraryListFilterChainBuilder builder = createBuilder(true, false, 2); + EmissionsService eService; + + @BeforeEach + public void setUpItineraries() { + bus = newItinerary(A).bus(21, T11_06, T11_09, B).build(); + car = newItinerary(A).drive(T11_30, T11_50, B).build(); + Map emissions = new HashMap<>(); + emissions.put(new FeedScopedId("F", "1"), 1.0); + eService = new DefaultEmissionsService(new EmissionsDataModel(emissions, 1.0)); + } + + @Test + public void emissionsTest() { + ItineraryListFilterChain chain = builder.withEmissions(new EmissionsFilter(eService)).build(); + List itineraries = chain.filter(List.of(bus, car)); + assertFalse(itineraries.stream().anyMatch(i -> i.getEmissionsPerPerson().getCo2() == null)); + } + } } diff --git a/src/test/java/org/opentripplanner/routing/core/MoneyTest.java b/src/test/java/org/opentripplanner/routing/core/MoneyTest.java index 5d3ed8a1b46..2323b396d5c 100644 --- a/src/test/java/org/opentripplanner/routing/core/MoneyTest.java +++ b/src/test/java/org/opentripplanner/routing/core/MoneyTest.java @@ -81,6 +81,12 @@ void times() { assertEquals(Money.usDollars(4), oneDollar.times(4)); } + @Test + void half() { + assertEquals(Money.usDollars(0.50f), Money.usDollars(0.99f).half()); + assertEquals(Money.usDollars(0.38f), Money.usDollars(0.75f).half()); + } + @Test void greaterThan() { assertTrue(twoDollars.greaterThan(oneDollar)); diff --git a/src/test/java/org/opentripplanner/routing/graph/GraphSerializationTest.java b/src/test/java/org/opentripplanner/routing/graph/GraphSerializationTest.java index bcb5eb3a627..0b4a7456e52 100644 --- a/src/test/java/org/opentripplanner/routing/graph/GraphSerializationTest.java +++ b/src/test/java/org/opentripplanner/routing/graph/GraphSerializationTest.java @@ -19,6 +19,7 @@ import org.opentripplanner.TestOtpModel; import org.opentripplanner.datastore.api.FileType; import org.opentripplanner.datastore.file.FileDataSource; +import org.opentripplanner.ext.emissions.EmissionsDataModel; import org.opentripplanner.framework.geometry.HashGridSpatialIndex; import org.opentripplanner.graph_builder.issue.api.DataImportIssueSummary; import org.opentripplanner.service.worldenvelope.WorldEnvelopeRepository; @@ -61,7 +62,8 @@ public class GraphSerializationTest { public void testRoundTripSerializationForGTFSGraph() throws Exception { TestOtpModel model = ConstantsForTests.buildNewPortlandGraph(true); var weRepo = new DefaultWorldEnvelopeRepository(); - testRoundTrip(model.graph(), model.transitModel(), weRepo); + var emissionsDataModel = new EmissionsDataModel(); + testRoundTrip(model.graph(), model.transitModel(), weRepo, emissionsDataModel); } /** @@ -71,7 +73,8 @@ public void testRoundTripSerializationForGTFSGraph() throws Exception { public void testRoundTripSerializationForNetexGraph() throws Exception { TestOtpModel model = ConstantsForTests.buildNewMinimalNetexGraph(); var worldEnvelopeRepository = new DefaultWorldEnvelopeRepository(); - testRoundTrip(model.graph(), model.transitModel(), worldEnvelopeRepository); + var emissionsDataModel = new EmissionsDataModel(); + testRoundTrip(model.graph(), model.transitModel(), worldEnvelopeRepository, emissionsDataModel); } // Ideally we'd also test comparing two separate but identical complex graphs, built separately from the same inputs. @@ -169,7 +172,8 @@ private static void assertNoDifferences(Graph g1, Graph g2) { private void testRoundTrip( Graph originalGraph, TransitModel originalTransitModel, - WorldEnvelopeRepository worldEnvelopeRepository + WorldEnvelopeRepository worldEnvelopeRepository, + EmissionsDataModel emissionsDataModel ) throws Exception { // Now round-trip the graph through serialization. File tempFile = TempFile.createTempFile("graph", "pdx"); @@ -179,7 +183,8 @@ private void testRoundTrip( worldEnvelopeRepository, BuildConfig.DEFAULT, RouterConfig.DEFAULT, - DataImportIssueSummary.empty() + DataImportIssueSummary.empty(), + emissionsDataModel ); serializedObj.save(new FileDataSource(tempFile, FileType.GRAPH)); SerializedGraphObject deserializedGraph = SerializedGraphObject.load(tempFile); diff --git a/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index 519514ab9ed..f7f290e4608 100644 --- a/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -115,6 +115,7 @@ public SpeedTest( TestServerContext.createWorldEnvelopeService(), TestServerContext.createRealtimeVehicleService(transitService), TestServerContext.createVehicleRentalService(), + TestServerContext.createEmissionsService(), config.flexConfig, List.of(), null diff --git a/src/test/resources/org/opentripplanner/apis/gtfs/expectations/plan-extended.json b/src/test/resources/org/opentripplanner/apis/gtfs/expectations/plan-extended.json index 95c96f3c8d7..2ab0b978cf0 100644 --- a/src/test/resources/org/opentripplanner/apis/gtfs/expectations/plan-extended.json +++ b/src/test/resources/org/opentripplanner/apis/gtfs/expectations/plan-extended.json @@ -7,6 +7,9 @@ "endTime" : 1580644800000, "generalizedCost" : 4072, "accessibilityScore" : 0.5, + "emissionsPerPerson" : { + "co2" : 123.0 + }, "legs" : [ { "mode" : "WALK", diff --git a/src/test/resources/org/opentripplanner/apis/gtfs/queries/plan-extended.graphql b/src/test/resources/org/opentripplanner/apis/gtfs/queries/plan-extended.graphql index 0ffc31b50b9..7fdc24822a4 100644 --- a/src/test/resources/org/opentripplanner/apis/gtfs/queries/plan-extended.graphql +++ b/src/test/resources/org/opentripplanner/apis/gtfs/queries/plan-extended.graphql @@ -20,6 +20,9 @@ endTime generalizedCost accessibilityScore + emissionsPerPerson { + co2 + } legs { mode from { diff --git a/src/test/resources/standalone/config/build-config.json b/src/test/resources/standalone/config/build-config.json index 643234fd7b7..fb8c682bbda 100644 --- a/src/test/resources/standalone/config/build-config.json +++ b/src/test/resources/standalone/config/build-config.json @@ -73,5 +73,9 @@ "enabled": true } } - ] + ], + "emissions": { + "carAvgCo2PerKm": 170, + "carAvgOccupancy": 1.3 + } }