mgplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mgplot/__init__.py +121 -0
- mgplot/bar_plot.py +107 -0
- mgplot/colors.py +199 -0
- mgplot/date_utils.py +324 -0
- mgplot/finalise_plot.py +335 -0
- mgplot/finalisers.py +364 -0
- mgplot/growth_plot.py +275 -0
- mgplot/kw_type_checking.py +460 -0
- mgplot/line_plot.py +178 -0
- mgplot/multi_plot.py +339 -0
- mgplot/postcovid_plot.py +106 -0
- mgplot/py.typed +1 -0
- mgplot/revision_plot.py +60 -0
- mgplot/run_plot.py +182 -0
- mgplot/seastrend_plot.py +74 -0
- mgplot/settings.py +164 -0
- mgplot/summary_plot.py +240 -0
- mgplot/test.py +31 -0
- mgplot/utilities.py +254 -0
- mgplot-0.1.0.dist-info/METADATA +53 -0
- mgplot-0.1.0.dist-info/RECORD +24 -0
- mgplot-0.1.0.dist-info/WHEEL +5 -0
- mgplot-0.1.0.dist-info/licenses/LICENSE +8 -0
- mgplot-0.1.0.dist-info/top_level.txt +1 -0
mgplot/date_utils.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
date_utils.py
|
|
3
|
+
This module contains functions to work with date-like
|
|
4
|
+
(i.e. not time-like) PeriodIndex frequencies in Pandas.
|
|
5
|
+
It is used to label the x-axis of a plot with the
|
|
6
|
+
appropriate date-like labels.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import calendar
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pandas import Period, PeriodIndex, period_range
|
|
12
|
+
from matplotlib.pyplot import Axes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DateLike(Enum):
|
|
16
|
+
"""Recognised date-like PeriodIndex frequencies"""
|
|
17
|
+
|
|
18
|
+
YEARS = 1
|
|
19
|
+
QUARTERS = 2
|
|
20
|
+
MONTHS = 3
|
|
21
|
+
DAYS = 4
|
|
22
|
+
BAD = 5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
frequencies = {
|
|
26
|
+
# freq: [Periods from smaller to larger]
|
|
27
|
+
"D": [DateLike.DAYS, DateLike.MONTHS, DateLike.YEARS],
|
|
28
|
+
"M": [DateLike.MONTHS, DateLike.YEARS],
|
|
29
|
+
"Q": [DateLike.QUARTERS, DateLike.YEARS],
|
|
30
|
+
"Y": [DateLike.YEARS],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
r_freqs = {v[0]: k for k, v in frequencies.items()}
|
|
34
|
+
|
|
35
|
+
intervals = {
|
|
36
|
+
DateLike.YEARS: [1, 2, 4, 5, 10, 20, 40, 50, 100, 200, 400, 500, 1000],
|
|
37
|
+
DateLike.QUARTERS: [1, 2],
|
|
38
|
+
DateLike.MONTHS: [1, 2, 3, 4, 6],
|
|
39
|
+
DateLike.DAYS: [1, 2, 4, 7, 14],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_count(p: PeriodIndex, max_ticks: int) -> tuple[int, DateLike, int]:
|
|
44
|
+
"""
|
|
45
|
+
Work out the label frequency and interval for a date-like
|
|
46
|
+
PeriodIndex.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
- p: PeriodIndex - the PeriodIndex
|
|
50
|
+
- max_ticks - the maximum number of ticks [suggestive]
|
|
51
|
+
|
|
52
|
+
Returns a tuple:
|
|
53
|
+
- the roughly anticipated number of ticks to highlight: int
|
|
54
|
+
- the type of ticks to highlight (eg. days/months/quarters/years): str
|
|
55
|
+
- the tick interval (ie. number of days/months/quarters/years): int
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# --- sanity checks
|
|
59
|
+
error = (0, DateLike.BAD, 0)
|
|
60
|
+
if p.empty:
|
|
61
|
+
return error
|
|
62
|
+
freq: str = p.freqstr[0].upper()
|
|
63
|
+
if freq not in frequencies:
|
|
64
|
+
print("Unrecognised date-like PeriodIndex frequency {freq}")
|
|
65
|
+
return error
|
|
66
|
+
|
|
67
|
+
# --- calculate
|
|
68
|
+
for test_freq in frequencies[freq]:
|
|
69
|
+
r_freq = r_freqs[test_freq]
|
|
70
|
+
for interval in intervals[test_freq]:
|
|
71
|
+
count = (
|
|
72
|
+
p.max().asfreq(r_freq, how="end").ordinal
|
|
73
|
+
- p.min().asfreq(r_freq, how="end").ordinal
|
|
74
|
+
+ 1
|
|
75
|
+
) // interval
|
|
76
|
+
if count <= max_ticks:
|
|
77
|
+
return count, test_freq, interval
|
|
78
|
+
return error
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def day_labeller(labels: dict[Period, str]) -> dict[Period, str]:
|
|
82
|
+
"""Label the selected days."""
|
|
83
|
+
|
|
84
|
+
def add_month(label: str, month: str) -> str:
|
|
85
|
+
return f"{label}\n{month}"
|
|
86
|
+
|
|
87
|
+
def add_year(label: str, year: str) -> str:
|
|
88
|
+
label = label.replace("\n", " ") if len(label) > 2 else f"{label} {month}"
|
|
89
|
+
label = f"{label}\n{year}"
|
|
90
|
+
return label
|
|
91
|
+
|
|
92
|
+
if not labels:
|
|
93
|
+
return labels
|
|
94
|
+
|
|
95
|
+
start = min(labels.keys())
|
|
96
|
+
month_previous: str = calendar.month_abbr[start.month]
|
|
97
|
+
year_previous: str = str(start.year)
|
|
98
|
+
final_year = True
|
|
99
|
+
|
|
100
|
+
for period in sorted(labels.keys()):
|
|
101
|
+
label = str(period.day)
|
|
102
|
+
month = calendar.month_abbr[period.month]
|
|
103
|
+
year = str(period.year)
|
|
104
|
+
|
|
105
|
+
if month_previous != month:
|
|
106
|
+
label = add_month(label, month)
|
|
107
|
+
|
|
108
|
+
if year_previous != year:
|
|
109
|
+
final_year = False
|
|
110
|
+
label = add_year(label, year)
|
|
111
|
+
|
|
112
|
+
labels[period] = label
|
|
113
|
+
|
|
114
|
+
if final_year:
|
|
115
|
+
final_period = max(labels.keys())
|
|
116
|
+
labels[final_period] = add_year(label, year)
|
|
117
|
+
|
|
118
|
+
return labels
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def month_locator(p: PeriodIndex, interval: int) -> dict[Period, str]:
|
|
122
|
+
"""Select the months to label."""
|
|
123
|
+
|
|
124
|
+
subset = PeriodIndex([c for c in p if c.day == 1]) if p.freqstr[0] == "D" else p
|
|
125
|
+
|
|
126
|
+
start = 0
|
|
127
|
+
if interval > 1:
|
|
128
|
+
mod_months = [(c.month - 1) % interval for c in subset]
|
|
129
|
+
start = 0 if 0 not in mod_months else mod_months.index(0)
|
|
130
|
+
return {k: "" for k in subset[start::interval]}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def month_labeller(labels: dict[Period, str]) -> dict[Period, str]:
|
|
134
|
+
"""Label the selected months."""
|
|
135
|
+
|
|
136
|
+
if not labels:
|
|
137
|
+
return labels
|
|
138
|
+
|
|
139
|
+
start = min(labels.keys())
|
|
140
|
+
year_previous: str = str(start.year)
|
|
141
|
+
final_year = True
|
|
142
|
+
|
|
143
|
+
for period in sorted(labels.keys()):
|
|
144
|
+
label = calendar.month_abbr[period.month]
|
|
145
|
+
year = str(period.year)
|
|
146
|
+
|
|
147
|
+
if year_previous != year or period.month == 1:
|
|
148
|
+
label = f"{label}\n{year}"
|
|
149
|
+
year_previous = year
|
|
150
|
+
final_year = False
|
|
151
|
+
|
|
152
|
+
labels[period] = label
|
|
153
|
+
|
|
154
|
+
if final_year:
|
|
155
|
+
final_period = max(labels.keys())
|
|
156
|
+
label = labels[final_period]
|
|
157
|
+
year = str(final_period.year)
|
|
158
|
+
label = f"{label}\n{year}"
|
|
159
|
+
labels[final_period] = label
|
|
160
|
+
|
|
161
|
+
return labels
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def qtr_locator(p: PeriodIndex, interval: int) -> dict[Period, str]:
|
|
165
|
+
"""Select the quarters to label."""
|
|
166
|
+
|
|
167
|
+
start = 0
|
|
168
|
+
if interval > 1:
|
|
169
|
+
mod_qtrs = [(c.quarter - 1) % interval for c in p]
|
|
170
|
+
start = 0 if 0 not in mod_qtrs else mod_qtrs.index(0)
|
|
171
|
+
return {k: "" for k in p[start::interval]}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def qtr_labeller(labels: dict[Period, str]) -> dict[Period, str]:
|
|
175
|
+
"""Label the selected quarters."""
|
|
176
|
+
|
|
177
|
+
if not labels:
|
|
178
|
+
return labels
|
|
179
|
+
|
|
180
|
+
final_year = True
|
|
181
|
+
for period in sorted(labels.keys()):
|
|
182
|
+
quarter = period.quarter
|
|
183
|
+
label = f"Q{quarter}"
|
|
184
|
+
if quarter == 1:
|
|
185
|
+
final_year = False
|
|
186
|
+
label = f"{label}\n{period.year}"
|
|
187
|
+
labels[period] = label
|
|
188
|
+
|
|
189
|
+
if final_year:
|
|
190
|
+
final_period = max(labels.keys())
|
|
191
|
+
label = labels[final_period]
|
|
192
|
+
year = str(final_period.year)
|
|
193
|
+
label = f"{label}\n{year}"
|
|
194
|
+
labels[final_period] = label
|
|
195
|
+
|
|
196
|
+
return labels
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def year_locator(p: PeriodIndex, interval: int) -> dict[Period, str]:
|
|
200
|
+
"""Select the years to label."""
|
|
201
|
+
|
|
202
|
+
match p.freqstr[0]:
|
|
203
|
+
case "D":
|
|
204
|
+
subset = PeriodIndex([c for c in p if c.month == 1 and c.day == 1])
|
|
205
|
+
case "M":
|
|
206
|
+
subset = PeriodIndex([c for c in p if c.month == 1])
|
|
207
|
+
case "Q":
|
|
208
|
+
subset = PeriodIndex([c for c in p if c.quarter == 1])
|
|
209
|
+
case _:
|
|
210
|
+
subset = p
|
|
211
|
+
|
|
212
|
+
start = 0
|
|
213
|
+
if interval > 1:
|
|
214
|
+
mod_years = [(c.year) % interval for c in subset]
|
|
215
|
+
start = 0 if 0 not in mod_years else mod_years.index(0)
|
|
216
|
+
return {k: "" for k in subset[start::interval]}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def year_labeller(labels: dict[Period, str]) -> dict[Period, str]:
|
|
220
|
+
"""Label the selected years."""
|
|
221
|
+
|
|
222
|
+
if not labels:
|
|
223
|
+
return labels
|
|
224
|
+
|
|
225
|
+
for period in sorted(labels.keys()):
|
|
226
|
+
label = str(period.year)
|
|
227
|
+
labels[period] = label
|
|
228
|
+
return labels
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def make_labels(p: PeriodIndex, max_ticks: int) -> dict[Period, str]:
|
|
232
|
+
"""
|
|
233
|
+
Provide a dictionary of labels for the date-like PeriodIndex.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
- p: PeriodIndex - the PeriodIndex
|
|
237
|
+
- max_ticks - the maximum number of ticks [suggestive]
|
|
238
|
+
|
|
239
|
+
Returns a dictionary:
|
|
240
|
+
- keys are the Periods to label
|
|
241
|
+
- values are the labels to apply
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
labels: dict[Period, str] = {}
|
|
245
|
+
max_ticks = max(max_ticks, 4)
|
|
246
|
+
count, date_like, interval = get_count(p, max_ticks)
|
|
247
|
+
if date_like == DateLike.BAD:
|
|
248
|
+
return labels
|
|
249
|
+
|
|
250
|
+
target_freq = r_freqs[date_like]
|
|
251
|
+
complete = period_range(start=p.min(), end=p.max(), freq=p.freqstr)
|
|
252
|
+
|
|
253
|
+
match target_freq:
|
|
254
|
+
case "D":
|
|
255
|
+
start = 0 if interval == 2 and count % 2 else interval // 2
|
|
256
|
+
labels = {k: "" for k in complete[start::interval]}
|
|
257
|
+
labels = day_labeller(labels)
|
|
258
|
+
|
|
259
|
+
case "M":
|
|
260
|
+
labels = month_locator(complete, interval)
|
|
261
|
+
labels = month_labeller(labels)
|
|
262
|
+
|
|
263
|
+
case "Q":
|
|
264
|
+
labels = qtr_locator(complete, interval)
|
|
265
|
+
labels = qtr_labeller(labels)
|
|
266
|
+
|
|
267
|
+
case "Y":
|
|
268
|
+
labels = year_locator(complete, interval)
|
|
269
|
+
labels = year_labeller(labels)
|
|
270
|
+
|
|
271
|
+
return labels
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def make_ilabels(p: PeriodIndex, max_ticks: int) -> tuple[list[int], list[str]]:
|
|
275
|
+
"""
|
|
276
|
+
From a PeriodIndex, create a list of integer ticks and ticklabels
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
- p: PeriodIndex - the PeriodIndex
|
|
280
|
+
- max_ticks - the maximum number of ticks [suggestive]
|
|
281
|
+
|
|
282
|
+
Returns a tuple:
|
|
283
|
+
- list of integer ticks
|
|
284
|
+
- list of tick label strings
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
labels = make_labels(p, max_ticks)
|
|
288
|
+
base = p.min().ordinal
|
|
289
|
+
ticks = [x.ordinal - base for x in sorted(labels.keys())]
|
|
290
|
+
ticklabels = [labels[x] for x in sorted(labels.keys())]
|
|
291
|
+
|
|
292
|
+
return ticks, ticklabels
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def set_labels(axes: Axes, p: PeriodIndex, max_ticks: int = 10) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Set the x-axis labels for a date-like PeriodIndex.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
- axes: Axes - the axes to set the labels on
|
|
301
|
+
- p: PeriodIndex - the PeriodIndex
|
|
302
|
+
- max_ticks: int - the maximum number of ticks [suggestive]
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
ticks, ticklabels = make_ilabels(p, max_ticks)
|
|
306
|
+
axes.set_xticks(ticks)
|
|
307
|
+
axes.set_xticklabels(ticklabels, rotation=0, ha="center")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# --- test ---
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
|
|
313
|
+
tests = [
|
|
314
|
+
PeriodIndex(["2020-01-01", "2020-01-02", "2020-01-03", "2020-01-04"], freq="D"),
|
|
315
|
+
period_range(start="2020-01-01", end="2020-01-15", freq="D"),
|
|
316
|
+
period_range(start="2020-02-01", end="2022-07-15", freq="D"),
|
|
317
|
+
period_range(start="2020-Q2", end="2022-Q4", freq="Q"),
|
|
318
|
+
period_range(start="2000-Q2", end="2022-Q4", freq="Q"),
|
|
319
|
+
period_range(start="1950-01-01", end="2026-12-15", freq="D"),
|
|
320
|
+
]
|
|
321
|
+
for index, test in enumerate(tests):
|
|
322
|
+
print(f"Test {index + 1}")
|
|
323
|
+
print("Labels:", make_labels(test, 10), "\n")
|
|
324
|
+
print("========")
|
mgplot/finalise_plot.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
finalise_plot.py:
|
|
3
|
+
This module provides a function to finalise and save plots to the
|
|
4
|
+
file system. It is used to publish plots.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# --- imports
|
|
8
|
+
# from typing import Any
|
|
9
|
+
import re
|
|
10
|
+
import matplotlib as mpl
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
from matplotlib.pyplot import Axes, Figure
|
|
13
|
+
import matplotlib.dates as mdates
|
|
14
|
+
|
|
15
|
+
from mgplot.settings import get_setting
|
|
16
|
+
from mgplot.kw_type_checking import (
|
|
17
|
+
report_kwargs,
|
|
18
|
+
validate_expected,
|
|
19
|
+
ExpectedTypeDict,
|
|
20
|
+
validate_kwargs,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- constants
|
|
25
|
+
# filename limitations - regex used to map the plot title to a filename
|
|
26
|
+
_remove = re.compile(r"[^0-9A-Za-z]") # sensible file names from alphamum title
|
|
27
|
+
_reduce = re.compile(r"[-]+") # eliminate multiple hyphens
|
|
28
|
+
|
|
29
|
+
# map of the acceptable kwargs for finalise_plot()
|
|
30
|
+
# make sure "legend" is last in the _splat_kwargs tuple ...
|
|
31
|
+
_splat_kwargs = ("axhspan", "axvspan", "axhline", "axvline", "legend")
|
|
32
|
+
_value_must_kwargs = ("title", "xlabel", "ylabel")
|
|
33
|
+
_value_may_kwargs = ("ylim", "xlim", "yscale", "xscale")
|
|
34
|
+
_value_kwargs = _value_must_kwargs + _value_may_kwargs
|
|
35
|
+
_annotation_kwargs = ("lfooter", "rfooter", "lheader", "rheader")
|
|
36
|
+
|
|
37
|
+
_file_kwargs = ("pre_tag", "tag", "chart_dir", "file_type", "dpi")
|
|
38
|
+
_fig_kwargs = ("figsize", "show", "preserve_lims", "remove_legend")
|
|
39
|
+
_oth_kwargs = (
|
|
40
|
+
"zero_y",
|
|
41
|
+
"y0",
|
|
42
|
+
"x0",
|
|
43
|
+
"dont_save",
|
|
44
|
+
"dont_close",
|
|
45
|
+
"concise_dates",
|
|
46
|
+
)
|
|
47
|
+
_ACCEPTABLE_KWARGS = frozenset(
|
|
48
|
+
_value_kwargs
|
|
49
|
+
+ _splat_kwargs
|
|
50
|
+
+ _file_kwargs
|
|
51
|
+
+ _annotation_kwargs
|
|
52
|
+
+ _fig_kwargs
|
|
53
|
+
+ _oth_kwargs
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
FINALISE_KW_TYPES: ExpectedTypeDict = {
|
|
57
|
+
# - value kwargs
|
|
58
|
+
"title": (str, type(None)),
|
|
59
|
+
"xlabel": (str, type(None)),
|
|
60
|
+
"ylabel": (str, type(None)),
|
|
61
|
+
"ylim": (tuple, (float, int), type(None)),
|
|
62
|
+
"xlim": (tuple, (float, int), type(None)),
|
|
63
|
+
"yscale": (str, type(None)),
|
|
64
|
+
"xscale": (str, type(None)),
|
|
65
|
+
# - splat kwargs
|
|
66
|
+
"legend": (dict, (str, (int, float, str)), bool, type(None)),
|
|
67
|
+
"axhspan": (dict, (str, (int, float, str)), type(None)),
|
|
68
|
+
"axvspan": (dict, (str, (int, float, str)), type(None)),
|
|
69
|
+
"axhline": (dict, (str, (int, float, str)), type(None)),
|
|
70
|
+
"axvline": (dict, (str, (int, float, str)), type(None)),
|
|
71
|
+
# - file kwargs
|
|
72
|
+
"pre_tag": str,
|
|
73
|
+
"tag": str,
|
|
74
|
+
"chart_dir": str,
|
|
75
|
+
"file_type": str,
|
|
76
|
+
"dpi": int,
|
|
77
|
+
# - fig kwargs
|
|
78
|
+
"remove_legend": (type(None), bool),
|
|
79
|
+
"preserve_lims": (type(None), bool),
|
|
80
|
+
"figsize": (tuple, (float, int)),
|
|
81
|
+
"show": bool,
|
|
82
|
+
# - annotation kwargs
|
|
83
|
+
"lfooter": str,
|
|
84
|
+
"rfooter": str,
|
|
85
|
+
"lheader": str,
|
|
86
|
+
"rheader": str,
|
|
87
|
+
# - Other kwargs
|
|
88
|
+
"zero_y": bool,
|
|
89
|
+
"y0": bool,
|
|
90
|
+
"x0": bool,
|
|
91
|
+
"dont_save": bool,
|
|
92
|
+
"dont_close": bool,
|
|
93
|
+
"concise_dates": bool,
|
|
94
|
+
}
|
|
95
|
+
validate_expected(FINALISE_KW_TYPES, "finalise_plot")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _internal_consistency_kwargs():
|
|
99
|
+
"""Quick check to ensure that the kwargs checkers are consistent."""
|
|
100
|
+
|
|
101
|
+
bad = False
|
|
102
|
+
for k in FINALISE_KW_TYPES:
|
|
103
|
+
if k not in _ACCEPTABLE_KWARGS:
|
|
104
|
+
bad = True
|
|
105
|
+
print(f"Key {k} in FINALISE_KW_TYPES but not _ACCEPTABLE_KWARGS")
|
|
106
|
+
|
|
107
|
+
for k in _ACCEPTABLE_KWARGS:
|
|
108
|
+
if k not in FINALISE_KW_TYPES:
|
|
109
|
+
bad = True
|
|
110
|
+
print(f"Key {k} in _ACCEPTABLE_KWARGS but not FINALISE_KW_TYPES")
|
|
111
|
+
|
|
112
|
+
if bad:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"Internal error: _ACCEPTABLE_KWARGS and FINALISE_KW_TYPES are inconsistent."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_internal_consistency_kwargs()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# - private utility functions for finalise_plot()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _apply_value_kwargs(axes: Axes, settings: tuple, **kwargs) -> None:
|
|
125
|
+
"""Set matplotlib elements by name using Axes.set()."""
|
|
126
|
+
|
|
127
|
+
for setting in settings:
|
|
128
|
+
value = kwargs.get(setting, None)
|
|
129
|
+
if value is None and setting not in _value_must_kwargs:
|
|
130
|
+
continue
|
|
131
|
+
axes.set(**{setting: value})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Set matplotlib elements dynamically using setting_name and splat.
|
|
137
|
+
This is used for legend, axhspan, axvspan, axhline, and axvline.
|
|
138
|
+
These can be ignored if not in kwargs, or set to None in kwargs.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
for method_name in settings:
|
|
142
|
+
if method_name in kwargs:
|
|
143
|
+
|
|
144
|
+
if kwargs[method_name] is None or kwargs[method_name] is False:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if kwargs[method_name] is True: # use the global default settings
|
|
148
|
+
kwargs[method_name] = get_setting(method_name)
|
|
149
|
+
|
|
150
|
+
# splat the kwargs to the method
|
|
151
|
+
if isinstance(kwargs[method_name], dict):
|
|
152
|
+
method = getattr(axes, method_name)
|
|
153
|
+
method(**kwargs[method_name])
|
|
154
|
+
else:
|
|
155
|
+
print(
|
|
156
|
+
f"Warning expected dict argument: {method_name} but got "
|
|
157
|
+
+ f"{type(kwargs[method_name])}."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _apply_annotations(axes: Axes, **kwargs) -> None:
|
|
162
|
+
"""Set figure size and apply chart annotations."""
|
|
163
|
+
|
|
164
|
+
fig = axes.figure
|
|
165
|
+
fig_size = get_setting("figsize") if "figsize" not in kwargs else kwargs["figsize"]
|
|
166
|
+
if not isinstance(fig, mpl.figure.SubFigure):
|
|
167
|
+
fig.set_size_inches(*fig_size)
|
|
168
|
+
|
|
169
|
+
annotations = {
|
|
170
|
+
"rfooter": (0.99, 0.001, "right", "bottom"),
|
|
171
|
+
"lfooter": (0.01, 0.001, "left", "bottom"),
|
|
172
|
+
"rheader": (0.99, 0.999, "right", "top"),
|
|
173
|
+
"lheader": (0.01, 0.999, "left", "top"),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for annotation in _annotation_kwargs:
|
|
177
|
+
if annotation in kwargs:
|
|
178
|
+
x_pos, y_pos, h_align, v_align = annotations[annotation]
|
|
179
|
+
fig.text(
|
|
180
|
+
x_pos,
|
|
181
|
+
y_pos,
|
|
182
|
+
kwargs[annotation],
|
|
183
|
+
ha=h_align,
|
|
184
|
+
va=v_align,
|
|
185
|
+
fontsize=8,
|
|
186
|
+
fontstyle="italic",
|
|
187
|
+
color="#999999",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _apply_late_kwargs(axes: Axes, **kwargs) -> None:
|
|
192
|
+
"""Apply settings found in kwargs, after plotting the data."""
|
|
193
|
+
_apply_splat_kwargs(axes, _splat_kwargs, **kwargs)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _apply_kwargs(axes: Axes, **kwargs) -> None:
|
|
197
|
+
"""Apply settings found in kwargs."""
|
|
198
|
+
|
|
199
|
+
def check_kwargs(name):
|
|
200
|
+
return name in kwargs and kwargs[name]
|
|
201
|
+
|
|
202
|
+
_apply_value_kwargs(axes, _value_kwargs, **kwargs)
|
|
203
|
+
_apply_annotations(axes, **kwargs)
|
|
204
|
+
|
|
205
|
+
if check_kwargs("zero_y"):
|
|
206
|
+
bottom, top = axes.get_ylim()
|
|
207
|
+
adj = (top - bottom) * 0.02
|
|
208
|
+
if bottom > -adj:
|
|
209
|
+
axes.set_ylim(bottom=-adj)
|
|
210
|
+
if top < adj:
|
|
211
|
+
axes.set_ylim(top=adj)
|
|
212
|
+
|
|
213
|
+
if check_kwargs("y0"):
|
|
214
|
+
low, high = axes.get_ylim()
|
|
215
|
+
if low < 0 < high:
|
|
216
|
+
axes.axhline(y=0, lw=0.66, c="#555555")
|
|
217
|
+
|
|
218
|
+
if check_kwargs("x0"):
|
|
219
|
+
low, high = axes.get_xlim()
|
|
220
|
+
if low < 0 < high:
|
|
221
|
+
axes.axvline(x=0, lw=0.66, c="#555555")
|
|
222
|
+
|
|
223
|
+
if check_kwargs("concise_dates"):
|
|
224
|
+
locator = mdates.AutoDateLocator()
|
|
225
|
+
formatter = mdates.ConciseDateFormatter(locator)
|
|
226
|
+
axes.xaxis.set_major_locator(locator)
|
|
227
|
+
axes.xaxis.set_major_formatter(formatter)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _save_to_file(fig: Figure, **kwargs) -> None:
|
|
231
|
+
"""Save the figure to file."""
|
|
232
|
+
|
|
233
|
+
saving = not kwargs.get("dont_save", False) # save by default
|
|
234
|
+
if saving:
|
|
235
|
+
chart_dir = kwargs.get("chart_dir", get_setting("chart_dir"))
|
|
236
|
+
if not chart_dir.endswith("/"):
|
|
237
|
+
chart_dir += "/"
|
|
238
|
+
|
|
239
|
+
title = "" if "title" not in kwargs else kwargs["title"]
|
|
240
|
+
max_title_len = 150 # avoid overly long file names
|
|
241
|
+
shorter = title if len(title) < max_title_len else title[:max_title_len]
|
|
242
|
+
pre_tag = kwargs.get("pre_tag", "")
|
|
243
|
+
tag = kwargs.get("tag", "")
|
|
244
|
+
file_title = re.sub(_remove, "-", shorter).lower()
|
|
245
|
+
file_title = re.sub(_reduce, "-", file_title)
|
|
246
|
+
file_type = kwargs.get("file_type", get_setting("file_type")).lower()
|
|
247
|
+
dpi = kwargs.get("dpi", get_setting("file_dpi"))
|
|
248
|
+
fig.savefig(f"{chart_dir}{pre_tag}{file_title}-{tag}.{file_type}", dpi=dpi)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# - public functions for finalise_plot()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def finalise_plot(axes: Axes, **kwargs) -> None:
|
|
255
|
+
"""
|
|
256
|
+
A function to finalise and save plots to the file system. The filename
|
|
257
|
+
for the saved plot is constructed from the global chart_dir, the plot's title,
|
|
258
|
+
any specified tag text, and the file_type for the plot.
|
|
259
|
+
|
|
260
|
+
Arguments:
|
|
261
|
+
- axes - matplotlib axes object - required
|
|
262
|
+
- kwargs
|
|
263
|
+
- title: str - plot title, also used to create the save file name
|
|
264
|
+
- xlabel: str | None - text label for the x-axis
|
|
265
|
+
- ylabel: str | None - label for the y-axis
|
|
266
|
+
- pre_tag: str - text before the title in file name
|
|
267
|
+
- tag: str - text after the title in the file name
|
|
268
|
+
(useful for ensuring that same titled charts do not over-write)
|
|
269
|
+
- chart_dir: str - location of the chart directory
|
|
270
|
+
- file_type: str - specify a file type - eg. 'png' or 'svg'
|
|
271
|
+
- lfooter: str - text to display on bottom left of plot
|
|
272
|
+
- rfooter: str - text to display of bottom right of plot
|
|
273
|
+
- lheader: str - text to display on top left of plot
|
|
274
|
+
- rheader: str - text to display of top right of plot
|
|
275
|
+
- figsize: tuple[float, float] - figure size in inches - eg. (8, 4)
|
|
276
|
+
- show: bool - whether to show the plot or not
|
|
277
|
+
- zero_y: bool - ensure y=0 is included in the plot.
|
|
278
|
+
- y0: bool - highlight the y=0 line on the plot (if in scope)
|
|
279
|
+
- x0: bool - highlights the x=0 line on the plot
|
|
280
|
+
- dont_save: bool - dont save the plot to the file system
|
|
281
|
+
- dont_close: bool - dont close the plot
|
|
282
|
+
- dpi: int - dots per inch for the saved chart
|
|
283
|
+
- legend: bool | dict - if dict, use as the arguments to pass to axes.legend(),
|
|
284
|
+
if True pass the global default arguments to axes.legend()
|
|
285
|
+
- axhspan: dict - arguments to pass to axes.axhspan()
|
|
286
|
+
- axvspan: dict - arguments to pass to axes.axvspan()
|
|
287
|
+
- axhline: dict - arguments to pass to axes.axhline()
|
|
288
|
+
- axvline: dict - arguments to pass to axes.axvline()
|
|
289
|
+
- ylim: tuple[float, float] - set lower and upper y-axis limits
|
|
290
|
+
- xlim: tuple[float, float] - set lower and upper x-axis limits
|
|
291
|
+
- preserve_lims: bool - if True, preserve the original axes limits,
|
|
292
|
+
lims saved at the start, and restored after the tight layout
|
|
293
|
+
- remove_legend: bool | None - if True, remove the legend from the plot
|
|
294
|
+
- report_kwargs: bool - if True, report the kwargs used in this function
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
- None
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
# --- sanity checks
|
|
301
|
+
report_kwargs(called_from="finalise_plot", **kwargs)
|
|
302
|
+
validate_kwargs(FINALISE_KW_TYPES, "finalise_plot", **kwargs)
|
|
303
|
+
|
|
304
|
+
# --- remember should we need to restore the axes limits
|
|
305
|
+
xlim, ylim = axes.get_xlim(), axes.get_ylim()
|
|
306
|
+
|
|
307
|
+
# margins
|
|
308
|
+
# axes.use_sticky_margins = False ### CHECK THIS
|
|
309
|
+
axes.margins(0.02)
|
|
310
|
+
axes.autoscale(tight=False) # This is problematic ...
|
|
311
|
+
|
|
312
|
+
_apply_kwargs(axes, **kwargs)
|
|
313
|
+
|
|
314
|
+
# tight layout and save the figure
|
|
315
|
+
fig = axes.figure
|
|
316
|
+
if not isinstance(fig, mpl.figure.SubFigure): # should never be a SubFigure
|
|
317
|
+
fig.tight_layout(pad=1.1)
|
|
318
|
+
if "preserve_lims" in kwargs and kwargs["preserve_lims"]:
|
|
319
|
+
# restore the original limits of the axes
|
|
320
|
+
axes.set_xlim(xlim)
|
|
321
|
+
axes.set_ylim(ylim)
|
|
322
|
+
_apply_late_kwargs(axes, **kwargs)
|
|
323
|
+
legend = axes.get_legend()
|
|
324
|
+
if legend and kwargs.get("remove_legend", False):
|
|
325
|
+
legend.remove()
|
|
326
|
+
_save_to_file(fig, **kwargs)
|
|
327
|
+
|
|
328
|
+
# show the plot in Jupyter Lab
|
|
329
|
+
if "show" in kwargs and kwargs["show"]:
|
|
330
|
+
plt.show()
|
|
331
|
+
|
|
332
|
+
# And close
|
|
333
|
+
closing = True if "dont_close" not in kwargs else not kwargs["dont_close"]
|
|
334
|
+
if closing:
|
|
335
|
+
plt.close()
|