Skip to content

Commit 6fea878

Browse files
authored
Merge pull request #139 from openscm/plume-plot
Plume plot
2 parents 6012b2b + 565dfd3 commit 6fea878

9 files changed

Lines changed: 2240 additions & 1247 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Changelog
44
master
55
------
66

7+
- (`#139 <https://github.com/openscm/scmdata/pull/139>`_) Update filter to handle metadata columns which contain a mix of data types
8+
- (`#139 <https://github.com/openscm/scmdata/pull/139>`_) Add :meth:`ScmRun.plumeplot`
79
- (`#140 <https://github.com/openscm/scmdata/pull/140>`_) Add workaround for installing scmdata with Python 3.6 on windows to handle lack of cftime 1.3.1 wheel
810
- (`#138 <https://github.com/openscm/scmdata/pull/138>`_) Add :meth:`ScmRun.quantiles_over`
911
- (`#137 <https://github.com/openscm/scmdata/pull/137>`_) Fix :meth:`scmdata.ScmRun.to_csv` so that writing and reading is circular (i.e. you end up where you started if you write a file and then read it straight back into a new :obj:`scmdata.ScmRun` instance)

notebooks/summary-statistics.ipynb

Lines changed: 1591 additions & 1235 deletions
Large diffs are not rendered by default.

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"scipy",
4545
"xlrd<=1.2.0", # support reading excel
4646
]
47-
REQUIREMENTS_PLOTTING = ["seaborn"]
47+
REQUIREMENTS_PLOTTING = ["matplotlib", "seaborn"]
4848
REQUIREMENTS_NOTEBOOKS = (
4949
["notebook", "ipywidgets"] + REQUIREMENTS_PLOTTING + REQUIREMENTS_OPTIONAL
5050
)

