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,181 @@
1
+ """Spatial and attribute subsetting.
2
+
3
+ A domain narrows a dataset to the part a plot is about. Applying one is a
4
+ two-step template: an optional reprojection onto another dataset's geometry,
5
+ then the subclass's filter.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from ClimateGraph.utils.registry import RegistryMixin
14
+
15
+ if TYPE_CHECKING:
16
+ from ClimateGraph.data.data import Data
17
+
18
+
19
+ class Domain(RegistryMixin, ABC):
20
+ """The Domain abstract class.
21
+
22
+ A class that abstracts the domain definition and interface.
23
+ This class contains and implements attributes and methods common to all the domain subclasses.
24
+
25
+ A domain application is a two-step template: an optional spatial *resample*
26
+ onto another dataset's geometry (``resample_to`` in the config), followed by
27
+ the subclass's own *filter* step (``_filter``). This lets a single domain
28
+ express "this data, on that geometry, for this subset".
29
+ """
30
+
31
+ registry: dict[str, type["Domain"]] = dict()
32
+ aliases: list[str] = list()
33
+ config: type["BaseModel"] | None = None
34
+
35
+ @classmethod
36
+ def create(
37
+ cls,
38
+ name: str,
39
+ type: str,
40
+ domain_config: BaseModel,
41
+ target_data: "Data | None" = None,
42
+ ) -> "Domain":
43
+ """Build a Domain of the subclass matching ``type``.
44
+
45
+ Parameters
46
+ ----------
47
+ name : str
48
+ Name of the object, used for reference inside ClimateGraph execution.
49
+ type : str
50
+ Type of Domain, used for lookup in the Domain registry.
51
+ domain_config : BaseModel
52
+ BaseModel object of the corresponding config as a Pydantic model. Used as arguments for the actual domain handling.
53
+ target_data : Data | None, optional
54
+ Resolved target dataset for the optional resample pre-step. The parser
55
+ resolves ``domain_config.resample_to`` (a dataset name) into the actual
56
+ ``Data`` object and passes it here. ``None`` when the domain does no
57
+ resampling. By default None.
58
+
59
+ Returns
60
+ -------
61
+ Domain
62
+ Domain object with the config, ready to be applied.
63
+ """
64
+
65
+ domain_class = cls.get_domain_class(type)
66
+ kwargs = domain_config.model_extra if domain_config.model_extra else {}
67
+
68
+ return domain_class(
69
+ name,
70
+ domain_config=domain_config,
71
+ target_data=target_data,
72
+ **kwargs,
73
+ )
74
+
75
+ def __init__(
76
+ self,
77
+ name: str,
78
+ domain_config: BaseModel,
79
+ target_data: "Data | None" = None,
80
+ **kwargs,
81
+ ):
82
+ """Create a domain. ``target_data`` is the geometry to reproject onto, if any.
83
+
84
+ Parameters
85
+ ----------
86
+ name : str
87
+ Name of the object, used for reference inside ClimateGraph execution.
88
+ domain_config : BaseModel
89
+ BaseModel object of the corresponding config as a Pydantic model. Used as arguments for the actual domain handling.
90
+ target_data : Data | None, optional
91
+ Resolved target dataset for the optional resample pre-step, or None
92
+ when the domain does no resampling. By default None.
93
+ """
94
+ self.domain_config = domain_config
95
+
96
+ self.name = name
97
+ self.domain_kwargs = kwargs
98
+ self._resample_target = target_data
99
+
100
+ @classmethod
101
+ def check_domain_class(cls, type: str) -> bool:
102
+ """Return True if ``type`` names a registered domain."""
103
+ return cls.check_class(type)
104
+
105
+ @classmethod
106
+ def get_domain_class(cls, name: str):
107
+ """Look up a registered domain subclass by name."""
108
+ return cls.get_class(name)
109
+
110
+ def apply(self, data: "Data") -> "Data":
111
+ """Apply the domain: optional resample pre-step, then the filter step.
112
+
113
+ Parameters
114
+ ----------
115
+ data : Data
116
+ Data object into which the domain will be applied.
117
+
118
+ Returns
119
+ -------
120
+ Data
121
+ Data object with the domain applied. When ``resample_to`` is set the
122
+ result carries the target's topology/geometry (and the source's identity);
123
+ otherwise the topology is unchanged.
124
+ """
125
+ if self._resample_target is not None:
126
+ data = self._resample(data)
127
+ return self._filter(data)
128
+
129
+ def _resample(self, data: "Data") -> "Data":
130
+ """Reproject ``data`` onto the resample target's geometry.
131
+
132
+ ``resample_vars`` is purely spatial, so the reprojected result keeps
133
+ ``data``'s own time axis and inherits the target's spatial coords (site /
134
+ region / lat / lon). It memoizes on the source, so several domains sharing a
135
+ ``resample_to`` target (a ``one_for_each`` fan-out, or many hand-written
136
+ domains onto one obs network) pay the projection cost once — the 2nd..Nth
137
+ ``_resample`` here hit that cache. The returned value is a cheap
138
+ target-topology wrapper wearing the SOURCE's identity (name + vars).
139
+
140
+ Parameters
141
+ ----------
142
+ data : Data
143
+ Source data to reproject.
144
+
145
+ Returns
146
+ -------
147
+ Data
148
+ The source data on the target's topology.
149
+ """
150
+ if data is self._resample_target:
151
+ return data
152
+ vars_list = list(data.obj.data_vars)
153
+ resampled_ds = self._resample_target.resample_vars(
154
+ data,
155
+ vars_list,
156
+ radius_of_influence=self.domain_config.radius_of_influence,
157
+ engine=self.domain_config.engine,
158
+ engine_kwargs=self.domain_config.engine_kwargs,
159
+ )
160
+ clean_ds = resampled_ds.rename({f"{v}__{data.name}": v for v in vars_list})
161
+ result = self._resample_target.copy()
162
+ result.obj = clean_ds
163
+ result.name = data.name
164
+ result._vars = data.vars
165
+ return result
166
+
167
+ @abstractmethod
168
+ def _filter(self, data: "Data") -> "Data":
169
+ """Subclass hook: narrow ``data`` (already resampled if requested).
170
+
171
+ Parameters
172
+ ----------
173
+ data : Data
174
+ Data object to filter.
175
+
176
+ Returns
177
+ -------
178
+ Data
179
+ Filtered Data object. If the filter doesn't apply, the original is returned.
180
+ """
181
+ pass
@@ -0,0 +1,279 @@
1
+ """The concrete domain types and their config models."""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any, Literal
5
+
6
+ import geopandas as gpd
7
+ import pandas as pd
8
+ import regionmask
9
+ import shapely.geometry as gm
10
+ from pydantic import BaseModel, model_validator
11
+
12
+ from .domain import Domain
13
+
14
+ if TYPE_CHECKING:
15
+ from ClimateGraph.data.data import Data
16
+
17
+
18
+ class BaseDomainConfig(BaseModel):
19
+ """BaseDomainConfig Shared config fields for every domain.
20
+
21
+ Carries the optional spatial resample pre-step: when ``resample_to`` names
22
+ another dataset, the domain first reprojects the incoming data onto that
23
+ dataset's geometry, then runs its own filter over the resampled result.
24
+ """
25
+
26
+ resample_to: str | None = None
27
+ """Name of another dataset to reproject onto before filtering. None = no resample."""
28
+ radius_of_influence: int = 50000
29
+ engine: str = "pyresample"
30
+ engine_kwargs: dict | None = None
31
+
32
+
33
+ class AttributeConfig(BaseDomainConfig):
34
+ """AttributeConfig Pydantic model for the Attribute domain definition in the config file."""
35
+
36
+ type: Literal["attribute", "attr"]
37
+ field_name: str
38
+ field_value: Any
39
+ one_for_each: bool = False
40
+ """When field_value is a list and this is True, the parser expands this
41
+ single domain block into one Attribute domain per value. When False (default),
42
+ the set is analyzed together."""
43
+
44
+
45
+ class Attribute(Domain):
46
+ """Attribute Domain specification by attribute in a xr.Dataset, uses Attribute==Value as a mask"""
47
+
48
+ config = AttributeConfig
49
+ aliases = ["attribute", "attr"]
50
+
51
+ def _filter(self, data: "Data") -> "Data":
52
+ """Keep the data where the field matches the configured value.
53
+
54
+ Parameters
55
+ ----------
56
+ data : Data
57
+ Data to be filtered
58
+
59
+ Returns
60
+ -------
61
+ Data
62
+ Data filtered containing the attribute defined.
63
+ """
64
+ field_name = self.domain_config.field_name
65
+ field_value = self.domain_config.field_value
66
+ if isinstance(field_value, list):
67
+ mask = data.obj[field_name].isin(field_value)
68
+ else:
69
+ mask = data.obj[field_name] == field_value
70
+ result = data.copy()
71
+ result.obj = data.obj.where(mask.compute(), drop=True)
72
+ return result
73
+
74
+
75
+ class PolygonConfig(BaseDomainConfig):
76
+ """PolygonConfig Pydantic model for the Polygon domain definition in the config file. The only argument is vertex which is a List of tuples representing vertices, or List of vertices (List of List of tuples)"""
77
+
78
+ type: Literal["polygon", "poly"]
79
+ vertex: list[tuple[float, float]] | list[list[tuple[float, float]]]
80
+
81
+
82
+ class Polygon(Domain):
83
+ """Polygon Domain specification by polygons specified with a list of vertices, or list of list of vertices (multipolygon)"""
84
+
85
+ config = PolygonConfig
86
+ aliases = ["polygon", "poly"]
87
+
88
+ def _filter(self, data: "Data") -> "Data":
89
+ """Build polygons from the configured vertices and mask the data with them.
90
+
91
+ Parameters
92
+ ----------
93
+ data : Data
94
+ Data to be filtered by the polygons
95
+
96
+ Returns
97
+ -------
98
+ Data
99
+ Data filtered by the polygon(s)
100
+ """
101
+ vertex = self.domain_config.vertex
102
+ if isinstance(vertex[0], tuple):
103
+ vertex = [vertex]
104
+ polygons = []
105
+
106
+ for p in vertex:
107
+ polygons.append(gm.Polygon(p))
108
+
109
+ polygons = gm.MultiPolygon(polygons)
110
+ regions = regionmask.Regions([polygons])
111
+
112
+ # Regionmask requires "lat" and "lon"
113
+ region_mask = regions.mask(
114
+ data.obj.rename({"latitude": "lat", "longitude": "lon"})
115
+ )
116
+ region_mask = region_mask.rename({"lat": "latitude", "lon": "longitude"})
117
+
118
+ result = data.copy()
119
+ result.obj = data.obj.where(region_mask.notnull())
120
+ return result
121
+
122
+
123
+ class AllConfig(BaseDomainConfig):
124
+ """AllConfig Pydantic model for the All domain — no filtering applied."""
125
+
126
+ type: Literal["all"]
127
+
128
+
129
+ class All(Domain):
130
+ """All domain that returns data unchanged — explicit 'use all data' marker.
131
+
132
+ With ``resample_to`` set it becomes a pure reprojection (resample, no filter).
133
+ """
134
+
135
+ config = AllConfig
136
+ aliases = ["all"]
137
+
138
+ def _filter(self, data: "Data") -> "Data":
139
+ """Return the data unchanged."""
140
+ return data
141
+
142
+
143
+ class PointItem(BaseModel):
144
+ """A single named point for an inline ``Points`` domain."""
145
+
146
+ name: str
147
+ lat: float
148
+ lon: float
149
+
150
+
151
+ # Tolerated CSV header spellings for each logical column.
152
+ _POINT_COL_ALIASES = {
153
+ "name": ("name", "site", "station", "id"),
154
+ "lat": ("latitude", "lat", "latitud"),
155
+ "lon": ("longitude", "lon", "longitud"),
156
+ }
157
+
158
+
159
+ class PointsConfig(BaseDomainConfig):
160
+ """PointsConfig Resample onto a set of named points given inline or via CSV.
161
+
162
+ The domain carries its own target geometry (the points), so ``resample_to`` is
163
+ unused; ``radius_of_influence``/``engine`` from the base still drive the resample.
164
+ """
165
+
166
+ type: Literal["points", "pts"]
167
+ path: Path | None = None
168
+ """CSV of named points (columns: name + latitude/longitude, aliases tolerated)."""
169
+ points: list[PointItem] | None = None
170
+ """Inline alternative to ``path``: a list of ``{name, lat, lon}``."""
171
+ name_col: str = "name"
172
+ lat_col: str = "latitude"
173
+ lon_col: str = "longitude"
174
+ one_for_each: bool = False
175
+ """Expand into one domain per point (named by the point) instead of one grouped
176
+ domain over all points."""
177
+ select_name: str | None = None
178
+ """Internal: set by the parser on each fan-out expansion to the point to keep."""
179
+
180
+ @model_validator(mode="after")
181
+ def _exactly_one_source(self) -> "PointsConfig":
182
+ """Require exactly one source of points: an inline list or a file."""
183
+ if (self.path is None) == (self.points is None):
184
+ raise ValueError(
185
+ "Points domain needs exactly one of 'path' (CSV) or 'points' (inline)."
186
+ )
187
+ return self
188
+
189
+ def _resolve_col(self, columns, logical: str, configured: str) -> str:
190
+ """Find the CSV column for a logical field (configured name, else an alias)."""
191
+ lower = {c.lower(): c for c in columns}
192
+ for candidate in (configured, *_POINT_COL_ALIASES[logical]):
193
+ if candidate.lower() in lower:
194
+ return lower[candidate.lower()]
195
+ raise ValueError(
196
+ f"Points CSV is missing a '{logical}' column "
197
+ f"(looked for {configured!r} or {_POINT_COL_ALIASES[logical]}); "
198
+ f"has {list(columns)}."
199
+ )
200
+
201
+ def resolve_points(self) -> list[tuple[str, float, float]]:
202
+ """Return the points as ``[(name, lat, lon), ...]`` from inline or CSV."""
203
+ if self.points is not None:
204
+ return [(p.name, p.lat, p.lon) for p in self.points]
205
+ df = pd.read_csv(self.path)
206
+ name_c = self._resolve_col(df.columns, "name", self.name_col)
207
+ lat_c = self._resolve_col(df.columns, "lat", self.lat_col)
208
+ lon_c = self._resolve_col(df.columns, "lon", self.lon_col)
209
+ return [
210
+ (str(r[name_c]), float(r[lat_c]), float(r[lon_c])) for _, r in df.iterrows()
211
+ ]
212
+
213
+
214
+ class Points(Domain):
215
+ """Points domain: resample any dataset onto a fixed set of named points.
216
+
217
+ The parser builds a ``PointSurface`` target from the config's points and injects
218
+ it as the resample target, so the base ``apply`` template reprojects onto the
219
+ points and then ``_filter`` runs. Grouped (``select_name`` None) keeps every
220
+ point; a fan-out expansion selects its single named point.
221
+ """
222
+
223
+ config = PointsConfig
224
+ aliases = ["points", "pts"]
225
+
226
+ def _filter(self, data: "Data") -> "Data":
227
+ """Select a single site when this domain is one of a fan-out expansion.
228
+
229
+ The resampling onto the point set has already happened in ``apply``.
230
+ Without ``select_name`` the whole point set is kept.
231
+ """
232
+ sel = self.domain_config.select_name
233
+ if sel is None:
234
+ return data
235
+ result = data.copy()
236
+ result.obj = data.obj.sel(site=[sel])
237
+ return result
238
+
239
+
240
+ class ShapefileConfig(BaseDomainConfig):
241
+ """ShapefileConfig Pydantic model for the Polygon domain definition in the config file. Receives a local path to the shapefile, and a value for filtering."""
242
+
243
+ type: Literal["shapefile", "shp"]
244
+ path: Path
245
+ field_value: Any
246
+
247
+
248
+ class Shapefile(Domain):
249
+ """Shapefile domain defined by an imported shapefile and a field value inside of it."""
250
+
251
+ config = ShapefileConfig
252
+ aliases = ["shapefile", "shp"]
253
+
254
+ def _filter(self, data: "Data") -> "Data":
255
+ """Build a region mask from the shapefile and apply it to the data.
256
+
257
+ Parameters
258
+ ----------
259
+ data : Data
260
+ Data to be filtered by the shapefile.
261
+
262
+ Returns
263
+ -------
264
+ Data
265
+ Data filtered by the shapefile
266
+ """
267
+ path = self.domain_config.path
268
+ field_value = self.domain_config.field_value
269
+
270
+ regions = regionmask.from_geopandas(gpd.read_file(path))
271
+
272
+ region_mask = regions.mask(
273
+ data.obj.rename({"latitude": "lat", "longitude": "lon"})
274
+ )
275
+ region_mask = region_mask.rename({"lat": "latitude", "lon": "longitude"})
276
+
277
+ result = data.copy()
278
+ result.obj = data.obj.where(region_mask == field_value)
279
+ return result
@@ -0,0 +1,11 @@
1
+ """Figure generation and the plot types available."""
2
+
3
+ import importlib
4
+ import pkgutil
5
+
6
+ for module in pkgutil.iter_modules(__path__):
7
+ importlib.import_module(f"{__name__}.{module.name}")
8
+
9
+ from .plot import Plot
10
+
11
+ __all__ = ["Plot"]