ClimateGraph 0.4.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.
- ClimateGraph/__init__.py +9 -0
- ClimateGraph/appkernel.py +148 -0
- ClimateGraph/cli.py +63 -0
- ClimateGraph/data/__init__.py +8 -0
- ClimateGraph/data/data.py +508 -0
- ClimateGraph/data/point_surface.py +91 -0
- ClimateGraph/data/regular_grid.py +25 -0
- ClimateGraph/data/satellite_swath.py +21 -0
- ClimateGraph/domain/__init__.py +11 -0
- ClimateGraph/domain/domain.py +181 -0
- ClimateGraph/domain/domains.py +279 -0
- ClimateGraph/plot/__init__.py +11 -0
- ClimateGraph/plot/plot.py +402 -0
- ClimateGraph/plot/plots.py +1054 -0
- ClimateGraph/plot/primitive.py +99 -0
- ClimateGraph/plot/primitives.py +160 -0
- ClimateGraph/reader/__init__.py +15 -0
- ClimateGraph/reader/reader/__init__.py +14 -0
- ClimateGraph/reader/reader/point_surface/__init__.py +7 -0
- ClimateGraph/reader/reader/point_surface/csv_base.py +135 -0
- ClimateGraph/reader/reader/point_surface/default.py +36 -0
- ClimateGraph/reader/reader/point_surface/dmc.py +10 -0
- ClimateGraph/reader/reader/point_surface/sinca.py +10 -0
- ClimateGraph/reader/reader/point_surface/single_file.py +60 -0
- ClimateGraph/reader/reader/point_surface/station_per_file.py +55 -0
- ClimateGraph/reader/reader/point_surface/variable_per_file.py +91 -0
- ClimateGraph/reader/reader/reader.py +353 -0
- ClimateGraph/reader/reader/regular_grid/__init__.py +7 -0
- ClimateGraph/reader/reader/regular_grid/chimere.py +24 -0
- ClimateGraph/reader/reader/regular_grid/default.py +51 -0
- ClimateGraph/reader/reader/regular_grid/wrf.py +53 -0
- ClimateGraph/utils/__init__.py +1 -0
- ClimateGraph/utils/control_model.py +292 -0
- ClimateGraph/utils/dataset_utils.py +287 -0
- ClimateGraph/utils/general_utils.py +259 -0
- ClimateGraph/utils/parser.py +269 -0
- ClimateGraph/utils/registry.py +81 -0
- ClimateGraph/utils/resample_engine.py +156 -0
- climategraph-0.4.0.dist-info/METADATA +177 -0
- climategraph-0.4.0.dist-info/RECORD +44 -0
- climategraph-0.4.0.dist-info/WHEEL +5 -0
- climategraph-0.4.0.dist-info/entry_points.txt +2 -0
- climategraph-0.4.0.dist-info/licenses/LICENSE +21 -0
- climategraph-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Figure generation.
|
|
2
|
+
|
|
3
|
+
The abstract base for every plot type: resolves the domains a figure runs
|
|
4
|
+
under, iterates the time, domain and variable product, and handles axis
|
|
5
|
+
decoration and saving so the concrete types only draw.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import matplotlib as mpl
|
|
13
|
+
import numpy as np
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
mpl.use("Agg")
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
|
|
19
|
+
from ClimateGraph.data import Data
|
|
20
|
+
from ClimateGraph.domain import Domain
|
|
21
|
+
from ClimateGraph.utils.general_utils import normalize_time
|
|
22
|
+
from ClimateGraph.utils.registry import RegistryMixin
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _grid_positions(
|
|
28
|
+
lo: float,
|
|
29
|
+
hi: float,
|
|
30
|
+
spec: int | str,
|
|
31
|
+
offset: float = 0.0,
|
|
32
|
+
tick_locs: list[float] | None = None,
|
|
33
|
+
) -> list[float]:
|
|
34
|
+
"""Where to draw an axis's reference lines.
|
|
35
|
+
|
|
36
|
+
Two modes:
|
|
37
|
+
- ``spec == "ticks"``: one line per major tick (``tick_locs``), spaced by the
|
|
38
|
+
median gap between them.
|
|
39
|
+
- ``spec`` an int N: exactly N evenly-spaced *interior* lines — none land on
|
|
40
|
+
the spines — spaced by ``(hi - lo) / (N + 1)``.
|
|
41
|
+
|
|
42
|
+
``offset`` then shifts every position by that fraction of the spacing (0.5 =
|
|
43
|
+
midway between neighbours), and anything pushed outside ``(lo, hi)`` is dropped
|
|
44
|
+
so lines never draw over the frame.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
lo, hi : float
|
|
49
|
+
The axis limits.
|
|
50
|
+
spec : int | str
|
|
51
|
+
``"ticks"`` or the number of interior lines wanted.
|
|
52
|
+
offset : float, optional
|
|
53
|
+
Shift as a fraction of the line spacing, by default 0.0.
|
|
54
|
+
tick_locs : list[float] | None, optional
|
|
55
|
+
Major tick locations, required for the ``"ticks"`` mode.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
list[float]
|
|
60
|
+
Positions to draw lines at, clipped to the axis interior.
|
|
61
|
+
"""
|
|
62
|
+
if spec == "ticks":
|
|
63
|
+
base = [t for t in (tick_locs or []) if lo <= t <= hi]
|
|
64
|
+
if len(base) < 2:
|
|
65
|
+
spacing = (hi - lo) if base else 0.0
|
|
66
|
+
else:
|
|
67
|
+
spacing = float(np.median(np.diff(sorted(base))))
|
|
68
|
+
else:
|
|
69
|
+
n = int(spec)
|
|
70
|
+
if n < 1:
|
|
71
|
+
return []
|
|
72
|
+
spacing = (hi - lo) / (n + 1)
|
|
73
|
+
base = list(np.linspace(lo, hi, n + 2)[1:-1])
|
|
74
|
+
|
|
75
|
+
shift = offset * spacing
|
|
76
|
+
return [p + shift for p in base if lo < p + shift < hi]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
FIGURE_KWARGS = {
|
|
80
|
+
"figsize",
|
|
81
|
+
"dpi",
|
|
82
|
+
"layout",
|
|
83
|
+
"facecolor",
|
|
84
|
+
"edgecolor",
|
|
85
|
+
"frameon",
|
|
86
|
+
"linewidth",
|
|
87
|
+
}
|
|
88
|
+
SAVEFIG_KWARGS = {
|
|
89
|
+
"dpi",
|
|
90
|
+
"format",
|
|
91
|
+
"transparent",
|
|
92
|
+
"bbox_inches",
|
|
93
|
+
"pad_inches",
|
|
94
|
+
"facecolor",
|
|
95
|
+
"edgecolor",
|
|
96
|
+
"orientation",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Plot(RegistryMixin, ABC):
|
|
101
|
+
"""The Plot abstract class.
|
|
102
|
+
|
|
103
|
+
A class that abstracts the plot definition and interface.
|
|
104
|
+
This class contains and implements attributes and methods common to all the plot subclasses.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
registry: dict[str, type["Plot"]] = dict()
|
|
108
|
+
aliases: list[str] = list()
|
|
109
|
+
config: type["BaseModel"] | None = None
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def create(
|
|
113
|
+
cls,
|
|
114
|
+
name: str,
|
|
115
|
+
type: str,
|
|
116
|
+
plot_config: BaseModel,
|
|
117
|
+
data_registry: dict[str, Data],
|
|
118
|
+
domain_registry: dict[str, Domain],
|
|
119
|
+
output_path: Path,
|
|
120
|
+
) -> "Plot":
|
|
121
|
+
"""Creation of a Plot object with the adequate Plot subclass. Meant to be set and lazily ran.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
name : str
|
|
126
|
+
Name of the object, used for reference inside ClimateGraph execution.
|
|
127
|
+
type : str
|
|
128
|
+
Type of Plot, used for lookup in the Plot registry.
|
|
129
|
+
plot_config : BaseModel
|
|
130
|
+
BaseModel object of the corresponding config as a Pydantic model. Used as arguments for the actual plotting.
|
|
131
|
+
data_registry : dict[str, Data]
|
|
132
|
+
Dictionary with all the Data references so Plot's can use them for plotting.
|
|
133
|
+
domain_registry : dict[str, Domain]
|
|
134
|
+
Dictionary with all the Domain references so Plot's can use them for plotting.
|
|
135
|
+
output_path : Path
|
|
136
|
+
Path in which to leave the plot results.
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
Plot
|
|
141
|
+
_description_
|
|
142
|
+
"""
|
|
143
|
+
plot_class = cls.get_plot_class(type)
|
|
144
|
+
kwargs = plot_config.model_extra if plot_config.model_extra else {}
|
|
145
|
+
|
|
146
|
+
return plot_class(
|
|
147
|
+
name,
|
|
148
|
+
plot_config=plot_config,
|
|
149
|
+
data_registry=data_registry,
|
|
150
|
+
domain_registry=domain_registry,
|
|
151
|
+
output_path=output_path,
|
|
152
|
+
**kwargs,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self, name, plot_config, data_registry, domain_registry, output_path, **kwargs
|
|
157
|
+
):
|
|
158
|
+
"""Plot initialization dunder method.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
name : str
|
|
163
|
+
Name of the object, used for reference inside ClimateGraph execution.
|
|
164
|
+
plot_config : BaseModel
|
|
165
|
+
BaseModel object of the corresponding config as a Pydantic model. Used as arguments for the actual domain handling.
|
|
166
|
+
data_registry : dict[str, Data]
|
|
167
|
+
Dictionary with all the Data references so Plot's can use them for plotting.
|
|
168
|
+
domain_registry : dict[str, Domain]
|
|
169
|
+
Dictionary with all the Domain references so Plot's can use them for plotting.
|
|
170
|
+
output_path : Path
|
|
171
|
+
Path in which to leave the plot results.
|
|
172
|
+
"""
|
|
173
|
+
self.plot_config = plot_config
|
|
174
|
+
|
|
175
|
+
self.name = name
|
|
176
|
+
self.data = data_registry
|
|
177
|
+
self.domains = domain_registry
|
|
178
|
+
self.plot_kwargs = kwargs
|
|
179
|
+
|
|
180
|
+
self.output_path = Path(output_path / name)
|
|
181
|
+
self.output_path.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
self._done = False
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def check_plot_class(cls, type: str) -> bool:
|
|
187
|
+
"""Return True if ``type`` names a registered plot."""
|
|
188
|
+
return cls.check_class(type)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def get_plot_class(cls, name: str):
|
|
192
|
+
"""Look up a registered plot subclass by name."""
|
|
193
|
+
return cls.get_class(name)
|
|
194
|
+
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def plot(self):
|
|
197
|
+
"""Abstract method. Run the plot operation with the instance attributes and plot configuration."""
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
def resolve_domains(self) -> dict[str, Domain | None]:
|
|
201
|
+
"""Select the domains this plot references from the registry.
|
|
202
|
+
|
|
203
|
+
Falls back to a single unnamed no-op slot (``{"": None}``) when the plot
|
|
204
|
+
declares no domains, so callers can iterate uniformly.
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
dict[str, Domain | None]
|
|
209
|
+
Mapping of domain name to Domain (or ``None`` for the no-op slot).
|
|
210
|
+
"""
|
|
211
|
+
domains = {
|
|
212
|
+
name: dom
|
|
213
|
+
for name, dom in self.domains.items()
|
|
214
|
+
if name in self.plot_config.domains
|
|
215
|
+
}
|
|
216
|
+
return domains or {"": None}
|
|
217
|
+
|
|
218
|
+
def iterate_contexts(
|
|
219
|
+
self,
|
|
220
|
+
times,
|
|
221
|
+
domains: dict[str, Domain | None],
|
|
222
|
+
vars: list[str] | None,
|
|
223
|
+
):
|
|
224
|
+
"""Yield the (time, domain, var) render contexts.
|
|
225
|
+
|
|
226
|
+
Centralizes the time x domain x var nesting duplicated across the plot
|
|
227
|
+
classes. Time is normalized here (a single date, an interval, or a list
|
|
228
|
+
fanning out into N entries). When ``vars`` is ``None`` a single ``None``
|
|
229
|
+
var slot is yielded per (time, domain) pair — the per-subplot-var-scope
|
|
230
|
+
case where the var is decided downstream.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
times : str | list[str] | None
|
|
235
|
+
The plot's ``time`` field (raw), normalized internally.
|
|
236
|
+
domains : dict[str, Domain | None]
|
|
237
|
+
Resolved domains (see ``resolve_domains``).
|
|
238
|
+
vars : list[str] | None
|
|
239
|
+
Variable names to fan out over, or ``None`` for a single ``None`` slot.
|
|
240
|
+
|
|
241
|
+
Yields
|
|
242
|
+
------
|
|
243
|
+
tuple[str | None, str, Domain | None, str | None]
|
|
244
|
+
``(time_interval, domain_name, domain, var)`` tuples.
|
|
245
|
+
"""
|
|
246
|
+
for time_interval in normalize_time(times):
|
|
247
|
+
for dom_name, dom in domains.items():
|
|
248
|
+
if vars is None:
|
|
249
|
+
yield time_interval, dom_name, dom, None
|
|
250
|
+
else:
|
|
251
|
+
for var in vars:
|
|
252
|
+
yield time_interval, dom_name, dom, var
|
|
253
|
+
|
|
254
|
+
def figure_kwargs(self, **defaults) -> dict:
|
|
255
|
+
"""Build the kwargs for ``plt.figure`` from ``plot_kwargs``.
|
|
256
|
+
|
|
257
|
+
Picks the figure-relevant keys (see ``FIGURE_KWARGS``) out of the
|
|
258
|
+
user-supplied ``plot_kwargs`` and layers them on top of any ``defaults``
|
|
259
|
+
the caller passes, so a plot method only has to write
|
|
260
|
+
``plt.figure(**self.figure_kwargs(figsize=(8, 5)))`` instead of a
|
|
261
|
+
per-key ``.get`` for each matplotlib knob.
|
|
262
|
+
|
|
263
|
+
Parameters
|
|
264
|
+
----------
|
|
265
|
+
**defaults
|
|
266
|
+
Per-plot fallbacks (e.g. ``figsize``) used when the user didn't
|
|
267
|
+
supply that key in the YAML.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
dict
|
|
272
|
+
Kwargs ready to splat into ``plt.figure``.
|
|
273
|
+
"""
|
|
274
|
+
merged = {"figsize": (6, 6), "layout": "constrained", **defaults}
|
|
275
|
+
merged.update({k: v for k, v in self.plot_kwargs.items() if k in FIGURE_KWARGS})
|
|
276
|
+
return merged
|
|
277
|
+
|
|
278
|
+
def savefig_kwargs(self, **defaults) -> dict:
|
|
279
|
+
"""Build the kwargs for ``figure.savefig`` from ``plot_kwargs``.
|
|
280
|
+
|
|
281
|
+
Same idea as ``figure_kwargs`` but for the save-relevant keys (see
|
|
282
|
+
``SAVEFIG_KWARGS``).
|
|
283
|
+
|
|
284
|
+
Returns
|
|
285
|
+
-------
|
|
286
|
+
dict
|
|
287
|
+
Kwargs ready to splat into ``figure.savefig``.
|
|
288
|
+
"""
|
|
289
|
+
merged = {"dpi": 400, "format": "jpg", "transparent": False, **defaults}
|
|
290
|
+
merged.update(
|
|
291
|
+
{k: v for k, v in self.plot_kwargs.items() if k in SAVEFIG_KWARGS}
|
|
292
|
+
)
|
|
293
|
+
return merged
|
|
294
|
+
|
|
295
|
+
def _decorate_axes(self, figure: mpl.figure.Figure):
|
|
296
|
+
"""Apply the config's axis decoration to every data axes.
|
|
297
|
+
|
|
298
|
+
Colorbar axes are skipped. Cartopy ``GeoAxes`` route their grid through
|
|
299
|
+
``gridlines()`` (a proper geographic graticule) using the same computed
|
|
300
|
+
positions as regular axes, so the count and offset behave identically.
|
|
301
|
+
Named ticks are skipped on GeoAxes — they need an explicit ``crs=``.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
figure : mpl.figure.Figure
|
|
306
|
+
The rendered figure, decorated in place.
|
|
307
|
+
"""
|
|
308
|
+
grid = getattr(self.plot_config, "grid", None)
|
|
309
|
+
xticks = getattr(self.plot_config, "xticks", None)
|
|
310
|
+
yticks = getattr(self.plot_config, "yticks", None)
|
|
311
|
+
if grid is None and not xticks and not yticks:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
for ax in figure.axes:
|
|
315
|
+
if ax.get_label() == "<colorbar>":
|
|
316
|
+
continue
|
|
317
|
+
is_geo = hasattr(ax, "projection")
|
|
318
|
+
|
|
319
|
+
if grid is not None:
|
|
320
|
+
self._apply_grid(ax, grid, is_geo)
|
|
321
|
+
|
|
322
|
+
if xticks or yticks:
|
|
323
|
+
if is_geo:
|
|
324
|
+
log.debug(
|
|
325
|
+
"Named ticks are not applied to map axes (they need an "
|
|
326
|
+
"explicit CRS transform); skipping for %r.",
|
|
327
|
+
self.name,
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
if xticks:
|
|
331
|
+
ax.set_xticks(list(xticks))
|
|
332
|
+
ax.set_xticklabels(list(xticks.values()))
|
|
333
|
+
if yticks:
|
|
334
|
+
ax.set_yticks(list(yticks))
|
|
335
|
+
ax.set_yticklabels(list(yticks.values()))
|
|
336
|
+
|
|
337
|
+
def _apply_grid(self, ax, grid, is_geo: bool):
|
|
338
|
+
"""Draw the dashed reference lines on one axes."""
|
|
339
|
+
style = {
|
|
340
|
+
"color": grid.color,
|
|
341
|
+
"alpha": grid.alpha,
|
|
342
|
+
"linestyle": grid.linestyle,
|
|
343
|
+
"linewidth": grid.linewidth,
|
|
344
|
+
}
|
|
345
|
+
positions = {}
|
|
346
|
+
for axis, spec in (("x", grid.x), ("y", grid.y)):
|
|
347
|
+
if spec is None:
|
|
348
|
+
continue
|
|
349
|
+
lo, hi = ax.get_xlim() if axis == "x" else ax.get_ylim()
|
|
350
|
+
ticks = (
|
|
351
|
+
ax.get_xticks().tolist() if axis == "x" else ax.get_yticks().tolist()
|
|
352
|
+
)
|
|
353
|
+
positions[axis] = _grid_positions(lo, hi, spec, grid.offset, ticks)
|
|
354
|
+
|
|
355
|
+
if not positions:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
if is_geo:
|
|
359
|
+
locs = {}
|
|
360
|
+
if "x" in positions:
|
|
361
|
+
locs["xlocs"] = positions["x"]
|
|
362
|
+
if "y" in positions:
|
|
363
|
+
locs["ylocs"] = positions["y"]
|
|
364
|
+
ax.gridlines(draw_labels=False, **locs, **style)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
for value in positions.get("x", []):
|
|
368
|
+
ax.axvline(value, zorder=0, **style)
|
|
369
|
+
for value in positions.get("y", []):
|
|
370
|
+
ax.axhline(value, zorder=0, **style)
|
|
371
|
+
|
|
372
|
+
def _finalize(self, figure: mpl.figure.Figure, filename: str):
|
|
373
|
+
"""Post-render pipeline: decorate the axes, then write the figure.
|
|
374
|
+
|
|
375
|
+
The single place every plot class funnels through once its data is drawn
|
|
376
|
+
(mirrors ``Reader.read -> _postprocess -> _finalize``). Additional
|
|
377
|
+
figure-level decorators hook in here rather than inside ``savefig``,
|
|
378
|
+
which stays a pure writer.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
figure : mpl.figure.Figure
|
|
383
|
+
Matplotlib figure to decorate and save.
|
|
384
|
+
filename : str
|
|
385
|
+
Filename to be used. Doesn't have to include file format, just name.
|
|
386
|
+
"""
|
|
387
|
+
self._decorate_axes(figure)
|
|
388
|
+
self.savefig(figure, filename)
|
|
389
|
+
|
|
390
|
+
def savefig(self, figure: mpl.figure.Figure, filename: str):
|
|
391
|
+
"""Matplotlib Figure saving. Used by plot function to save to system. Manages kwargs given through the plot configuration relevant to saving.
|
|
392
|
+
|
|
393
|
+
Parameters
|
|
394
|
+
----------
|
|
395
|
+
figure : mpl.figure.Figure
|
|
396
|
+
Matplotlib figure to be saved.
|
|
397
|
+
filename : str
|
|
398
|
+
Filename to be used. Doesn't have to include file format, just name.
|
|
399
|
+
"""
|
|
400
|
+
figure.savefig(self.output_path / filename, **self.savefig_kwargs())
|
|
401
|
+
|
|
402
|
+
plt.close(fig=figure)
|