Skip to content

Commit 2abbf21

Browse files
authored
Merge pull request #171 from sadielbartholomew/plotting-improvements
Improve plot output (hatching, unit formatting, mean lines, etc.)
2 parents 4ac54aa + 6d3d6bf commit 2abbf21

File tree

1 file changed

+209
-23
lines changed

1 file changed

+209
-23
lines changed

cats/plotting.py

Lines changed: 209 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
try:
22
import matplotlib.pyplot as plt
33
import matplotlib.dates as mdates
4+
from matplotlib.patches import Rectangle
5+
from matplotlib.ticker import FuncFormatter
6+
47
have_matplotlib = True
58
except ImportError:
69
have_matplotlib = False
7-
10+
811

912
def plotplan(CI_forecast, output):
1013
"""
@@ -13,55 +16,238 @@ def plotplan(CI_forecast, output):
1316
if not have_matplotlib:
1417
print("To plot graphs you must import matplotlib")
1518
print("e.g. \"pip install 'climate-aware-task-scheduler[plots]'\"")
16-
return None
17-
19+
return
20+
1821
# Just for now pull the CI forecast apart... probably belongs as method...
1922
values = []
2023
times = []
2124
now_values = []
2225
now_times = []
2326
opt_values = []
2427
opt_times = []
28+
2529
# For our now and optimal series, start with the starting data (interpolated)
2630
opt_times.append(output.carbonIntensityOptimal.start)
2731
opt_values.append(output.carbonIntensityOptimal.start_value)
2832
now_times.append(output.carbonIntensityNow.start)
2933
now_values.append(output.carbonIntensityNow.start_value)
34+
3035
# Build the three time series of point from the API
3136
for point in CI_forecast:
3237
values.append(point.value)
3338
times.append(point.datetime)
34-
if (point.datetime >= output.carbonIntensityOptimal.start and
35-
point.datetime <= output.carbonIntensityOptimal.end):
39+
if (
40+
point.datetime >= output.carbonIntensityOptimal.start
41+
and point.datetime <= output.carbonIntensityOptimal.end
42+
):
3643
opt_values.append(point.value)
3744
opt_times.append(point.datetime)
38-
if (point.datetime >= output.carbonIntensityNow.start and
39-
point.datetime <= output.carbonIntensityNow.end):
45+
if (
46+
point.datetime >= output.carbonIntensityNow.start
47+
and point.datetime <= output.carbonIntensityNow.end
48+
):
4049
now_values.append(point.value)
4150
now_times.append(point.datetime)
51+
4252
# For our now and optimal series, end with the end data (interpolated)
4353
opt_times.append(output.carbonIntensityOptimal.end)
4454
opt_values.append(output.carbonIntensityOptimal.end_value)
4555
now_times.append(output.carbonIntensityNow.end)
4656
now_values.append(output.carbonIntensityNow.end_value)
4757

58+
# Determine if there is any window overlap, since then we add an extra
59+
# element to the legend to make clear how the overlap region looks but
60+
# otherwise we can leave it out. There will be an overlap if the end of
61+
# the 'now' time is more than the start of the optimal time since the
62+
# optimal is only ever time shifted forwards.
63+
windows_overlap = (
64+
output.carbonIntensityNow.end > output.carbonIntensityOptimal.start
65+
)
66+
4867
# Make the plot (should probably take fig and ax as opt args...)
49-
fig, ax = plt.subplots()
50-
ax.fill_between(times, 0.0, values, alpha=0.2, color='b')
51-
ax.fill_between(now_times, 0.0, now_values, alpha=0.6, color='r')
52-
ax.fill_between(opt_times, 0.0, opt_values, alpha=0.6, color='g')
53-
54-
ax.text(0.125, 1.075, f"Mean carbon intensity if job started now: {output.carbonIntensityNow.value:.2f} gCO2eq/kWh",
55-
transform=ax.transAxes, color='red')
56-
ax.text(0.125, 1.025, f"Mean carbon intensity at optimal time: {output.carbonIntensityOptimal.value:.2f} gCO2eq/kWh",
57-
transform=ax.transAxes, color='green')
58-
59-
ax.set_xlabel("Time (dd-mm-yy hh)")
60-
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d-%m-%y %H:%M"))
61-
ax.xaxis.set_minor_formatter(mdates.DateFormatter("%d-%m-%y %H:%M"))
62-
ax.set_ylabel("Forecast carbon intensity (gCO2eq/kWh)")
63-
ax.grid(True)
68+
69+
# Use an accessibility-approved colour scheme to ensure plot is
70+
# comprehendable for all (though also use hatching and clear text so
71+
# that no information is only encoded by colour choice/view)
72+
plt.style.use("tableau-colorblind10")
73+
forecast_colour = "tab:blue"
74+
now_colour = "tab:red"
75+
optimal_colour = "tab:green"
76+
77+
# Supply a figsize a bit different o the default (6.4, 4.8) to make the
78+
# plot a little bit more square else the x-axis label gets a bit cut off
79+
# due to long datetime labels
80+
fig, ax = plt.subplots(figsize=(7.0, 5.5))
81+
82+
# Filling under curves for the forecast, run now time and optimal run time
83+
ax.fill_between(
84+
times,
85+
0.0,
86+
values,
87+
alpha=0.2,
88+
color=forecast_colour,
89+
edgecolor=forecast_colour,
90+
label="Forecast",
91+
)
92+
# Show 'now' window in red with black hatch lines for contrast
93+
ax.fill_between(
94+
now_times,
95+
0.0,
96+
now_values,
97+
alpha=0.6,
98+
color=now_colour,
99+
label="If job started now",
100+
hatch="///",
101+
edgecolor="k",
102+
)
103+
# Show 'optimal' window in green with hatch lines in opposite direction
104+
# but also in black: for any overlapping regions on the two windows, this
105+
# therefore results in a distinguishable cross-hatch pattern
106+
ax.fill_between(
107+
opt_times,
108+
0.0,
109+
opt_values,
110+
alpha=0.6,
111+
color=optimal_colour,
112+
label="Optimal job window",
113+
hatch="\\\\\\",
114+
edgecolor="k",
115+
)
116+
117+
# In case the 'now' and 'optimal' windows overlap, is nice to show just
118+
# how that looks on legend, namely crosshatch in an (ugly) khaki colour
119+
# that represents the mixture of the transparent red and green. To do
120+
# this, use matplotlib's patches to create a proxy object i.e. patch.
121+
if windows_overlap:
122+
overlap_patch = Rectangle(
123+
# Arbitrary huge number to ensure dummy patch is outside plot area
124+
(1e10, 1e10),
125+
1,
126+
1,
127+
facecolor="#6f8d4a", # mix colour from image colour picker tool
128+
edgecolor="k",
129+
hatch="////\\\\\\\\",
130+
label="Overlap area (now + optimal)",
131+
transform=ax.transAxes, # << use axes coords instead of data coords
132+
)
133+
ax.add_patch(overlap_patch)
134+
handles, labels = ax.get_legend_handles_labels()
135+
handles.append(overlap_patch)
136+
labels.append(overlap_patch.get_label())
137+
ax.legend(handles=handles, labels=labels)
138+
139+
now_value = output.carbonIntensityNow.value
140+
optimal_value = output.carbonIntensityOptimal.value
141+
# To avoid having to check if a user has (La)TeX available, to format the
142+
# units, use matploltib built-in lightweight TeX parser 'Mathtext' via
143+
# '$' symbols. Allows subscript on 2, etc. Needs a raw string to work.
144+
units = r"$\mathrm{g\,CO_{2}\,eq\;kWh^{-1}}$"
145+
146+
ax.text(
147+
0.5,
148+
1.05,
149+
f"Projected carbon intensity ({units}) mean...",
150+
ha="center",
151+
va="bottom",
152+
fontsize=14,
153+
transform=ax.transAxes,
154+
)
155+
ax.text(
156+
0.45,
157+
1.0,
158+
f"...if job started now: {now_value:.2f}",
159+
ha="right",
160+
va="bottom",
161+
color=now_colour,
162+
fontsize=14,
163+
transform=ax.transAxes,
164+
)
165+
# Separator to divide the two described figures ('now' and 'optimal')
166+
ax.text(
167+
0.5,
168+
1.0,
169+
r"$\to$",
170+
ha="center",
171+
va="bottom",
172+
color="black",
173+
fontsize=14,
174+
transform=ax.transAxes,
175+
)
176+
ax.text(
177+
0.55,
178+
1.0,
179+
f"...at optimal time: {optimal_value:.2f}",
180+
ha="left",
181+
va="bottom",
182+
color=optimal_colour,
183+
fontsize=14,
184+
transform=ax.transAxes,
185+
)
186+
187+
# For a nice illustration of CI saved, plot the lines corresponding to
188+
# the mean value for the 'now' and 'optimal' cases:
189+
plt.axhline(
190+
y=now_value,
191+
color=now_colour,
192+
linestyle="--",
193+
alpha=0.4,
194+
label="Mean if started now",
195+
)
196+
plt.axhline(
197+
y=optimal_value,
198+
color=optimal_colour,
199+
linestyle="--",
200+
alpha=0.4,
201+
label="Mean for optimal window",
202+
)
203+
204+
# Include subtle markers at each data point, in case it helps to
205+
# distinguish forecast points from the trend (esp. useful if there)
206+
# is a similar trend across/for 1 hour or more i.e. 3+ data points
207+
ax.scatter(times, values, color=forecast_colour, s=8, alpha=0.3)
208+
ax.scatter(now_times, now_values, color=now_colour, s=8, alpha=0.3)
209+
ax.scatter(opt_times, opt_values, color=optimal_colour, s=8, alpha=0.3)
210+
211+
def tick_formatting(x, pos):
212+
"""Format datetimes so the x-axis labels become more readable.
213+
214+
Namely, only show the full 'yy-mm-dd hh:mm' format date at the start
215+
of a day i.e. at hh:mm = 00:00, doing so in bold to provide emphasis.
216+
Otherwise show an ellipsis and then the hh:mm component only. This
217+
makes it much quicker for a reader to parse the date and time
218+
axis span and partitioning.
219+
"""
220+
dt = mdates.num2date(x)
221+
222+
# Would otherwise always full date at the first tick, as well as
223+
# at every point we get to a new day i.e. 00:00 (every four major
224+
# ticks), but then the date runs off the figure area to the left. So
225+
# it should be clear enough with two full dates plotted - which will
226+
# be the case always since we have a 48 hour window
227+
if dt.hour == 0 and dt.minute == 0:
228+
# Note \text{} required around '-' symbol else it is
229+
# interpreted as a minus sign and becomes so long with spacing
230+
# around that it pushes the x axis label off the figure below
231+
date_bold = dt.strftime(r"\mathbf{%y\text{-}%m\text{-}%d}")
232+
time_part = dt.strftime("%H:%M")
233+
return rf"${date_bold}\ {time_part}$"
234+
else:
235+
# All other ticks get ellipsis + time
236+
time_part = dt.strftime("%H:%M")
237+
return rf"$\ldots\ {time_part}$"
238+
239+
# The x axis label needs some padding at the figure foot else it gets a
240+
# bit cut off due to the length of some datetime x labels
241+
ax.set_xlabel(r"Time ($\mathbf{yy\text{-}mm\text{-}dd}$ hh:mm)")
242+
ax.xaxis.set_major_formatter(FuncFormatter(tick_formatting))
243+
ax.set_ylabel(rf"Forecast carbon intensity ({units})")
64244
ax.label_outer()
245+
246+
ax.grid(True)
247+
ax.legend()
248+
65249
fig.autofmt_xdate()
250+
ax.set_ylim(bottom=0) # start y-axis at 0, negative CI not possible!
251+
252+
plt.subplots_adjust(bottom=0.20)
66253
plt.show()
67-
return None

0 commit comments

Comments
 (0)