11try :
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
58except ImportError :
69 have_matplotlib = False
7-
10+
811
912def 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