src/scmdata/filters.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,12 @@ def pattern_match( # pylint: disable=too-many-arguments,too-many-locals
159159
for s in _values:
160160
if isinstance(s, str) and s == "":
161161
s = np.nan
162-
if isinstance(s, str):
162+
163+
use_string_comparison = isinstance(s, str) or (
164+
not np.isnan(s) and pd.api.types.is_string_dtype(meta_col.categories.dtype)
165+
)
166+
167+
if use_string_comparison:
163168
if not regexp and s == "*" and level is None:
164169
matches |= True
165170
else:
@@ -176,7 +181,7 @@ def pattern_match( # pylint: disable=too-many-arguments,too-many-locals
176181
) + "$"
177182
pattern = re.compile(_regexp if not regexp else str(s))
178183

179-
subset = [m for m in meta_col.categories if pattern.match(m)]
184+
subset = [m for m in meta_col.categories if pattern.match(str(m))]
180185

181186
if level is not None:
182187
depth = find_depth(meta_col, str(s), level, separator=separator)

src/scmdata/plotting.py

Lines changed: 291 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@
33
44
See the example notebook 'plotting-with-seaborn.ipynb' for usage examples
55
"""
6-
76
import warnings
7+
from itertools import cycle
88

99
import numpy as np
1010

11+
try:
12+
import matplotlib.lines as mlines
13+
import matplotlib.patches as mpatches
14+
import matplotlib.pyplot as plt
15+
16+
has_matplotlib = True
17+
except ImportError: # pragma: no cover
18+
plt = None
19+
has_matplotlib = False
20+
1121
try:
1222
import seaborn as sns
1323

@@ -65,6 +75,7 @@ def lineplot(self, time_axis=None, **kwargs): # pragma: no cover
6575
kwargs.setdefault("y", "value")
6676
if "scenario" in self.meta_attributes:
6777
kwargs.setdefault("hue", "scenario")
78+
6879
kwargs.setdefault("ci", "sd")
6980
kwargs.setdefault("estimator", np.median)
7081

@@ -79,6 +90,284 @@ def lineplot(self, time_axis=None, **kwargs): # pragma: no cover
7990
return ax
8091

8192

93+
def plumeplot( # pragma: no cover
94+
self,
95+
ax=None,
96+
quantiles_plumes=[((0.05, 0.95), 0.5), ((0.5,), 1.0),],
97+
hue_var="scenario",
98+
hue_label="Scenario",
99+
palette=None,
100+
style_var="variable",
101+
style_label="Variable",
102+
dashes=None,
103+
linewidth=2,
104+
time_axis=None,
105+
pre_calculated=False,
106+
quantile_over=("ensemble_member",),
107+
):
108+
"""
109+
Make a plume plot, showing plumes for custom quantiles
110+
111+
Parameters
112+
----------
113+
ax : :obj:`matplotlib.axes._subplots.AxesSubplot`
114+
Axes on which to make the plot
115+
116+
quantiles_plumes : list[tuple[tuple, float]]
117+
Configuration to use when plotting quantiles. Each element is a tuple,
118+
the first element of which is itself a tuple and the second element of
119+
which is the alpha to use for the quantile. If the first element has
120+
length two, these two elements are the quantiles to plot and a plume
121+
will be made between these two quantiles. If the first element has
122+
length one, then a line will be plotted to represent this quantile.
123+
124+
hue_var : str
125+
The column of ``self.meta`` which should be used to distinguish
126+
different hues.
127+
128+
hue_label : str
129+
Label to use in the legend for ``hue_var``.
130+
131+
palette : dict
132+
Dictionary defining the colour to use for different values of
133+
``hue_var``.
134+
135+
style_var : str
136+
The column of ``self.meta`` which should be used to distinguish
137+
different styles.
138+
139+
style_label : str
140+
Label to use in the legend for ``style_var``.
141+
142+
dashes : dict
143+
Dictionary defining the style to use for different values of
144+
``style_var``.
145+
146+
linewidth : float
147+
Width of lines to use (for quantiles which are not to be shown as
148+
plumes)
149+
150+
time_axis : str
151+
Time axis to use for the plot (see :meth:`~ScmRun.timeseries`)
152+
153+
pre_calculated : bool
154+
Are the quantiles pre-calculated? If no, the quantiles will be
155+
calculated within this function. Pre-calculating the quantiles using
156+
:meth:`ScmRun.quantiles_over` can lead to faster plotting if multiple
157+
plots are to be made with the same quantiles.
158+
159+
quantile_over : str, tuple[str]
160+
Columns of ``self.meta`` over which the quantiles should be calculated.
161+
Only used if ``pre_calculated`` is ``False``.
162+
163+
Returns
164+
-------
165+
:obj:`matplotlib.axes._subplots.AxesSubplot`, list
166+
Axes on which the plot was made and the legend items we have made (in
167+
case the user wants to move the legend to a different position for
168+
example)
169+
170+
Examples
171+
--------
172+
>>> scmrun = ScmRun(
173+
... data=np.random.random((10, 3)).T,
174+
... columns={
175+
... "model": ["a_iam"],
176+
... "climate_model": ["a_model"] * 5 + ["a_model_2"] * 5,
177+
... "scenario": ["a_scenario"] * 5 + ["a_scenario_2"] * 5,
178+
... "ensemble_member": list(range(5)) + list(range(5)),
179+
... "region": ["World"],
180+
... "variable": ["Surface Air Temperature Change"],
181+
... "unit": ["K"],
182+
... },
183+
... index=[2005, 2010, 2015],
184+
... )
185+
186+
Plot the plumes, calculated over the different ensemble members.
187+
188+
>>> scmrun.plumeplot(quantile_over="ensemble_member")
189+
190+
Pre-calculate the quantiles, then plot
191+
192+
>>> summary_stats = ScmRun(
193+
... scmrun.quantiles_over("ensemble_member", quantiles=quantiles)
194+
... )
195+
>>> summary_stats.plumeplot(pre_calculated=True)
196+
197+
Note
198+
----
199+
``scmdata`` is not a plotting library so this function is provided as is,
200+
with little testing. In some ways, it is more intended as inspiration for
201+
other users than as a robust plotting tool.
202+
"""
203+
if not has_matplotlib:
204+
raise ImportError("matplotlib is not installed. Run 'pip install matplotlib'")
205+
206+
if not pre_calculated:
207+
quantiles = [v for qv in quantiles_plumes for v in qv[0]]
208+
_pdf = type(self)(self.quantiles_over(quantile_over, quantiles=quantiles))
209+
else:
210+
_pdf = self
211+
212+
if ax is None:
213+
ax = plt.figure().add_subplot(111)
214+
215+
_palette = {} if palette is None else palette
216+
217+
if dashes is None:
218+
_dashes = {}
219+
lines = ["-", "--", "-.", ":"]
220+
linestyle_cycler = cycle(lines)
221+
else:
222+
_dashes = dashes
223+
224+
_plotted_lines = False
225+
226+
quantile_labels = {}
227+
for q, alpha in quantiles_plumes:
228+
for hdf in _pdf.groupby(hue_var):
229+
hue_value = hdf.get_unique_meta(hue_var, no_duplicates=True)
230+
pkwargs = {"alpha": alpha}
231+
232+
for hsdf in hdf.groupby(style_var):
233+
style_value = hsdf.get_unique_meta(style_var, no_duplicates=True)
234+
235+
xaxis = hsdf.timeseries(time_axis=time_axis).columns.tolist()
236+
if palette is not None:
237+
try:
238+
pkwargs["color"] = _palette[hue_value]
239+
except KeyError as exc:
240+
error_msg = "{} not in palette: {}".format(hue_value, palette)
241+
raise KeyError(error_msg) from exc
242+
243+
elif hue_value in _palette:
244+
pkwargs["color"] = _palette[hue_value]
245+
246+
if len(q) == 2:
247+
label = "{:.0f}th - {:.0f}th".format(q[0] * 100, q[1] * 100)
248+
p = ax.fill_between(
249+
xaxis,
250+
_get_1d_or_raise(
251+
hsdf.filter(quantile=q[0]), hue_var, style_var
252+
),
253+
_get_1d_or_raise(
254+
hsdf.filter(quantile=q[1]), hue_var, style_var
255+
),
256+
label=label,
257+
**pkwargs
258+
)
259+
260+
if palette is None:
261+
_palette[hue_value] = p.get_facecolor()[0]
262+
263+
elif len(q) == 1:
264+
_plotted_lines = True
265+
266+
if dashes is not None:
267+
try:
268+
pkwargs["linestyle"] = _dashes[style_value]
269+
except KeyError as exc:
270+
error_msg = "{} not in dashes: {}".format(
271+
style_value, dashes
272+
)
273+
raise KeyError(error_msg) from exc
274+
else:
275+
_dashes[style_value] = next(linestyle_cycler)
276+
pkwargs["linestyle"] = _dashes[style_value]
277+
278+
if isinstance(q[0], str):
279+
label = q[0]
280+
else:
281+
label = "{:.0f}th".format(q[0] * 100)
282+
283+
p = ax.plot(
284+
xaxis,
285+
_get_1d_or_raise(
286+
hsdf.filter(quantile=q[0]), hue_var, style_var
287+
),
288+
label=label,
289+
linewidth=linewidth,
290+
**pkwargs
291+
)[0]
292+
293+
if dashes is None:
294+
_dashes[style_value] = p.get_linestyle()
295+
296+
else:
297+
raise ValueError(
298+
"quantiles to plot must be of length one or two, "
299+
"received: {}".format(q)
300+
)
301+
302+
if label not in quantile_labels:
303+
quantile_labels[label] = p
304+
305+
# Fake the line handles for the legend
306+
hue_val_lines = [
307+
mlines.Line2D([0], [0], color=_palette[hue_value], label=hue_value)
308+
for hue_value in self.get_unique_meta(hue_var)
309+
]
310+
311+
legend_items = [
312+
mpatches.Patch(alpha=0, label="Quantiles"),
313+
*quantile_labels.values(),
314+
mpatches.Patch(alpha=0, label=hue_label),
315+
*hue_val_lines,
316+
]
317+
318+
if _plotted_lines:
319+
style_val_lines = [
320+
mlines.Line2D(
321+
[0],
322+
[0],
323+
linestyle=_dashes[style_value],
324+
label=style_value,
325+
color="gray",
326+
linewidth=linewidth,
327+
)
328+
for style_value in self.get_unique_meta(style_var)
329+
]
330+
legend_items += [
331+
mpatches.Patch(alpha=0, label=style_label),
332+
*style_val_lines,
333+
]
334+
else:
335+
if dashes is not None:
336+
warnings.warn(
337+
"`dashes` was passed but no lines were plotted, the style settings will not be used"
338+
)
339+
340+
ax.legend(handles=legend_items, loc="best")
341+
342+
units = self.get_unique_meta("unit")
343+
if len(units) == 1:
344+
ax.set_ylabel(units[0])
345+
346+
return ax, legend_items
347+
348+
349+
def _get_1d_or_raise(in_scmrun, hue_var, style_var):
350+
out_arr = in_scmrun.values.squeeze()
351+
if len(out_arr.shape) > 1:
352+
quantile = in_scmrun.get_unique_meta("quantile", True)
353+
hue_var_value = in_scmrun.get_unique_meta(hue_var, True)
354+
style_var_value = in_scmrun.get_unique_meta(style_var, True)
355+
error_msg = (
356+
"More than one timeseries for "
357+
"quantile: {}, "
358+
"{}: {}, "
359+
"{}: {}.\n"
360+
"Please process your data to create unique quantile timeseries "
361+
"before calling :meth:`plumeplot`.\n"
362+
"Found: {}".format(
363+
quantile, hue_var, hue_var_value, style_var, style_var_value, in_scmrun,
364+
)
365+
)
366+
raise ValueError(error_msg)
367+
368+
return out_arr
369+
370+
82371
def _deprecated_line_plot(self, **kwargs): # pragma: no cover
83372
"""
84373
Make a line plot via `seaborn's lineplot <https://seaborn.pydata.org/generated/seaborn.lineplot.html>`_
@@ -112,6 +401,7 @@ def inject_plotting_methods(cls):
112401
methods = [
113402
("lineplot", lineplot),
114403
("line_plot", _deprecated_line_plot), # for compatibility
404+
("plumeplot", plumeplot),
115405
]
116406

117407
for name, f in methods:

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,24 @@ def test_processing_scm_df():
327327
)
328328

329329

330+
@pytest.fixture(scope="function")
331+
def plumeplot_scmrun():
332+
n_ems = 30
333+
yield ScmRun(
334+
data=np.random.random((n_ems * 2, 3)).T,
335+
columns={
336+
"model": ["a_iam"],
337+
"climate_model": ["a_model"] * n_ems + ["a_model_2"] * n_ems,
338+
"scenario": ["a_scenario"] * n_ems + ["a_scenario_2"] * n_ems,
339+
"ensemble_member": list(range(n_ems)) + list(range(n_ems)),
340+
"region": ["World"],
341+
"variable": ["Surface Air Temperature Change"],
342+
"unit": ["K"],
343+
},
344+
index=[datetime(2005, 1, 1), datetime(2010, 1, 1), datetime(2015, 6, 12)],
345+
)
346+
347+
330348
append_scm_df_pairs_cols = {
331349
"model": ["a_iam"],
332350
"climate_model": ["a_model"],
@@ -491,6 +509,7 @@ def test_append_scm_runs(request):
491509
def iamdf_type():
492510
if not IamDataFrame:
493511
pytest.skip("pyam not installed")
512+
494513
return IamDataFrame
495514

496515

0 commit comments

Comments
 (0)