From 6fa3cf573c4f76a50e9231945cc6bf85a8a86d51 Mon Sep 17 00:00:00 2001 From: Tavis Date: Wed, 8 Jul 2026 12:18:23 -0700 Subject: [PATCH 1/2] Update USGS Water Levels app with new API and richer river options. Migrate to the modern USGS Water Data API, add custom display names, expand river parameters, and filter the parameter picker by water body type. Co-authored-by: Cursor --- apps/usgswaterlevels/usgs_water_levels.star | 286 ++++++++++---------- apps/usgswaterlevels/usgs_water_levels.webp | Bin 15658 -> 15864 bytes 2 files changed, 142 insertions(+), 144 deletions(-) diff --git a/apps/usgswaterlevels/usgs_water_levels.star b/apps/usgswaterlevels/usgs_water_levels.star index 1dc285125..a06c77cd7 100644 --- a/apps/usgswaterlevels/usgs_water_levels.star +++ b/apps/usgswaterlevels/usgs_water_levels.star @@ -13,22 +13,22 @@ load("time.star", "time") # Default USGS site ID (Lake Champlain at Burlington, VT) DEFAULT_SITE_ID = "04294500" CACHE_TTL_SECONDS = 3600 # 1 hour +USGS_API_BASE = "https://api.waterdata.usgs.gov/ogcapi/v0/collections" def main(config): - site_id = config.get("site_id", DEFAULT_SITE_ID) + site_id = normalize_site_id(config.get("site_id", DEFAULT_SITE_ID)) water_body_type = config.get("water_body_type", "0") # 0 for lakes, 1 for rivers param_code = config.get("param_code", "auto") # auto or specific code + custom_display_name = config.get("display_name", "") - # Get cached data or fetch new data + # Get fresh data, falling back to cache only when the fetch fails cache_key = "usgs_data_" + site_id + "_" + water_body_type + "_" + param_code cached_data = cache.get(cache_key) - - if cached_data != None: + data = fetch_usgs_data(site_id, water_body_type, param_code) + if data: + cache.set(cache_key, json.encode(data), ttl_seconds = CACHE_TTL_SECONDS) + elif cached_data != None: data = json.decode(cached_data) - else: - data = fetch_usgs_data(site_id, water_body_type, param_code) - if data: - cache.set(cache_key, json.encode(data), ttl_seconds = CACHE_TTL_SECONDS) if not data: return render.Root( @@ -41,6 +41,8 @@ def main(config): ), ) + site_name = custom_display_name if custom_display_name else data["site_name"] + # Create the display - 3 box layout return render.Root( child = render.Row( @@ -57,8 +59,8 @@ def main(config): children = [ render.Marquee( width = 54, - child = render.Text(data["site_name"], color = "#4A90E2", font = "tom-thumb"), - ) if len(data["site_name"]) > 12 else render.Text(data["site_name"], color = "#888", font = "tom-thumb"), + child = render.Text(site_name, color = "#4A90E2", font = "tom-thumb"), + ) if len(site_name) > 12 else render.Text(site_name, color = "#888", font = "tom-thumb"), render.Text(data["param_description"], color = "#ff4", font = "tom-thumb"), ], ), @@ -109,133 +111,94 @@ def main(config): ), ) -def fetch_usgs_data(site_id, water_body_type, param_code): - # Get 30 days of data for high/low calculation, plus current data - end_date = time.now() - start_date_30d = time.now() - time.parse_duration("720h") # 30 days - start_date_current = time.now() - time.parse_duration("24h") # Current data +def normalize_site_id(site_id): + site_id = site_id.strip() + if site_id.startswith("USGS-"): + site_id = site_id[5:] + return site_id + +def monitoring_location_id(site_id): + return "USGS-" + normalize_site_id(site_id) + +def fetch_site_name(location_id): + url = USGS_API_BASE + "/monitoring-locations/items/" + location_id + resp = http.get(url, ttl_seconds = CACHE_TTL_SECONDS) + if resp.status_code != 200: + print("Error fetching site metadata: " + str(resp.status_code)) + return None - start_str_30d = start_date_30d.format("2006-01-02") - start_str_current = start_date_current.format("2006-01-02") - end_str = end_date.format("2006-01-02") + props = resp.json().get("properties", {}) + return props.get("monitoring_location_name") - # Select parameter codes based on settings +def get_param_codes(water_body_type, param_code): if param_code == "auto": - # Auto-select based on water body type - prioritize working codes for lakes if water_body_type == "0": - # Lake/reservoir - try working codes first for Lake Champlain - param_codes = ["62614", "00010", "00095", "62610", "62611", "00062", "00065"] # Temp, conductance, then elevation codes - else: - # River/stream gage height - param_codes = ["00065", "00010", "00095"] # Gage height, then temp, conductance - else: - # Use the specific parameter code selected by user - param_codes = [param_code] + return ["62614", "00062", "62610", "62611", "00065", "00010", "00095"] + return ["00065", "00060", "72255", "00010", "00095", "00048", "62614", "00062", "62610", "62611"] + return [param_code] - # Try each parameter code until we find data - for param_code_to_try in param_codes: - print("Trying parameter code: " + param_code_to_try) +def fetch_usgs_data(site_id, water_body_type, param_code): + site_id = normalize_site_id(site_id) + if site_id == "": + print("Empty site ID") + return None - # USGS Instantaneous Values Web Service URL - url = "https://waterservices.usgs.gov/nwis/iv/" - - # First get 30 days of data for high/low calculation - param_parts_30d = [] - param_parts_30d.append("format=json") - param_parts_30d.append("sites=" + site_id) - param_parts_30d.append("startDT=" + start_str_30d) - param_parts_30d.append("endDT=" + end_str) - param_parts_30d.append("parameterCd=" + param_code_to_try) - param_parts_30d.append("siteStatus=all") - param_string_30d = "&".join(param_parts_30d) - full_url_30d = url + "?" + param_string_30d - - print("Fetching 30-day USGS data from: " + full_url_30d) - - resp_30d = http.get(full_url_30d, ttl_seconds = CACHE_TTL_SECONDS) - if resp_30d.status_code != 200: - print("Error fetching 30-day USGS data: " + str(resp_30d.status_code)) - continue + location_id = monitoring_location_id(site_id) + site_name = fetch_site_name(location_id) + param_codes = get_param_codes(water_body_type, param_code) - usgs_data_30d = resp_30d.json() + end_date = time.now() + start_date_30d = time.now() - time.parse_duration("720h") # 30 days + start_str = start_date_30d.format("2006-01-02") + "T00:00:00Z" + end_str = end_date.format("2006-01-02") + "T23:59:59Z" - if not usgs_data_30d.get("value") or not usgs_data_30d["value"].get("timeSeries"): - print("No 30-day time series data found for parameter " + param_code_to_try) - continue + for param_code_to_try in param_codes: + print("Trying parameter code: " + param_code_to_try) - time_series_30d = usgs_data_30d["value"]["timeSeries"][0] - values_30d = time_series_30d["values"][0]["value"] + param_parts = [] + param_parts.append("monitoring_location_id=" + location_id) + param_parts.append("parameter_code=" + param_code_to_try) + param_parts.append("datetime=" + start_str + "/" + end_str) + param_parts.append("limit=10000") + full_url = USGS_API_BASE + "/continuous/items?" + "&".join(param_parts) - if not values_30d: - print("No 30-day values found in time series for parameter " + param_code_to_try) - continue + print("Fetching USGS continuous data from: " + full_url) - # Now get current data - param_parts_current = [] - param_parts_current.append("format=json") - param_parts_current.append("sites=" + site_id) - param_parts_current.append("startDT=" + start_str_current) - param_parts_current.append("endDT=" + end_str) - param_parts_current.append("parameterCd=" + param_code_to_try) - param_parts_current.append("siteStatus=all") - param_string_current = "&".join(param_parts_current) - full_url_current = url + "?" + param_string_current - - print("Fetching current USGS data from: " + full_url_current) - - resp_current = http.get(full_url_current, ttl_seconds = CACHE_TTL_SECONDS) - if resp_current.status_code != 200: - print("Error fetching current USGS data: " + str(resp_current.status_code)) + resp = http.get(full_url, ttl_seconds = CACHE_TTL_SECONDS) + if resp.status_code != 200: + print("Error fetching continuous data: " + str(resp.status_code)) continue - usgs_data_current = resp_current.json() - - if not usgs_data_current.get("value") or not usgs_data_current["value"].get("timeSeries"): - print("No current time series data found for parameter " + param_code_to_try) + features = resp.json().get("features", []) + if not features: + print("No continuous data found for parameter " + param_code_to_try) continue - time_series_current = usgs_data_current["value"]["timeSeries"][0] - site_info = time_series_current["sourceInfo"] - values_current = time_series_current["values"][0]["value"] + processed_values = [] + for feature in features: + val_str = feature.get("properties", {}).get("value") + if val_str != None and val_str != "": + processed_values.append(float(val_str)) - if not values_current: - print("No current values found in time series for parameter " + param_code_to_try) + if not processed_values: + print("No valid values found for parameter " + param_code_to_try) continue print("Found data with parameter code: " + param_code_to_try) - # Process the 30-day values for high/low - processed_values_30d = [] - for value in values_30d: - val_str = value.get("value", "") - if val_str and val_str != "": - processed_values_30d.append(float(val_str)) - - # Process current values - processed_values_current = [] - for value in values_current[-10:]: # Last 10 readings - val_str = value.get("value", "") - if val_str and val_str != "": - processed_values_current.append(float(val_str)) - - if not processed_values_current: - print("No valid current values found for parameter " + param_code_to_try) - continue + high_30d = max(processed_values) + low_30d = min(processed_values) + current_value = processed_values[len(processed_values) - 1] - # Calculate 30-day high and low - high_30d = None - low_30d = None - if processed_values_30d: - high_30d = max(processed_values_30d) - low_30d = min(processed_values_30d) + recent_values = processed_values + if len(processed_values) > 10: + recent_values = processed_values[len(processed_values) - 10:] - # Calculate trend from current data trend = "Unknown" - if len(processed_values_current) >= 6: - recent_vals = processed_values_current[-3:] - older_vals = processed_values_current[:3] + if len(recent_values) >= 6: + recent_vals = recent_values[len(recent_values) - 3:] + older_vals = recent_values[:3] - # Calculate averages manually since sum() isn't available recent_total = 0 for val in recent_vals: recent_total = recent_total + val @@ -253,30 +216,22 @@ def fetch_usgs_data(site_id, water_body_type, param_code): else: trend = "Stable" - # Get parameter description for display - param_description = get_param_description(param_code_to_try) - - # Convert temperature from Celsius to Fahrenheit if it's temperature data - current_value = processed_values_current[-1] if processed_values_current else 0 - if param_code_to_try == "00010": # Water temperature - current_value = (current_value * 9 / 5) + 32 # Convert C to F - if high_30d != None: - high_30d = (high_30d * 9 / 5) + 32 - if low_30d != None: - low_30d = (low_30d * 9 / 5) + 32 + if param_code_to_try == "00010": + current_value = (current_value * 9 / 5) + 32 + high_30d = (high_30d * 9 / 5) + 32 + low_30d = (low_30d * 9 / 5) + 32 return { - "site_name": site_info["siteName"], - "values": processed_values_current, + "site_name": site_name if site_name else site_id, + "values": recent_values, "current_value": current_value, "high_30d": high_30d, "low_30d": low_30d, "trend": trend, "units": get_param_units(param_code_to_try), - "param_description": param_description, + "param_description": get_param_description(param_code_to_try), } - # If we get here, no parameter codes worked print("No data found for any parameter codes") return None @@ -287,6 +242,9 @@ def get_param_description(param_code): "00010": "Water Temp", "00095": "Conductance", "00065": "Gage Height", + "00060": "Flow Rate", + "72255": "Velocity", + "00048": "Gas Saturation", "00062": "Lake Level", "62610": "Lake Level", "62611": "Lake Level", @@ -299,6 +257,9 @@ def get_param_units(param_code): "00010": "°F", # Temperature in Fahrenheit "00095": "μS/cm", "00065": "ft", # Gage height in feet + "00060": "cfs", # Discharge in cubic feet per second + "72255": "ft/s", # Mean water velocity + "00048": "%", # Dissolved gas saturation "00062": "ft", # Lake elevation in feet "62610": "ft", # Lake elevation NGVD 1929 in feet "62611": "ft", # Lake elevation NAVD 1988 in feet @@ -312,6 +273,9 @@ def get_param_icon(param_description): "Water Temp": "🌡️", "Conductance": "⚡", "Gage Height": "📏", + "Flow Rate": "💨", + "Velocity": "🏃", + "Gas Saturation": "🫧", "Lake Level": "🌊", "Daily Level": "📊", } @@ -460,6 +424,45 @@ def get_trend_color(trend): else: return "#FFD93D" # Yellow +def get_lake_param_options(): + return [ + schema.Option(display = "Auto (tries multiple)", value = "auto"), + schema.Option(display = "Daily Lake Level", value = "62614"), + schema.Option(display = "Lake/Reservoir Elevation", value = "00062"), + schema.Option(display = "Lake Elevation - NGVD 1929", value = "62610"), + schema.Option(display = "Lake Elevation - NAVD 1988", value = "62611"), + schema.Option(display = "Water Temperature", value = "00010"), + schema.Option(display = "Specific Conductance", value = "00095"), + ] + +def get_river_param_options(): + return [ + schema.Option(display = "Auto (tries multiple)", value = "auto"), + schema.Option(display = "Gage Height", value = "00065"), + schema.Option(display = "Discharge (Flow)", value = "00060"), + schema.Option(display = "Water Velocity", value = "72255"), + schema.Option(display = "Dissolved Gas Saturation", value = "00048"), + schema.Option(display = "Water Temperature", value = "00010"), + schema.Option(display = "Specific Conductance", value = "00095"), + ] + +def param_options_for_water_body(water_body_type): + if water_body_type == "1": + options = get_river_param_options() + else: + options = get_lake_param_options() + + return [ + schema.Dropdown( + id = "param_code", + name = "Data Parameter", + desc = "Choose what type of data to display", + icon = "ruler", + default = "auto", + options = options, + ), + ] + def get_schema(): # Configuration schema for the app return schema.Schema( @@ -468,10 +471,17 @@ def get_schema(): schema.Text( id = "site_id", name = "USGS Site ID", - desc = "Enter the USGS site ID for your local water body", + desc = "Enter the USGS site ID (e.g. 04294500 or USGS-04294500)", icon = "water", default = DEFAULT_SITE_ID, ), + schema.Text( + id = "display_name", + name = "Custom Display Name", + desc = "Leave blank to use USGS defined name", + icon = "user", + default = "", + ), schema.Dropdown( id = "water_body_type", name = "Water Body Type", @@ -483,22 +493,10 @@ def get_schema(): schema.Option(display = "River/Stream", value = "1"), ], ), - schema.Dropdown( + schema.Generated( id = "param_code", - name = "Data Parameter", - desc = "Choose what type of data to display", - icon = "ruler", - default = "00010", - options = [ - schema.Option(display = "Auto (tries multiple)", value = "auto"), - schema.Option(display = "Daily Lake Level", value = "62614"), - schema.Option(display = "Water Temperature", value = "00010"), - schema.Option(display = "Specific Conductance", value = "00095"), - schema.Option(display = "Lake Elevation - NGVD 1929", value = "62610"), - schema.Option(display = "Lake Elevation - NAVD 1988", value = "62611"), - schema.Option(display = "Lake/Reservoir Elevation", value = "00062"), - schema.Option(display = "River/Stream Gage Height", value = "00065"), - ], + source = "water_body_type", + handler = param_options_for_water_body, ), ], ) diff --git a/apps/usgswaterlevels/usgs_water_levels.webp b/apps/usgswaterlevels/usgs_water_levels.webp index 2ef1d4b26422ddf1ed79b6f616d439235981ec58..c2c3b200d6559fe4b366e2c94f6106a3e0b1f763 100644 GIT binary patch literal 15864 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 YcquMHY|bT$r~FHcF3#s{QORci4+p;U$p8QV literal 15658 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 Date: Wed, 8 Jul 2026 19:19:38 +0000 Subject: [PATCH 2/2] chore: auto-update manifest metadata [skip ci] --- apps/usgswaterlevels/manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/usgswaterlevels/manifest.yaml b/apps/usgswaterlevels/manifest.yaml index 5fb2a7c47..5e68f19c7 100644 --- a/apps/usgswaterlevels/manifest.yaml +++ b/apps/usgswaterlevels/manifest.yaml @@ -12,4 +12,4 @@ tags: - tech - tracking published: '2025-09-03T12:59:36Z' -updated: '2025-12-17T23:02:59Z' +updated: '2026-07-08T19:18:23Z'