diff --git a/src/main/java/com/conveyal/gtfs/loader/JdbcTableWriter.java b/src/main/java/com/conveyal/gtfs/loader/JdbcTableWriter.java index 8484e80c3..e525fb604 100644 --- a/src/main/java/com/conveyal/gtfs/loader/JdbcTableWriter.java +++ b/src/main/java/com/conveyal/gtfs/loader/JdbcTableWriter.java @@ -257,13 +257,21 @@ public String update(Integer id, String json, boolean autoCommit) throws SQLExce } } + /** + * Deprecated method to normalize stop times before stop time interpolation. Defaults to + * false for interpolation. + */ + public int normalizeStopTimesForPattern(int id, int beginWithSequence) throws SQLException { + return normalizeStopTimesForPattern(id, beginWithSequence, false); + } + /** * For a given pattern id and starting stop sequence (inclusive), normalize all stop times to match the pattern * stops' travel times. * * @return number of stop times updated */ - public int normalizeStopTimesForPattern(int id, int beginWithSequence) throws SQLException { + public int normalizeStopTimesForPattern(int id, int beginWithSequence, boolean interpolateStopTimes) throws SQLException { try { JDBCTableReader patternStops = new JDBCTableReader( Table.PATTERN_STOP, @@ -280,7 +288,7 @@ public int normalizeStopTimesForPattern(int id, int beginWithSequence) throws SQ patternStopsToNormalize.add(patternStop); } } - int stopTimesUpdated = updateStopTimesForPatternStops(patternStopsToNormalize); + int stopTimesUpdated = updateStopTimesForPatternStops(patternStopsToNormalize, interpolateStopTimes); connection.commit(); return stopTimesUpdated; } catch (Exception e) { @@ -771,6 +779,36 @@ private int updateStopTimesForPatternStop(ObjectNode patternStop, int previousTr return travelTime + dwellTime; } + /** + * Updates the non-timepoint stop times between two timepoints using the speed implied by + * the travel time between them. Ignores any existing default_travel_time or default_dwell_time + * entered for the non-timepoint stops. + */ + private int interpolateTimesFromTimepoints( + PatternStop patternStop, + List timepoints, + Integer timepointNumber, + double previousShapeDistTraveled + ) { + if (timepointNumber == 0 || timepoints.size() == 1 || timepointNumber >= timepoints.size()) { + throw new IllegalStateException("Issue in pattern stops which prevents interpolation (e.g. less than 2 timepoints)"); + } + PatternStop nextTimepoint = timepoints.get(timepointNumber); + PatternStop lastTimepoint = timepoints.get(timepointNumber-1); + + if ( + nextTimepoint == null || + nextTimepoint.default_travel_time == Entity.INT_MISSING || + nextTimepoint.shape_dist_traveled == Entity.DOUBLE_MISSING || + lastTimepoint.shape_dist_traveled == Entity.DOUBLE_MISSING + ) { + throw new IllegalStateException("Error with stop time interpolation: timepoint or shape_dist_traveled is null"); + } + + double timepointSpeed = (nextTimepoint.shape_dist_traveled - lastTimepoint.shape_dist_traveled) / nextTimepoint.default_travel_time; + return (int) Math.round((patternStop.shape_dist_traveled - previousShapeDistTraveled) / timepointSpeed); + } + /** * Normalizes all stop times' arrivals and departures for an ordered set of pattern stops. This set can be the full * set of stops for a pattern or just a subset. Typical usage for this method would be to overwrite the arrival and @@ -781,8 +819,9 @@ private int updateStopTimesForPatternStop(ObjectNode patternStop, int previousTr * * TODO? add param Set serviceIdFilters service_id values to filter trips on */ - private int updateStopTimesForPatternStops(List patternStops) throws SQLException { + private int updateStopTimesForPatternStops(List patternStops, boolean interpolateStopTimes) throws SQLException { PatternStop firstPatternStop = patternStops.iterator().next(); + List timepoints = patternStops.stream().filter(ps -> ps.timepoint == 1).collect(Collectors.toList()); int firstStopSequence = firstPatternStop.stop_sequence; // Prepare SQL query to determine the time that should form the basis for adding the travel time values. int previousStopSequence = firstStopSequence > 0 ? firstStopSequence - 1 : 0; @@ -815,16 +854,40 @@ private int updateStopTimesForPatternStops(List patternStops) throw for (String tripId : timesForTripIds.keySet()) { // Initialize travel time with previous stop time value. int cumulativeTravelTime = timesForTripIds.get(tripId); + int cumulativeInterpolatedTime = cumulativeTravelTime; + int timepointNumber = 0; + double previousShapeDistTraveled = 0; // Used for calculating timepoint speed for interpolation for (PatternStop patternStop : patternStops) { + boolean isTimepoint = patternStop.timepoint == 1; + if (isTimepoint) timepointNumber++; // Gather travel/dwell time for pattern stop (being sure to check for missing values). int travelTime = patternStop.default_travel_time == Entity.INT_MISSING ? 0 : patternStop.default_travel_time; + if (interpolateStopTimes) { + if (patternStop.shape_dist_traveled == Entity.DOUBLE_MISSING) { + throw new IllegalStateException("Shape_dist_traveled must be defined for all stops in order to perform interpolation"); + } + // Override travel time if we're interpolating between timepoints. + if (!isTimepoint) travelTime = interpolateTimesFromTimepoints(patternStop, timepoints, timepointNumber, previousShapeDistTraveled); + previousShapeDistTraveled += patternStop.shape_dist_traveled; + } int dwellTime = patternStop.default_dwell_time == Entity.INT_MISSING ? 0 : patternStop.default_dwell_time; int oneBasedIndex = 1; // Increase travel time by current pattern stop's travel and dwell times (and set values for update). - cumulativeTravelTime += travelTime; - updateStopTimeStatement.setInt(oneBasedIndex++, cumulativeTravelTime); - cumulativeTravelTime += dwellTime; - updateStopTimeStatement.setInt(oneBasedIndex++, cumulativeTravelTime); + if (!isTimepoint && interpolateStopTimes) { + // We don't want to increment the true cumulative travel time because that adjusts the timepoint + // times later in the pattern. + // Dwell times are ignored right now as they do not fit the typical use case for interpolation. + // They may be incorporated by accounting for all dwell times in intermediate stops when calculating + // the timepoint speed. + cumulativeInterpolatedTime += travelTime; + updateStopTimeStatement.setInt(oneBasedIndex++, cumulativeInterpolatedTime); + updateStopTimeStatement.setInt(oneBasedIndex++, cumulativeInterpolatedTime); + } else { + cumulativeTravelTime += travelTime; + updateStopTimeStatement.setInt(oneBasedIndex++, cumulativeTravelTime); + cumulativeTravelTime += dwellTime; + updateStopTimeStatement.setInt(oneBasedIndex++, cumulativeTravelTime); + } updateStopTimeStatement.setString(oneBasedIndex++, tripId); updateStopTimeStatement.setInt(oneBasedIndex++, patternStop.stop_sequence); stopTimesTracker.addBatch(); diff --git a/src/test/java/com/conveyal/gtfs/dto/PatternStopDTO.java b/src/test/java/com/conveyal/gtfs/dto/PatternStopDTO.java index e089041d4..cba32143b 100644 --- a/src/test/java/com/conveyal/gtfs/dto/PatternStopDTO.java +++ b/src/test/java/com/conveyal/gtfs/dto/PatternStopDTO.java @@ -23,4 +23,12 @@ public PatternStopDTO (String patternId, String stopId, int stopSequence) { stop_id = stopId; stop_sequence = stopSequence; } + + public PatternStopDTO (String patternId, String stopId, int stopSequence, int timepointValue, double shape_dist_traveledValue) { + timepoint = timepointValue; + pattern_id = patternId; + stop_id = stopId; + stop_sequence = stopSequence; + shape_dist_traveled = shape_dist_traveledValue; + } } diff --git a/src/test/java/com/conveyal/gtfs/loader/JDBCTableWriterTest.java b/src/test/java/com/conveyal/gtfs/loader/JDBCTableWriterTest.java index 62f2a8d57..44dab2b04 100644 --- a/src/test/java/com/conveyal/gtfs/loader/JDBCTableWriterTest.java +++ b/src/test/java/com/conveyal/gtfs/loader/JDBCTableWriterTest.java @@ -64,9 +64,12 @@ public class JDBCTableWriterTest { private static String testGtfsGLSnapshotNamespace; private static String simpleServiceId = "1"; private static String firstStopId = "1"; + private static String secondStopId= "1.5"; private static String lastStopId = "2"; private static double firstStopLat = 34.2222; private static double firstStopLon = -87.333; + private static double secondStopLat = 34.2227; + private static double secondStopLon = -87.3335; private static double lastStopLat = 34.2233; private static double lastStopLon = -87.334; private static String sharedShapeId = "shared_shape_id"; @@ -93,6 +96,7 @@ public static void setUpClass() throws SQLException, IOException, InvalidNamespa // Create a service calendar and two stops, both of which are necessary to perform pattern and trip tests. createWeekdayCalendar(simpleServiceId, "20180103", "20180104"); createSimpleStop(firstStopId, "First Stop", firstStopLat, firstStopLon); + createSimpleStop(secondStopId, "Second Stop", secondStopLat, secondStopLon); createSimpleStop(lastStopId, "Last Stop", lastStopLat, lastStopLon); /** Load the following real-life GTFS for use with {@link JDBCTableWriterTest#canUpdateServiceId()} **/ @@ -838,29 +842,25 @@ public void canCreateUpdateAndDeleteFrequencyTripForFrequencyPattern() throws IO )); } - /** - * Checks that {@link JdbcTableWriter#normalizeStopTimesForPattern(int, int)} can normalize stop times to a pattern's - * default travel times. - */ - @Test - public void canNormalizePatternStopTimes() throws IOException, SQLException, InvalidNamespaceException { - // Store Table and Class values for use in test. + private static String normalizeStopsForPattern( + PatternStopDTO[] patternStops, + int updatedStopSequence, + boolean interpolateStopTimes, + int initialTravelTime, + int updatedTravelTime, + int startTime, + String patternId + ) throws SQLException, InvalidNamespaceException, IOException { final Table tripsTable = Table.TRIPS; - int initialTravelTime = 60; // one minute - int startTime = 6 * 60 * 60; // 6AM - String patternId = "123456"; - PatternStopDTO[] patternStops = new PatternStopDTO[]{ - new PatternStopDTO(patternId, firstStopId, 0), - new PatternStopDTO(patternId, lastStopId, 1) - }; - patternStops[1].default_travel_time = initialTravelTime; + PatternDTO pattern = createRouteAndPattern(newUUID(), - patternId, - "Pattern A", - null, - new ShapePointDTO[]{}, - patternStops, - 0); + patternId, + "Pattern A", + null, + new ShapePointDTO[]{}, + patternStops, + 0); + // Create trip with travel times that match pattern stops. TripDTO tripInput = constructTimetableTrip(pattern.pattern_id, pattern.route_id, startTime, initialTravelTime); JdbcTableWriter createTripWriter = createTestTableWriter(tripsTable); @@ -869,20 +869,79 @@ public void canNormalizePatternStopTimes() throws IOException, SQLException, Inv TripDTO createdTrip = mapper.readValue(createTripOutput, TripDTO.class); // Update pattern stop with new travel time. JdbcTableWriter patternUpdater = createTestTableWriter(Table.PATTERNS); - int updatedTravelTime = 3600; // one hour - pattern.pattern_stops[1].default_travel_time = updatedTravelTime; + pattern.pattern_stops[updatedStopSequence].default_travel_time = updatedTravelTime; String updatedPatternOutput = patternUpdater.update(pattern.id, mapper.writeValueAsString(pattern), true); LOG.info("Updated pattern output: {}", updatedPatternOutput); // Normalize stop times. JdbcTableWriter updateTripWriter = createTestTableWriter(tripsTable); - updateTripWriter.normalizeStopTimesForPattern(pattern.id, 0); + updateTripWriter.normalizeStopTimesForPattern(pattern.id, 0, interpolateStopTimes); + + return createdTrip.trip_id; + } + + /** + * Checks that {@link JdbcTableWriter#normalizeStopTimesForPattern(int, int, boolean)} can interpolate stop times between timepoints. + */ + @Test + private void canInterpolatePatternStopTimes() throws IOException, SQLException, InvalidNamespaceException { + // Parameters are shared with canNormalizePatternStopTimes, but maintained for test flexibility. + int startTime = 6 * 60 * 60; // 6AM + int initialTravelTime = 60; // seconds + int updatedTravelTime = 600; // ten minutes + String patternId = "123456-interpolated"; + double[] shapeDistTraveledValues = new double[] {0.0, 300.0, 600.0}; + double timepointTravelTime = (shapeDistTraveledValues[2] - shapeDistTraveledValues[0]) / updatedTravelTime; // 1 m/s + + // Create the array of patterns, set the timepoints properly. + PatternStopDTO[] patternStops = new PatternStopDTO[]{ + new PatternStopDTO(patternId, firstStopId, 0, 1, shapeDistTraveledValues[0]), + new PatternStopDTO(patternId, secondStopId, 1, 0, shapeDistTraveledValues[1]), + new PatternStopDTO(patternId, lastStopId, 2, 1, shapeDistTraveledValues[2]), + }; + + patternStops[2].default_travel_time = initialTravelTime; + + // Pass the array of patterns to the body method with param + String createdTripId = normalizeStopsForPattern(patternStops, 2, true, initialTravelTime, updatedTravelTime, startTime, patternId); + // Read pattern stops from database and check that the arrivals/departures have been updated. JDBCTableReader stopTimesTable = new JDBCTableReader(Table.STOP_TIMES, - testDataSource, - testNamespace + ".", - EntityPopulator.STOP_TIME); + testDataSource, + testNamespace + ".", + EntityPopulator.STOP_TIME); + int index = 0; + for (StopTime stopTime : stopTimesTable.getOrdered(createdTripId)) { + LOG.info("stop times i={} arrival={} departure={}", index, stopTime.arrival_time, stopTime.departure_time); + int calculatedArrivalTime = (int) (startTime + shapeDistTraveledValues[index] * timepointTravelTime); + assertThat(stopTime.arrival_time, equalTo(calculatedArrivalTime)); + index++; + } + } + + /** + * Checks that {@link JdbcTableWriter#normalizeStopTimesForPattern(int, int, boolean)} can normalize stop times to a pattern's + * default travel times. + */ + @Test + public void canNormalizePatternStopTimes() throws IOException, SQLException, InvalidNamespaceException { + // Parameters are shared with canNormalizePatternStopTimes, but maintained for test flexibility. + int initialTravelTime = 60; // one minute + int startTime = 6 * 60 * 60; // 6AM + int updatedTravelTime = 3600; + String patternId = "123456"; + + PatternStopDTO[] patternStops = new PatternStopDTO[]{ + new PatternStopDTO(patternId, firstStopId, 0), + new PatternStopDTO(patternId, lastStopId, 1) + }; + + String createdTripId = normalizeStopsForPattern(patternStops, 1, false, initialTravelTime, updatedTravelTime, startTime, patternId); + JDBCTableReader stopTimesTable = new JDBCTableReader(Table.STOP_TIMES, + testDataSource, + testNamespace + ".", + EntityPopulator.STOP_TIME); int index = 0; - for (StopTime stopTime : stopTimesTable.getOrdered(createdTrip.trip_id)) { + for (StopTime stopTime : stopTimesTable.getOrdered(createdTripId)) { LOG.info("stop times i={} arrival={} departure={}", index, stopTime.arrival_time, stopTime.departure_time); assertThat(stopTime.arrival_time, equalTo(startTime + index * updatedTravelTime)); index++; @@ -990,7 +1049,7 @@ private TripDTO constructFrequencyTrip(String patternId, String routeId, int sta /** * Construct (without writing to the database) a timetable trip. */ - private TripDTO constructTimetableTrip(String patternId, String routeId, int startTime, int travelTime) { + private static TripDTO constructTimetableTrip(String patternId, String routeId, int startTime, int travelTime) { TripDTO tripInput = new TripDTO(); tripInput.pattern_id = patternId; tripInput.route_id = routeId;