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)