diff --git a/src/main/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleBuilder.java b/src/main/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleBuilder.java index 83f1302d..ad4468fb 100644 --- a/src/main/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleBuilder.java +++ b/src/main/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleBuilder.java @@ -4,6 +4,7 @@ import ch.naviqore.gtfs.schedule.type.RouteType; import ch.naviqore.gtfs.schedule.type.ServiceDayTime; import ch.naviqore.gtfs.schedule.type.TransferType; +import ch.naviqore.utils.cache.ValueObjectCache; import ch.naviqore.utils.spatial.GeoCoordinate; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -15,7 +16,6 @@ import java.util.EnumSet; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** * Implements a builder pattern for constructing instances of {@link GtfsSchedule}. This builder helps assemble a GTFS @@ -30,7 +30,8 @@ @Log4j2 public class GtfsScheduleBuilder { - private final Cache cache = new Cache(); + private final ValueObjectCache localDateCache = new ValueObjectCache<>(); + private final ValueObjectCache serviceDayTimeCache = new ValueObjectCache<>(); private final Map agencies = new HashMap<>(); private final Map calendars = new HashMap<>(); private final Map stops = new HashMap<>(); @@ -80,7 +81,8 @@ public GtfsScheduleBuilder addCalendar(String id, EnumSet serviceDays throw new IllegalArgumentException("Calendar " + id + " already exists"); } log.debug("Adding calendar {}", id); - calendars.put(id, new Calendar(id, serviceDays, cache.getOrAdd(startDate), cache.getOrAdd(endDate))); + calendars.put(id, + new Calendar(id, serviceDays, localDateCache.getOrAdd(startDate), localDateCache.getOrAdd(endDate))); return this; } @@ -91,7 +93,7 @@ public GtfsScheduleBuilder addCalendarDate(String calendarId, LocalDate date, Ex throw new IllegalArgumentException("Calendar " + calendarId + " does not exist"); } log.debug("Adding calendar {}-{}", calendarId, date); - CalendarDate calendarDate = new CalendarDate(calendar, cache.getOrAdd(date), type); + CalendarDate calendarDate = new CalendarDate(calendar, localDateCache.getOrAdd(date), type); calendar.addCalendarDate(calendarDate); return this; } @@ -129,7 +131,8 @@ public GtfsScheduleBuilder addStopTime(String tripId, String stopId, ServiceDayT throw new IllegalArgumentException("Stop " + stopId + " does not exist"); } log.debug("Adding stop time at {} to trip {} ({}-{})", stopId, tripId, arrival, departure); - StopTime stopTime = new StopTime(stop, trip, cache.getOrAdd(arrival), cache.getOrAdd(departure)); + StopTime stopTime = new StopTime(stop, trip, serviceDayTimeCache.getOrAdd(arrival), + serviceDayTimeCache.getOrAdd(departure)); stop.addStopTime(stopTime); trip.addStopTime(stopTime); return this; @@ -187,13 +190,14 @@ public void reset() { } private void clear() { - log.debug("Clearing maps and cache of the builder"); + log.debug("Clearing cache and maps of the builder"); + localDateCache.clear(); + serviceDayTimeCache.clear(); agencies.clear(); calendars.clear(); stops.clear(); routes.clear(); trips.clear(); - cache.clear(); } private void checkNotBuilt() { @@ -201,25 +205,4 @@ private void checkNotBuilt() { throw new IllegalStateException("Cannot modify builder after build() has been called."); } } - - /** - * Cache for value objects - */ - static class Cache { - private final Map localDates = new ConcurrentHashMap<>(); - private final Map serviceDayTimes = new ConcurrentHashMap<>(); - - public LocalDate getOrAdd(LocalDate value) { - return localDates.computeIfAbsent(value, k -> value); - } - - public ServiceDayTime getOrAdd(ServiceDayTime value) { - return serviceDayTimes.computeIfAbsent(value, k -> value); - } - - public void clear() { - localDates.clear(); - serviceDayTimes.clear(); - } - } } diff --git a/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java b/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java index dc1238ec..e477bdef 100644 --- a/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java +++ b/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java @@ -3,6 +3,10 @@ import lombok.EqualsAndHashCode; import lombok.Getter; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + /** * Service day time *

@@ -14,14 +18,33 @@ @EqualsAndHashCode @Getter public final class ServiceDayTime implements Comparable { + + public static final int HOURS_IN_DAY = 24; + public static final int MINUTES_IN_HOUR = 60; + public static final int SECONDS_IN_MINUTE = 60; + public static final int SECONDS_IN_HOUR = MINUTES_IN_HOUR * SECONDS_IN_MINUTE; + public static final int SECONDS_IN_DAY = HOURS_IN_DAY * SECONDS_IN_HOUR; + private final int totalSeconds; public ServiceDayTime(int seconds) { + if (seconds < 0) { + throw new IllegalArgumentException("Seconds cannot be negative."); + } this.totalSeconds = seconds; } public ServiceDayTime(int hours, int minutes, int seconds) { - this.totalSeconds = seconds + 60 * minutes + 3600 * hours; + if (hours < 0) { + throw new IllegalArgumentException("Hours cannot be negative."); + } + if (minutes < 0 || minutes >= MINUTES_IN_HOUR) { + throw new IllegalArgumentException("Minutes must be between 0 and 59 inclusive"); + } + if (seconds < 0 || seconds >= SECONDS_IN_MINUTE) { + throw new IllegalArgumentException("Seconds must be between 0 and 59 inclusive"); + } + this.totalSeconds = seconds + SECONDS_IN_MINUTE * minutes + SECONDS_IN_HOUR * hours; } public static ServiceDayTime parse(String timeString) { @@ -32,6 +55,20 @@ public static ServiceDayTime parse(String timeString) { return new ServiceDayTime(hours, minutes, seconds); } + public LocalTime toLocalTime() { + int hours = totalSeconds / SECONDS_IN_HOUR; + int minutes = (totalSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE; + int seconds = totalSeconds % SECONDS_IN_MINUTE; + return LocalTime.of(hours % HOURS_IN_DAY, minutes, seconds); + } + + public LocalDateTime toLocalDateTime(LocalDate date) { + LocalTime localTime = this.toLocalTime(); + int hours = totalSeconds / SECONDS_IN_HOUR; + LocalDate adjustedDate = date.plusDays(hours / HOURS_IN_DAY); + return LocalDateTime.of(adjustedDate, localTime); + } + @Override public int compareTo(ServiceDayTime o) { return Integer.compare(totalSeconds, o.totalSeconds); @@ -39,9 +76,10 @@ public int compareTo(ServiceDayTime o) { @Override public String toString() { - int hours = totalSeconds / 3600; - int minutes = (totalSeconds % 3600) / 60; - int seconds = totalSeconds % 60; + int hours = totalSeconds / SECONDS_IN_HOUR; + int minutes = (totalSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE; + int seconds = totalSeconds % SECONDS_IN_MINUTE; return String.format("%02d:%02d:%02d", hours, minutes, seconds); } + } diff --git a/src/main/java/ch/naviqore/raptor/GtfsToRaptorConverter.java b/src/main/java/ch/naviqore/raptor/GtfsToRaptorConverter.java index 78d5ecf7..bd3fc6ed 100644 --- a/src/main/java/ch/naviqore/raptor/GtfsToRaptorConverter.java +++ b/src/main/java/ch/naviqore/raptor/GtfsToRaptorConverter.java @@ -24,8 +24,8 @@ @Log4j2 public class GtfsToRaptorConverter { - private final Set subRoutes = new HashSet<>(); - private final Set stops = new HashSet<>(); + private final Set addedSubRoutes = new HashSet<>(); + private final Set addedStops = new HashSet<>(); private final RaptorBuilder builder = Raptor.builder(); private final GtfsRoutePartitioner partitioner; private final GtfsSchedule schedule; @@ -42,15 +42,29 @@ public Raptor convert(LocalDate date) { for (Trip trip : activeTrips) { GtfsRoutePartitioner.SubRoute subRoute = partitioner.getSubRoute(trip); - if (!subRoutes.contains(subRoute)) { - subRoutes.add(subRoute); - builder.addRoute(subRoute.getId()); - addRouteStops(trip, subRoute); + // add route if not already + if (!addedSubRoutes.contains(subRoute)) { + List stopIds = subRoute.getStopsSequence().stream().map(Stop::getId).toList(); + + // add stops of that are not already added + for (String stopId : stopIds) { + if (!addedStops.contains(stopId)) { + builder.addStop(stopId); + addedStops.add(stopId); + } + } + + builder.addRoute(subRoute.getId(), stopIds); + addedSubRoutes.add(subRoute); } - for (StopTime stopTime : trip.getStopTimes()) { - builder.addStopTime(stopTime.stop().getId(), subRoute.getId(), stopTime.arrival().getTotalSeconds(), - stopTime.departure().getTotalSeconds()); + // add current trip + builder.addTrip(trip.getId(), subRoute.getId()); + List stopTimes = trip.getStopTimes(); + for (int i = 0; i < stopTimes.size(); i++) { + StopTime stopTime = stopTimes.get(i); + builder.addStopTime(subRoute.getId(), trip.getId(), i, stopTime.stop().getId(), + stopTime.arrival().getTotalSeconds(), stopTime.departure().getTotalSeconds()); } } @@ -59,21 +73,9 @@ public Raptor convert(LocalDate date) { return builder.build(); } - private void addRouteStops(Trip trip, GtfsRoutePartitioner.SubRoute subRoute) { - for (StopTime stopTime : trip.getStopTimes()) { - Stop stop = stopTime.stop(); - - if (!stops.contains(stop)) { - stops.add(stop); - builder.addStop(stop.getId()); - } - - builder.addRouteStop(stop.getId(), subRoute.getId()); - } - } - private void addTransfers() { - for (Stop stop : stops) { + for (String stopId : addedStops) { + Stop stop = schedule.getStops().get(stopId); for (Transfer transfer : stop.getTransfers()) { if (transfer.getTransferType() == TransferType.MINIMUM_TIME && stop != transfer.getToStop() && transfer.getMinTransferTime() .isPresent()) { diff --git a/src/main/java/ch/naviqore/raptor/model/Connection.java b/src/main/java/ch/naviqore/raptor/model/Connection.java new file mode 100644 index 00000000..b0b6e9fa --- /dev/null +++ b/src/main/java/ch/naviqore/raptor/model/Connection.java @@ -0,0 +1,122 @@ +package ch.naviqore.raptor.model; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A connection is a sequence of legs to travel from an origin stop to destination stop. + */ +@NoArgsConstructor +@Getter +@ToString +public class Connection implements Comparable { + + private List legs = new ArrayList<>(); + + private static void validateLegOrder(Leg current, Leg next) { + if (!current.toStopId.equals(next.fromStopId)) { + throw new IllegalStateException("Legs are not connected: " + current + " -> " + next); + } + if (current.arrivalTime < current.departureTime) { + throw new IllegalStateException("Arrival time must be after departure time: " + current); + } + if (current.arrivalTime > next.departureTime) { + throw new IllegalStateException( + "Arrival time must be before next departure time: " + current + " -> " + next); + } + } + + void addLeg(Leg leg) { + this.legs.add(leg); + } + + void initialize() { + // sort legs by departure time + Collections.sort(legs); + // make sure that the legs are connected and times are consistent + for (int i = 0; i < legs.size() - 1; i++) { + Leg current = legs.get(i); + Leg next = legs.get(i + 1); + validateLegOrder(current, next); + } + // make legs immutable and remove unnecessary allocated memory + this.legs = List.copyOf(legs); + } + + @Override + public int compareTo(@NotNull Connection other) { + return Integer.compare(this.getArrivalTime(), other.getArrivalTime()); + } + + public int getDepartureTime() { + return legs.getFirst().departureTime; + } + + public int getArrivalTime() { + return legs.getLast().arrivalTime; + } + + public String getFromStopId() { + return legs.getFirst().fromStopId; + } + + public String getToStopId() { + return legs.getLast().toStopId; + } + + public int getDuration() { + return getArrivalTime() - getDepartureTime(); + } + + public int getNumFootPathTransfers() { + return (int) legs.stream().filter(l -> l.type == LegType.FOOTPATH).count(); + } + + public int getNumSameStationTransfers() { + int transferCounter = 0; + for (int i = 0; i < legs.size() - 1; i++) { + Leg current = legs.get(i); + Leg next = legs.get(i + 1); + if (current.type == LegType.ROUTE && next.type == LegType.ROUTE) { + transferCounter++; + } + } + return transferCounter; + } + + public int getNumTransfers() { + return getNumFootPathTransfers() + getNumSameStationTransfers(); + } + + public int getNumRouteLegs() { + return (int) legs.stream().filter(l -> l.type == LegType.ROUTE).count(); + } + + /** + * Types of legs in a connection. + */ + public enum LegType { + FOOTPATH, + ROUTE + } + + /** + * A leg is a part of a connection that is travelled on the same route and transport mode, without a transfer. + */ + public record Leg(String routeId, String fromStopId, String toStopId, int departureTime, int arrivalTime, + LegType type) implements Comparable { + + @Override + public int compareTo(@NotNull Connection.Leg other) { + return Integer.compare(this.departureTime, other.departureTime); + } + + } + +} diff --git a/src/main/java/ch/naviqore/raptor/model/Lookup.java b/src/main/java/ch/naviqore/raptor/model/Lookup.java index 0c4483a9..751b45a7 100644 --- a/src/main/java/ch/naviqore/raptor/model/Lookup.java +++ b/src/main/java/ch/naviqore/raptor/model/Lookup.java @@ -2,5 +2,5 @@ import java.util.Map; -public record Lookup(Map stops, Map routes) { +record Lookup(Map stops, Map routes) { } diff --git a/src/main/java/ch/naviqore/raptor/model/Raptor.java b/src/main/java/ch/naviqore/raptor/model/Raptor.java index a293b343..eedecadd 100644 --- a/src/main/java/ch/naviqore/raptor/model/Raptor.java +++ b/src/main/java/ch/naviqore/raptor/model/Raptor.java @@ -1,21 +1,20 @@ package ch.naviqore.raptor.model; +import lombok.NonNull; import lombok.extern.log4j.Log4j2; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * Raptor algorithm implementation - * - * @author munterfi */ @Log4j2 public class Raptor { + public final static int INFINITY = Integer.MAX_VALUE; public final static int NO_INDEX = -1; + public final static int SAME_STOP_TRANSFER_TIME = 120; + private final InputValidator validator = new InputValidator(); // lookup private final Map stopsToIdx; private final Map routesToIdx; @@ -43,102 +42,307 @@ public static RaptorBuilder builder() { return new RaptorBuilder(); } - public void routeEarliestArrival(String sourceStop, String targetStop, int departureTime) { - log.debug("Routing earliest arrival from {} to {} at {}", sourceStop, targetStop, departureTime); + public List routeEarliestArrival(@NonNull String sourceStopId, @NonNull String targetStopId, + int departureTime) { + InputValidator.validateDepartureTime(departureTime); + InputValidator.validateStopIds(sourceStopId, targetStopId); + int sourceStopIdx = validator.validateAndGetStopIdx(sourceStopId); + int targetStopIdx = validator.validateAndGetStopIdx(targetStopId); - final int sourceIdx = stopsToIdx.get(sourceStop); - final int targetIdx = stopsToIdx.get(targetStop); + log.info("Routing earliest arrival from {} to {} at {}", sourceStopId, targetStopId, departureTime); + List earliestArrivalsPerRound = spawnFromSourceStop(sourceStopIdx, targetStopIdx, departureTime); + // get pareto-optimal solutions + return reconstructParetoOptimalSolutions(earliestArrivalsPerRound, targetStopIdx); + } + + // this implementation will spawn from source stop until all stops are reached with all pareto optimal connections + private List spawnFromSourceStop(int sourceStopIdx, int departureTime) { + return spawnFromSourceStop(sourceStopIdx, NO_INDEX, departureTime); + } + + // if targetStopIdx is set (>= 0), then the search will stop when target stop cannot be pareto optimized + private List spawnFromSourceStop(int sourceStopIdx, int targetStopIdx, int departureTime) { // initialization - int[] earliestArrival = new int[stops.length]; - Arrays.fill(earliestArrival, Integer.MAX_VALUE); - earliestArrival[sourceIdx] = departureTime; - - // add first stop to marked stops - Set marked = new HashSet<>(); - marked.add(sourceIdx); - - // perform rounds - int round = 0; - while (!marked.isEmpty()) { - log.info("Processing round {} (= transfers), marked: {}", round, - marked.stream().map(stopIdx -> stops[stopIdx].id()).toList()); - Set nextMarked = new HashSet<>(); - for (int stopIdx : marked) { - log.debug("Processing marked stop {} - {}", stopIdx, stops[stopIdx].id()); - Stop stop = stops[stopIdx]; - for (int i = stop.stopRouteIdx(); i < stop.numberOfRoutes(); i++) { - int routeIdx = stopRoutes[i]; - Route route = routes[routeIdx]; - log.debug("Scanning route {} - {}", routeIdx, route.id()); - // iterate until current stop index is found on route - int stopOffset = 0; - for (int j = route.firstRouteStopIdx(); j < route.firstRouteStopIdx() + route.numberOfStops(); j++) { - if (routeStops[j].stopIndex() == stopIdx) { - break; + final int[] earliestArrivals = new int[stops.length]; + Arrays.fill(earliestArrivals, INFINITY); + // subtract same stop transfer time, as this will be added by default before scanning routes + earliestArrivals[sourceStopIdx] = departureTime - SAME_STOP_TRANSFER_TIME; + + final List earliestArrivalsPerRound = new ArrayList<>(); + earliestArrivalsPerRound.add(new Leg[stops.length]); + earliestArrivalsPerRound.getFirst()[sourceStopIdx] = new Leg(0, departureTime, ArrivalType.INITIAL, NO_INDEX, + sourceStopIdx, null); + + Set markedStops = new HashSet<>(); + markedStops.add(sourceStopIdx); + + // expand footpaths for source stop + expandFootpathsForSourceStop(earliestArrivals, earliestArrivalsPerRound, markedStops, sourceStopIdx, + departureTime); + + // continue with further rounds as long as there are new marked stops + int round = 1; + while (!markedStops.isEmpty()) { + log.debug("Scanning routes for round {}", round); + Set markedStopsNext = new HashSet<>(); + + // initialize the earliest arrivals for current round + Leg[] earliestArrivalsLastRound = earliestArrivalsPerRound.get(round - 1); + earliestArrivalsPerRound.add(new Leg[stops.length]); + Leg[] earliestArrivalsThisRound = earliestArrivalsPerRound.get(round); + + // get routes of marked stops + Set routesToScan = new HashSet<>(); + for (int stopIdx : markedStops) { + Stop currentStop = stops[stopIdx]; + int stopRouteIdx = currentStop.stopRouteIdx(); + int stopRouteEndIdx = stopRouteIdx + currentStop.numberOfRoutes(); + while (stopRouteIdx < stopRouteEndIdx) { + routesToScan.add(stopRoutes[stopRouteIdx]); + stopRouteIdx++; + } + } + log.debug("Routes to scan: {}", routesToScan); + + // scan routes + for (int currentRouteIdx : routesToScan) { + Route currentRoute = routes[currentRouteIdx]; + log.debug("Scanning route {}", currentRoute.id()); + final int firstRouteStopIdx = currentRoute.firstRouteStopIdx(); + final int firstStopTimeIdx = currentRoute.firstStopTimeIdx(); + final int numberOfStops = currentRoute.numberOfStops(); + final int numberOfTrips = currentRoute.numberOfTrips(); + int tripOffset = 0; + boolean enteredTrip = false; + int tripEntryTime = 0; + Leg enteredAtArrival = null; + + // iterate over stops in route + for (int stopOffset = 0; stopOffset < numberOfStops; stopOffset++) { + int stopIdx = routeStops[firstRouteStopIdx + stopOffset].stopIndex(); + Stop stop = stops[stopIdx]; + int earliestArrivalTime = earliestArrivals[stopIdx]; + + // find first marked stop in route + if (!enteredTrip) { + if (earliestArrivalTime == INFINITY) { + // when current arrival is infinity (Integer.MAX_VALUE), then the stop cannot be reached + log.debug("Stop {} cannot be reached, continue", stop.id()); + continue; + } + + if (!markedStops.contains(stopIdx)) { + // this stop has already been scanned in previous round without improved arrival time + log.debug("Stop {} was not improved in previous round, continue", stop.id()); + continue; + } + + if (stopOffset + 1 == numberOfStops) { + // last stop in route, does not make sense to check for trip to enter + log.debug("Stop {} is last stop in route, continue", stop.id()); + continue; + } + + // got first marked stop in the route + log.debug("Got first entry point at stop {} at {}", stop.id(), earliestArrivalTime); + enteredTrip = true; + } else { + // in this case we are on a trip and need to check if arrival time has improved + // get time of arrival on current trip + StopTime stopTime = stopTimes[firstStopTimeIdx + tripOffset * numberOfStops + stopOffset]; + if (stopTime.arrival() < earliestArrivalTime) { + log.debug("Stop {} was improved", stop.id()); + + // check if search should be stopped after finding the best time + if (targetStopIdx >= 0 && stopTime.arrival() >= earliestArrivals[targetStopIdx]) { + log.debug("Stop {} is not better than best time, continue", stop.id()); + continue; + } + + earliestArrivals[stopIdx] = stopTime.arrival(); + earliestArrivalsThisRound[stopIdx] = new Leg(tripEntryTime, stopTime.arrival(), + ArrivalType.ROUTE, currentRouteIdx, stopIdx, enteredAtArrival); + // mark stop improvement for next round + markedStopsNext.add(stopIdx); + // earlier trip is not possible + continue; + } else { + log.debug("Stop {} was not improved", stop.id()); + Leg previous = earliestArrivalsLastRound[stopIdx]; + if (previous == null || previous.arrivalTime >= stopTime.arrival()) { + log.debug( + "Stop {} has been improved in same round, earlier trip not possible within this round", + stop.id()); + continue; + } else { + log.debug("Checking for earlier trips at stop {}", stop.id()); + } } - stopOffset++; } - log.debug("Stop offset on route {} is {} - {}", route.id(), stopOffset, - stops[routeStops[route.firstRouteStopIdx() + stopOffset].stopIndex()].id()); - // find active trip: check if possible to hop on trip - int arrivalTimeAtCurrentStop = earliestArrival[stopIdx]; - int activeTrip = 0; - for (int k = route.firstStopTimeIdx() + stopOffset; k < route.firstStopTimeIdx() + route.numberOfTrips() * route.numberOfStops(); k += route.numberOfStops()) { - // TODO: Consider dwell time - if (stopTimes[k].departure() >= arrivalTimeAtCurrentStop) { + + // find active trip, increase trip offset + tripOffset = 0; + enteredAtArrival = earliestArrivalsLastRound[stopIdx]; + while (tripOffset < numberOfTrips) { + StopTime currentStopTime = stopTimes[firstStopTimeIdx + tripOffset * numberOfStops + stopOffset]; + if (currentStopTime.departure() >= enteredAtArrival.arrivalTime + SAME_STOP_TRANSFER_TIME) { + log.debug("Found active trip ({}) on route {}", tripOffset, currentRoute.id()); + tripEntryTime = currentStopTime.departure(); break; } - activeTrip++; - } - log.debug("Scanning active trip number {} on route {} - {}", activeTrip, routeIdx, route.id()); - int from = route.firstStopTimeIdx() + activeTrip * route.numberOfStops() + stopOffset; - int to = route.firstStopTimeIdx() + (activeTrip + 1) * route.numberOfStops(); - int currentRouteStopIdx = route.firstRouteStopIdx() + stopOffset; - for (int k = from; k < to; k++) { - int currentStopIdx = routeStops[currentRouteStopIdx].stopIndex(); - if (stopTimes[k].arrival() < earliestArrival[currentStopIdx]) { - earliestArrival[currentStopIdx] = stopTimes[k].arrival(); - nextMarked.add(currentStopIdx); + if (tripOffset < numberOfTrips - 1) { + tripOffset++; + } else { + // no active trip found + log.debug("No active trip found on route {}", currentRoute.id()); + enteredTrip = false; + break; } - currentRouteStopIdx++; } } } - // relax transfers (= footpaths) - for (int stopIdx : marked) { - Stop stop = stops[stopIdx]; - if (stop.transferIdx() == NO_INDEX) { + // relax footpaths for all markedStops + // temp variable to add any new stops to markedStopsNext + Set newStops = new HashSet<>(); + for (int stopIdx : markedStopsNext) { + Stop currentStop = stops[stopIdx]; + if (currentStop.numberOfTransfers() == 0) { continue; } - for (int k = stop.transferIdx(); k < stop.numberOfTransfers(); k++) { - Transfer transfer = transfers[k]; - int targetStopIdx = transfer.targetStopIdx(); - int arrivalTimeAfterTransfer = earliestArrival[stopIdx] + transfer.duration(); - if (arrivalTimeAfterTransfer < earliestArrival[targetStopIdx]) { - earliestArrival[targetStopIdx] = arrivalTimeAfterTransfer; - nextMarked.add(targetStopIdx); + for (int i = currentStop.transferIdx(); i < currentStop.numberOfTransfers(); i++) { + Transfer transfer = transfers[i]; + // TODO: Handle variable SAME_STOP_TRANSFER_TIMEs + int newTargetStopArrivalTime = earliestArrivals[stopIdx] + transfer.duration() - SAME_STOP_TRANSFER_TIME; + + // update improved arrival time + if (earliestArrivals[transfer.targetStopIdx()] > newTargetStopArrivalTime) { + log.debug("Stop {} was improved by transfer from stop {}", stops[transfer.targetStopIdx()].id(), + stops[stopIdx].id()); + earliestArrivals[transfer.targetStopIdx()] = newTargetStopArrivalTime; + earliestArrivalsThisRound[transfer.targetStopIdx()] = new Leg(earliestArrivals[stopIdx], + newTargetStopArrivalTime, ArrivalType.TRANSFER, i, transfer.targetStopIdx(), + earliestArrivalsThisRound[stopIdx]); + newStops.add(transfer.targetStopIdx()); } } } + markedStopsNext.addAll(newStops); - // prepare for next round - marked = nextMarked; + // prepare next round + markedStops = markedStopsNext; round++; } - // print results for debugging - for (int i = 0; i < earliestArrival.length; i++) { - if (earliestArrival[i] == Integer.MAX_VALUE) { - earliestArrival[i] = -1; + return earliestArrivalsPerRound; + } + + private List reconstructParetoOptimalSolutions(List earliestArrivalsPerRound, + int targetStopIdx) { + final List connections = new ArrayList<>(); + + // iterate over all rounds + for (Leg[] legs : earliestArrivalsPerRound) { + Leg arrival = legs[targetStopIdx]; + + // target stop not reached in this round + if (arrival == null) { + continue; + } + + // iterate through arrivals starting at target stop + Connection connection = new Connection(); + while (arrival.type != ArrivalType.INITIAL) { + String id; + String fromStopId = stops[arrival.previous.stopIdx].id(); + String toStopId = stops[arrival.stopIdx].id(); + Connection.LegType type; + int departureTime = arrival.departureTime; + int arrivalTime = arrival.arrivalTime; + if (arrival.type == ArrivalType.ROUTE) { + id = routes[arrival.routeOrTransferIdx].id(); + type = Connection.LegType.ROUTE; + } else if (arrival.type == ArrivalType.TRANSFER) { + id = String.format("transfer_%s_%s", fromStopId, toStopId); + type = Connection.LegType.FOOTPATH; + // include same stop transfer time (which is subtracted before scanning routes) + arrivalTime += SAME_STOP_TRANSFER_TIME; + } else { + throw new IllegalStateException("Unknown arrival type"); + } + connection.addLeg(new Connection.Leg(id, fromStopId, toStopId, departureTime, arrivalTime, type)); + arrival = arrival.previous; + } + + // initialize connection: Reverse order of legs and add connection + if (!connection.getLegs().isEmpty()) { + connection.initialize(); + connections.add(connection); + } + + } + + return connections; + } + + private void expandFootpathsForSourceStop(int[] earliestArrivals, List earliestArrivalsPerRound, + Set markedStops, int sourceStopIdx, int departureTime) { + // if stop has no transfers, then no footpaths can be expanded + if (stops[sourceStopIdx].numberOfTransfers() == 0) { + return; + } + // mark all transfer stops, no checks needed for since all transfers will improve arrival time and can be + // marked + Stop sourceStop = stops[sourceStopIdx]; + for (int i = sourceStop.transferIdx(); i < sourceStop.transferIdx() + sourceStop.numberOfTransfers(); i++) { + Transfer transfer = transfers[i]; + int newTargetStopArrivalTime = departureTime + transfer.duration() - SAME_STOP_TRANSFER_TIME; + earliestArrivals[transfer.targetStopIdx()] = newTargetStopArrivalTime; + earliestArrivalsPerRound.getFirst()[transfer.targetStopIdx()] = new Leg(departureTime, + newTargetStopArrivalTime, ArrivalType.TRANSFER, i, transfer.targetStopIdx(), + earliestArrivalsPerRound.getFirst()[sourceStopIdx]); + markedStops.add(transfer.targetStopIdx()); + } + } + + private enum ArrivalType { + INITIAL, + ROUTE, + TRANSFER + } + + private record Leg(int departureTime, int arrivalTime, ArrivalType type, int routeOrTransferIdx, int stopIdx, + Leg previous) { + } + + /** + * Validate inputs to raptor. + */ + private class InputValidator { + private static final int MIN_DEPARTURE_TIME = 0; + private static final int MAX_DEPARTURE_TIME = 48 * 60 * 60; // 48 hours + + private static void validateStopIds(String sourceStopId, String targetStopId) { + if (sourceStopId.equals(targetStopId)) { + throw new IllegalArgumentException("Source and target stop IDs must not be the same."); } } - for (Stop stop : stops) { - System.out.println(stop.id() + ": " + earliestArrival[stopsToIdx.get(stop.id())]); + + private static void validateDepartureTime(int departureTime) { + if (departureTime < MIN_DEPARTURE_TIME || departureTime > MAX_DEPARTURE_TIME) { + throw new IllegalArgumentException( + "Departure time must be between " + MIN_DEPARTURE_TIME + " and " + MAX_DEPARTURE_TIME + " seconds."); + } } - log.debug("Earliest arrival at {}: {}", targetStop, earliestArrival[stopsToIdx.get(targetStop)]); + private int validateAndGetStopIdx(String stopId) { + try { + return stopsToIdx.get(stopId); + } catch (NullPointerException e) { + throw new IllegalArgumentException("Stop id " + stopId + " not found."); + } + } } } diff --git a/src/main/java/ch/naviqore/raptor/model/RaptorBuilder.java b/src/main/java/ch/naviqore/raptor/model/RaptorBuilder.java index ddbd5474..fd2dc260 100644 --- a/src/main/java/ch/naviqore/raptor/model/RaptorBuilder.java +++ b/src/main/java/ch/naviqore/raptor/model/RaptorBuilder.java @@ -3,13 +3,20 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.extern.log4j.Log4j2; +import org.jetbrains.annotations.NotNull; import java.util.*; /** - * Builds the Raptor and its internal data structures - *

- * Note: The builder expects that stops, routes, route stops and stop times are added in the correct order. + * Builds the Raptor and its internal data structures. Ensures that all stops, routes, trips, stop times, and transfers + * are correctly added and validated before constructing the Raptor model: + *

    + *
  • All stops must have at least one route serving them.
  • + *
  • All stops of a route must be known before adding the route.
  • + *
  • All trips of a route must have the same stop sequence.
  • + *
  • Each stop time of a trip must have a departure time after the previous stop time's arrival time.
  • + *
  • All trips in the final route container must be sorted by departure time.
  • + *
* * @author munterfi */ @@ -18,164 +25,199 @@ public class RaptorBuilder { private final Map stops = new HashMap<>(); - private final Map routes = new HashMap<>(); - private final Map> routeStops = new HashMap<>(); - private final Map> stopTimes = new HashMap<>(); - private final Map> stopRoutes = new HashMap<>(); + private final Map routeBuilders = new HashMap<>(); private final Map> transfers = new HashMap<>(); + private final Map> stopRoutes = new HashMap<>(); - private int stopSize = 0; - private int routeSize = 0; - private int routeStopSize = 0; - private int stopTimeSize = 0; - private int transferSize = 0; + int stopTimeSize = 0; + int routeStopSize = 0; + int transferSize = 0; public RaptorBuilder addStop(String id) { if (stops.containsKey(id)) { throw new IllegalArgumentException("Stop " + id + " already exists"); } + log.debug("Adding stop: id={}", id); stops.put(id, stops.size()); - stopSize++; + stopRoutes.put(id, new HashSet<>()); + return this; } - public RaptorBuilder addRoute(String id) { - if (routes.containsKey(id)) { + public RaptorBuilder addRoute(String id, List stopIds) { + if (routeBuilders.containsKey(id)) { throw new IllegalArgumentException("Route " + id + " already exists"); } - log.debug("Adding route: id={}", id); - routes.put(id, routes.size()); - routeSize++; + + for (String stopId : stopIds) { + if (!stops.containsKey(stopId)) { + throw new IllegalArgumentException("Stop " + stopId + " does not exist"); + } + stopRoutes.get(stopId).add(id); + } + + log.debug("Adding route: id={}, stopSequence={}", id, stopIds); + routeBuilders.put(id, new RouteBuilder(id, stopIds)); + routeStopSize += stopIds.size(); + return this; } - public RaptorBuilder addRouteStop(String stopId, String routeId) { - log.debug("Adding route stop: stopId={}, routeId={}", stopId, routeId); - if (!stops.containsKey(stopId)) { - throw new IllegalArgumentException("Stop " + stopId + " does not exist"); - } - if (!routes.containsKey(routeId)) { - throw new IllegalArgumentException("Route " + routeId + " does not exist"); - } - routeStops.computeIfAbsent(routeId, k -> new ArrayList<>()).add(stopId); - stopRoutes.computeIfAbsent(stopId, k -> new HashSet<>()).add(routeId); - routeStopSize++; + public RaptorBuilder addTrip(String tripId, String routeId) { + getRouteBuilder(routeId).addTrip(tripId); return this; } - public RaptorBuilder addStopTime(String stopId, String routeId, int arrival, int departure) { - log.debug("Adding stop time: stopId={}, routeId={}, arrival={}, departure={}", stopId, routeId, arrival, - departure); - if (!stops.containsKey(stopId)) { - log.error("Stop {} does not exist", stopId); - // TODO: Reactivate after test for consistency of route stops. - // throw new IllegalArgumentException("Stop " + stopId + " does not exist"); - } - if (!routes.containsKey(routeId)) { - throw new IllegalArgumentException("Route " + routeId + " does not exist"); - } - stopTimes.computeIfAbsent(routeId, k -> new ArrayList<>()).add(new StopTime(arrival, departure)); + public RaptorBuilder addStopTime(String routeId, String tripId, int position, String stopId, int arrival, + int departure) { + StopTime stopTime = new StopTime(arrival, departure); + getRouteBuilder(routeId).addStopTime(tripId, position, stopId, stopTime); stopTimeSize++; + return this; } public RaptorBuilder addTransfer(String sourceStopId, String targetStopId, int duration) { log.debug("Adding transfer: sourceStopId={}, targetStopId={}, duration={}", sourceStopId, targetStopId, duration); + if (!stops.containsKey(sourceStopId)) { throw new IllegalArgumentException("Source stop " + sourceStopId + " does not exist"); } + if (!stops.containsKey(targetStopId)) { throw new IllegalArgumentException("Target stop " + targetStopId + " does not exist"); } + transfers.computeIfAbsent(sourceStopId, k -> new ArrayList<>()) .add(new Transfer(stops.get(targetStopId), duration)); transferSize++; + return this; } public Raptor build() { - Lookup lookup = buildLookup(); - StopContext stopContext = buildStopContext(); - RouteTraversal routeTraversal = buildRouteTraversal(); - log.info("Initialize Raptor with {} stops, {} routes, {} route stops, {} stop times, {} transfers", stopSize, - routeSize, routeStopSize, stopTimeSize, transferSize); + log.info("Initialize Raptor with {} stops, {} routes, {} route stops, {} stop times, {} transfers", + stops.size(), routeBuilders.size(), routeStopSize, stopTimeSize, transferSize); + + // build route containers and the raptor array-based data structures + List routeContainers = buildAndSortRouteContainers(); + Lookup lookup = buildLookup(routeContainers); + StopContext stopContext = buildStopContext(lookup); + RouteTraversal routeTraversal = buildRouteTraversal(routeContainers); + return new Raptor(lookup, stopContext, routeTraversal); } - private Lookup buildLookup() { - log.debug("Building lookup with {} stops and {} routes", stopSize, routeSize); - return new Lookup(new HashMap<>(stops), new HashMap<>(routes)); + private @NotNull List buildAndSortRouteContainers() { + return routeBuilders.values().parallelStream().map(RouteBuilder::build).sorted().toList(); + } + + private Lookup buildLookup(List routeContainers) { + log.debug("Building lookup with {} stops and {} routes", stops.size(), routeContainers.size()); + Map routes = new HashMap<>(routeContainers.size()); + + // assign idx to routes based on sorted order + for (int i = 0; i < routeContainers.size(); i++) { + RouteBuilder.RouteContainer routeContainer = routeContainers.get(i); + routes.put(routeContainer.id(), i); + } + + return new Lookup(Map.copyOf(stops), Map.copyOf(routes)); } - private StopContext buildStopContext() { - log.debug("Building stop context with {} stops and {} transfers", stopSize, transferSize); - Stop[] stopArr = new Stop[stopSize]; + private StopContext buildStopContext(Lookup lookup) { + log.debug("Building stop context with {} stops and {} transfers", stops.size(), transferSize); + + // allocate arrays in needed size + Stop[] stopArr = new Stop[stops.size()]; int[] stopRouteArr = new int[stopRoutes.values().stream().mapToInt(Set::size).sum()]; Transfer[] transferArr = new Transfer[transferSize]; - int transferCnt = 0; - int stopRouteCnt = 0; + // iterate over stops and populate arrays + int transferIdx = 0; + int stopRouteIdx = 0; for (Map.Entry entry : stops.entrySet()) { String stopId = entry.getKey(); int stopIdx = entry.getValue(); + // check if stop has no routes: Unserved stops are useless in the raptor data structure + Set currentStopRoutes = stopRoutes.get(stopId); + if (currentStopRoutes == null) { + throw new IllegalStateException("Stop " + stopId + " has no routes"); + } + + // get the number of (optional) transfers List currentTransfers = transfers.get(stopId); - int currentTransferCnt = 0; + int numberOfTransfers = currentTransfers == null ? 0 : currentTransfers.size(); + + // add stop entry to stop array + stopArr[stopIdx] = new Stop(stopId, stopRouteIdx, currentStopRoutes.size(), + numberOfTransfers == 0 ? Raptor.NO_INDEX : transferIdx, numberOfTransfers); + + // add transfer entry to transfer array if there are any if (currentTransfers != null) { for (Transfer transfer : currentTransfers) { - transferArr[transferCnt++] = transfer; - currentTransferCnt++; + transferArr[transferIdx++] = transfer; } } - Set currentStopRoutes = stopRoutes.get(stopId); - if (currentStopRoutes == null) { - throw new IllegalStateException("Stop " + stopId + " has no routes"); - } + // add route index entries to stop route array for (String routeId : currentStopRoutes) { - stopRouteArr[stopRouteCnt++] = routes.get(routeId); + stopRouteArr[stopRouteIdx++] = lookup.routes().get(routeId); } - - stopArr[stopIdx] = new Stop(stopId, stopRouteCnt - currentStopRoutes.size(), currentStopRoutes.size(), - currentTransferCnt == 0 ? Raptor.NO_INDEX : transferCnt - currentTransferCnt, currentTransferCnt); } + return new StopContext(transferArr, stopArr, stopRouteArr); } - private RouteTraversal buildRouteTraversal() { - log.debug("Building route traversal with {} routes, {} route stops, {} stop times", routeSize, routeStopSize, - stopTimeSize); - Route[] routeArr = new Route[routeSize]; + private RouteTraversal buildRouteTraversal(List routeContainers) { + log.debug("Building route traversal with {} routes, {} route stops, {} stop times", routeContainers.size(), + routeStopSize, stopTimeSize); + + // allocate arrays in needed size + Route[] routeArr = new Route[routeContainers.size()]; RouteStop[] routeStopArr = new RouteStop[routeStopSize]; StopTime[] stopTimeArr = new StopTime[stopTimeSize]; + // iterate over routes and populate arrays int routeStopCnt = 0; int stopTimeCnt = 0; - for (Map.Entry entry : routes.entrySet()) { - String routeId = entry.getKey(); - int routeIdx = entry.getValue(); - - List currentRouteStops = routeStops.get(routeId); - if (currentRouteStops == null) { - throw new IllegalStateException("Route " + routeId + " has no route stops"); - } - for (String routeStop : currentRouteStops) { - routeStopArr[routeStopCnt++] = new RouteStop(stops.get(routeStop), routeIdx); + for (int routeIdx = 0; routeIdx < routeContainers.size(); routeIdx++) { + RouteBuilder.RouteContainer routeContainer = routeContainers.get(routeIdx); + + // add route entry to route array + final int numberOfStops = routeContainer.stopSequence().size(); + final int numberOfTrips = routeContainer.trips().size(); + routeArr[routeIdx] = new Route(routeContainer.id(), routeStopCnt, numberOfStops, stopTimeCnt, + numberOfTrips); + + // add stops to route stop array + Map stopSequence = routeContainer.stopSequence(); + for (int position = 0; position < numberOfStops; position++) { + int stopIdx = stops.get(stopSequence.get(position)); + routeStopArr[routeStopCnt++] = new RouteStop(stopIdx, routeIdx); } - List currentStopTimes = stopTimes.get(routeId); - if (currentStopTimes == null) { - throw new IllegalStateException("Route " + routeId + " has no stop times"); - } - for (StopTime stopTime : currentStopTimes) { - stopTimeArr[stopTimeCnt++] = stopTime; + // add times to stop time array + for (StopTime[] stopTimes : routeContainer.trips().values()) { + for (StopTime stopTime : stopTimes) { + stopTimeArr[stopTimeCnt++] = stopTime; + } } - - routeArr[routeIdx] = new Route(routeId, routeStopCnt - currentRouteStops.size(), currentRouteStops.size(), - stopTimeCnt - currentStopTimes.size(), currentStopTimes.size()); } + return new RouteTraversal(stopTimeArr, routeArr, routeStopArr); } + + private @NotNull RouteBuilder getRouteBuilder(String routeId) { + RouteBuilder routeBuilder = routeBuilders.get(routeId); + if (routeBuilder == null) { + throw new IllegalArgumentException("Route " + routeId + " does not exist"); + } + + return routeBuilder; + } + } diff --git a/src/main/java/ch/naviqore/raptor/model/Route.java b/src/main/java/ch/naviqore/raptor/model/Route.java index a7d526cc..89998cd5 100644 --- a/src/main/java/ch/naviqore/raptor/model/Route.java +++ b/src/main/java/ch/naviqore/raptor/model/Route.java @@ -1,4 +1,4 @@ package ch.naviqore.raptor.model; -public record Route(String id, int firstRouteStopIdx, int numberOfStops, int firstStopTimeIdx, int numberOfTrips) { +record Route(String id, int firstRouteStopIdx, int numberOfStops, int firstStopTimeIdx, int numberOfTrips) { } diff --git a/src/main/java/ch/naviqore/raptor/model/RouteBuilder.java b/src/main/java/ch/naviqore/raptor/model/RouteBuilder.java new file mode 100644 index 00000000..9a74cb52 --- /dev/null +++ b/src/main/java/ch/naviqore/raptor/model/RouteBuilder.java @@ -0,0 +1,125 @@ +package ch.naviqore.raptor.model; + +import lombok.extern.log4j.Log4j2; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Builds route containers that hold valid trips for a given route. + *

+ * The builder ensures that: + *

    + *
  • All trips of a route have the same stop sequence.
  • + *
  • Each stop time of a trip has a departure time that is temporally after the previous stop time's arrival time.
  • + *
  • In the final route container all trips are sorted according to their departure time.
  • + *
+ */ +@Log4j2 +class RouteBuilder { + + private final String routeId; + private final Map stopSequence = new HashMap<>(); + private final Map trips = new HashMap<>(); + + RouteBuilder(String routeId, List stopIds) { + this.routeId = routeId; + for (int i = 0; i < stopIds.size(); i++) { + stopSequence.put(i, stopIds.get(i)); + } + } + + void addTrip(String tripId) { + log.debug("Adding trip: id={} routeId={}", tripId, routeId); + if (trips.containsKey(tripId)) { + throw new IllegalArgumentException("Trip " + tripId + " already exists."); + } + trips.put(tripId, new StopTime[stopSequence.size()]); + } + + void addStopTime(String tripId, int position, String stopId, StopTime stopTime) { + log.debug("Adding stop time: tripId={}, position={}, stopId={}, stopTime={}", tripId, position, stopId, + stopTime); + + if (position < 0 || position >= stopSequence.size()) { + throw new IllegalArgumentException( + "Position " + position + " is out of bounds [0, " + stopSequence.size() + ")."); + } + + StopTime[] stopTimes = trips.get(tripId); + if (stopTimes == null) { + throw new IllegalArgumentException("Trip " + tripId + " does not exist."); + } + + if (!stopSequence.get(position).equals(stopId)) { + throw new IllegalArgumentException("Stop " + stopId + " does not match stop " + stopSequence.get( + position) + " at position " + position + "."); + } + + if (stopTimes[position] != null) { + throw new IllegalArgumentException("Stop time for stop " + stopId + " already exists."); + } + + if (position > 0) { + StopTime previousStopTime = stopTimes[position - 1]; + if (previousStopTime != null && previousStopTime.departure() > stopTime.arrival()) { + throw new IllegalArgumentException( + "Departure time at previous stop is greater than arrival time at current stop."); + } + } + + if (position < stopTimes.length - 1) { + StopTime nextStopTime = stopTimes[position + 1]; + if (nextStopTime != null && stopTime.departure() > nextStopTime.arrival()) { + throw new IllegalArgumentException( + "Departure time at current stop is greater than arrival time at next stop."); + } + } + + stopTimes[position] = stopTime; + } + + private void validate() { + for (Map.Entry trip : trips.entrySet()) { + StopTime[] stopTimes = trip.getValue(); + for (Map.Entry stop : stopSequence.entrySet()) { + // ensure all stop times are set and therefore all trips must have the same stops + if (stopTimes[stop.getKey()] == null) { + throw new IllegalStateException( + "Stop time at stop " + stop.getKey() + " on trip " + trip.getKey() + " not set."); + } + } + } + } + + RouteContainer build() { + log.debug("Validating and building route {}", routeId); + validate(); + + // sort trips by the departure time of the first stop + List> sortedEntries = new ArrayList<>(trips.entrySet()); + sortedEntries.sort(Comparator.comparingInt(entry -> entry.getValue()[0].departure())); + + // populate sortedTrips in the same order as sortedEntries, LinkedHashMap stores insertion order + LinkedHashMap sortedTrips = new LinkedHashMap<>(sortedEntries.size()); + for (Map.Entry entry : sortedEntries) { + sortedTrips.put(entry.getKey(), entry.getValue()); + } + + return new RouteContainer(routeId, stopSequence, sortedTrips); + } + + record RouteContainer(String id, Map stopSequence, + LinkedHashMap trips) implements Comparable { + + @Override + public int compareTo(@NotNull RouteContainer o) { + StopTime thisFirstStopTime = this.trips.values().iterator().next()[0]; + StopTime otherFirstStopTime = o.trips.values().iterator().next()[0]; + return Integer.compare(thisFirstStopTime.departure(), otherFirstStopTime.departure()); + } + + } +} + + diff --git a/src/main/java/ch/naviqore/raptor/model/RouteStop.java b/src/main/java/ch/naviqore/raptor/model/RouteStop.java index 1c7fc9fd..5f9c5b26 100644 --- a/src/main/java/ch/naviqore/raptor/model/RouteStop.java +++ b/src/main/java/ch/naviqore/raptor/model/RouteStop.java @@ -1,4 +1,4 @@ package ch.naviqore.raptor.model; -public record RouteStop(int stopIndex, int routeIndex) { +record RouteStop(int stopIndex, int routeIndex) { } \ No newline at end of file diff --git a/src/main/java/ch/naviqore/raptor/model/RouteTraversal.java b/src/main/java/ch/naviqore/raptor/model/RouteTraversal.java index 3b2c3935..f467f818 100644 --- a/src/main/java/ch/naviqore/raptor/model/RouteTraversal.java +++ b/src/main/java/ch/naviqore/raptor/model/RouteTraversal.java @@ -7,5 +7,5 @@ * @param routes routes * @param routeStops route stops */ -public record RouteTraversal(StopTime[] stopTimes, Route[] routes, RouteStop[] routeStops) { +record RouteTraversal(StopTime[] stopTimes, Route[] routes, RouteStop[] routeStops) { } diff --git a/src/main/java/ch/naviqore/raptor/model/Stop.java b/src/main/java/ch/naviqore/raptor/model/Stop.java index 52c9a63e..17a9b49d 100644 --- a/src/main/java/ch/naviqore/raptor/model/Stop.java +++ b/src/main/java/ch/naviqore/raptor/model/Stop.java @@ -1,4 +1,4 @@ package ch.naviqore.raptor.model; -public record Stop(String id, int stopRouteIdx, int numberOfRoutes, int transferIdx, int numberOfTransfers) { +record Stop(String id, int stopRouteIdx, int numberOfRoutes, int transferIdx, int numberOfTransfers) { } diff --git a/src/main/java/ch/naviqore/raptor/model/StopContext.java b/src/main/java/ch/naviqore/raptor/model/StopContext.java index db25e9e5..3900d187 100644 --- a/src/main/java/ch/naviqore/raptor/model/StopContext.java +++ b/src/main/java/ch/naviqore/raptor/model/StopContext.java @@ -1,4 +1,4 @@ package ch.naviqore.raptor.model; -public record StopContext(Transfer[] transfers, Stop[] stops, int[] stopRoutes) { +record StopContext(Transfer[] transfers, Stop[] stops, int[] stopRoutes) { } diff --git a/src/main/java/ch/naviqore/raptor/model/StopTime.java b/src/main/java/ch/naviqore/raptor/model/StopTime.java index f499e04b..08cf72a4 100644 --- a/src/main/java/ch/naviqore/raptor/model/StopTime.java +++ b/src/main/java/ch/naviqore/raptor/model/StopTime.java @@ -1,4 +1,11 @@ package ch.naviqore.raptor.model; -public record StopTime(int arrival, int departure) { +record StopTime(int arrival, int departure) { + + public StopTime { + if (arrival > departure) { + throw new IllegalArgumentException("Arrival time must be before departure time."); + } + } + } diff --git a/src/main/java/ch/naviqore/raptor/model/Transfer.java b/src/main/java/ch/naviqore/raptor/model/Transfer.java index 64294cf2..c8699d00 100644 --- a/src/main/java/ch/naviqore/raptor/model/Transfer.java +++ b/src/main/java/ch/naviqore/raptor/model/Transfer.java @@ -1,4 +1,4 @@ package ch.naviqore.raptor.model; -public record Transfer(int targetStopIdx, int duration) { +record Transfer(int targetStopIdx, int duration) { } diff --git a/src/main/java/ch/naviqore/utils/cache/ValueObjectCache.java b/src/main/java/ch/naviqore/utils/cache/ValueObjectCache.java new file mode 100644 index 00000000..a09f68fe --- /dev/null +++ b/src/main/java/ch/naviqore/utils/cache/ValueObjectCache.java @@ -0,0 +1,38 @@ +package ch.naviqore.utils.cache; + +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +/** + * A generic cache for immutable value objects. + *

+ * The cache stores value objects of type T, ensuring that only one instance of each unique value is retained in + * memory. + * + * @param the type of the value objects to be cached + */ +@NoArgsConstructor +public class ValueObjectCache { + + private final Map cache = new HashMap<>(); + + /** + * Retrieves the value from the cache or adds it if it does not exist. + * + * @param value the value to be cached + * @return the cached value + */ + public T getOrAdd(T value) { + return cache.computeIfAbsent(value, k -> value); + } + + /** + * Clear the cache. + */ + public void clear() { + cache.clear(); + } + +} diff --git a/src/test/java/ch/naviqore/Benchmark.java b/src/test/java/ch/naviqore/Benchmark.java index fed21c7b..e24938d2 100644 --- a/src/test/java/ch/naviqore/Benchmark.java +++ b/src/test/java/ch/naviqore/Benchmark.java @@ -3,10 +3,16 @@ import ch.naviqore.BenchmarkData.Dataset; import ch.naviqore.gtfs.schedule.GtfsScheduleReader; import ch.naviqore.gtfs.schedule.model.GtfsSchedule; +import ch.naviqore.gtfs.schedule.model.Stop; +import ch.naviqore.gtfs.schedule.model.StopTime; +import ch.naviqore.gtfs.schedule.model.Trip; +import ch.naviqore.gtfs.schedule.type.ServiceDayTime; import ch.naviqore.raptor.GtfsToRaptorConverter; +import ch.naviqore.raptor.model.Connection; import ch.naviqore.raptor.model.Raptor; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.extern.log4j.Log4j2; import java.io.IOException; import java.io.PrintWriter; @@ -17,9 +23,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Random; +import java.util.*; /** * Benchmark for Raptor routing algorithm. @@ -27,25 +31,36 @@ * Measures the time it takes to route a number of requests using Raptor algorithm on large GTFS datasets. *

* Note: To run this benchmark, ensure that the log level is set to INFO in the - * {@code src/test/resources/log4j2.properties} file. + * {@code src/test/resources/log4j2-test.properties} file. * * @author munterfi */ @NoArgsConstructor(access = AccessLevel.PRIVATE) +@Log4j2 final class Benchmark { - private static final int N = 10000; + // dataset private static final Dataset DATASET = Dataset.SWITZERLAND; - private static final LocalDate DATE = LocalDate.of(2024, 4, 26); - private static final int MAX_SECONDS_IN_DAY = 86400; + private static final LocalDate SCHEDULE_DATE = LocalDate.of(2024, 4, 26); + + // sampling + /** + * Limit in seconds after midnight for the departure time. Only allow early departure times, otherwise many + * connections crossing the complete schedule (region) are not feasible. + */ + private static final int DEPARTURE_TIME_LIMIT = 8 * 60 * 60; + private static final long RANDOM_SEED = 1234; + private static final int SAMPLE_SIZE = 10000; + + // constants private static final long MONITORING_INTERVAL_MS = 30000; private static final int NS_TO_MS_CONVERSION_FACTOR = 1_000_000; + private static final int NOT_AVAILABLE = -1; public static void main(String[] args) throws IOException, InterruptedException { GtfsSchedule schedule = initializeSchedule(); Raptor raptor = initializeRaptor(schedule); - List stopIds = new ArrayList<>(schedule.getStops().keySet()); - RouteRequest[] requests = sampleRouteRequests(stopIds); + RouteRequest[] requests = sampleRouteRequests(schedule); RoutingResult[] results = processRequests(raptor, requests); writeResultsToCsv(results); } @@ -58,7 +73,7 @@ private static GtfsSchedule initializeSchedule() throws IOException, Interrupted } private static Raptor initializeRaptor(GtfsSchedule schedule) throws InterruptedException { - Raptor raptor = new GtfsToRaptorConverter(schedule).convert(DATE); + Raptor raptor = new GtfsToRaptorConverter(schedule).convert(SCHEDULE_DATE); manageResources(); return raptor; } @@ -68,14 +83,24 @@ private static void manageResources() throws InterruptedException { Thread.sleep(MONITORING_INTERVAL_MS); } - private static RouteRequest[] sampleRouteRequests(List stopIds) { - Random random = new Random(); - RouteRequest[] requests = new RouteRequest[Benchmark.N]; - for (int i = 0; i < Benchmark.N; i++) { + private static RouteRequest[] sampleRouteRequests(GtfsSchedule schedule) { + // extract valid stops for day + Set uniqueStopIds = new HashSet<>(); + for (Trip trip : schedule.getActiveTrips(SCHEDULE_DATE)) { + for (StopTime stopTime : trip.getStopTimes()) { + uniqueStopIds.add(stopTime.stop().getId()); + } + } + List stopIds = new ArrayList<>(uniqueStopIds); + + // sample + Random random = new Random(RANDOM_SEED); + RouteRequest[] requests = new RouteRequest[SAMPLE_SIZE]; + for (int i = 0; i < SAMPLE_SIZE; i++) { int sourceIndex = random.nextInt(stopIds.size()); int destinationIndex = getRandomDestinationIndex(stopIds.size(), sourceIndex, random); - requests[i] = new RouteRequest(stopIds.get(sourceIndex), stopIds.get(destinationIndex), - random.nextInt(MAX_SECONDS_IN_DAY)); + requests[i] = new RouteRequest(schedule.getStops().get(stopIds.get(sourceIndex)), + schedule.getStops().get(stopIds.get(destinationIndex)), random.nextInt(DEPARTURE_TIME_LIMIT)); } return requests; } @@ -90,18 +115,45 @@ private static RoutingResult[] processRequests(Raptor raptor, RouteRequest[] req RoutingResult[] responses = new RoutingResult[requests.length]; for (int i = 0; i < requests.length; i++) { long startTime = System.nanoTime(); - // TODO: RaptorResponse result = - raptor.routeEarliestArrival(requests[i].sourceStop(), requests[i].targetStop(), - requests[i].departureTime()); - long endTime = System.nanoTime(); - responses[i] = new RoutingResult(requests[i].sourceStop(), requests[i].targetStop(), - requests[i].departureTime(), 0, 0, 0, (endTime - startTime) / NS_TO_MS_CONVERSION_FACTOR); + try { + List connections = raptor.routeEarliestArrival(requests[i].sourceStop().getId(), + requests[i].targetStop().getId(), requests[i].departureTime()); + long endTime = System.nanoTime(); + responses[i] = toResult(i, requests[i], connections, startTime, endTime); + } catch (IllegalArgumentException e) { + log.error("Could not process route request: {}", e.getMessage()); + } + } return responses; } + private static RoutingResult toResult(int id, RouteRequest request, List connections, long startTime, + long endTime) { + Optional earliestDepartureTime = toLocalDatetime( + connections.stream().mapToInt(Connection::getDepartureTime).min().orElse(NOT_AVAILABLE)); + Optional earliestArrivalTime = toLocalDatetime( + connections.stream().mapToInt(Connection::getArrivalTime).min().orElse(NOT_AVAILABLE)); + int minDuration = connections.stream().mapToInt(Connection::getDuration).min().orElse(NOT_AVAILABLE); + int maxDuration = connections.stream().mapToInt(Connection::getDuration).max().orElse(NOT_AVAILABLE); + int minTransfers = connections.stream().mapToInt(Connection::getNumTransfers).min().orElse(NOT_AVAILABLE); + int maxTransfers = connections.stream().mapToInt(Connection::getNumTransfers).max().orElse(NOT_AVAILABLE); + long beelineDistance = Math.round( + request.sourceStop.getCoordinate().distanceTo(request.targetStop.getCoordinate())); + long processingTime = (endTime - startTime) / NS_TO_MS_CONVERSION_FACTOR; + return new RoutingResult(id, request.sourceStop().getId(), request.targetStop().getId(), + request.sourceStop().getName(), request.targetStop.getName(), + toLocalDatetime(request.departureTime).orElseThrow(), connections.size(), earliestDepartureTime, + earliestArrivalTime, minDuration, maxDuration, minTransfers, maxTransfers, beelineDistance, + processingTime); + } + private static void writeResultsToCsv(RoutingResult[] results) throws IOException { - String header = "source_stop,target_stop,requested_departure_time,departure_time,arrival_time,transfers,processing_time_ms"; + String[] headers = {"id", "source_stop_id", "target_stop_id", "source_stop_name", "target_stop_name", + "requested_departure_time", "connections", "earliest_departure_time", "earliest_arrival_time", + "min_duration", "max_duration", "min_transfers", "max_transfers", "beeline_distance", + "processing_time_ms"}; + String header = String.join(",", headers); String folderPath = String.format("benchmark/output/%s", DATASET.name().toLowerCase()); String fileName = String.format("%s_raptor_results.csv", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ss"))); @@ -116,17 +168,33 @@ private static void writeResultsToCsv(RoutingResult[] results) throws IOExceptio writer.println(header); for (RoutingResult result : results) { - writer.printf("%s,%s,%d,%d,%d,%d,%d%n", result.sourceStop(), result.targetStop(), - result.requestedDepartureTime(), result.departureTime(), result.arrivalTime(), - result.transfers(), result.time()); + writer.printf("%d,%s,%s,\"%s\",\"%s\",%s,%d,%s,%s,%d,%d,%d,%d,%d,%d%n", result.id, result.sourceStopId, + result.targetStopId, result.sourceStopName, result.targetStopName, + result.requestedDepartureTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), result.connections, + result.earliestDepartureTime.map(dt -> dt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + .orElse("N/A"), + result.earliestArrivalTime.map(dt -> dt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + .orElse("N/A"), result.minDuration, result.maxDuration, result.minTransfers, + result.maxTransfers, result.beelineDistance, result.processingTime); } } } - record RouteRequest(String sourceStop, String targetStop, int departureTime) { + private static Optional toLocalDatetime(int seconds) { + if (seconds == NOT_AVAILABLE) { + return Optional.empty(); + } + return Optional.of(new ServiceDayTime(seconds).toLocalDateTime(SCHEDULE_DATE)); } - record RoutingResult(String sourceStop, String targetStop, int requestedDepartureTime, int departureTime, - int arrivalTime, int transfers, long time) { + record RouteRequest(Stop sourceStop, Stop targetStop, int departureTime) { } + + record RoutingResult(int id, String sourceStopId, String targetStopId, String sourceStopName, String targetStopName, + LocalDateTime requestedDepartureTime, int connections, + Optional earliestDepartureTime, Optional earliestArrivalTime, + int minDuration, int maxDuration, int minTransfers, int maxTransfers, long beelineDistance, + long processingTime) { + } + } diff --git a/src/test/java/ch/naviqore/gtfs/schedule/GtfsScheduleTestData.java b/src/test/java/ch/naviqore/gtfs/schedule/GtfsScheduleTestData.java index 5d26b32c..e365dfea 100644 --- a/src/test/java/ch/naviqore/gtfs/schedule/GtfsScheduleTestData.java +++ b/src/test/java/ch/naviqore/gtfs/schedule/GtfsScheduleTestData.java @@ -16,7 +16,7 @@ public final class GtfsScheduleTestData { public static final String SAMPLE_FEED = "sample-feed-1"; public static final String SAMPLE_FEED_ZIP = SAMPLE_FEED + ".zip"; - public static final String RESOURCE_PATH = "gtfs/schedule/" + SAMPLE_FEED_ZIP; + public static final String RESOURCE_PATH = "ch/naviqore/gtfs/schedule/" + SAMPLE_FEED_ZIP; public static File prepareZipDataset(@TempDir Path tempDir) throws IOException { try (InputStream is = GtfsScheduleReaderIT.class.getClassLoader().getResourceAsStream(RESOURCE_PATH)) { diff --git a/src/test/java/ch/naviqore/gtfs/schedule/type/ServiceDayTimeTest.java b/src/test/java/ch/naviqore/gtfs/schedule/type/ServiceDayTimeTest.java new file mode 100644 index 00000000..bca5bee5 --- /dev/null +++ b/src/test/java/ch/naviqore/gtfs/schedule/type/ServiceDayTimeTest.java @@ -0,0 +1,121 @@ +package ch.naviqore.gtfs.schedule.type; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static org.junit.jupiter.api.Assertions.*; + +class ServiceDayTimeTest { + + @Nested + class Constructor { + + @Test + void shouldCreateServiceDayTimeFromSeconds() { + ServiceDayTime sdt = new ServiceDayTime(3661); + assertEquals(3661, sdt.getTotalSeconds()); + } + + @Test + void shouldCreateServiceDayTimeFromHoursMinutesSeconds() { + ServiceDayTime sdt = new ServiceDayTime(1, 1, 1); + assertEquals(3661, sdt.getTotalSeconds()); + } + + @Test + void shouldThrowExceptionForNegativeSeconds() { + assertThrows(IllegalArgumentException.class, () -> new ServiceDayTime(-1)); + } + + @Test + void shouldThrowExceptionForNegativeHoursMinutesSeconds() { + assertThrows(IllegalArgumentException.class, () -> new ServiceDayTime(-1, 0, 0)); + assertThrows(IllegalArgumentException.class, () -> new ServiceDayTime(0, -1, 0)); + assertThrows(IllegalArgumentException.class, () -> new ServiceDayTime(0, 0, -1)); + } + + @Test + void shouldThrowExceptionForInvalidMinutes() { + assertThrows(IllegalArgumentException.class, () -> new ServiceDayTime(0, ServiceDayTime.MINUTES_IN_HOUR, 0)); + } + + @Test + void shouldThrowExceptionForInvalidSeconds() { + assertThrows(IllegalArgumentException.class, () -> new ServiceDayTime(0, 0, ServiceDayTime.SECONDS_IN_MINUTE)); + } + + } + + @Nested + class Parse { + + @Test + void shouldParseTimeStringCorrectly() { + ServiceDayTime sdt = ServiceDayTime.parse("01:01:01"); + assertEquals(3661, sdt.getTotalSeconds()); + } + } + + @Nested + class Comparison { + + @Test + void shouldCompareServiceDayTimesCorrectly() { + ServiceDayTime sdt1 = new ServiceDayTime(ServiceDayTime.SECONDS_IN_HOUR); + ServiceDayTime sdt2 = new ServiceDayTime(2 * ServiceDayTime.SECONDS_IN_HOUR); + ServiceDayTime sdt3 = new ServiceDayTime(ServiceDayTime.SECONDS_IN_HOUR); + + assertTrue(sdt1.compareTo(sdt2) < 0); + assertTrue(sdt2.compareTo(sdt1) > 0); + assertEquals(0, sdt1.compareTo(sdt3)); + } + } + + @Nested + class Conversion { + + @Test + void shouldConvertToLocalTime() { + ServiceDayTime sdt = new ServiceDayTime(25, 30, 0); + LocalTime expectedTime = LocalTime.of(1, 30, 0); + assertEquals(expectedTime, sdt.toLocalTime()); + } + + @Test + void shouldConvertZeroSecondsToLocalTime() { + ServiceDayTime sdt = new ServiceDayTime(0); + LocalTime expectedTime = LocalTime.MIDNIGHT; + assertEquals(expectedTime, sdt.toLocalTime()); + } + + @Test + void shouldConvertToLocalDateTime() { + LocalDate baseDate = LocalDate.of(2024, 1, 1); + ServiceDayTime sdt = new ServiceDayTime(25, 30, 0); + LocalDateTime expectedDateTime = LocalDateTime.of(2024, 1, 2, 1, 30, 0); + assertEquals(expectedDateTime, sdt.toLocalDateTime(baseDate)); + } + + @Test + void shouldConvertZeroSecondsToLocalDateTime() { + LocalDate baseDate = LocalDate.of(2024, 1, 1); + ServiceDayTime sdt = new ServiceDayTime(0); + LocalDateTime expectedDateTime = LocalDateTime.of(baseDate, LocalTime.MIDNIGHT); + assertEquals(expectedDateTime, sdt.toLocalDateTime(baseDate)); + } + + @Test + void shouldConvertTotalSecondsInDayToLocalDateTime() { + LocalDate baseDate = LocalDate.of(2024, 1, 1); + ServiceDayTime sdt = new ServiceDayTime(ServiceDayTime.SECONDS_IN_DAY); + LocalDateTime expectedDateTime = LocalDateTime.of(2024, 1, 2, 0, 0, 0); + assertEquals(expectedDateTime, sdt.toLocalDateTime(baseDate)); + } + + } + +} diff --git a/src/test/java/ch/naviqore/raptor/model/RaptorTest.java b/src/test/java/ch/naviqore/raptor/model/RaptorTest.java index ee4c989d..dffbe0bd 100644 --- a/src/test/java/ch/naviqore/raptor/model/RaptorTest.java +++ b/src/test/java/ch/naviqore/raptor/model/RaptorTest.java @@ -3,106 +3,188 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; -import java.util.HashSet; import java.util.List; -import java.util.Set; + +import static ch.naviqore.raptor.model.RaptorTestBuilder.SECONDS_IN_HOUR; +import static org.junit.jupiter.api.Assertions.*; /** * Tests for the Raptor class. - *

- * Simple example schedule for testing: - *

- *                      M
- *                      |
- *        I ---- J ---- K ---- L      R
- *        |             |             |
- *        |             N ---- O ---- P ---- Q
- *        |                           |
- * A ---- B ---- C ---- D ---- E ---- F ---- G
- *        |                           |
- *        H                           S
- * 
- *

- * Routes: - *

    - *
  • R1: A, B, C, D, E, F, G
  • - *
  • R2: H, B, I, J, K, L
  • - *
  • R3: M, K, N, O, P, Q
  • - *
  • R4: R, P, F, S
  • - *
- *

- * Transfers: - *

    - *
  • N <--> D: 60*10
  • - *
  • L <--> R: 60*3
  • - *
- * - * @author munterfi */ +@ExtendWith(RaptorTestExtension.class) class RaptorTest { - private static final int DAY_START = 5 * 60 * 60; - private static final int DAY_END = 25 * 60 * 60; - private static final int TRAVEL_TIME = 60 * 5; - private static final int DWELL_TIME = 60 * 2; - private static final List ROUTES = List.of( - new Route("R1", List.of("A", "B", "C", "D", "E", "F", "G"), 15 * 60, 60), - new Route("R2", List.of("H", "B", "I", "J", "K", "L"), 30 * 60, 5 * 60), - new Route("R3", List.of("M", "K", "N", "O", "P", "Q"), 15 * 60, 7 * 60), - new Route("R4", List.of("R", "P", "F", "S"), 60 * 60, 0)); - private static final List TRANSFERS = List.of(new Transfer("N", "D", 60 * 10), - new Transfer("L", "R", 60 * 3)); - private Raptor raptor; - - @BeforeEach - void setUp() { - Set addedStops = new HashSet<>(); - RaptorBuilder builder = Raptor.builder(); - ROUTES.forEach(route -> { - builder.addRoute(route.id + "-F"); - builder.addRoute(route.id + "-R"); - route.stops.forEach(stop -> { - if (!addedStops.contains(stop)) { - builder.addStop(stop); - addedStops.add(stop); - } - }); - for (int i = 0; i < route.stops.size(); i++) { - builder.addRouteStop(route.stops.get(i), route.id + "-F"); - builder.addRouteStop(route.stops.get(route.stops.size() - 1 - i), route.id + "-R"); + @Nested + class EarliestArrival { + + @Test + void shouldFindConnectionsBetweenIntersectingRoutes(RaptorTestBuilder builder) { + // Should return two pareto optimal connections: + // 1. Connection (with two route legs and one transfer (including footpath) --> slower but fewer transfers) + // - Route R1-F from A to D + // - Foot Transfer from D to N + // - Route R3-F from N to Q + + // 2. Connection (with three route legs and two transfers (same station) --> faster but more transfers) + // - Route R1-F from A to F + // - Route R4-R from F to P + // - Route R3-F from P to Q + Raptor raptor = builder.buildWithDefaults(); + + String sourceStop = "A"; + String targetStop = "Q"; + int departureTime = 8 * SECONDS_IN_HOUR; + List connections = raptor.routeEarliestArrival(sourceStop, targetStop, departureTime); + + // check if 2 connections were found + assertEquals(2, connections.size()); + + // check if the first connection is correct + Connection connection1 = connections.getFirst(); + assertEquals(sourceStop, connection1.getFromStopId()); + assertEquals(targetStop, connection1.getToStopId()); + assertTrue(connection1.getDepartureTime() >= departureTime, + "Departure time should be greater equal than searched for departure time"); + // check that transfers make sense + assertEquals(1, connection1.getNumFootPathTransfers()); + assertEquals(1, connection1.getNumTransfers()); + assertEquals(0, connection1.getNumSameStationTransfers()); + + // check second connection + Connection connection2 = connections.get(1); + assertEquals(sourceStop, connection2.getFromStopId()); + assertEquals(targetStop, connection2.getToStopId()); + assertTrue(connection2.getDepartureTime() >= departureTime, + "Departure time should be greater equal than searched for departure time"); + // check that transfers make sense + assertEquals(0, connection2.getNumFootPathTransfers()); + assertEquals(2, connection2.getNumTransfers()); + assertEquals(2, connection2.getNumSameStationTransfers()); + + // compare two connections (make sure they are pareto optimal) + assertTrue(connection1.getDuration() > connection2.getDuration(), + "First connection should be slower than second connection"); + assertTrue(connection1.getNumRouteLegs() < connection2.getNumRouteLegs(), + "First connection should have fewer route legs than second connection"); + } + + @Test + void routeBetweenTwoStopsOnSameRoute(RaptorTestBuilder builder) { + Raptor raptor = builder.buildWithDefaults(); + + String sourceStop = "A"; + String targetStop = "B"; + int departureTime = 8 * SECONDS_IN_HOUR; + List connections = raptor.routeEarliestArrival(sourceStop, targetStop, departureTime); + assertEquals(1, connections.size()); + Connection connection = connections.getFirst(); + assertEquals(sourceStop, connection.getFromStopId()); + assertEquals(targetStop, connection.getToStopId()); + assertTrue(connection.getDepartureTime() >= departureTime, + "Departure time should be greater equal than searched for departure time"); + assertEquals(0, connection.getNumFootPathTransfers()); + assertEquals(0, connection.getNumTransfers()); + assertEquals(0, connection.getNumSameStationTransfers()); + } + + @Test + void shouldNotFindConnectionBetweenNotLinkedStops(RaptorTestBuilder builder) { + // Omit route R2/R4 and transfers to make stop Q (on R3) unreachable from A (on R1) + Raptor raptor = builder.withAddRoute1_AG().withAddRoute3_MQ().build(); + + String sourceStop = "A"; + String targetStop = "Q"; + int departureTime = 8 * SECONDS_IN_HOUR; + List connections = raptor.routeEarliestArrival(sourceStop, targetStop, departureTime); + assertTrue(connections.isEmpty(), "No connection should be found"); + } + + @Test + void shouldFindConnectionBetweenOnlyFootpath(RaptorTestBuilder builder) { + Raptor raptor = builder.withAddRoute1_AG() + .withAddRoute2_HL() + .withAddRoute3_MQ() + .withAddRoute4_RS() + .withAddTransfer1_ND(1) + .withAddTransfer2_LR() + .build(); + + String sourceStop = "N"; + String targetStop = "D"; + int departureTime = 8 * SECONDS_IN_HOUR; + List connections = raptor.routeEarliestArrival(sourceStop, targetStop, departureTime); + assertEquals(1, connections.size()); + Connection connection = connections.getFirst(); + assertEquals(sourceStop, connection.getFromStopId()); + assertEquals(targetStop, connection.getToStopId()); + assertTrue(connection.getDepartureTime() >= departureTime, + "Departure time should be greater equal than searched for departure time"); + assertEquals(1, connection.getNumFootPathTransfers()); + assertEquals(1, connection.getNumTransfers()); + assertEquals(0, connection.getNumSameStationTransfers()); + assertEquals(0, connection.getNumRouteLegs()); + } + + @Nested + class InputValidation { + + private Raptor raptor; + + @BeforeEach + void setUp(RaptorTestBuilder builder) { + raptor = builder.buildWithDefaults(); } - int time = DAY_START + route.offset; - while (time < DAY_END) { - for (int i = 0; i < route.stops.size(); i++) { - builder.addStopTime(route.stops.get(i), route.id + "-F", time, time + TRAVEL_TIME); - builder.addStopTime(route.stops.get(route.stops.size() - 1 - i), route.id + "-R", time, - time + TRAVEL_TIME); - time += TRAVEL_TIME + DWELL_TIME; - } + + @Test + void shouldThrowErrorWhenSourceStopNotExists() { + String sourceStop = "NonExistentStop"; + String targetStop = "A"; + int departureTime = 8 * SECONDS_IN_HOUR; + + assertThrows(IllegalArgumentException.class, + () -> raptor.routeEarliestArrival(sourceStop, targetStop, departureTime), + "Source stop has to exists"); } - }); - TRANSFERS.forEach(transfer -> { - builder.addTransfer(transfer.sourceStop, transfer.targetStop, transfer.duration); - builder.addTransfer(transfer.targetStop, transfer.sourceStop, transfer.duration); - }); - raptor = builder.build(); - } - record Route(String id, List stops, int headway, int offset) { - } + @Test + void shouldThrowErrorWhenTargetStopNotExists() { + String sourceStop = "A"; + String targetStop = "NonExistentStop"; + int departureTime = 8 * SECONDS_IN_HOUR; - record Transfer(String sourceStop, String targetStop, int duration) { - } + assertThrows(IllegalArgumentException.class, + () -> raptor.routeEarliestArrival(sourceStop, targetStop, departureTime), + "Target stop has to exists"); + } - @Nested - class EarliestArrival { - @Test - void testRoutingBetweenIntersectingRoutes() { - raptor.routeEarliestArrival("A", "Q", 8 * 60 * 60); + @Test + void shouldThrowErrorWhenDepartureTimeIsOutOfRange() { + String sourceStop = "A"; + String targetStop = "B"; + + assertThrows(IllegalArgumentException.class, + () -> raptor.routeEarliestArrival(sourceStop, targetStop, -1), + "Departure time cannot be negative"); + assertThrows(IllegalArgumentException.class, + () -> raptor.routeEarliestArrival(sourceStop, targetStop, 49 * SECONDS_IN_HOUR), + "Departure time cannot be greater than two days"); + } + + @Test + void shouldThrowErrorWhenRequestBetweenSameStop() { + String sourceStop = "A"; + String targetStop = "A"; + int departureTime = 8 * SECONDS_IN_HOUR; + + assertThrows(IllegalArgumentException.class, + () -> raptor.routeEarliestArrival(sourceStop, targetStop, departureTime), + "Stops cannot be the same"); + } - // TODO: assertThat... } + } } diff --git a/src/test/java/ch/naviqore/raptor/model/RaptorTestBuilder.java b/src/test/java/ch/naviqore/raptor/model/RaptorTestBuilder.java new file mode 100644 index 00000000..3d6f7bb6 --- /dev/null +++ b/src/test/java/ch/naviqore/raptor/model/RaptorTestBuilder.java @@ -0,0 +1,179 @@ +package ch.naviqore.raptor.model; + +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Test builder to set up a raptor for testing purposes. + *

+ * Simple example schedule for testing: + *

+ *                      M
+ *                      |
+ *        I ---- J ---- K ---- L #### R
+ *        |             |             |
+ *        |             N ---- O ---- P ---- Q
+ *        |             #             |
+ * A ---- B ---- C ---- D ---- E ---- F ---- G
+ *        |                           |
+ *        H                           S
+ * 
+ *

+ * Routes: + *

    + *
  • R1: A, B, C, D, E, F, G
  • + *
  • R2: H, B, I, J, K, L
  • + *
  • R3: M, K, N, O, P, Q
  • + *
  • R4: R, P, F, S
  • + *
+ *

+ * Transfers: + *

    + *
  • N <--> D: 60*10
  • + *
  • L <--> R: 60*3
  • + *
+ */ +@NoArgsConstructor +public class RaptorTestBuilder { + + static final int SECONDS_IN_HOUR = 3600; + private static final int DAY_START_HOUR = 5; + private static final int DAY_END_HOUR = 25; + + private final List routes = new ArrayList<>(); + private final List transfers = new ArrayList<>(); + + private static Raptor build(List routes, List transfers, int dayStart, int dayEnd) { + RaptorBuilder builder = Raptor.builder(); + Set addedStops = new HashSet<>(); + + for (Route route : routes) { + // define route ids + String routeIdF = route.id + "-F"; + String routeIdR = route.id + "-R"; + + // add stops + for (String stop : route.stops) { + if (!addedStops.contains(stop)) { + builder.addStop(stop); + addedStops.add(stop); + } + } + + // add routes + builder.addRoute(routeIdF, route.stops); + builder.addRoute(routeIdR, route.stops.reversed()); + + // add trips + int tripCount = 0; + int time = dayStart * SECONDS_IN_HOUR + route.firstDepartureOffset * 60; + while (time < dayEnd * SECONDS_IN_HOUR) { + + // add trips + String tripIdF = String.format("%s-F-%s", route.id, tripCount); + String tripIdR = String.format("%s-R-%s", route.id, tripCount); + builder.addTrip(tripIdF, routeIdF).addTrip(tripIdR, routeIdR); + tripCount++; + + // add stop times + int departureTime = time; + // first stop of trip has no arrival time + int arrivalTime = departureTime; + for (int i = 0; i < route.stops.size(); i++) { + if (i + 1 == route.stops.size()) { + // last stop of trip has no departure time + departureTime = arrivalTime; + } + builder.addStopTime(routeIdF, tripIdF, i, route.stops.get(i), arrivalTime, departureTime); + builder.addStopTime(routeIdR, tripIdR, i, route.stops.get(route.stops.size() - 1 - i), arrivalTime, + departureTime); + + arrivalTime = departureTime + route.travelTimeBetweenStops * 60; + departureTime = arrivalTime + route.dwellTimeAtSTop * 60; + } + + time += route.headWayTime * 60; + } + } + + for (Transfer transfer : transfers) { + builder.addTransfer(transfer.sourceStop, transfer.targetStop, transfer.duration * 60); + builder.addTransfer(transfer.targetStop, transfer.sourceStop, transfer.duration * 60); + } + + return builder.build(); + } + + public RaptorTestBuilder withAddRoute1_AG() { + routes.add(new Route("R1", List.of("A", "B", "C", "D", "E", "F", "G"))); + return this; + } + + public RaptorTestBuilder withAddRoute2_HL() { + routes.add(new Route("R2", List.of("H", "B", "I", "J", "K", "L"))); + return this; + } + + public RaptorTestBuilder withAddRoute3_MQ() { + routes.add(new Route("R3", List.of("M", "K", "N", "O", "P", "Q"))); + return this; + } + + public RaptorTestBuilder withAddRoute4_RS() { + routes.add(new Route("R4", List.of("R", "P", "F", "S"))); + return this; + } + + public RaptorTestBuilder withAddTransfer1_ND() { + return withAddTransfer1_ND(60); + } + + public RaptorTestBuilder withAddTransfer1_ND(int duration) { + transfers.add(new Transfer("N", "D", duration)); + return this; + } + + public RaptorTestBuilder withAddTransfer2_LR() { + transfers.add(new Transfer("L", "R", 30)); + return this; + } + + public Raptor build() { + return build(routes, transfers, DAY_START_HOUR, DAY_END_HOUR); + } + + public Raptor buildWithDefaults() { + return this.withAddRoute1_AG() + .withAddRoute2_HL() + .withAddRoute3_MQ() + .withAddRoute4_RS() + .withAddTransfer1_ND() + .withAddTransfer2_LR() + .build(); + } + + /** + * Route, times are in minutes. + * + * @param headWayTime the time between the trip departures. + */ + private record Route(String id, List stops, int firstDepartureOffset, int headWayTime, + int travelTimeBetweenStops, int dwellTimeAtSTop) { + + public Route(String id, List stops) { + this(id, stops, 0, 15, 5, 1); + } + + } + + /** + * Transfer, times are in minutes. + */ + private record Transfer(String sourceStop, String targetStop, int duration) { + } + +} diff --git a/src/test/java/ch/naviqore/raptor/model/RaptorTestExtension.java b/src/test/java/ch/naviqore/raptor/model/RaptorTestExtension.java new file mode 100644 index 00000000..368a3963 --- /dev/null +++ b/src/test/java/ch/naviqore/raptor/model/RaptorTestExtension.java @@ -0,0 +1,22 @@ +package ch.naviqore.raptor.model; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; + +/** + * Extension for JUnit 5 tests for injecting RaptorTestBuilder instances. This extension allows test methods to receive + * a RaptorTestBuilder instance as a parameter. + */ +public class RaptorTestExtension implements ParameterResolver { + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return parameterContext.getParameter().getType().equals(RaptorTestBuilder.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return new RaptorTestBuilder(); + } +} \ No newline at end of file diff --git a/src/test/java/ch/naviqore/raptor/model/RouteBuilderTest.java b/src/test/java/ch/naviqore/raptor/model/RouteBuilderTest.java new file mode 100644 index 00000000..d989c792 --- /dev/null +++ b/src/test/java/ch/naviqore/raptor/model/RouteBuilderTest.java @@ -0,0 +1,288 @@ +package ch.naviqore.raptor.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RouteBuilderTest { + + private static final String ROUTE_1 = "route1"; + private static final String TRIP_1 = "trip1"; + private static final String STOP_1 = "stop1"; + private static final String STOP_2 = "stop2"; + private static final String STOP_3 = "stop3"; + private RouteBuilder builder; + + @Nested + class LinearRoute { + + private static final List STOP_IDS = List.of(STOP_1, STOP_2, STOP_3); + + @BeforeEach + void setUp() { + builder = new RouteBuilder(ROUTE_1, STOP_IDS); + builder.addTrip(TRIP_1); + } + + @Nested + class AddTrip { + + @Test + void shouldAddValidTrips() { + assertDoesNotThrow(() -> builder.addTrip("trip2")); + } + + @Test + void shouldNotAddDuplicateTrip() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addTrip(TRIP_1)); + assertEquals("Trip trip1 already exists.", exception.getMessage()); + } + } + + @Nested + class AddStopTime { + + @Test + void shouldAddValidStopTimes() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(300, 400); + StopTime stopTime3 = new StopTime(500, 600); + + assertDoesNotThrow(() -> builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1)); + assertDoesNotThrow(() -> builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2)); + assertDoesNotThrow(() -> builder.addStopTime(TRIP_1, 2, STOP_3, stopTime3)); + } + + @Test + void shouldNotAddStopTimeToNonExistentTrip() { + StopTime stopTime = new StopTime(100, 200); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime("nonexistentTrip", 0, STOP_1, stopTime)); + assertEquals("Trip nonexistentTrip does not exist.", exception.getMessage()); + } + + @Test + void shouldNotAddStopTimeWithNegativePosition() { + StopTime stopTime = new StopTime(100, 200); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, -1, STOP_1, stopTime)); + assertEquals("Position -1 is out of bounds [0, " + STOP_IDS.size() + ").", exception.getMessage()); + } + + @Test + void shouldNotAddStopTimeWithPositionOutOfBounds() { + StopTime stopTime = new StopTime(100, 200); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, STOP_IDS.size(), STOP_1, stopTime)); + assertEquals("Position " + STOP_IDS.size() + " is out of bounds [0, " + STOP_IDS.size() + ").", + exception.getMessage()); + } + + @Test + void shouldNotAddStopTimeForNonMatchingStop() { + StopTime stopTime = new StopTime(100, 200); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, 0, "nonexistentStop", stopTime)); + assertEquals("Stop nonexistentStop does not match stop stop1 at position 0.", exception.getMessage()); + exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, 0, STOP_2, stopTime)); + assertEquals("Stop stop2 does not match stop stop1 at position 0.", exception.getMessage()); + } + + @Test + void shouldNotAddDuplicateStopTimes() { + StopTime stopTime = new StopTime(100, 200); + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, 0, STOP_1, stopTime)); + assertEquals("Stop time for stop stop1 already exists.", exception.getMessage()); + } + + @Test + void shouldNotAddStopTimesWithOverlapOnPreviousStop() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(150, 250); // overlapping times + + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2)); + assertEquals("Departure time at previous stop is greater than arrival time at current stop.", + exception.getMessage()); + } + + @Test + void shouldNotAddStopTimesWithOverlapOnNextStop() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(250, 350); + StopTime stopTime3 = new StopTime(300, 400); + + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + builder.addStopTime(TRIP_1, 2, STOP_3, stopTime3); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2)); + assertEquals("Departure time at current stop is greater than arrival time at next stop.", + exception.getMessage()); + } + } + } + + @Nested + class CircularRoute { + + private static final List STOP_IDS = List.of(STOP_1, STOP_2, STOP_3, STOP_1); + + @BeforeEach + void setUp() { + builder = new RouteBuilder(ROUTE_1, STOP_IDS); + builder.addTrip(TRIP_1); + } + + @Test + void shouldAddSameStopTwice() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(300, 400); + StopTime stopTime3 = new StopTime(500, 600); + StopTime stopTime4 = new StopTime(700, 800); + + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2); + builder.addStopTime(TRIP_1, 2, STOP_3, stopTime3); + builder.addStopTime(TRIP_1, 3, STOP_1, stopTime4); + + assertDoesNotThrow(builder::build); + } + + @Test + void shouldAddSameStopTwiceRandomOrder() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(300, 400); + StopTime stopTime3 = new StopTime(500, 600); + StopTime stopTime4 = new StopTime(700, 800); + + builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2); + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + builder.addStopTime(TRIP_1, 3, STOP_1, stopTime4); + builder.addStopTime(TRIP_1, 2, STOP_3, stopTime3); + + assertDoesNotThrow(builder::build); + } + + @Test + void shouldNotAddStopTimesWithOverlapOnNextStop() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(300, 400); + StopTime stopTime3 = new StopTime(500, 650); + StopTime stopTime4 = new StopTime(600, 700); + + builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2); + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + builder.addStopTime(TRIP_1, 2, STOP_3, stopTime3); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> builder.addStopTime(TRIP_1, 3, STOP_1, stopTime4)); + assertEquals("Departure time at previous stop is greater than arrival time at current stop.", + exception.getMessage()); + } + } + + @Nested + class Build { + + private static final List STOP_IDS_1 = List.of(STOP_1, STOP_2, STOP_3); + + @BeforeEach + void setUp() { + builder = new RouteBuilder(ROUTE_1, STOP_IDS_1); + builder.addTrip(TRIP_1); + } + + @Nested + class Validate { + + @Test + void shouldValidateCompleteRoute() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(300, 400); + StopTime stopTime3 = new StopTime(500, 600); + + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2); + builder.addStopTime(TRIP_1, 2, STOP_3, stopTime3); + + assertDoesNotThrow(() -> builder.build()); + } + + @Test + void shouldNotValidateWhenStopTimeIsMissing() { + StopTime stopTime1 = new StopTime(100, 200); + StopTime stopTime2 = new StopTime(300, 400); + + builder.addStopTime(TRIP_1, 0, STOP_1, stopTime1); + builder.addStopTime(TRIP_1, 1, STOP_2, stopTime2); + + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> builder.build()); + assertTrue(exception.getMessage().contains("Stop time at stop 2 on trip trip1 not set.")); + } + } + + @Nested + class MultipleRoutes { + + private static final String ROUTE_2 = "route2"; + private static final String TRIP_2 = "trip2"; + private static final List STOP_IDS_2 = List.of(STOP_3, STOP_2, STOP_1); + private RouteBuilder builder2; + + @BeforeEach + void setUp() { + builder.addTrip(TRIP_2); + builder2 = new RouteBuilder(ROUTE_2, STOP_IDS_2); + builder2.addTrip(TRIP_1); + builder2.addTrip(TRIP_2); + } + + @Test + void shouldSortRouteContainersByFirstTripDepartureTime() { + // add everything in reverse order: latest stops first + // route1, trip2: third trip departure + builder.addStopTime(TRIP_2, 2, STOP_3, new StopTime(900, 900)); + builder.addStopTime(TRIP_2, 1, STOP_2, new StopTime(600, 700)); + builder.addStopTime(TRIP_2, 0, STOP_1, new StopTime(400, 500)); + + // route2, trip2: fourth trip departure + builder2.addStopTime(TRIP_2, 2, STOP_1, new StopTime(950, 950)); + builder2.addStopTime(TRIP_2, 1, STOP_2, new StopTime(650, 750)); + builder2.addStopTime(TRIP_2, 0, STOP_3, new StopTime(450, 550)); + + // route1, trip1: second trip departure + builder.addStopTime(TRIP_1, 2, STOP_3, new StopTime(550, 650)); + builder.addStopTime(TRIP_1, 1, STOP_2, new StopTime(350, 450)); + builder.addStopTime(TRIP_1, 0, STOP_1, new StopTime(150, 250)); + + // route2, trip1: first trip departure + builder2.addStopTime(TRIP_1, 2, STOP_1, new StopTime(500, 600)); + builder2.addStopTime(TRIP_1, 1, STOP_2, new StopTime(300, 400)); + builder2.addStopTime(TRIP_1, 0, STOP_3, new StopTime(100, 200)); + + // build route containers + List containers = new ArrayList<>(); + containers.add(builder.build()); + containers.add(builder2.build()); + containers.sort(Comparator.naturalOrder()); + + // check order + List expectedOrder = List.of(ROUTE_2, ROUTE_1); + List actualOrder = containers.stream().map(RouteBuilder.RouteContainer::id).toList(); + assertEquals(expectedOrder, actualOrder); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/ch/naviqore/utils/cache/ValueObjectCacheTest.java b/src/test/java/ch/naviqore/utils/cache/ValueObjectCacheTest.java new file mode 100644 index 00000000..6204784f --- /dev/null +++ b/src/test/java/ch/naviqore/utils/cache/ValueObjectCacheTest.java @@ -0,0 +1,44 @@ +package ch.naviqore.utils.cache; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class ValueObjectCacheTest { + + @Test + void shouldReturnSameInstanceForSameValue() { + ValueObjectCache cache = new ValueObjectCache<>(); + LocalDate date1 = LocalDate.of(2023, 1, 1); + LocalDate date2 = LocalDate.of(2023, 1, 1); + + // add date1 to the cache + LocalDate cachedDate1 = cache.getOrAdd(date1); + assertEquals(date1, cachedDate1); + + // add date2 to the cache and check if it's the same instance as date1 + LocalDate cachedDate2 = cache.getOrAdd(date2); + assertSame(cachedDate1, cachedDate2); + } + + @Test + void shouldClearCacheAndReturnNewInstanceForSameValue() { + ValueObjectCache cache = new ValueObjectCache<>(); + LocalDate date1 = LocalDate.of(2023, 1, 1); + LocalDate date2 = LocalDate.of(2023, 1, 1); + + // add date1 to the cache + LocalDate cachedDate1 = cache.getOrAdd(date1); + + // clear the cache and add date2 to the cache + cache.clear(); + LocalDate cachedDate2 = cache.getOrAdd(date2); + + // ensure the cache adds the instance as new + assertNotSame(cachedDate1, cachedDate2); + // ensure the date values are the same + assertEquals(cachedDate1, cachedDate2); + } +} \ No newline at end of file diff --git a/src/test/resources/gtfs/schedule/SOURCE.md b/src/test/resources/ch/naviqore/gtfs/schedule/SOURCE.md similarity index 100% rename from src/test/resources/gtfs/schedule/SOURCE.md rename to src/test/resources/ch/naviqore/gtfs/schedule/SOURCE.md diff --git a/src/test/resources/gtfs/schedule/sample-feed-1.zip b/src/test/resources/ch/naviqore/gtfs/schedule/sample-feed-1.zip similarity index 100% rename from src/test/resources/gtfs/schedule/sample-feed-1.zip rename to src/test/resources/ch/naviqore/gtfs/schedule/sample-feed-1.zip