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.
Files changed (44) hide show
  1. ClimateGraph/__init__.py +9 -0
  2. ClimateGraph/appkernel.py +148 -0
  3. ClimateGraph/cli.py +63 -0
  4. ClimateGraph/data/__init__.py +8 -0
  5. ClimateGraph/data/data.py +508 -0
  6. ClimateGraph/data/point_surface.py +91 -0
  7. ClimateGraph/data/regular_grid.py +25 -0
  8. ClimateGraph/data/satellite_swath.py +21 -0
  9. ClimateGraph/domain/__init__.py +11 -0
  10. ClimateGraph/domain/domain.py +181 -0
  11. ClimateGraph/domain/domains.py +279 -0
  12. ClimateGraph/plot/__init__.py +11 -0
  13. ClimateGraph/plot/plot.py +402 -0
  14. ClimateGraph/plot/plots.py +1054 -0
  15. ClimateGraph/plot/primitive.py +99 -0
  16. ClimateGraph/plot/primitives.py +160 -0
  17. ClimateGraph/reader/__init__.py +15 -0
  18. ClimateGraph/reader/reader/__init__.py +14 -0
  19. ClimateGraph/reader/reader/point_surface/__init__.py +7 -0
  20. ClimateGraph/reader/reader/point_surface/csv_base.py +135 -0
  21. ClimateGraph/reader/reader/point_surface/default.py +36 -0
  22. ClimateGraph/reader/reader/point_surface/dmc.py +10 -0
  23. ClimateGraph/reader/reader/point_surface/sinca.py +10 -0
  24. ClimateGraph/reader/reader/point_surface/single_file.py +60 -0
  25. ClimateGraph/reader/reader/point_surface/station_per_file.py +55 -0
  26. ClimateGraph/reader/reader/point_surface/variable_per_file.py +91 -0
  27. ClimateGraph/reader/reader/reader.py +353 -0
  28. ClimateGraph/reader/reader/regular_grid/__init__.py +7 -0
  29. ClimateGraph/reader/reader/regular_grid/chimere.py +24 -0
  30. ClimateGraph/reader/reader/regular_grid/default.py +51 -0
  31. ClimateGraph/reader/reader/regular_grid/wrf.py +53 -0
  32. ClimateGraph/utils/__init__.py +1 -0
  33. ClimateGraph/utils/control_model.py +292 -0
  34. ClimateGraph/utils/dataset_utils.py +287 -0
  35. ClimateGraph/utils/general_utils.py +259 -0
  36. ClimateGraph/utils/parser.py +269 -0
  37. ClimateGraph/utils/registry.py +81 -0
  38. ClimateGraph/utils/resample_engine.py +156 -0
  39. climategraph-0.4.0.dist-info/METADATA +177 -0
  40. climategraph-0.4.0.dist-info/RECORD +44 -0
  41. climategraph-0.4.0.dist-info/WHEEL +5 -0
  42. climategraph-0.4.0.dist-info/entry_points.txt +2 -0
  43. climategraph-0.4.0.dist-info/licenses/LICENSE +21 -0
  44. 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)