diff --git a/.gitignore b/.gitignore index 95b5515..b2e81fc 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,6 @@ Rscripts/ # Development dev + +# Deployment +deployments.txt diff --git a/Dockerfile b/Dockerfile index 345f36e..89d5e92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,5 @@ RUN uv pip install -e . EXPOSE 80 # Start the server -CMD ["uv", "run", "streamlit", "run", "hydroshift/streamlit_app.py", "--server.port=80", "--server.address=0.0.0.0", "--server.headless=true"] +CMD uv run hydroshift/add_analytics.py && uv run streamlit run hydroshift/streamlit_app.py \ + --server.port=80 --server.address=0.0.0.0 --server.headless=true diff --git a/docker-compose.yml b/docker-compose.yml index 1787a17..e27a8ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,14 @@ services: restart: unless-stopped mem_limit: 4GB mem_reservation: 2GB - + stdin_open: true + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.client.rule=Host(`${PROD_APP_HOST}`)' + - 'traefik.http.routers.client.tls=true' + - 'traefik.http.routers.client.tls.certresolver=letsencrypt' + - 'traefik.docker.network=hydroshift_hydroshift' + - 'traefik.http.routers.static.middlewares=secHeaders@file' networks: hydroshift: driver: bridge diff --git a/hydroshift/__init__.py b/hydroshift/__init__.py index b3f4756..ae73625 100644 --- a/hydroshift/__init__.py +++ b/hydroshift/__init__.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/hydroshift/_pages/__init__.py b/hydroshift/_pages/__init__.py index 2719f4e..200b338 100644 --- a/hydroshift/_pages/__init__.py +++ b/hydroshift/_pages/__init__.py @@ -1,3 +1,4 @@ -from hydroshift._pages.changepoint import changepoint -from hydroshift._pages.homepage import homepage, reset_homepage -from hydroshift._pages.summary import summary +from hydroshift._pages.changepoint import changepoint as changepoint +from hydroshift._pages.homepage import homepage as homepage +from hydroshift._pages.homepage import reset_homepage as reset_homepage +from hydroshift._pages.summary import summary as summary diff --git a/hydroshift/_pages/changepoint.py b/hydroshift/_pages/changepoint.py index 1768ddd..f5d1bc2 100644 --- a/hydroshift/_pages/changepoint.py +++ b/hydroshift/_pages/changepoint.py @@ -72,9 +72,7 @@ def get_change_windows(self, max_dist: float = 10) -> tuple: current_group_tests = set(test_dict[dates[0]]) for i in range(1, len(dates)): - if ( - (dates[i] - current_group[-1]).days / 365 - ) < max_dist: # 10 years in days + if ((dates[i] - current_group[-1]).days / 365) < max_dist: # 10 years in days current_group.append(dates[i]) current_group_tests = current_group_tests.union(test_dict[dates[i]]) else: @@ -93,12 +91,7 @@ def get_max_pvalue(self) -> tuple[float, int]: """Get the minimum p value where all tests agree and count how often that occurred.""" all_tests = self.pval_df.fillna(1).max(axis=1).to_frame(name="pval") all_tests["run"] = ((all_tests != all_tests.shift(1)) * 1).cumsum() - for ind, r in ( - all_tests.groupby("pval") - .agg({"run": pd.Series.nunique}) - .sort_index() - .iterrows() - ): + for ind, r in all_tests.groupby("pval").agg({"run": pd.Series.nunique}).sort_index().iterrows(): if r.run > 0: min_p = ind count = r.run @@ -127,9 +120,7 @@ def ffa_png(self): @property def cp_df(self): """Get a dataframe representing changepoints identified in the streaming analysis.""" - cpa_df = pd.DataFrame.from_dict( - self.cp_dict, orient="index", columns=["Tests Identifying Change"] - ) + cpa_df = pd.DataFrame.from_dict(self.cp_dict, orient="index", columns=["Tests Identifying Change"]) cpa_df.index = cpa_df.index.date return cpa_df @@ -184,9 +175,7 @@ def results_text(self) -> str: "plural": p_count > 1, "len_cp": len(self.cp_dict), "len_cp_str": num_2_word(len(self.cp_dict)), - "test_count": num_2_word( - len(self.cp_dict[next(iter(self.cp_dict))].split(",")) - ), + "test_count": num_2_word(len(self.cp_dict[next(iter(self.cp_dict))].split(","))), "grp_count": num_2_word(len(groups)), "plural_2": len(groups) > 0, } @@ -222,9 +211,7 @@ def word_data(self) -> BytesIO: document.add_heading("Modified flood frequency analysis", level=2) self.add_markdown_to_doc(document, self.ffa_text) document.add_picture(self.ffa_png, width=Inches(6.5)) - self.add_markdown_to_doc( - document, "**Figure 2.** Modified flood frequency analysis." - ) + self.add_markdown_to_doc(document, "**Figure 2.** Modified flood frequency analysis.") self.add_markdown_to_doc(document, "**Table 2.** Modified flood quantiles.") self.add_table_from_df(self.ffa_df, document, index_name="Regime Period") @@ -235,9 +222,7 @@ def word_data(self) -> BytesIO: document.save(out) return out - def add_table_from_df( - self, df: pd.DataFrame, document: Document, index_name: str = None - ): + def add_table_from_df(self, df: pd.DataFrame, document: Document, index_name: str = None): if index_name is not None: df = df.copy() cols = df.columns @@ -269,10 +254,12 @@ def validate_data(self): # Validate if data is None: - return False, "Unable to retrieve data." + return False, "Unable to retrieve data." elif len(data) < st.session_state.burn_in: st.session_state.valid_data = False - return False, "Not enough peaks available for analysis. {} peaks found, but burn-in length was {}".format(len(data["peak_va"]), st.session_state.burn_in) + return False, "Not enough peaks available for analysis. {} peaks found, but burn-in length was {}".format( + len(data["peak_va"]), st.session_state.burn_in + ) else: return True, None @@ -336,9 +323,7 @@ def make_sidebar(): # Make data editor. Unique key allows for refreshing if "data_editor_key" not in st.session_state: refresh_data_editor() - start_config = st.column_config.DateColumn( - "Regime Start", format="D/M/YYYY" - ) + start_config = st.column_config.DateColumn("Regime Start", format="D/M/YYYY") end_config = st.column_config.DateColumn("Regime End", format="D/M/YYYY") st.data_editor( init_data, @@ -351,13 +336,12 @@ def make_sidebar(): st.divider() write_template("data_sources_side_bar.html") + def run_analysis(): """Run the change point model analysis.""" cpa = st.session_state.changepoint cpa.pval_df = get_pvalues(cpa.gage.ams) - cpa.cp_dict = get_changepoints( - cpa.gage.ams, st.session_state.arlo_slider, st.session_state.burn_in - ) + cpa.cp_dict = get_changepoints(cpa.gage.ams, st.session_state.arlo_slider, st.session_state.burn_in) @st.cache_data(max_entries=MAX_CACHE_ENTRIES) @@ -392,7 +376,7 @@ def ffa_analysis(data: pd.DataFrame, regimes: list): if "Regime Start" in r and "Regime End" in r: sub = data.loc[r["Regime Start"] : r["Regime End"]].copy() peaks = sub["peak_va"].values - label = f'{r["Regime Start"]} - {r["Regime End"]}' + label = f"{r['Regime Start']} - {r['Regime End']}" lp3 = LP3Analysis( st.session_state.gage_id, peaks, @@ -464,10 +448,7 @@ def make_body(): def warnings(): """Print warnings on data validity etc.""" - if ( - st.session_state.changepoint.gage.ams is not None - and "peak_va" in st.session_state.changepoint.gage.ams.columns - ): + if st.session_state.changepoint.gage.ams is not None and "peak_va" in st.session_state.changepoint.gage.ams.columns: if st.session_state.changepoint.gage.missing_dates_ams: st.warning( "Missing {} dates between {} and {}".format( diff --git a/hydroshift/_pages/homepage.py b/hydroshift/_pages/homepage.py index f28ee45..a77c2c8 100644 --- a/hydroshift/_pages/homepage.py +++ b/hydroshift/_pages/homepage.py @@ -1,24 +1,11 @@ import streamlit as st + from hydroshift._pages import summary from hydroshift.consts import DEFAULT_GAGE from hydroshift.utils.jinja import write_template - -def homepage(): - """Landing page for app.""" - # st.session_state["gage_id"] = None - st.set_page_config(layout="centered", initial_sidebar_state ="collapsed") - - st.markdown( - """ +PAGE_CSS = """ - """, + """ + + +def homepage(): + """Landing page for app.""" + st.set_page_config(layout="centered", initial_sidebar_state="collapsed") + + st.markdown( + PAGE_CSS, unsafe_allow_html=True, ) - # --- Centered content --- - with st.container(horizontal_alignment ="center"): + with st.container(horizontal_alignment="center"): st.title("HydroShift") - with st.container(horizontal=True, horizontal_alignment ="center"): + with st.container(horizontal=True, horizontal_alignment="center"): st.image("hydroshift/images/logo_base.png", width=400) st.subheader("USGS Streamflow Change Detection Tool") @@ -55,7 +77,7 @@ def homepage(): gage_input = st.text_input("Enter a USGS Gage Number:", placeholder="e.g., 01646500") - with st.container(horizontal=True, horizontal_alignment ="center"): + with st.container(horizontal=True, horizontal_alignment="center"): submit = st.button("Submit") demo = st.button("Use Demo Data") @@ -70,6 +92,7 @@ def homepage(): write_template("footer.html") + def reset_homepage(): st.session_state["gage_id"] = None st.rerun() diff --git a/hydroshift/_pages/summary.py b/hydroshift/_pages/summary.py index 41eae26..70fe791 100644 --- a/hydroshift/_pages/summary.py +++ b/hydroshift/_pages/summary.py @@ -1,6 +1,5 @@ -from datetime import date import datetime -import time +from datetime import date import folium import streamlit as st @@ -137,14 +136,13 @@ def section_monthly_mean(gage: Gage): "Log-Pearson III (LP3) Analysis": section_lp3, "AMS Seasonal Ranking": section_ams_seasonal, "Daily Mean Streamflow": section_daily_mean, - "Monthly Mean Streamflow": section_monthly_mean + "Monthly Mean Streamflow": section_monthly_mean, } - def summary(): """Display summary plots for various timeseries associated with this gage.""" - st.set_page_config(page_title="Gage Summary", layout="wide", initial_sidebar_state ="auto") + st.set_page_config(page_title="Gage Summary", layout="wide", initial_sidebar_state="auto") # Sidebar for input with st.sidebar: @@ -180,7 +178,10 @@ def summary(): # Display site metadata st.subheader("Site Information") write_template("site_summary.md", gage.site_data) - st.link_button("Go to USGS", f'https://waterdata.usgs.gov/monitoring-location/USGS-{st.session_state["gage_id"]}/') + st.link_button( + "Go to USGS", + f"https://waterdata.usgs.gov/monitoring-location/USGS-{st.session_state['gage_id']}/", + ) with st.spinner(): gage.raise_warnings() diff --git a/hydroshift/add_analytics.py b/hydroshift/add_analytics.py new file mode 100644 index 0000000..227b347 --- /dev/null +++ b/hydroshift/add_analytics.py @@ -0,0 +1,34 @@ +import os +from bs4 import BeautifulSoup +import pathlib +import shutil +import streamlit as st + +GA_ID = "google_analytics" +GA_TAG = os.getenv("GA_TAG", "") # pull tag from env, fallback to empty +GA_SCRIPT = f""" + + + +""" + +def inject_ga(): + index_path = pathlib.Path(st.__file__).parent / "static" / "index.html" + soup = BeautifulSoup(index_path.read_text(), features="html.parser") + if not soup.find(id=GA_ID): + bck_index = index_path.with_suffix('.bck') + if bck_index.exists(): + shutil.copy(bck_index, index_path) + else: + shutil.copy(index_path, bck_index) + html = str(soup) + new_html = html.replace('', '\n' + GA_SCRIPT) + index_path.write_text(new_html) + +inject_ga() diff --git a/hydroshift/logging.py b/hydroshift/app_logging.py similarity index 93% rename from hydroshift/logging.py rename to hydroshift/app_logging.py index ad42744..1d53665 100644 --- a/hydroshift/logging.py +++ b/hydroshift/app_logging.py @@ -1,8 +1,8 @@ # logging_config.py import logging import logging.handlers -from pathlib import Path import sys +from pathlib import Path def handle_uncaught(exc_type, exc_value, exc_traceback): @@ -18,7 +18,7 @@ def setup_logging(log_dir: str = "logs", log_level: int = logging.INFO, log_file log_path = Path(log_dir) / log_file # Establish format - log_format = ("%(asctime)s | %(levelname)-8s | %(name)s.%(funcName)s:%(lineno)d | %(message)s") + log_format = "%(asctime)s | %(levelname)-8s | %(name)s.%(funcName)s:%(lineno)d | %(message)s" formatter = logging.Formatter(log_format, "%Y-%m-%d %H:%M:%S") # Establish global settings diff --git a/hydroshift/consts.py b/hydroshift/consts.py index 751e56a..a092df6 100644 --- a/hydroshift/consts.py +++ b/hydroshift/consts.py @@ -1,4 +1,5 @@ """Shared variables.""" + # Performance MAX_CACHE_ENTRIES = 25 @@ -60,6 +61,7 @@ "C": "All or part of the record affected by Urbanization, Mining, Agricultural changes, Channelization, or other", } + ### IMAGES ### def svg2text(path: str) -> str: with open(path, "r") as f: @@ -67,6 +69,8 @@ def svg2text(path: str) -> str: if svg.startswith("", 1)[1] return svg.strip() + + GITHUB_SVG = svg2text("hydroshift/images/github_logo.svg") DEWBERRY_SVG = svg2text("hydroshift/images/dewberry_logo.svg") MAIL_SVG = svg2text("hydroshift/images/mail_logo.svg") diff --git a/hydroshift/rserver/start_r_server.py b/hydroshift/rserver/start_r_server.py index e0cd353..e7df0ab 100644 --- a/hydroshift/rserver/start_r_server.py +++ b/hydroshift/rserver/start_r_server.py @@ -7,12 +7,13 @@ import psutil import requests +import streamlit as st from hydroshift.consts import R_SERVER_PORT -import streamlit as st logger = logging.getLogger(__name__) + def server_running(port: str) -> bool: """Check if server is running.""" try: @@ -30,6 +31,7 @@ def stop_server(pid: int): logger.info("Stopping server") psutil.Process(pid).terminate() + @st.cache_resource def start_server(): """Start an R server.""" diff --git a/hydroshift/streamlit_app.py b/hydroshift/streamlit_app.py index 5e3d181..6df33de 100644 --- a/hydroshift/streamlit_app.py +++ b/hydroshift/streamlit_app.py @@ -1,13 +1,13 @@ import logging -import sys import time + import streamlit as st -from session import init_session_state from PIL import Image +from hydroshift.session import init_session_state -from hydroshift._pages import changepoint, homepage, summary, reset_homepage +from hydroshift._pages import changepoint, homepage, reset_homepage, summary +from hydroshift.app_logging import setup_logging -from hydroshift.logging import setup_logging setup_logging() logger = logging.getLogger(__name__) @@ -16,9 +16,10 @@ def navigator(): """Make sidebar for multi-page navigation.""" time.sleep(0.1) + # Define general style im = Image.open("hydroshift/images/favicon.ico") - st.set_page_config(page_title="HydroShift",page_icon=im) + st.set_page_config(page_title="HydroShift", page_icon=im) # Initialize state if "session_id" not in st.session_state: diff --git a/hydroshift/utils/data_retrieval.py b/hydroshift/utils/data_retrieval.py index 71c1607..681e13f 100644 --- a/hydroshift/utils/data_retrieval.py +++ b/hydroshift/utils/data_retrieval.py @@ -1,7 +1,4 @@ -from functools import cached_property import logging -import time -import traceback from typing import List import numpy as np @@ -12,9 +9,10 @@ from dataretrieval import NoSitesError, nwis from scipy.stats import genpareto -from hydroshift.consts import REGULATION_MAP, MAX_CACHE_ENTRIES +from hydroshift.consts import MAX_CACHE_ENTRIES, REGULATION_MAP from hydroshift.errors import GageNotFoundException from hydroshift.utils.common import group_consecutive_years + logger = logging.getLogger(__name__) @@ -37,8 +35,7 @@ def validate_id(idx: str): @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def latitude(self) -> float: """Latitude of gage.""" @@ -46,8 +43,7 @@ def latitude(self) -> float: @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def longitude(self) -> float: """Longitude of gage.""" @@ -55,8 +51,7 @@ def longitude(self) -> float: @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def elevation(self) -> float: """Elevation of gage.""" @@ -64,35 +59,28 @@ def elevation(self) -> float: @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def mean_basin_elevation(self) -> float: """Average elevation of gage watershed.""" try: - row = [ - r for r in self.streamstats["characteristics"] if r["variableTypeID"] == 6 - ] # Get ELEV param + row = [r for r in self.streamstats["characteristics"] if r["variableTypeID"] == 6] # Get ELEV param return row[0]["value"] except (KeyError, IndexError): return None @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def streamstats(self) -> pd.DataFrame: """Load AMS for this site.""" - r = requests.get( - f"https://streamstats.usgs.gov/gagestatsservices/stations/{self.gage_id}" - ) + r = requests.get(f"https://streamstats.usgs.gov/gagestatsservices/stations/{self.gage_id}") return r.json() @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def ams(self) -> pd.DataFrame: """Load AMS for this site.""" @@ -110,16 +98,14 @@ def missing_dates_ams(self) -> list: @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def flow_stats(self) -> pd.DataFrame: """Load flow statistics for this site.""" return get_flow_stats(self.gage_id) @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def get_daily_values(self, start_date: str, end_date: str) -> pd.DataFrame: """Load daily mean discharge for this site.""" @@ -131,8 +117,7 @@ def missing_dates_daily_values(self, start_date: str, end_date: str) -> list: @property @st.cache_data( - hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, - max_entries=MAX_CACHE_ENTRIES + hash_funcs={"hydroshift.utils.data_retrieval.Gage": lambda x: hash(x.gage_id)}, max_entries=MAX_CACHE_ENTRIES ) def monthly_values(self) -> pd.DataFrame: """Load monthly mean discharge for this site.""" @@ -161,22 +146,16 @@ def get_regulation_summary(self, major_codes=["3", "9"]) -> List[str]: code_key = code_str if code_key in REGULATION_MAP: - regulation_years.setdefault(code_key, set()).add( - row["water_year"] - ) + regulation_years.setdefault(code_key, set()).add(row["water_year"]) results = {"major": [], "minor": []} for code, years in regulation_years.items(): grouped_year_ranges = group_consecutive_years(sorted(years)) formatted_ranges = ", ".join(grouped_year_ranges) if code in major_codes: - results["major"].append( - f"{REGULATION_MAP[code]} for water years {formatted_ranges}" - ) + results["major"].append(f"{REGULATION_MAP[code]} for water years {formatted_ranges}") else: - results["minor"].append( - f"{REGULATION_MAP[code]} for water years {formatted_ranges}" - ) + results["minor"].append(f"{REGULATION_MAP[code]} for water years {formatted_ranges}") return results @@ -218,9 +197,7 @@ def regional_skew(self) -> float: if val == 9999: # California Eq. from USGS SIR 2010-5260 NL-ELEV eq if self.mean_basin_elevation is None: return None - val = (0 - 0.62) + 1.3 * ( - 1 - np.exp(0 - ((self.mean_basin_elevation) / 6500) ** 2) - ) + val = (0 - 0.62) + 1.3 * (1 - np.exp(0 - ((self.mean_basin_elevation) / 6500) ** 2)) return val @property @@ -267,7 +244,6 @@ def available_plots(self) -> list[str]: return plots - @st.cache_data(max_entries=MAX_CACHE_ENTRIES) def get_ams(gage_id): """Fetches Annual Maximum Series (AMS) peak flow data for a given gage.""" @@ -319,11 +295,12 @@ def load_site_data(gage_number: str) -> dict: "alt_datum_cd": resp["alt_datum_cd"].iloc[0], } + @st.cache_data(max_entries=MAX_CACHE_ENTRIES) def get_site_catalog(gage_number: str) -> dict: """Query NWIS for site information""" try: - df = nwis.what_sites(sites=gage_number, seriesCatalogOutput='true', ssl_check=True)[0] + df = nwis.what_sites(sites=gage_number, seriesCatalogOutput="true", ssl_check=True)[0] except Exception as e: logger.error("Error querying site: %s", e, exc_info=True) return df @@ -333,7 +310,7 @@ def get_site_catalog(gage_number: str) -> dict: def get_daily_values(gage_id, start_date, end_date): """Fetches mean daily flow values for a given gage.""" try: - dv = nwis.get_dv(gage_id, start_date, end_date, ssl_check=True, parameterCd ="00060")[0] + dv = nwis.get_dv(gage_id, start_date, end_date, ssl_check=True, parameterCd="00060")[0] except Exception: logger.debug(f"Daily Values could not be found for gage_id: {gage_id}") return None @@ -345,7 +322,7 @@ def get_daily_values(gage_id, start_date, end_date): def get_monthly_values(gage_id): """Fetches mean monthly flow values for a given gage and assigns a datetime column based on the year and month.""" try: - mv = nwis.get_stats(gage_id, statReportType="monthly", ssl_check=True, parameterCd = "00060")[0] + mv = nwis.get_stats(gage_id, statReportType="monthly", ssl_check=True, parameterCd="00060")[0] except Exception: logger.debug(f"Monthly Values could not be found for gage_id: {gage_id}") return None @@ -379,9 +356,7 @@ def check_missing_dates(df, freq): elif freq == "monthly": df["date"] = pd.to_datetime(df["date"]) - full_range = pd.date_range( - start=df["date"].min(), end=df["date"].max(), freq="MS" - ) + full_range = pd.date_range(start=df["date"].min(), end=df["date"].max(), freq="MS") elif freq == "water_year": if not isinstance(df.index, pd.DatetimeIndex): @@ -389,9 +364,7 @@ def check_missing_dates(df, freq): df["water_year"] = df.index.year.where(df.index.month < 10, df.index.year + 1) - full_water_years = set( - range(df["water_year"].min(), df["water_year"].max() + 1) - ) + full_water_years = set(range(df["water_year"].min(), df["water_year"].max() + 1)) existing_water_years = set(df["water_year"]) missing_years = sorted(full_water_years - existing_water_years) @@ -423,15 +396,11 @@ def fake_ams() -> pd.DataFrame: rvs = np.concatenate(rvs, axis=0) dates = pd.date_range(start="1900-01-01", periods=len(rvs), freq="YE") water_year = dates.year - df = pd.DataFrame( - {"datetime": dates, "peak_va": rvs, "water_year": water_year} - ).set_index("datetime") + df = pd.DataFrame({"datetime": dates, "peak_va": rvs, "water_year": water_year}).set_index("datetime") return df @st.cache_resource def get_skew_raster(): """Load the skew raster into memory.""" - return rasterio.open( - __file__.replace("utils/data_retrieval.py", "data/skewmap_4326.tif") - ) + return rasterio.open(__file__.replace("utils/data_retrieval.py", "data/skewmap_4326.tif")) diff --git a/hydroshift/utils/ffa.py b/hydroshift/utils/ffa.py index 381b568..3b69d4c 100644 --- a/hydroshift/utils/ffa.py +++ b/hydroshift/utils/ffa.py @@ -18,9 +18,7 @@ class LP3Analysis: use_map_skew: bool = False est_method: str = "MLE" label: str = "" - return_periods: List[str] = field( - default_factory=lambda: [1.1, 2, 5, 10, 25, 50, 100, 500] - ) + return_periods: List[str] = field(default_factory=lambda: [1.1, 2, 5, 10, 25, 50, 100, 500]) # TODO: Add california equation. Likely best to subclass this. diff --git a/hydroshift/utils/plots.py b/hydroshift/utils/plots.py index 401ca31..07ff532 100644 --- a/hydroshift/utils/plots.py +++ b/hydroshift/utils/plots.py @@ -89,7 +89,7 @@ def plot_flow_stats(stats_df, gage_id): """Plot Flow Statistics using Plotly with month-abbreviation x-axis labels.""" # Ensure data is sorted # Create a datetime column - p_cols = ['p05_va', 'p10_va', 'p20_va', 'p25_va', 'p50_va', 'p75_va', 'p80_va', 'p90_va', 'p95_va'] + p_cols = ["p05_va", "p10_va", "p20_va", "p25_va", "p50_va", "p75_va", "p80_va", "p90_va", "p95_va"] stats_df[p_cols] = stats_df[p_cols].ffill() stats_df[p_cols] = stats_df[p_cols].fillna(0) stats_df["date"] = pd.to_datetime( @@ -229,9 +229,7 @@ def plot_lp3(data: LP3Analysis | list[LP3Analysis]): ) # Formatting - return_periods = [ - int(i) if i.is_integer() else round(i, 1) for i in i.return_periods - ] + return_periods = [int(i) if i.is_integer() else round(i, 1) for i in i.return_periods] if i.use_map_skew: skew_txt = "" else: diff --git a/pyproject.toml b/pyproject.toml index 5208099..340ef1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,3 +27,14 @@ dependencies = [ [tool.setuptools.packages.find] include = ["hydroshift*"] namespaces = false + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["F401", "I001"] # unused imports + import sorting + +[dependency-groups] +dev = [ + "ruff>=0.13.1", +] diff --git a/uv.lock b/uv.lock index ef7d559..5896b86 100644 --- a/uv.lock +++ b/uv.lock @@ -625,6 +625,11 @@ dependencies = [ { name = "streamlit-folium" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "dataretrieval", specifier = "==1.0.11" }, @@ -646,6 +651,9 @@ requires-dist = [ { name = "streamlit-folium", specifier = "==0.24.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.13.1" }] + [[package]] name = "idna" version = "3.10" @@ -1923,6 +1931,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, ] +[[package]] +name = "ruff" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987, upload-time = "2025-09-18T19:52:44.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308, upload-time = "2025-09-18T19:51:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258, upload-time = "2025-09-18T19:52:00.184Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554, upload-time = "2025-09-18T19:52:02.753Z" }, + { url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181, upload-time = "2025-09-18T19:52:05.279Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599, upload-time = "2025-09-18T19:52:07.497Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178, upload-time = "2025-09-18T19:52:10.189Z" }, + { url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474, upload-time = "2025-09-18T19:52:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531, upload-time = "2025-09-18T19:52:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267, upload-time = "2025-09-18T19:52:17.649Z" }, + { url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120, upload-time = "2025-09-18T19:52:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084, upload-time = "2025-09-18T19:52:23.032Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105, upload-time = "2025-09-18T19:52:25.263Z" }, + { url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284, upload-time = "2025-09-18T19:52:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314, upload-time = "2025-09-18T19:52:30.212Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360, upload-time = "2025-09-18T19:52:32.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448, upload-time = "2025-09-18T19:52:35.545Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458, upload-time = "2025-09-18T19:52:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" }, +] + [[package]] name = "scipy" version = "1.15.2"