Skip to content

Commit 6c6dc23

Browse files
authored
Add plotly HTML viewer generation script (follow-up to PR#133) - new feature (#171)
This was merged into main by CPUC. PR #171 adds an optional Plotly-based HTML viewer (scripts/data transformation/ls_viewer_generation.py) for reviewing CEDARS unitized hourly load-shape outputs, and updates scripts/requirements.txt and scripts/Readme.md with installation and usage instructions.
1 parent 3664aa0 commit 6c6dc23

3 files changed

Lines changed: 273 additions & 3 deletions

File tree

scripts/Readme.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ Apply the following data transformation steps for each subfolder under your meas
4646
2. The current Com.py normalizing unit handling process is not fully robust. In particular, it currently does not support workflows where more than one unique NormUnit is present within a single starting workbook (DEER_EnergyPlus_Modelkit_Measure_list_working_*.xlsx). Measure developers can also choose the replace the numunit values as an external step if the current script functionality doesn't cover edge cases, for example if a normalizing unit varies based on climate-zone.
4747
3. Support for different types of normalizing units (vary by CZ, etc.) may require further enhancements to the normalization logic and feedbacks from measure developers.
4848

49-
### CEDARS formatted hourly Load shapes:
50-
1. Note new outputs "CEDARS_LoadShape_XXX.zip" from the scripts, these will be the new CEDARS formatted loadshapes containing the hourly consumption data for CEDARS use. CEDARS accepts them in zip format. In the script, there is a commented-out line (note the comment "#enable if just need csv export") to allow for user to export the table as a CSV on it's own, if the user wishes to do so, uncomment that line and run the script.
51-
49+
### CEDARS-ready, unitized hourly load shapes contained in zip files:
50+
1. Note new outputs "CEDARS_LoadShape_XXX.zip" from the scripts, these will be the new unitized and CEDARS-formatted loadshapes containing the hourly consumption data for CEDARS use. CEDARS accepts them in zip format.
51+
2. Also, in the script, the csv files output files "CEDARS_long_ls_\*.csv" and "CEDARS_ls_annual_loads_\*.csv" are enabled by default (note the comment "#enable if html viewer is needed / csv export is needed"), as they are the requirements for generating the plotly viewer, described in the section below. If these are not needed, comment-out those two lines (put # before line) and run the script.
52+
53+
### Plotly Viewer html files showing hourly load shapes by building type:
54+
1. Install the plotly library in your python environment. (i.e., pip install plotly, conda install plotly, depending on your environment)
55+
2. Place the csv file(s) "CEDARS_long_ls_\*.csv" and "CEDARS_ls_annual_loads_\*.csv" (both must be present) in the same directory as the script.
56+
3. Run the script: python ls_viewer_generation.py
57+
4. Interactive Plotly .html files will be generated for each available building type. Generating plots for many load shapes may take several minutes.
58+
59+
#### Each HTML file includes:
60+
* All hourly load shape profiles (8760 hours)
61+
* Interactive legend filtering (click / double-click)
62+
* Time zoom controls (1d / 1w / 1m + range slider)
63+
* Unified hover showing all traces at a given timestamp
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#%%
2+
import pandas as pd
3+
import numpy as np
4+
import plotly.graph_objects as go
5+
# %%
6+
#Function to create interactive Plotly figure of hourly loadshapes for a given building type,
7+
#with one line per unique Descriptor (combination of BldgType, BldgVint, BldgHVAC, BldgLoc, TechID)
8+
def plot_hourly_loadshapes(df, bldgtype, y_title="Hourly Energy Consumption, kWh", output_html: str | None = None):
9+
# -----------------------------
10+
# 1) Build figure (one trace per Descriptor)
11+
# -----------------------------
12+
fig = go.Figure()
13+
14+
for desc, g in df.groupby("Descriptor", sort=False):
15+
g = g.sort_values("timestamp")
16+
fig.add_trace(go.Scatter(
17+
x=g["timestamp"],
18+
y=g["hourly_consumption"],
19+
mode="lines",
20+
name=str(desc),
21+
line=dict(width=1),
22+
hovertemplate=(
23+
"<b>%{customdata}</b><br>"
24+
"Time: %{x|%Y-%m-%d %H:%M}<br>"
25+
"Hrly Consumption: %{y:.8f}<br>"
26+
"<extra></extra>"
27+
),
28+
customdata=[desc] * len(g) #hover label setting
29+
30+
))
31+
32+
# -----------------------------
33+
# 2) Layout: range selector, rangeslider, legend on the right
34+
# -----------------------------
35+
fig.update_layout(
36+
title=f"{bldgtype} Hourly Whole-building Load Shapes",
37+
template="simple_white",
38+
39+
xaxis=dict(
40+
title="Date-Time",
41+
type="date",
42+
rangeselector=dict(
43+
x=1.0, # right edge
44+
y=1.0, # top edge
45+
xanchor="right",
46+
yanchor="top",
47+
48+
buttons=[
49+
dict(count=1, step="day", stepmode="backward", label="1d"),
50+
dict(count=7, step="day", stepmode="backward", label="1w"),
51+
dict(count=1, step="month", stepmode="backward", label="1m"),
52+
dict(step="all", label="All"),
53+
]
54+
),
55+
rangeslider=dict(visible=True)
56+
),
57+
yaxis=dict(title=y_title),
58+
59+
# Legend on the right (vertical list)
60+
legend=dict(
61+
orientation="v",
62+
x=1.02, # push legend to the right side
63+
xanchor="left",
64+
y=1.0,
65+
yanchor="top",
66+
bgcolor="rgba(255,255,255,0.6)" # subtle background for readability
67+
),
68+
69+
# Leave decent right margin so the legend doesn't overlap the plot
70+
#margin=dict(l=60, r=220, t=60, b=60),
71+
)
72+
73+
# Compact legend outside on the right, maximize canvas
74+
fig.update_layout(
75+
showlegend=True,
76+
legend=dict(
77+
orientation="v",
78+
yanchor="top", y=0.78,
79+
xanchor="left", x=1.02, # outside on the right
80+
font=dict(size=9), # compact font
81+
itemsizing="constant", # consistent row height
82+
bgcolor="rgba(255,255,255,0.8)", # readable over any background
83+
bordercolor="#ccc",
84+
borderwidth=1,
85+
itemwidth=80, # (optional) constrain label width for tighter wrapping
86+
),
87+
)
88+
89+
90+
fig.update_layout(
91+
hovermode="x unified",
92+
hoverlabel=dict(
93+
bgcolor="rgba(255,255,255,0.9)",
94+
font_size=11
95+
)
96+
) #show all hover labels
97+
98+
99+
instructions = (
100+
"<b>Tips</b><br>"
101+
"1. Click inside the legend to select/unselect load shapes.<br>"
102+
"2. Double-click a legend item to isolate one load shape.<br>"
103+
"3. To zoom in/out quickly, use gray buttons to the immediate<br> left or select range with mouse (left-click + drag horizontally).<br>"
104+
"4. To adjust the range of the zoomed-in data shown,<br> use the lower Date-Time chart."
105+
)
106+
107+
right_gutter = 380
108+
fig.update_layout(margin=dict(l=10, r=right_gutter, t=60, b=40))
109+
110+
fig.add_annotation(
111+
x=1.02, y=1.0, # position near upper-right (paper coords)
112+
xref="paper", yref="paper",
113+
xanchor="left", yanchor="top",
114+
align="left",
115+
text=instructions,
116+
showarrow=False,
117+
font=dict(size=10, color="#111"),
118+
bgcolor="rgba(255,255,255,0.90)",
119+
bordercolor="#ccc",
120+
borderwidth=1,
121+
borderpad=8
122+
)
123+
124+
if output_html:
125+
fig.write_html(output_html, include_plotlyjs="cdn")
126+
127+
128+
return fig
129+
130+
131+
#Function to prep data for plotting:
132+
133+
#create hourly consumption table
134+
def calc_hourly_consumption(annual_csv, unitized_csv):
135+
#read data
136+
df_annual = pd.read_csv(annual_csv)
137+
df_unitized = pd.read_csv(unitized_csv)
138+
139+
#create lookup dict key for annual values
140+
id_cols = ['BldgType', 'BldgVint', 'BldgHVAC', 'BldgLoc', 'TechID']
141+
annual_lookup = df_annual.set_index(id_cols)['annual_sum'].to_dict()
142+
143+
#map annual data into loadshape table
144+
keys = list(map(tuple, df_unitized[id_cols].to_numpy()))
145+
df_unitized['annual_sum'] = pd.Series(keys).map(annual_lookup).to_numpy()
146+
147+
#apply annual sum
148+
#making sure total sum back to annual sum by adjusting with actual sum of UECproportion
149+
group_sum = df_unitized.groupby(id_cols)['UECproportion'].transform('sum')
150+
scale = df_unitized['annual_sum'] / group_sum.replace(0, np.nan)
151+
df_unitized['hourly_consumption'] = (df_unitized['UECproportion'] * scale).astype('float32')
152+
153+
return df_unitized
154+
155+
156+
#Creates a timestamp column from Source Year and Hour of Year, and a Descriptor column for grouping
157+
def cedars_data_loader_pre_plot(input_df):
158+
df = input_df
159+
df['Descriptor'] = df['BldgType'] + '|' + df['BldgVint'] + '|' + df['BldgHVAC'] + '|' + df['BldgLoc'] + '|' + df['TechID']
160+
161+
# Ensure numeric
162+
df["Source Year"] = pd.to_numeric(df["Source Year"], errors="coerce")
163+
df["Hour of Year"] = pd.to_numeric(df["Hour of Year"], errors="coerce")
164+
165+
# Use the row's own Source Year if it varies; otherwise this still works.
166+
# Timestamp = Jan 1 of that year + (hour-1) hours
167+
base = pd.to_datetime(df["Source Year"].astype("Int64").astype(str) + "-01-01", errors="coerce")
168+
df["timestamp"] = base + pd.to_timedelta(df["Hour of Year"] - 1, unit="h")
169+
170+
# --- sort by time just to be safe ---
171+
df = df.sort_values(['BldgType', 'BldgVint', 'BldgHVAC', 'BldgLoc','TechID', 'timestamp'])
172+
173+
return df
174+
175+
#%%
176+
#indicate input CSV file names (CEDARS processed format)
177+
unitized_dmo = 'CEDARS_long_ls_DMo.csv'
178+
unitized_mfm = 'CEDARS_long_ls_MFm.csv'
179+
unitized_sfm = 'CEDARS_long_ls_SFm.csv'
180+
unitized_com = 'CEDARS_long_ls_Com.csv'
181+
182+
annual_dmo = 'CEDARS_ls_annual_loads_DMo.csv'
183+
annual_mfm = 'CEDARS_ls_annual_loads_MFm.csv'
184+
annual_sfm = 'CEDARS_ls_annual_loads_SFm.csv'
185+
annual_com = 'CEDARS_ls_annual_loads_Com.csv'
186+
187+
188+
#%%
189+
#produce hourly consumption col
190+
191+
input_dmo = None
192+
input_mfm = None
193+
input_sfm = None
194+
input_com = None
195+
196+
try:
197+
input_dmo = calc_hourly_consumption(annual_dmo, unitized_dmo)
198+
print("DMo data loaded.")
199+
except FileNotFoundError:
200+
print("DMo files not found, skipping DMo.")
201+
202+
try:
203+
input_mfm = calc_hourly_consumption(annual_mfm, unitized_mfm)
204+
print("MFm data loaded.")
205+
except FileNotFoundError:
206+
print("MFm files not found, skipping MFm.")
207+
208+
try:
209+
input_sfm = calc_hourly_consumption(annual_sfm, unitized_sfm)
210+
print("SFm data loaded.")
211+
except FileNotFoundError:
212+
print("SFm files not found, skipping SFm.")
213+
214+
try:
215+
input_com = calc_hourly_consumption(annual_com, unitized_com)
216+
print("Com data loaded.")
217+
except FileNotFoundError:
218+
print("Com files not found, skipping Com.")
219+
220+
#%%
221+
#Residential plots
222+
#stacking 3 dfs on top of each other, if they exists
223+
res_parts = [x for x in [input_dmo, input_mfm, input_sfm] if x is not None]
224+
if len(res_parts) == 0:
225+
print("No Res data, please provide both 'CEDARS_long_ls_*.csv' and 'CEDARS_ls_annual_loads_*.csv' in the same folder.")
226+
else:
227+
input_res = pd.concat(res_parts, ignore_index=True)
228+
df_res = cedars_data_loader_pre_plot(input_res)
229+
#loop thru residential building types and export separate html for each
230+
for bldgtype in df_res['BldgType'].unique():
231+
print(f"Creating Plotly html for {bldgtype}...")
232+
df_bldg = df_res[df_res['BldgType'] == bldgtype]
233+
techgroup = df_bldg['TechGroup'].unique()[0]
234+
techtype = df_bldg['TechType'].unique()[0]
235+
output_html = f"{bldgtype}_WB_{techgroup}_{techtype}_Load_Shapes.html"
236+
plot_hourly_loadshapes(df_bldg, bldgtype=bldgtype, y_title="Hourly Energy Consumption, kWh", output_html=output_html)
237+
print("plot created.")
238+
239+
240+
# %%
241+
#Commercial plots
242+
243+
if input_com is None:
244+
print("No Com data, please provide both 'CEDARS_long_ls_Com.csv' and 'CEDARS_ls_annual_loads_Com.csv' in the same folder. Com skipped.")
245+
else:
246+
df_com = cedars_data_loader_pre_plot(input_com)
247+
#loop thru commercial building types and export separate html for each
248+
for bldgtype in df_com['BldgType'].unique():
249+
print(f"Creating Plotly html for {bldgtype}...")
250+
df_bldg = df_com[df_com['BldgType'] == bldgtype]
251+
techgroup = df_bldg['TechGroup'].unique()[0]
252+
techtype = df_bldg['TechType'].unique()[0]
253+
output_html = f"{bldgtype}_WB_{techgroup}_{techtype}_Load_Shapes.html"
254+
plot_hourly_loadshapes(df_bldg, bldgtype=bldgtype, y_title="Hourly Energy Consumption, kWh", output_html=output_html)
255+
print("plot created.")
256+
257+
#%%

scripts/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ xlsxwriter
66
lxml
77
matplotlib
88
tqdm
9+
plotly

0 commit comments

Comments
 (0)