Skip to content
Open
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
37 changes: 35 additions & 2 deletions src/visuals.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@
berkeley_gold = "#FDB515"
rose_medium = "#E7115E"

# Load profiles carry a placeholder year: the simulated profiles are typical-year
# EnergyPlus output stamped 2001, so the year says nothing about the results and is
# easily confused with the emission scenario year. Date labels therefore show month,
# day and - where the aggregation is finer than a day - time of day, but never the
# year. The stops keep the year off the axis at every zoom level.
DATE_TICKFORMATSTOPS = (
dict(dtickrange=[None, 3600000], value="%b %d, %H:%M"), # up to hourly ticks
dict(dtickrange=[3600000, 86400000], value="%b %d, %H:%M"), # hourly to daily
dict(dtickrange=[86400000, "M1"], value="%b %d"), # daily to monthly
dict(dtickrange=["M1", None], value="%b"), # monthly and coarser
)

# Hover date format per resampling frequency (see plot_meter_timeseries).
_HOVER_DATE_FORMATS = {
"H": "%b %d, %H:%M",
"D": "%b %d",
"W": "%b %d",
"M": "%B",
"MS": "%B",
"ME": "%B",
}


def hover_date_format(freq):
"""Return a year-free hover date format matching the aggregation frequency."""
return _HOVER_DATE_FORMATS.get(str(freq).upper(), "%b %d")


def apply_standard_layout(fig, y_offset=-0.4, subtitle_text=None):
# Keep existing annotations (like subplot titles)
Expand Down Expand Up @@ -533,6 +560,9 @@ def plot_meter_timeseries(
# Rename columns to user-friendly display names
df_resampled = df_resampled.rename(columns=format_meter_name)

# Date format for hover, matched to the aggregation (no year - see comment above)
date_format = hover_date_format(freq)

if not stacked:
# Melt for line chart
df_melt = df_resampled.reset_index().melt(
Expand Down Expand Up @@ -561,7 +591,7 @@ def plot_meter_timeseries(
meter_name = tr.name # px sets this
tr.meta = meter_name # so we can use %{meta}
tr.hovertemplate = (
"Time: %{x|%Y-%m-%d %H:%M}<br>"
f"Time: %{{x|{date_format}}}<br>"
"Meter: %{meta}<br>"
f"{usage_label}: " + f"%{{y:,.2f}} {hover_unit}"
"<extra></extra>"
Expand Down Expand Up @@ -607,12 +637,15 @@ def plot_meter_timeseries(
meter_name = tr.name
tr.meta = meter_name
tr.hovertemplate = (
"Time: %{x|%Y-%m-%d %H:%M}<br>"
f"Time: %{{x|{date_format}}}<br>"
"Meter: %{meta}<br>"
f"{usage_label}: " + f"%{{y:,.2f}} {hover_unit}"
"<extra></extra>"
)

# Keep the placeholder year off the date axis, including when the user zooms in
fig.update_xaxes(tickformatstops=DATE_TICKFORMATSTOPS)

return fig


Expand Down
111 changes: 111 additions & 0 deletions tests/test_visuals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Tests for result visualizations."""

from itertools import pairwise

import pandas as pd
import pytest

import utils.plotly_theme # noqa: F401 - imports for side effect (sets default Plotly theme)
from src.visuals import DATE_TICKFORMATSTOPS, hover_date_format, plot_meter_timeseries


def source_energy_df():
"""Hourly source energy results, stamped with the simulated (E+) year."""
index = pd.date_range("2001-01-01 01:00", periods=8760, freq="h", name="timestamp")
return pd.DataFrame(
{
"eq_scen_id": "eq_scen_1",
"em_scen_id": "em_scen_1",
"elec_awhp_h_Wh": 100000.0,
"elec_chiller_Wh": 50000.0,
"gas_boiler_Wh": 25000.0,
},
index=index,
)


def timeseries_figure(**kwargs):
"""Build the meter timeseries figure the results page renders."""
return plot_meter_timeseries(source_energy_df(), "eq_scen_1", "em_scen_1", **kwargs)


class TestTimeseriesDateFormat:
"""The plotted year is a placeholder and must not reach the user."""

def test_axis_tick_labels_omit_year(self):
"""Date ticks carry a format for every zoom level, none showing the year."""
for stacked in (False, True):
stops = timeseries_figure(freq="D", stacked=stacked).layout.xaxis.tickformatstops
assert stops, "date axis falls back to Plotly's default ticks, which show the year"
for stop in stops:
assert "%Y" not in stop.value
assert "%y" not in stop.value

def test_axis_tick_stops_cover_all_zoom_levels(self):
"""Zoom ranges are contiguous, so no zoom level falls back to the default."""
stops = timeseries_figure(freq="D").layout.xaxis.tickformatstops

assert stops[0].dtickrange[0] is None, "no format below the finest tick spacing"
assert stops[-1].dtickrange[1] is None, "no format above the coarsest tick spacing"
for previous, current in pairwise(stops):
assert previous.dtickrange[1] == current.dtickrange[0]

def test_hover_omits_year(self):
"""Hover labels show month and day, not the placeholder year."""
for stacked in (False, True):
fig = timeseries_figure(freq="D", stacked=stacked)
assert fig.data, "expected at least one meter trace"
for trace in fig.data:
assert "%Y" not in trace.hovertemplate
assert "%y" not in trace.hovertemplate
assert "Time: %{x|%b %d}" in trace.hovertemplate

def test_hover_keeps_time_of_day_for_hourly_aggregation(self):
"""Hourly aggregation still needs the hour to be readable."""
for trace in timeseries_figure(freq="h").data:
assert "Time: %{x|%b %d, %H:%M}" in trace.hovertemplate
assert "%Y" not in trace.hovertemplate

def test_hover_drops_day_for_monthly_aggregation(self):
"""A month-end bin is labelled by its month."""
for trace in timeseries_figure(freq="ME").data:
assert "Time: %{x|%B}" in trace.hovertemplate

def test_hover_date_format_per_frequency(self):
"""Every aggregation the user can pick maps to a year-free format."""
assert hover_date_format("h") == "%b %d, %H:%M"
assert hover_date_format("D") == "%b %d"
assert hover_date_format("W") == "%b %d"
assert hover_date_format("ME") == "%B"
assert hover_date_format("unknown") == "%b %d"

def test_axis_uses_shared_tick_formats(self):
"""The axis uses the module-level stops rather than a local copy."""
stops = timeseries_figure(freq="D").layout.xaxis.tickformatstops

assert [stop.value for stop in stops] == [s["value"] for s in DATE_TICKFORMATSTOPS]


class TestTimeseriesUnchangedBehaviour:
"""Formatting the dates must not move the plotted data."""

def test_daily_totals_are_unchanged(self):
"""Daily sums still equal the raw hourly energy, in the auto-scaled unit."""
fig = timeseries_figure(freq="D")

chiller = next(trace for trace in fig.data if "Chiller" in trace.name)
# first day starts at 01:00, so 23 hours; Wh scaled to MWh
assert chiller.y[0] == pytest.approx(50000.0 * 23 / 1e6)
assert chiller.y[1] == pytest.approx(50000.0 * 24 / 1e6)

def test_x_values_keep_full_timestamps(self):
"""Only the labels lose the year - the underlying data is untouched."""
fig = timeseries_figure(freq="D")

assert pd.Timestamp(fig.data[0].x[0]) == pd.Timestamp("2001-01-01")

def test_gas_can_still_be_excluded(self):
"""The gas toggle behaves as before."""
fig = timeseries_figure(freq="D", include_gas=False)

assert all("Gas" not in trace.name for trace in fig.data)