diff --git a/src/ext/java/org/opentripplanner/ext/restapi/mapping/ItineraryMapper.java b/src/ext/java/org/opentripplanner/ext/restapi/mapping/ItineraryMapper.java index d87cf33d71c..720d02fdd44 100644 --- a/src/ext/java/org/opentripplanner/ext/restapi/mapping/ItineraryMapper.java +++ b/src/ext/java/org/opentripplanner/ext/restapi/mapping/ItineraryMapper.java @@ -37,7 +37,10 @@ public ApiItinerary mapItinerary(Itinerary domain) { api.transitTime = domain.getTransitDuration().toSeconds(); api.waitingTime = domain.getWaitingDuration().toSeconds(); api.walkDistance = domain.getNonTransitDistanceMeters(); - api.generalizedCost = domain.getGeneralizedCost(); + // We list only the generalizedCostIncludingPenalty, this is the least confusing. We intend to + // delete this endpoint soon, so we will not make the proper change and add the + // generalizedCostIncludingPenalty to the response and update the debug client to show it. + api.generalizedCost = domain.getGeneralizedCostIncludingPenalty(); api.elevationLost = domain.getElevationLost(); api.elevationGained = domain.getElevationGained(); api.transfers = domain.getNumberOfTransfers(); diff --git a/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java b/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java index 638d7783e9a..a88c36ac039 100644 --- a/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java +++ b/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java @@ -70,6 +70,7 @@ import org.opentripplanner.apis.transmodel.model.plan.PathGuidanceType; import org.opentripplanner.apis.transmodel.model.plan.PlanPlaceType; import org.opentripplanner.apis.transmodel.model.plan.RoutingErrorType; +import org.opentripplanner.apis.transmodel.model.plan.TripPatternTimePenaltyType; import org.opentripplanner.apis.transmodel.model.plan.TripPatternType; import org.opentripplanner.apis.transmodel.model.plan.TripQuery; import org.opentripplanner.apis.transmodel.model.plan.TripType; @@ -314,6 +315,7 @@ private GraphQLSchema create() { gqlUtil ); + GraphQLObjectType tripPatternTimePenaltyType = TripPatternTimePenaltyType.create(); GraphQLObjectType tripMetadataType = TripMetadataType.create(gqlUtil); GraphQLObjectType placeType = PlanPlaceType.create( bikeRentalStationType, @@ -339,7 +341,12 @@ private GraphQLSchema create() { elevationStepType, gqlUtil ); - GraphQLObjectType tripPatternType = TripPatternType.create(systemNoticeType, legType, gqlUtil); + GraphQLObjectType tripPatternType = TripPatternType.create( + systemNoticeType, + legType, + tripPatternTimePenaltyType, + gqlUtil + ); GraphQLObjectType routingErrorType = RoutingErrorType.create(); GraphQLOutputType tripType = TripType.create( diff --git a/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternTimePenaltyType.java b/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternTimePenaltyType.java new file mode 100644 index 00000000000..744113929c7 --- /dev/null +++ b/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternTimePenaltyType.java @@ -0,0 +1,79 @@ +package org.opentripplanner.apis.transmodel.model.plan; + +import graphql.Scalars; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLObjectType; +import org.opentripplanner.framework.time.DurationUtils; + +public class TripPatternTimePenaltyType { + + public static GraphQLObjectType create() { + return GraphQLObjectType + .newObject() + .name("TimePenaltyWithCost") + .description( + """ + The time-penalty is applied to either the access-legs and/or egress-legs. Both access and + egress may contain more than one leg; Hence, the penalty is not a field on leg. + + Note! This is for debugging only. This type can change without notice. + """ + ) + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("appliedTo") + .description( + """ + The time-penalty is applied to either the access-legs and/or egress-legs. Both access + and egress may contain more than one leg; Hence, the penalty is not a field on leg. The + `appliedTo` describe witch part of the itinerary that this instance applies to. + """ + ) + .type(Scalars.GraphQLString) + .dataFetcher(environment -> penalty(environment).appliesTo()) + .build() + ) + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("timePenalty") + .description( + """ + The time-penalty added to the actual time/duration when comparing the itinerary with + other itineraries. This is used to decide witch is the best option, but is not visible + - the actual departure and arrival-times are not modified. + """ + ) + .type(Scalars.GraphQLString) + .dataFetcher(environment -> + DurationUtils.durationToStr(penalty(environment).penalty().time()) + ) + .build() + ) + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("generalizedCostDelta") + .description( + """ + The time-penalty does also propagate to the `generalizedCost`. As a result of the given + time-penalty, the generalized-cost also increased by the given amount. This delta is + included in the itinerary generalized-cost. In some cases the generalized-cost-delta is + excluded when comparing itineraries - that happens if one of the itineraries is a + "direct/street-only" itinerary. Time-penalty can not be set for direct searches, so it + needs to be excluded from such comparison to be fair. The unit is transit-seconds. + """ + ) + .type(Scalars.GraphQLInt) + .dataFetcher(environment -> penalty(environment).penalty().cost().toSeconds()) + .build() + ) + .build(); + } + + static TripPlanTimePenaltyDto penalty(DataFetchingEnvironment environment) { + return environment.getSource(); + } +} diff --git a/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternType.java b/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternType.java index 2238a39c139..c903016b91b 100644 --- a/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternType.java +++ b/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPatternType.java @@ -16,6 +16,7 @@ public class TripPatternType { public static GraphQLObjectType create( GraphQLOutputType systemNoticeType, GraphQLObjectType legType, + GraphQLObjectType timePenaltyType, GqlUtil gqlUtil ) { return GraphQLObjectType @@ -189,7 +190,7 @@ public static GraphQLObjectType create( .name("generalizedCost") .description("Generalized cost or weight of the itinerary. Used for debugging.") .type(Scalars.GraphQLInt) - .dataFetcher(env -> itinerary(env).getGeneralizedCost()) + .dataFetcher(env -> itinerary(env).getGeneralizedCostIncludingPenalty()) .build() ) .field( @@ -228,6 +229,23 @@ public static GraphQLObjectType create( .dataFetcher(env -> itinerary(env).getTransferPriorityCost()) .build() ) + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("timePenalty") + .description( + """ + A time and cost penalty applied to access and egress to favor regular scheduled + transit over potentially faster options with FLEX, Car, bike and scooter. + + Note! This field is meant for debugging only. The field can be removed without notice + in the future. + """ + ) + .type(new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(timePenaltyType)))) + .dataFetcher(env -> TripPlanTimePenaltyDto.of(itinerary(env))) + .build() + ) .build(); } diff --git a/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPlanTimePenaltyDto.java b/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPlanTimePenaltyDto.java new file mode 100644 index 00000000000..0e83384157f --- /dev/null +++ b/src/main/java/org/opentripplanner/apis/transmodel/model/plan/TripPlanTimePenaltyDto.java @@ -0,0 +1,32 @@ +package org.opentripplanner.apis.transmodel.model.plan; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.opentripplanner.framework.model.TimeAndCost; +import org.opentripplanner.model.plan.Itinerary; + +/** + * A simple data-transfer-object used to map from an itinerary to the API specific + * type. It is needed because we need to pass in the "appliedTo" field, which does not + * exist in the domain model. + */ +public record TripPlanTimePenaltyDto(String appliesTo, TimeAndCost penalty) { + static List of(Itinerary itinerary) { + return Stream + .of(of("access", itinerary.getAccessPenalty()), of("egress", itinerary.getEgressPenalty())) + .filter(Objects::nonNull) + .toList(); + } + + /** + * Package local to be unit-testable. + */ + @Nullable + static TripPlanTimePenaltyDto of(String appliedTo, TimeAndCost penalty) { + return penalty == null || penalty.isZero() + ? null + : new TripPlanTimePenaltyDto(appliedTo, penalty); + } +} diff --git a/src/main/java/org/opentripplanner/routing/api/request/framework/TimeAndCostPenaltyForEnum.java b/src/main/java/org/opentripplanner/routing/api/request/framework/TimeAndCostPenaltyForEnum.java index c45fc3e33a8..2f439801cb5 100644 --- a/src/main/java/org/opentripplanner/routing/api/request/framework/TimeAndCostPenaltyForEnum.java +++ b/src/main/java/org/opentripplanner/routing/api/request/framework/TimeAndCostPenaltyForEnum.java @@ -112,7 +112,7 @@ public static class Builder> { private final TimeAndCostPenaltyForEnum original; private final EnumMap values; - Builder(TimeAndCostPenaltyForEnum original) { + private Builder(TimeAndCostPenaltyForEnum original) { this.original = original; this.values = original.copyValues(); } diff --git a/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql b/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql index fbaa2418d7e..5c1912df4e6 100644 --- a/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql +++ b/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql @@ -1215,6 +1215,36 @@ type TimeAndDayOffset { time: Time } +""" +The time-penalty is applied to either the access-legs and/or egress-legs. Both access and +egress may contain more than one leg; Hence, the penalty is not a field on leg. + +Note! This is for debugging only. This type can change without notice. +""" +type TimePenaltyWithCost { + """ + The time-penalty is applied to either the access-legs and/or egress-legs. Both access + and egress may contain more than one leg; Hence, the penalty is not a field on leg. The + `appliedTo` describe witch part of the itinerary that this instance applies to. + """ + appliedTo: String + """ + The time-penalty does also propagate to the `generalizedCost`. As a result of the given + time-penalty, the generalized-cost also increased by the given amount. This delta is + included in the itinerary generalized-cost. In some cases the generalized-cost-delta is + excluded when comparing itineraries - that happens if one of the itineraries is a + "direct/street-only" itinerary. Time-penalty can not be set for direct searches, so it + needs to be excluded from such comparison to be fair. The unit is transit-seconds. + """ + generalizedCostDelta: Int + """ + The time-penalty added to the actual time/duration when comparing the itinerary with + other itineraries. This is used to decide witch is the best option, but is not visible + - the actual departure and arrival-times are not modified. + """ + timePenalty: String +} + "Scheduled passing times. These are not affected by real time updates." type TimetabledPassingTime { "Scheduled time of arrival at quay" @@ -1309,6 +1339,14 @@ type TripPattern { streetDistance: Float "Get all system notices." systemNotices: [SystemNotice!]! + """ + A time and cost penalty applied to access and egress to favor regular scheduled + transit over potentially faster options with FLEX, Car, bike and scooter. + + Note! This field is meant for debugging only. The field can be removed without notice + in the future. + """ + timePenalty: [TimePenaltyWithCost!]! "A cost calculated to favor transfer with higher priority. This field is meant for debugging only." transferPriorityCost: Int "A cost calculated to distribute wait-time and avoid very short transfers. This field is meant for debugging only." diff --git a/src/test/java/org/opentripplanner/apis/transmodel/model/plan/TripPlanTimePenaltyDtoTest.java b/src/test/java/org/opentripplanner/apis/transmodel/model/plan/TripPlanTimePenaltyDtoTest.java new file mode 100644 index 00000000000..579f9b73761 --- /dev/null +++ b/src/test/java/org/opentripplanner/apis/transmodel/model/plan/TripPlanTimePenaltyDtoTest.java @@ -0,0 +1,66 @@ +package org.opentripplanner.apis.transmodel.model.plan; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.model.Cost; +import org.opentripplanner.framework.model.TimeAndCost; +import org.opentripplanner.framework.time.DurationUtils; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.Place; +import org.opentripplanner.model.plan.TestItineraryBuilder; +import org.opentripplanner.transit.model._data.TransitModelForTest; + +class TripPlanTimePenaltyDtoTest { + + private static final TimeAndCost PENALTY = new TimeAndCost( + DurationUtils.duration("20m30s"), + Cost.costOfSeconds(21) + ); + + private final TransitModelForTest testModel = TransitModelForTest.of(); + private final Place placeA = Place.forStop(testModel.stop("A").build()); + private final Place placeB = Place.forStop(testModel.stop("B").build()); + + @Test + void testCreateFromSingeEntry() { + assertNull(TripPlanTimePenaltyDto.of("access", null)); + assertNull(TripPlanTimePenaltyDto.of("access", TimeAndCost.ZERO)); + assertEquals( + new TripPlanTimePenaltyDto("access", PENALTY), + TripPlanTimePenaltyDto.of("access", PENALTY) + ); + } + + @Test + void testCreateFromItineraryWithNoPenalty() { + var i = itinerary(); + assertEquals(List.of(), TripPlanTimePenaltyDto.of(i)); + } + + @Test + void testCreateFromItineraryWithAccess() { + var i = itinerary(); + i.setAccessPenalty(PENALTY); + assertEquals( + List.of(new TripPlanTimePenaltyDto("access", PENALTY)), + TripPlanTimePenaltyDto.of(i) + ); + } + + @Test + void testCreateFromItineraryWithEgress() { + var i = itinerary(); + i.setEgressPenalty(PENALTY); + assertEquals( + List.of(new TripPlanTimePenaltyDto("egress", PENALTY)), + TripPlanTimePenaltyDto.of(i) + ); + } + + private Itinerary itinerary() { + return TestItineraryBuilder.newItinerary(placeA).drive(100, 200, placeB).build(); + } +}