|
1 | 1 | package org.opendcs.usgs.waterdata; |
2 | 2 |
|
| 3 | +import java.time.Instant; |
3 | 4 | import java.util.ArrayList; |
4 | 5 | import java.util.HashMap; |
5 | 6 | import java.util.List; |
6 | 7 | import java.util.Map; |
7 | 8 | import java.util.Arrays; |
8 | 9 | import java.util.Collections; |
| 10 | +import java.util.logging.Logger; |
9 | 11 |
|
10 | 12 | /** |
11 | 13 | * Client for the USGS Water Data API ({@code api.waterdata.usgs.gov}). |
|
23 | 25 | */ |
24 | 26 | public class UsgsWaterDataApi { |
25 | 27 |
|
| 28 | + private static final Logger logger = Logger.getLogger(UsgsWaterDataApi.class.getName()); |
| 29 | + |
26 | 30 | public static final double UNDEFINED_DOUBLE = -Float.MAX_VALUE; |
27 | 31 |
|
28 | | - |
| 32 | + /** |
| 33 | + * Maximum number of responses the API returns per request |
| 34 | + */ |
| 35 | + static final int PAGE_LIMIT = 50000; |
| 36 | + |
29 | 37 | static final String ROOT_URL = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/"; |
30 | 38 | 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"; |
31 | 39 | 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 |
86 | 94 | return DailyValue.ensureContinuous(CsvFile.fromString(csv).mapRows(DailyValue::fromRow)); |
87 | 95 | } |
88 | 96 |
|
| 97 | + /** |
| 98 | + * Fetches continuous values for a date range, paging in chunks when necessary. |
| 99 | + */ |
89 | 100 | private static List<InstantaneousValue> fetchContinuousValues(String timeSeriesId, String startDate, String endDate) throws Exception { |
90 | 101 | if (TestSite.isTestSeriesId(timeSeriesId)) |
91 | 102 | 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(); |
96 | 150 | } |
97 | 151 |
|
98 | 152 | /** |
|
0 commit comments