Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,6 @@ Rscripts/

# Development
dev

# Deployment
deployments.txt
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion hydroshift/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "0.1.3"
7 changes: 4 additions & 3 deletions hydroshift/_pages/__init__.py
Original file line number Diff line number Diff line change
@@ -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
49 changes: 15 additions & 34 deletions hydroshift/_pages/changepoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
63 changes: 43 additions & 20 deletions hydroshift/_pages/homepage.py
Original file line number Diff line number Diff line change
@@ -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 = """
<style>
.stApp {
background: linear-gradient(
#f5f5f5 0%,
#f5f5f5 45%,
#b3c7e8 100%
);
}
.stAppDeployButton {display:none;}
.stAppHeader {display:none;}
.block-container {
Expand All @@ -38,24 +25,59 @@ def homepage():
box-shadow: 0px 4px 12px rgba(0,0,0,0.25);
color: black;
}
.stApp {
background: linear-gradient(
#f5f5f5 0%,
#f5f5f5 45%,
#b3c7e8 100%
) !important;
}

@media (prefers-color-scheme: dark) {
.stApp {
background: linear-gradient(
#05051c 0%,
#05051c 45%,
#17428a 100%
) !important;
}
a {
color: #d4d4d4 !important;
text-decoration: none;
}
a:hover {
color: #808080 !important;
text-decoration: underline;
}
p {
color: #d4d4d4 !important;
}
}
</style>
""",
"""


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")
write_template("app_description.md")

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")

Expand All @@ -70,6 +92,7 @@ def homepage():

write_template("footer.html")


def reset_homepage():
st.session_state["gage_id"] = None
st.rerun()
13 changes: 7 additions & 6 deletions hydroshift/_pages/summary.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from datetime import date
import datetime
import time
from datetime import date

import folium
import streamlit as st
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions hydroshift/add_analytics.py
Original file line number Diff line number Diff line change
@@ -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"""
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={GA_TAG}"></script>
<script id="{GA_ID}">
window.dataLayer = window.dataLayer || [];
function gtag(){{dataLayer.push(arguments);}}
gtag('js', new Date());

gtag('config', '{GA_TAG}');
</script>
"""

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('<head>', '<head>\n' + GA_SCRIPT)
index_path.write_text(new_html)

inject_ga()
4 changes: 2 additions & 2 deletions hydroshift/logging.py → hydroshift/app_logging.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions hydroshift/consts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Shared variables."""

# Performance
MAX_CACHE_ENTRIES = 25

Expand Down Expand Up @@ -60,13 +61,16 @@
"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:
svg = f.read()
if svg.startswith("<?xml"):
svg = svg.split("?>", 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")
Loading