Skip to content

Commit adfbab0

Browse files
authored
Feat: 5-min data visualization UI (#201)
2 parents 456a985 + 108056c commit adfbab0

File tree

11 files changed

+204
-86
lines changed

11 files changed

+204
-86
lines changed

pems_data/src/pems_data/services/stations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def get_imputed_agg_5min(self, station_id: str) -> pd.DataFrame:
8080
value (pandas.DataFrame): The station's data as a DataFrame.
8181
"""
8282

83-
cache_opts = {"key": self._build_cache_key("imputed", "agg", "5m", "station", station_id), "ttl": 300} # 5 minutes
83+
cache_opts = {"key": self._build_cache_key("imputed", "agg", "5m", "station", station_id), "ttl": 3600} # 1 hour
8484
columns = [
8585
"STATION_ID",
8686
"LANE",

pems_streamlit/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
# local package reference
99
# a wheel for this package is built during Docker build
1010
"pems_data",
11+
"plotly==6.2.0",
1112
"streamlit==1.45.1",
1213
]
1314

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import re
2+
3+
import pandas as pd
4+
import streamlit as st
5+
6+
from pems_data import ServiceFactory
7+
8+
from pems_streamlit.components.map_station_summary import map_station_summary
9+
from pems_streamlit.components.plot_5_min_traffic_data import plot_5_min_traffic_data
10+
11+
FACTORY = ServiceFactory()
12+
STATIONS = FACTORY.stations_service()
13+
S3 = FACTORY.s3_source
14+
15+
16+
@st.cache_data(ttl=3600) # Cache for 1 hour
17+
def load_station_metadata(district_number: str) -> pd.DataFrame:
18+
"""Loads metadata for all stations in the selected District from S3."""
19+
return STATIONS.get_district_metadata(district_number)
20+
21+
22+
@st.cache_data(ttl=3600) # Cache for 1 hour
23+
def get_available_days() -> set:
24+
"""
25+
Lists available days by inspecting S3 prefixes.
26+
"""
27+
# Find "day=", then capture one or more digits that immediately follow it
28+
pattern = re.compile(r"day=(\d+)")
29+
30+
# add as int only the text captured by the first set of parentheses to the set
31+
def match(m: re.Match):
32+
return int(m.group(1))
33+
34+
return S3.get_prefixes(pattern, initial_prefix=STATIONS.imputation_detector_agg_5min, match_func=match)
35+
36+
37+
@st.cache_data(ttl=3600) # Cache for 1 hour
38+
def load_station_data(station_id: str) -> pd.DataFrame:
39+
"""
40+
Loads station data for a specific station.
41+
"""
42+
return STATIONS.get_imputed_agg_5min(station_id)
43+
44+
45+
# --- STREAMLIT APP ---
46+
47+
48+
def main():
49+
query_params = st.query_params
50+
district_number = query_params.get("district_number", "")
51+
52+
df_station_metadata = load_station_metadata(district_number)
53+
54+
map_placeholder = st.empty()
55+
56+
station = st.selectbox(
57+
"Station",
58+
df_station_metadata["STATION_ID"],
59+
)
60+
61+
quantity = st.multiselect("Quantity", ["VOLUME_SUM", "OCCUPANCY_AVG", "SPEED_FIVE_MINS"])
62+
63+
num_lanes = int(df_station_metadata[df_station_metadata["STATION_ID"] == station]["PHYSICAL_LANES"].iloc[0])
64+
lane = st.multiselect(
65+
"Lane",
66+
list(range(1, num_lanes + 1)),
67+
)
68+
69+
with map_placeholder:
70+
df_selected_station = df_station_metadata.query("STATION_ID == @station")
71+
map_station_summary(df_selected_station)
72+
73+
days = st.multiselect("Days", get_available_days())
74+
75+
station_data_button = st.button("Load Station Data", type="primary")
76+
77+
error_placeholder = st.empty()
78+
plot_placeholder = st.empty()
79+
80+
if station_data_button:
81+
error_messages = []
82+
if len(quantity) == 0 or len(quantity) > 2:
83+
error_messages.append("- Please select one or two quantities to proceed.")
84+
if not lane:
85+
error_messages.append("- Please select at least one lane to proceed.")
86+
if not days:
87+
error_messages.append("- Please select at least one day to proceed.")
88+
if error_messages:
89+
full_error_message = "\n".join(error_messages)
90+
error_placeholder.error(full_error_message)
91+
else:
92+
df_station_data = load_station_data(station)
93+
filtered_df = df_station_data[
94+
(df_station_data["SAMPLE_TIMESTAMP"].dt.day.isin(days)) & (df_station_data["LANE"].isin(lane))
95+
]
96+
filtered_df_sorted = filtered_df.sort_values(by="SAMPLE_TIMESTAMP")
97+
98+
fig = plot_5_min_traffic_data(filtered_df_sorted, quantity, lane)
99+
plot_placeholder.plotly_chart(fig, use_container_width=True)
100+
101+
102+
if __name__ == "__main__":
103+
main()

pems_streamlit/src/pems_streamlit/apps/stations/app_stations.py

Lines changed: 0 additions & 67 deletions
This file was deleted.

pems_streamlit/src/pems_streamlit/components/__init__.py

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pandas as pd
2+
import streamlit as st
3+
4+
5+
def map_station_summary(df_station_metadata: pd.DataFrame):
6+
7+
map_col, info_col = st.columns([0.6, 0.4])
8+
9+
with map_col:
10+
map_df = df_station_metadata.rename(columns={"LATITUDE": "latitude", "LONGITUDE": "longitude"})
11+
map_df_cleaned = map_df.dropna(subset=["latitude", "longitude"])
12+
st.map(map_df_cleaned[["latitude", "longitude"]], height=265)
13+
14+
with info_col:
15+
with st.container(border=True):
16+
st.markdown(f"**Station {df_station_metadata['STATION_ID'].item()} - {df_station_metadata['NAME'].item()}**")
17+
st.markdown(
18+
f"{df_station_metadata["FREEWAY"].item()} - {df_station_metadata["DIRECTION"].item()}, {df_station_metadata["CITY_NAME"].item()}"
19+
)
20+
st.markdown(f"**County** {df_station_metadata["COUNTY_NAME"].item()}")
21+
st.markdown(f"**District** {df_station_metadata["DISTRICT"].item()}")
22+
st.markdown(f"**Absolute Post Mile** {df_station_metadata["ABSOLUTE_POSTMILE"].item()}")
23+
st.markdown(f"**Lanes** {df_station_metadata["PHYSICAL_LANES"].item()}")
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import pandas as pd
2+
import plotly.graph_objs as go
3+
4+
QUANTITY_CONFIG = {
5+
"VOLUME_SUM": {"name": "Volume (veh/hr)"},
6+
"OCCUPANCY_AVG": {"name": "Occupancy (%)"},
7+
"SPEED_FIVE_MINS": {"name": "Speed (mph)"},
8+
}
9+
10+
11+
def plot_5_min_traffic_data(df_station_data: pd.DataFrame, quantities: list, lanes: list):
12+
fig = go.Figure()
13+
14+
layout_updates = {
15+
"xaxis": dict(title="Time of Day"),
16+
"legend": dict(orientation="h", yanchor="top", y=-0.3, xanchor="center", x=0.5),
17+
}
18+
19+
# One quantity selected
20+
if len(quantities) == 1:
21+
qty_key = quantities[0]
22+
qty_name = QUANTITY_CONFIG[qty_key]["name"]
23+
24+
for lane in lanes:
25+
df_lane = df_station_data[df_station_data["LANE"] == lane]
26+
fig.add_trace(
27+
go.Scatter(
28+
x=df_lane["SAMPLE_TIMESTAMP"],
29+
y=df_lane[qty_key],
30+
mode="lines",
31+
name=f"Lane {lane} {qty_name.split(' ')[0]}",
32+
)
33+
)
34+
35+
layout_updates["title"] = dict(text=f"<b>{qty_name}</b>", x=0.5, xanchor="center")
36+
layout_updates["yaxis"] = dict(title=f"<b>{qty_name}</b>", side="left")
37+
38+
# Two quantities selected
39+
elif len(quantities) == 2:
40+
left_qty_key, right_qty_key = quantities[0], quantities[1]
41+
left_qty_name = QUANTITY_CONFIG[left_qty_key]["name"]
42+
right_qty_name = QUANTITY_CONFIG[right_qty_key]["name"]
43+
44+
for lane in lanes:
45+
df_lane = df_station_data[df_station_data["LANE"] == lane]
46+
fig.add_trace(
47+
go.Scatter(
48+
x=df_lane["SAMPLE_TIMESTAMP"],
49+
y=df_lane[left_qty_key],
50+
mode="lines",
51+
name=f"Lane {lane} {left_qty_name.split(' ')[0]}",
52+
)
53+
)
54+
fig.add_trace(
55+
go.Scatter(
56+
x=df_lane["SAMPLE_TIMESTAMP"],
57+
y=df_lane[right_qty_key],
58+
mode="lines",
59+
name=f"Lane {lane} {right_qty_name.split(' ')[0]}",
60+
yaxis="y2",
61+
)
62+
)
63+
64+
# Create layout for two axes
65+
layout_updates["title"] = dict(text=f"<b>{left_qty_name} vs. {right_qty_name}</b>", x=0.5, xanchor="center")
66+
layout_updates["yaxis"] = dict(title=f"<b>{left_qty_name}</b>", side="left")
67+
layout_updates["yaxis2"] = dict(title=f"<b>{right_qty_name}</b>", side="right", overlaying="y")
68+
69+
fig.update_layout(**layout_updates)
70+
71+
return fig

pems_web/src/pems_web/districts/templates/districts/district.html

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,11 @@
55
{% endblock headline %}
66

77
{% block districts-content %}
8-
<div class="row">
9-
<div class="col-lg-4 border">
10-
<h2>Form</h2>
11-
</div>
12-
</div>
138
<div class="row" style="min-height: 450px;">
14-
<div class="col-lg-12 border">
9+
<div class="col-lg-12">
1510
<iframe title="District {{ current_district.number }} visualization"
1611
class="w-100 h-100"
17-
src="{{ streamlit.url }}/stations--stations?embed=true&district_number={{ current_district.number }}">
12+
src="{{ streamlit.url }}/districts--stations?embed=true&district_number={{ current_district.number }}">
1813
</iframe>
1914
</div>
2015
</div>

pems_web/src/pems_web/districts/templates/districts/index.html

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,9 @@
1414
</div>
1515
<div class="col-lg-10 pb-lg-5">
1616
{% block districts-content %}
17-
<div class="row">
18-
<div class="col-lg-4 border">
19-
<h2>Form</h2>
20-
</div>
21-
<div class="col-lg-8 border">
22-
<h2>Chart</h2>
23-
</div>
24-
</div>
2517
<div class="row" style="min-height: 450px;">
26-
<div class="col-lg-12 border">
27-
<iframe title="District visualizations" class="w-100 h-100" src="{{ streamlit.url }}/stations--stations?embed=true">
18+
<div class="col-lg-12">
19+
<iframe title="District visualizations" class="w-100 h-100" src="{{ streamlit.url }}/districts--stations?embed=true">
2820
</iframe>
2921
</div>
3022
</div>

0 commit comments

Comments
 (0)