From c8717d0c36f956d5f63dd317e5e1473b16d91c74 Mon Sep 17 00:00:00 2001
From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com>
Date: Tue, 11 Aug 2026 11:18:22 +0000
Subject: [PATCH] fix: omit placeholder year from timeseries date labels
Load profiles are typical-year data, so the year on the meter timeseries axis
and hover (2001 for the simulated library profiles) carries no meaning and is
easily read as the emission scenario year. Format date labels as month/day, with
time of day when the aggregation is hourly, and keep the year off the axis at
every zoom level via tickformatstops.
---
src/visuals.py | 37 +++++++++++++-
tests/test_visuals.py | 111 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 146 insertions(+), 2 deletions(-)
create mode 100644 tests/test_visuals.py
diff --git a/src/visuals.py b/src/visuals.py
index 66f23b9..ddde8b4 100644
--- a/src/visuals.py
+++ b/src/visuals.py
@@ -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)
@@ -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(
@@ -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}
"
+ f"Time: %{{x|{date_format}}}
"
"Meter: %{meta}
"
f"{usage_label}: " + f"%{{y:,.2f}} {hover_unit}"
""
@@ -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}
"
+ f"Time: %{{x|{date_format}}}
"
"Meter: %{meta}
"
f"{usage_label}: " + f"%{{y:,.2f}} {hover_unit}"
""
)
+ # Keep the placeholder year off the date axis, including when the user zooms in
+ fig.update_xaxes(tickformatstops=DATE_TICKFORMATSTOPS)
+
return fig
diff --git a/tests/test_visuals.py b/tests/test_visuals.py
new file mode 100644
index 0000000..ed1f59f
--- /dev/null
+++ b/tests/test_visuals.py
@@ -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)