Skip to content

Commit

Permalink
Add McMinimumNumberItineraryFilter - a multi-criteria version of the …
Browse files Browse the repository at this point in the history
…MinNumIttnFilter
  • Loading branch information
t2gran committed Jun 18, 2024
1 parent 4d0dc4c commit e9fc8d6
Show file tree
Hide file tree
Showing 9 changed files with 662 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,14 @@
* can be sorted, if so the {@link #strictOrder()} should return false (this is the default).
*/
@FunctionalInterface
public interface SingeCriteriaComparator extends Comparator<Itinerary> {
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
* there is no dominance.
*/
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.
*/
Expand All @@ -56,22 +45,20 @@ 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<Itinerary> op) {
return new SingeCriteriaComparator() {
@Override
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Item> {

private final List<Item> 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<Item> iterator() {
return items.iterator();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* 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.
* <p>
* <b>IMPLEMENTATION DETAILS</b>
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Let's discuss an example:
* <pre>
* 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) ]
* ]
* </pre>
* 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)).
* <p>
* 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.
* <p>
* 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<SingeCriteriaComparator> comparators;

public McMinimumNumberItineraryFilter(
String name,
int minNumItineraries,
List<SingeCriteriaComparator> comparators
) {
this.name = name;
this.minNumItineraries = minNumItineraries;
this.comparators = comparators;
}

@Override
public String name() {
return name;
}

@Override
public List<Itinerary> flagForRemoval(List<Itinerary> 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();
}
}
Loading

0 comments on commit e9fc8d6

Please sign in to comment.