Skip to content

Commit eba168d

Browse files
committed
Chunk large 15-minute time-series queries
1 parent b119254 commit eba168d

2 files changed

Lines changed: 95 additions & 5 deletions

File tree

src/main/java/org/opendcs/usgs/waterdata/UsgsWaterDataApi.java

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package org.opendcs.usgs.waterdata;
22

3+
import java.time.Instant;
34
import java.util.ArrayList;
45
import java.util.HashMap;
56
import java.util.List;
67
import java.util.Map;
78
import java.util.Arrays;
89
import java.util.Collections;
10+
import java.util.logging.Logger;
911

1012
/**
1113
* Client for the USGS Water Data API ({@code api.waterdata.usgs.gov}).
@@ -23,9 +25,15 @@
2325
*/
2426
public class UsgsWaterDataApi {
2527

28+
private static final Logger logger = Logger.getLogger(UsgsWaterDataApi.class.getName());
29+
2630
public static final double UNDEFINED_DOUBLE = -Float.MAX_VALUE;
2731

28-
32+
/**
33+
* Maximum number of responses the API returns per request
34+
*/
35+
static final int PAGE_LIMIT = 50000;
36+
2937
static final String ROOT_URL = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/";
3038
static final String LOCATIONS_URL = ROOT_URL + "monitoring-locations/items?f=csv&lang=en-US&limit=50000&offset=0&agency_code=USGS&state_code=%s&site_type_code=%s";
3139
static final String TIME_SERIES_QUERY_ID = "items?f=csv&lang=en-US&limit=50000&properties=time,value&skipGeometry=true&sortby=time&offset=0&time_series_id=%s&time=%s/%s";
@@ -86,13 +94,59 @@ private static List<DailyValue> fetchDailyValues(String timeSeriesId, String sta
8694
return DailyValue.ensureContinuous(CsvFile.fromString(csv).mapRows(DailyValue::fromRow));
8795
}
8896

97+
/**
98+
* Fetches continuous values for a date range, paging in chunks when necessary.
99+
*/
89100
private static List<InstantaneousValue> fetchContinuousValues(String timeSeriesId, String startDate, String endDate) throws Exception {
90101
if (TestSite.isTestSeriesId(timeSeriesId))
91102
return TestSite.generateContinuousValues(startDate, endDate);
92-
String url = String.format(CONTINUOUS_URL_ID, timeSeriesId, startDate, endDate);
93-
String csv = WebUtility.getPage(url);
94-
if (csv == null || csv.isBlank()) return Collections.emptyList();
95-
return CsvFile.fromString(csv).mapRows(InstantaneousValue::fromRow);
103+
104+
List<InstantaneousValue> all = new ArrayList<>();
105+
String chunkStart = normalizeToInstant(startDate);
106+
String normalizedEnd = normalizeToInstant(endDate);
107+
Instant lastSeen = null;
108+
boolean continuation = false;
109+
while (true) {
110+
String url = String.format(CONTINUOUS_URL_ID, timeSeriesId, chunkStart, normalizedEnd);
111+
String csv = WebUtility.getPage(url);
112+
if (csv == null || csv.isBlank()) break;
113+
114+
List<InstantaneousValue> page = CsvFile.fromString(csv).mapRows(InstantaneousValue::fromRow);
115+
if (page.isEmpty()) break;
116+
117+
for (int i = 0; i < page.size(); i++) {
118+
InstantaneousValue iv = page.get(i);
119+
// A continuation query is inclusive of its start, so its first point repeats
120+
// the previous chunk's last point. Drop that one expected boundary duplicate.
121+
if (continuation && i == 0 && iv.time.equals(lastSeen)) continue;
122+
// Any other repeated time-stamp would break a database save. Keep the first
123+
// value for that time, drop the rest, and warn so it can be investigated.
124+
if (lastSeen != null && !iv.time.isAfter(lastSeen)) {
125+
logger.warning("Dropping duplicate time-stamp " + iv.time
126+
+ " in continuous series " + timeSeriesId);
127+
continue;
128+
}
129+
all.add(iv);
130+
lastSeen = iv.time;
131+
}
132+
133+
if (page.size() < PAGE_LIMIT) break;
134+
if (lastSeen == null || chunkStart.equals(lastSeen.toString())) break;
135+
chunkStart = lastSeen.toString();
136+
continuation = true;
137+
logger.info("Continuous query returned a full page; fetching next chunk from " + chunkStart);
138+
}
139+
return all.isEmpty() ? Collections.emptyList() : all;
140+
}
141+
142+
/**
143+
* Normalizes a date or date-time string to a full RFC 3339 instant
144+
* example: 2026-06-01T00:00:00Z
145+
*/
146+
static String normalizeToInstant(String dateOrDateTime) {
147+
// A date-only value ("2026-06-01") has no time component; make it midnight UTC.
148+
String instant = dateOrDateTime.contains("T") ? dateOrDateTime : dateOrDateTime + "T00:00:00Z";
149+
return Instant.parse(instant).toString();
96150
}
97151

98152
/**

src/test/java/org/opendcs/usgs/waterdata/UsgsWaterDataApiTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,42 @@ void getContinuousTimeSeriesDuplicateStatistic() throws Exception {
221221

222222
}
223223

224+
/**
225+
* Two years of continuous data at USGS-05529000 spans multiple pages, exercising
226+
* chunked queries; guards against duplicate time-stamps at the chunk boundaries.
227+
*
228+
* ./gradlew integrationTest --tests "org.opendcs.usgs.waterdata.UsgsWaterDataApiTest.getContinuousTimeSeries_twoYears_noDuplicateTimes" -PusgsDebug=true
229+
*/
230+
@Test
231+
@Tag("integration")
232+
void getContinuousTimeSeries_twoYears_noDuplicateTimes() throws Exception {
233+
String location_id = "USGS-05529000";
234+
String t1 = "2024-06-01T00:00:00Z";
235+
String t2 = "2026-06-01T00:00:00Z";
236+
237+
// All parameters, instantaneous statistic -> expect gage height + flow.
238+
List<TimeSeriesMetadata> series = TimeSeriesMetadata.filter(UsgsWaterDataApi.getTimeSeriesMetadata(location_id))
239+
.statisticId(Statistic.INSTANTANEOUS)
240+
.hasDateRange()
241+
.toList();
242+
243+
series.forEach(ts -> logger.info("Metadata: " + ts.parameterCode + " " + ts.parameterName
244+
+ " [" + ts.unitOfMeasure + "] " + ts.begin + " to " + ts.end));
245+
assertEquals(2, series.size(), "Expected exactly two instantaneous time-series (gage height and flow)");
246+
247+
for (TimeSeriesMetadata ts : series) {
248+
// getContinuousTimeSeries pages this 2-year range in chunks and drops any
249+
// duplicate time-stamp, so the returned series should be larger than one page.
250+
TimeSeries<InstantaneousValue> continuous = UsgsWaterDataApi.getContinuousTimeSeries(ts, t1, t2);
251+
logger.info(ts.parameterName + ": " + continuous.size() + " values from "
252+
+ continuous.get(0).time + " to " + continuous.get(continuous.size() - 1).time);
253+
// More than one page's worth of points proves the query was chunked.
254+
assertTrue(continuous.size() > 50000,
255+
"Expected more than 50,000 points (multiple chunks) for " + ts.parameterName
256+
+ " but got " + continuous.size());
257+
}
258+
}
259+
224260
/**
225261
* Tests retrieving annual peak streamflow from the legacy NWIS RDB service.
226262
*

0 commit comments

Comments
 (0)