diff --git a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparator.java b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparator.java index 8f757cee5a1..aa876d5bcdc 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparator.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparator.java @@ -20,7 +20,7 @@ * can be sorted, if so the {@link #strictOrder()} should return false (this is the default). */ @FunctionalInterface -public interface SingeCriteriaComparator extends Comparator { +public interface SingeCriteriaComparator { /** * The left criteria dominates the right criteria. Note! The right criteria my dominate * the left criteria if there is no {@link #strictOrder()}. If left and right are equals, then @@ -28,17 +28,6 @@ public interface SingeCriteriaComparator extends Comparator { */ boolean leftDominanceExist(Itinerary left, Itinerary right); - /** - * The compare function can be used to order elements based on the criteria for this instance. - * Note! This method should not be used if there is no {@link #strictOrder()}. - */ - @Override - default int compare(Itinerary left, Itinerary right) { - throw new IllegalStateException( - "This criteria can not be used to sort elements, there is no deterministic defined order." - ); - } - /** * Return true if the criteria can be deterministically sorted. */ @@ -56,10 +45,13 @@ static SingeCriteriaComparator compareGeneralizedCost() { @SuppressWarnings("OptionalGetWithoutIsPresent") static SingeCriteriaComparator compareTransitPriorityGroups() { - return (left, right) -> TransitGroupPriority32n.dominate(left.getGeneralizedCost2().get(), right.getGeneralizedCost2().get()); + return (left, right) -> + TransitGroupPriority32n.dominate( + left.getGeneralizedCost2().get(), + right.getGeneralizedCost2().get() + ); } - static SingeCriteriaComparator compareLessThan(final ToIntFunction op) { return new SingeCriteriaComparator() { @Override @@ -67,11 +59,6 @@ public boolean leftDominanceExist(Itinerary left, Itinerary right) { return op.applyAsInt(left) < op.applyAsInt(right); } - @Override - public int compare(Itinerary left, Itinerary right) { - return op.applyAsInt(left) - op.applyAsInt(right); - } - @Override public boolean strictOrder() { return true; diff --git a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/Group.java b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/Group.java new file mode 100644 index 00000000000..0f231517ae3 --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/Group.java @@ -0,0 +1,56 @@ +package org.opentripplanner.routing.algorithm.filterchain.filters.system.mcmin; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * The purpose of a group is to maintain a list of items, all optimal for a single + * criteria/comparator. After the group is created, then the criteria is no longer needed, so we do + * not keep a reference to the original criteria. + */ +class Group implements Iterable { + + private final List items = new ArrayList<>(); + + public Group(Item firstItem) { + add(firstItem); + } + + Item first() { + return items.getFirst(); + } + + boolean isEmpty() { + return items.isEmpty(); + } + + + boolean isSingleItemGroup() { + return items.size() == 1; + } + + void add(Item item) { + item.incGroupCount(); + items.add(item); + } + + void removeAllItems() { + items.forEach(Item::decGroupCount); + items.clear(); + } + + void addNewDominantItem(Item item) { + removeAllItems(); + add(item); + } + + boolean contains(Item item) { + return this.items.contains(item); + } + + @Override + public Iterator iterator() { + return items.iterator(); + } +} diff --git a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/Item.java b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/Item.java new file mode 100644 index 00000000000..58898f2ee63 --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/Item.java @@ -0,0 +1,47 @@ +package org.opentripplanner.routing.algorithm.filterchain.filters.system.mcmin; + +import org.opentripplanner.model.plan.Itinerary; + +/** + * An item is a decorated itinerary. The extra information added is the index in the input list + * (sort order) and a groupCount. The sort order is used to break ties, while the group-count is + * used to select the itinerary witch exist in the highest number of groups. The group dynamically + * updates the group-count; The count is incremented when an item is added to a group, and + * decremented when the group is removed from the State. + */ +class Item { + + private final Itinerary item; + private final int index; + private int groupCount = 0; + + Item(Itinerary item, int index) { + this.item = item; + this.index = index; + } + + /** + * An item is better than another if the groupCount is higher, and in case of a tie, if the sort + * index is lower. + */ + public boolean betterThan(Item o) { + return groupCount != o.groupCount ? groupCount > o.groupCount : index < o.index; + } + + Itinerary item() { + return item; + } + + void incGroupCount() { + ++this.groupCount; + } + + void decGroupCount() { + --this.groupCount; + } + + @Override + public String toString() { + return "Item #%d {count:%d, %s}".formatted(index, groupCount, item.toStr()); + } +} diff --git a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/McMinimumNumberItineraryFilter.java b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/McMinimumNumberItineraryFilter.java new file mode 100644 index 00000000000..b1e7b857c3b --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/McMinimumNumberItineraryFilter.java @@ -0,0 +1,102 @@ +package org.opentripplanner.routing.algorithm.filterchain.filters.system.mcmin; + +import java.util.List; +import java.util.function.Predicate; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.routing.algorithm.filterchain.filters.system.SingeCriteriaComparator; +import org.opentripplanner.routing.algorithm.filterchain.framework.spi.RemoveItineraryFlagger; + +/** + * This filter is used to reduce a set of itineraries down to the specified limit, if possible. + * The filter is guaranteed to keep at least the given {@code minNumItineraries} and also + * the best itinerary for each criterion. The criterion is defined using the list of + * {@code comparators}. + *

+ * The main usage of this filter is to combine it with a grouping filter and for each group + * make sure there is at least {@code minNumItineraries} and that the best itinerary with respect + * to each criterion is kept. So, if the grouping is based on time and riding common trips, then + * this filter will use the reminding criterion (transfers, generalized-cost, + * [transit-group-priority]) to filter the grouped set of itineraries. + *

+ * IMPLEMENTATION DETAILS + *

+ * This is not a trivial problem. In most cases, the best itinerary for a given criteria is unique, + * but there might be ties - same number of transfers, same cost, and/or different priority groups. + * In case of a tie, we will look if an itinerary is "best-in-group" for more than one criterion, + * if so we pick the one witch is best in the highest number of groups. Again, if there is a tie + * (best in the same number of groups), then we fall back to the given itinerary sorting order. + *

+ * This filter will use the order of the input itineraries to break ties. So, make sure to call the + * appropriate sort function before this filter is invoked. + *

+ * Note! For a criteria like num-of-transfers or generalized-cost, there is only one set of "best" + * itineraries, and usually there are only one or a few itineraries. In case there is more than one, + * picking just one is fine. But, for transit-group-priority there might be more than on set of + * itineraries. For each set, we need to pick one itinerary for the final result. Each of these + * sets may or may not have more than one itinerary. + *

+ * Let's discuss an example: + *

+ *   minNumItineraries = 4
+ *   comparators = [ generalized-cost, min-num-transfers, transit-group-priority ]
+ *   itineraries: [
+ *    #0 : [ 1000, 2, (a) ]
+ *    #1 : [ 1000, 3, (a,b) ]
+ *    #2 : [ 1000, 3, (b) ]
+ *    #3 : [ 1200, 1, (a,b) ]
+ *    #4 : [ 1200, 1, (a) ]
+ *    #5 : [ 1300, 2, (c) ]
+ *    #6 : [ 1300, 3, (c) ]
+ *   ]
+ * 
+ * The best itineraries by generalized-cost are (#0, #1, #2). The best itineraries by + * min-num-transfers are (#3, #4). The best itineraries by transit-group-priority are + * (a:(#0, #4), b:(#2), c:(#5, #6)). + *

+ * So we need to pick one from each group (#0, #1, #2), (#3, #4), (#0, #4), (#2), and (#5, #6). + * Since #2 is a single, we pick it first. Itinerary #2 is also one of the best + * generalized-cost itineraries - so we are done with generalized-cost itineraries as well. The two + * groups left are (#3, #4), (#0, #4), and (#5, #6). #4 exists in 2 groups, so we pick it next. Now + * we are left with (#5, #6). To break the tie, we look at the sort-order. We pick + * itinerary #5. Result: #2, #4, and #5. + *

+ * The `minNumItineraries` limit is not met, so we need to pick another itinerary, we use the + * sort-order again and add itinerary #0. The result returned is: [#0, #2, #4, #5] + */ +public class McMinimumNumberItineraryFilter implements RemoveItineraryFlagger { + + private final String name; + private final int minNumItineraries; + private final List comparators; + + public McMinimumNumberItineraryFilter( + String name, + int minNumItineraries, + List comparators + ) { + this.name = name; + this.minNumItineraries = minNumItineraries; + this.comparators = comparators; + } + + @Override + public String name() { + return name; + } + + @Override + public List flagForRemoval(List itineraries) { + if (itineraries.size() <= minNumItineraries) { + return List.of(); + } + var state = new State(itineraries, comparators); + state.findAllSingleItemGroupsAndAddTheItemToTheResult(); + state.findTheBestItemsUntilAllGroupsAreRepresentedInTheResult(); + state.fillUpTheResultWithMinimumNumberOfItineraries(minNumItineraries); + + // We now have the itineraries we want, but we must invert this and return the + // list of itineraries to drop - keeping the original order + var ok = state.getResult(); + return itineraries.stream().filter(Predicate.not(ok::contains)).toList(); + } +} diff --git a/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/State.java b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/State.java new file mode 100644 index 00000000000..acce6a963f4 --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/State.java @@ -0,0 +1,190 @@ +package org.opentripplanner.routing.algorithm.filterchain.filters.system.mcmin; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.Nullable; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.routing.algorithm.filterchain.filters.system.SingeCriteriaComparator; + +/** + * Keep a list of items, groups and the result in progress. This is just a class for + * simple bookkeeping for the state of the filter. + */ +class State { + private final List items; + private final List groups; + private final List result = new ArrayList<>(); + + /** + * Initialize the state by wrapping each itinerary in an item (with index) and create groups for + * each criterion with the best itineraries (can be more than one with, for example, the same + * cost). There should be at least one itinerary from each group surviving the filtering process. + * The same itinerary can exist in multiple groups. + */ + State(List itineraries, List comparators) { + this.items = createListOfItems(itineraries); + this.groups = createGroups(items, comparators); + } + + List getResult() { + return result.stream().map(Item::item).toList(); + } + + /** + * Find and add all groups with a single item in them and add them to the result + */ + void findAllSingleItemGroupsAndAddTheItemToTheResult() { + var item = findItemInFirstSingleItemGroup(groups); + while (item != null) { + addToResult(item); + item = findItemInFirstSingleItemGroup(groups); + } + } + + /** + * Find the items with the highest group count and the lowest index. Theoretically, there might be + * a smaller set of itineraries that TOGETHER represent all groups than what we achieve here, but + * it is fare more complicated to compute - so this is probably good enough. + */ + void findTheBestItemsUntilAllGroupsAreRepresentedInTheResult() { + while (!groups.isEmpty()) { + addToResult(findBestItem(groups)); + } + } + + /** + * Fill up with itineraries until the minimum number of itineraries is reached + */ + void fillUpTheResultWithMinimumNumberOfItineraries(int minNumItineraries) { + int end = Math.min(items.size(), minNumItineraries); + for (int i = 0; result.size() < end; ++i) { + var it = items.get(i); + if (!result.contains(it)) { + result.add(it); + } + } + } + + private void addToResult(Item item) { + result.add(item); + removeGroupsWitchContainsItem(item); + } + + /** + * If an itinerary is accepted into the final result, then all groups that contain that itinerary + * can be removed. In addition, the item groupCount should be decremented if a group is dropped. + * This makes sure that the groups represented in the final result do not count when selecting the + * next item. + */ + private void removeGroupsWitchContainsItem(Item item) { + for (Group group : groups) { + if (group.contains(item)) { + group.removeAllItems(); + } + } + groups.removeIf(Group::isEmpty); + } + + + /** + * The best item is the one witch exists in most groups, and in case of a tie, the sort order/ + * itinerary index is used. + */ + private static Item findBestItem(List groups) { + var candidate = groups.getFirst().first(); + for (Group group : groups) { + for (Item item : group) { + if (item.betterThan(candidate)) { + candidate = item; + } + } + } + return candidate; + } + + /** + * Search through all groups and return all items witch comes from groups with only one item. + */ + @Nullable + private static Item findItemInFirstSingleItemGroup(List groups) { + return groups.stream().filter(Group::isSingleItemGroup).findFirst().map(Group::first).orElse(null); + } + + private static ArrayList createListOfItems(List itineraries) { + var items = new ArrayList(); + for (int i = 0; i < itineraries.size(); i++) { + items.add(new Item(itineraries.get(i), i)); + } + return items; + } + + private static List createGroups(Collection items, List comparators) { + List groups = new ArrayList<>(); + for (SingeCriteriaComparator comparator : comparators) { + if (comparator.strictOrder()) { + groups.add(createOrderedGroup(items, comparator)); + } else { + groups.addAll(createUnorderedGroups(items, comparator)); + } + } + return groups; + } + + /** + * In a strict ordered group only one optimal value exist for the criteria defined by the given + * {@code comparator}. All items that have this value should be included in the group created. + */ + private static Group createOrderedGroup(Collection items, SingeCriteriaComparator comparator) { + Group group = null; + for (Item item : items) { + if (group == null) { + group = new Group(item); + continue; + } + var current = group.first(); + if (comparator.leftDominanceExist(item.item(), current.item())) { + group.addNewDominantItem(item); + } else if (!comparator.leftDominanceExist(current.item(), item.item())) { + group.add(item); + } + } + return group; + } + + /** + * For a none strict ordered criteria, multiple optimal values exist. The criterion is defined by + * the given {@code comparator}. This method will create a group for each optimal value found in + * the given set of items. + * + * @see #createOrderedGroup(Collection, SingeCriteriaComparator) + */ + private static Collection createUnorderedGroups( + Collection items, + SingeCriteriaComparator comparator + ) { + List result = new ArrayList<>(); + + for (Item item : items) { + int groupCount = result.size(); + for (Group group : result) { + var groupItem = group.first().item(); + if (comparator.leftDominanceExist(groupItem, item.item())) { + if (comparator.leftDominanceExist(item.item(), groupItem)) { + // Mutual dominance => the item belong in another group + --groupCount; + } + } else { + if (comparator.leftDominanceExist(item.item(), groupItem)) { + group.removeAllItems(); + } + group.add(item); + } + } + if (groupCount == 0) { + result.add(new Group(item)); + } + } + return result; + } +} diff --git a/src/test/java/org/opentripplanner/model/plan/TestItineraryBuilder.java b/src/test/java/org/opentripplanner/model/plan/TestItineraryBuilder.java index ddff2e76fca..edaafabd753 100644 --- a/src/test/java/org/opentripplanner/model/plan/TestItineraryBuilder.java +++ b/src/test/java/org/opentripplanner/model/plan/TestItineraryBuilder.java @@ -394,7 +394,7 @@ public TestItineraryBuilder carHail(int duration, Place to) { } public TestItineraryBuilder withGeneralizedCost2(int c2) { - this.c2 = c2; + this.c2 = c2; return this; } @@ -403,16 +403,24 @@ public Itinerary egress(int walkDuration) { return build(); } + /** + * Override any value set for c1. The given value will be assigned to the itinerary + * independent of any values set on the legs. + */ + public Itinerary build(int c1) { + this.c1 = c1; + return build(); + } + public Itinerary build() { Itinerary itinerary = new Itinerary(legs); itinerary.setGeneralizedCost(c1); - if(c2 != NOT_SET) { + if (c2 != NOT_SET) { itinerary.setGeneralizedCost2(c2); } return itinerary; } - /* private methods */ /** Create a dummy trip */ diff --git a/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparatorTest.java b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparatorTest.java index d94d21673c4..5435a8864c7 100644 --- a/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparatorTest.java +++ b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/SingeCriteriaComparatorTest.java @@ -2,16 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.opentripplanner.model.plan.TestItineraryBuilder.newItinerary; -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.opentripplanner.framework.collection.CompositeComparator; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.model.plan.Place; import org.opentripplanner.routing.algorithm.raptoradapter.transit.cost.grouppriority.TransitGroupPriority32n; @@ -57,34 +53,6 @@ static void setUp() { assertEquals(expectedCost, oneTransferLowCost.getGeneralizedCost()); } - @Test - void compare() { - var l = new ArrayList(); - l.add(zeroTransferHighCost); - l.add(zeroTransferLowCost); - l.add(oneTransferLowCost); - - l.sort( - new CompositeComparator<>( - SingeCriteriaComparator.compareGeneralizedCost(), - SingeCriteriaComparator.compareNumTransfers() - ) - ); - - assertEquals(List.of(zeroTransferLowCost, oneTransferLowCost, zeroTransferHighCost), l); - } - - @Test - void compareThrowsExceptionIfNotStrictOrder() { - assertThrows( - IllegalStateException.class, - () -> - SingeCriteriaComparator - .compareTransitPriorityGroups() - .compare(zeroTransferLowCost, zeroTransferHighCost) - ); - } - @Test void strictOrder() { assertTrue(SingeCriteriaComparator.compareNumTransfers().strictOrder()); @@ -103,11 +71,6 @@ void compareNumTransfers() { // strict order expected assertTrue(subject.strictOrder()); - - // Compare - assertEquals(0, subject.compare(zeroTransferHighCost, zeroTransferLowCost)); - assertEquals(-1, subject.compare(zeroTransferLowCost, oneTransferLowCost)); - assertEquals(1, subject.compare(oneTransferLowCost, zeroTransferLowCost)); } @Test @@ -125,23 +88,12 @@ void compareGeneralizedCost() { // strict order expected assertTrue(subject.strictOrder()); - - // Compare - assertTrue(0 < subject.compare(zeroTransferHighCost, zeroTransferLowCost)); - assertTrue(0 > subject.compare(zeroTransferLowCost, zeroTransferHighCost)); - assertEquals(0, subject.compare(zeroTransferLowCost, oneTransferLowCost)); } @Test void compareTransitPriorityGroups() { - var group1 = newItinerary(A) - .bus(1, START, END_LOW, C) - .withGeneralizedCost2(1) - .build(); - var group2 = newItinerary(A) - .bus(1, START, END_LOW, C) - .withGeneralizedCost2(2) - .build(); + var group1 = newItinerary(A).bus(1, START, END_LOW, C).withGeneralizedCost2(1).build(); + var group2 = newItinerary(A).bus(1, START, END_LOW, C).withGeneralizedCost2(2).build(); var group1And2 = newItinerary(A) .bus(1, START, END_LOW, C) .withGeneralizedCost2(TransitGroupPriority32n.mergeInGroupId(1, 2)) diff --git a/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/ItemTest.java b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/ItemTest.java new file mode 100644 index 00000000000..fbb2f5dea57 --- /dev/null +++ b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/ItemTest.java @@ -0,0 +1,56 @@ +package org.opentripplanner.routing.algorithm.filterchain.filters.system.mcmin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.model.plan.TestItineraryBuilder.newItinerary; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.Place; +import org.opentripplanner.transit.model._data.TransitModelForTest; + +class ItemTest { + + private static final TransitModelForTest TEST_MODEL = TransitModelForTest.of(); + private static final Place A = TEST_MODEL.place("A", 10, 11); + private static final Place B = TEST_MODEL.place("B", 10, 11); + private static final Itinerary ITINERARY = newItinerary(A).bus(1, 1, 2, B).build(); + + @Test + void betterThan() { + var i1 = new Item(ITINERARY, 3); + var i2 = new Item(ITINERARY, 7); + + // i1 is better than i2 because the index is lower + assertTrue(i1.betterThan(i2)); + assertFalse(i2.betterThan(i1)); + + // Incrementing both does not change anything + i1.incGroupCount(); + i2.incGroupCount(); + assertTrue(i1.betterThan(i2)); + assertFalse(i2.betterThan(i1)); + + // Incrementing i2 make it better + i2.incGroupCount(); + assertFalse(i1.betterThan(i2)); + assertTrue(i2.betterThan(i1)); + } + + @Test + void item() { + assertSame(ITINERARY, new Item(ITINERARY, 7).item()); + } + + @Test + void testToString() { + Item item = new Item(ITINERARY, 7); + assertEquals("Item #7 {count:0, A ~ BUS 1 0:00:01 0:00:02 ~ B [Cā‚121]}", item.toString()); + item.incGroupCount(); + assertEquals("Item #7 {count:1, A ~ BUS 1 0:00:01 0:00:02 ~ B [Cā‚121]}", item.toString()); + item.decGroupCount(); + assertEquals("Item #7 {count:0, A ~ BUS 1 0:00:01 0:00:02 ~ B [Cā‚121]}", item.toString()); + } +} diff --git a/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/McMinimumNumberItineraryFilterTest.java b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/McMinimumNumberItineraryFilterTest.java new file mode 100644 index 00000000000..8d3d03cb857 --- /dev/null +++ b/src/test/java/org/opentripplanner/routing/algorithm/filterchain/filters/system/mcmin/McMinimumNumberItineraryFilterTest.java @@ -0,0 +1,192 @@ +package org.opentripplanner.routing.algorithm.filterchain.filters.system.mcmin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.model.plan.TestItineraryBuilder.newItinerary; + +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.Place; +import org.opentripplanner.routing.algorithm.filterchain.filters.system.SingeCriteriaComparator; +import org.opentripplanner.routing.algorithm.raptoradapter.transit.cost.grouppriority.TransitGroupPriority32n; +import org.opentripplanner.transit.model._data.TransitModelForTest; + +class McMinimumNumberItineraryFilterTest { + + private static final TransitModelForTest TEST_MODEL = TransitModelForTest.of(); + + private static final Place A = TEST_MODEL.place("A", 10, 11); + private static final Place B = TEST_MODEL.place("B", 10, 13); + private static final Place C = TEST_MODEL.place("C", 10, 14); + private static final Place D = TEST_MODEL.place("D", 10, 15); + private static final Place E = TEST_MODEL.place("E", 10, 15); + private static final Place[] PLACES = { A, B, C, D, E }; + + private static final int START = 3600 * 10; + private static final int GROUP_A = TransitGroupPriority32n.groupId(1); + private static final int GROUP_B = TransitGroupPriority32n.groupId(2); + private static final int GROUP_C = TransitGroupPriority32n.groupId(3); + private static final int GROUP_AB = TransitGroupPriority32n.mergeInGroupId(GROUP_A, GROUP_B); + private static final int GROUP_BC = TransitGroupPriority32n.mergeInGroupId(GROUP_B, GROUP_C); + private static final int GROUP_ABC = TransitGroupPriority32n.mergeInGroupId(GROUP_AB, GROUP_C); + + private static final boolean EXP_KEEP = true; + private static final boolean EXP_DROP = false; + + private static final int COST_LOW = 1000; + private static final int COST_MED = 1200; + private static final int COST_HIGH = 1500; + + private static final int TX_0 = 0; + private static final int TX_1 = 1; + private static final int TX_2 = 2; + + private final McMinimumNumberItineraryFilter subject = new McMinimumNumberItineraryFilter( + "test", + 2, + List.of( + SingeCriteriaComparator.compareGeneralizedCost(), + SingeCriteriaComparator.compareNumTransfers(), + SingeCriteriaComparator.compareTransitPriorityGroups() + ) + ); + + static TestRow row( + boolean expected, + int c1, + int nTransfers, + int transitGroups, + String description + ) { + return new TestRow(expected, c1, nTransfers, transitGroups); + } + + static List> filterTestCases() { + return List.of( + List.of(/* Should not fail for an empty list of itineraries*/), + List.of( + // Test minNumItinerariesLimit = 2 + row(EXP_KEEP, COST_LOW, TX_1, GROUP_A, "Best in everything"), + row(EXP_KEEP, COST_HIGH, TX_2, GROUP_AB, "Worse, kept because minNumItinerariesLimit is 2") + ), + List.of( + // Test minNumItinerariesLimit, first is added + row(EXP_KEEP, COST_HIGH, TX_2, GROUP_ABC, "Worst, kept because of minNumItinerariesLimit"), + row(EXP_KEEP, COST_LOW, TX_0, GROUP_A, "Best in everything"), + row(EXP_DROP, COST_HIGH, TX_1, GROUP_AB, "Dropped because not better than #2.") + ), + List.of( + // The minNumItinerariesLimit is met, so no extra itinerary(#0) is added + row(EXP_DROP, COST_HIGH, TX_2, GROUP_AB, "First element is dropped"), + row(EXP_KEEP, COST_LOW, TX_1, GROUP_B, "Best cost and group B"), + row(EXP_KEEP, COST_MED, TX_0, GROUP_A, "Best nTransfers and group A") + ), + List.of( + row(EXP_KEEP, COST_LOW, TX_2, GROUP_A, "Best: c1 and group A"), + row(EXP_DROP, COST_LOW, TX_1, GROUP_AB, "Best compromise: c1, Tx, and group AB"), + row(EXP_KEEP, COST_LOW, TX_2, GROUP_C, "Best: c1 and group C"), + row(EXP_KEEP, COST_MED, TX_0, GROUP_BC, "Best: num-of-transfers") + ), + /** + * This is the example explained in JavaDoc {@link McMinimumNumberItineraryFilter} + */ + List.of( + row(EXP_DROP, COST_LOW, TX_1, GROUP_A, ""), + row(EXP_DROP, COST_LOW, TX_2, GROUP_AB, ""), + row(EXP_KEEP, COST_LOW, TX_2, GROUP_B, "Kept -> Only one in group B"), + row(EXP_DROP, COST_MED, TX_0, GROUP_AB, ""), + row(EXP_KEEP, COST_MED, TX_0, GROUP_A, "Kept -> Best transfer and group A"), + row(EXP_KEEP, COST_HIGH, TX_1, GROUP_C, "Kept -> Best group C, tie with #6"), + row(EXP_DROP, COST_HIGH, TX_2, GROUP_C, "") + ) + ); + } + + @ParameterizedTest + @MethodSource("filterTestCases") + void filterTest(List rows) { + var input = rows.stream().map(TestRow::create).toList(); + var expected = rows.stream().filter(TestRow::expected).map(TestRow::create).toList(); + + var result = subject.removeMatchesForTest(input); + + assertEquals(toStr(expected), toStr(result)); + } + + @Test + void testName() { + assertEquals("test", subject.name()); + } + + /** + * Make sure the test setup is correct - this does not test anything in src/main + */ + @Test + void testGroupsToString() { + assertEquals("A", groupsToString(GROUP_A)); + assertEquals("B", groupsToString(GROUP_B)); + assertEquals("C", groupsToString(GROUP_C)); + assertEquals("AB", groupsToString(GROUP_AB)); + assertEquals("BC", groupsToString(GROUP_BC)); + assertEquals("ABC", groupsToString(GROUP_ABC)); + } + + private static String groupsToString(int groups) { + var buf = new StringBuilder(); + char ch = 'A'; + // Check for 5 groups - the test does not use so many, but it does not matter + for (int i = 0; i < 5; ++i) { + int mask = 1 << i; + if ((groups & mask) != 0) { + buf.append(ch); + } + ch = (char) (ch + 1); + } + return buf.toString(); + } + + private static String toStr(List list) { + return list + .stream() + .map(i -> + "[ %d %d %s ]".formatted( + i.getGeneralizedCost(), + i.getNumberOfTransfers(), + groupsToString(i.getGeneralizedCost2().orElse(-1)) + ) + ) + .collect(Collectors.joining(", ")); + } + + record TestRow(boolean expected, int c1, int nTransfers, int transitGroupIds) { + Itinerary create() { + int start = START; + var builder = newItinerary(A); + + if (nTransfers < 0) { + builder.drive(start, ++start, E); + } else { + builder.bus(1, ++start, ++start, PLACES[1]); + for (int i = 0; i < nTransfers; i++) { + builder.bus(1, ++start, ++start, PLACES[i + 2]); + } + builder.withGeneralizedCost2(transitGroupIds); + } + return builder.build(c1); + } + + @Override + public String toString() { + // The red-x is a unicode character(U+274C) and should be visible in most IDEs. + return "%s %d %d %s".formatted( + expected ? "" : "āŒ", + c1, + nTransfers, + groupsToString(transitGroupIds) + ); + } + } +}