Skip to content

Commit fd1035d

Browse files
committed
added save data after read, and after resample. added a all domain, operation traceback for data manipulation saved to dataset history and logged into debug mode log, abstracted the registry into its own mixin, made specific dim reduction possible with np funcs, sel and isel, added better pydantic error reports
1 parent e206ffd commit fd1035d

16 files changed

Lines changed: 522 additions & 198 deletions

File tree

ClimateGraph/data/data.py

Lines changed: 41 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -11,75 +11,34 @@
1111
from ClimateGraph.reader import Reader
1212
from ClimateGraph.reader.reader.reader import ReadSpec
1313
from ClimateGraph.utils.dataset_utils import (
14+
_record,
1415
change_unit,
16+
dim_reduction,
1517
normalize_vars,
1618
time_resampling,
1719
)
1820
from ClimateGraph.utils.general_utils import ReductionMethodEnum
21+
from ClimateGraph.utils.registry import RegistryMixin
1922

2023

21-
class Data(ABC):
24+
class Data(RegistryMixin, ABC):
2225
"""The Data abstract class.
2326
2427
A class that abstracts the core nature of environmental data, independent of the topology of the data.
2528
This class contains and implements attributes and methods common to all the Data that ClimateGraph is supposed to handle.
2629
"""
2730

2831
registry: dict[str, type["Data"]] = {}
29-
type_aliases: list[str] = list()
30-
31-
# Allows for self registering, and alias registering, for then looking up the right Data Class.
32-
def __init_subclass__(cls, **kwargs):
33-
"""__init_subclass__ This Dunder method is being used to dinamically register all inheriting classes from Data, this helps with Data creation."""
34-
super().__init_subclass__(**kwargs)
35-
Data.registry[cls.__name__.lower()] = cls
36-
37-
for alias in getattr(cls, "type_aliases", []):
38-
Data.registry[alias.lower()] = cls
32+
aliases: list[str] = list()
33+
geom_dims: tuple[str, ...] = ()
3934

4035
@classmethod
4136
def get_data_subclass(cls, name: str):
42-
"""get_data_subclass Method for getting a class object from a string, centralizes the lookup operation for further development of smart lookup.
43-
44-
Parameters
45-
----------
46-
name : str
47-
String to use for lookup in the Data registry
48-
49-
Returns
50-
-------
51-
type
52-
Object of Data subclass requested
53-
54-
Raises
55-
------
56-
ValueError
57-
No subclass available for the requested string
58-
"""
59-
_name = name.lower()
60-
try:
61-
data_class = cls.registry[_name]
62-
except KeyError as err:
63-
raise ValueError(
64-
f"No type named {name} recognized. Options are {Data.registry.keys()} (case insensitive)."
65-
) from err
66-
return data_class
37+
return cls.get_class(name)
6738

6839
@classmethod
69-
def check_topology_type(cls, type: str):
70-
"""check_topology_type Method for checking if a string correlates to a Data subclass, meant to have the same lookup mechanism as get_data_subclass
71-
72-
Parameters
73-
----------
74-
type : str
75-
String to lookup in Data class registry.
76-
77-
Returns
78-
-------
79-
boolean
80-
Boolean representing existence of a correlation between the provided string and a Data subclass.
81-
"""
82-
return type.lower() in cls.registry
40+
def check_topology_type(cls, type: str) -> bool:
41+
return cls.check_class(type)
8342

8443
@classmethod
8544
def create(
@@ -334,6 +293,7 @@ def get_var(
334293
keep_dims: str | list[str] | None = None,
335294
reduction_dims: str | list[str] | None = None,
336295
as_array: bool = False,
296+
dim_reduce: dict[str, str | dict] | None = None,
337297
) -> xr.DataArray | np.ndarray:
338298
"""get_var Get variable from the obj attribute.
339299
@@ -372,6 +332,9 @@ def get_var(
372332
)
373333
xa = self.obj.data_vars[var_name]
374334

335+
if dim_reduce is not None:
336+
xa = dim_reduction(xa, dim_reduce, name=var_name)
337+
375338
if reduction_func is not None:
376339
reduction_func = (
377340
ReductionMethodEnum(reduction_func).func
@@ -381,6 +344,10 @@ def get_var(
381344
if reduction_dims is None and keep_dims is not None:
382345
reduction_dims = list(set(self.dims) - set(keep_dims))
383346
xa = xa.reduce(reduction_func, reduction_dims)
347+
_record(
348+
xa,
349+
f"reduced {var_name!r} over {reduction_dims} with {reduction_func.__name__}",
350+
)
384351

385352
if in_unit is not None:
386353
xa = change_unit(xa, self.var_unit(var_name), in_unit)
@@ -440,7 +407,6 @@ def resample_vars(
440407
time_interval: str | None = None,
441408
radius_of_influence: int = 10000,
442409
time_tolerance: str | None = "30min",
443-
# reduction_dims: str | List[str] | None = None, reduction_func: Callable | None = None
444410
) -> xr.DataArray | xr.Dataset:
445411
"""resample_vars Resample the requested vars using Pyresample and the geom attributes. Until now only NearestNeighbour method is being used.
446412
@@ -523,29 +489,44 @@ def _resample(x):
523489
fill_value=np.nan,
524490
)
525491

526-
# Here if im resampling into a point surface topology then height will always get dropped. Unless its a point in space not surface.
492+
# input_core_dims: only the geom_dims of the source (the horizontal dims
493+
# the pyresample geometry was built from). Any extra dims like z are left
494+
# out so apply_ufunc loops over them automatically, broadcasting the
495+
# horizontal resampling across each vertical level independently.
496+
src_geom_dims = [d for d in var_src.dims if d in set(other.geom_dims)]
497+
dst_geom_dims = [d for d in var_dst_dims if d in set(self.geom_dims)]
527498
resampled = xr.apply_ufunc(
528499
_resample,
529500
var_src,
530-
input_core_dims=[
531-
[d for d in var_src.dims if d != "time"]
532-
], # remove time from core dims so it loops over just time
533-
output_core_dims=[
534-
[d for d in var_dst_dims if d != "time"]
535-
], # Produces a new array with the new geom minus time
501+
input_core_dims=[src_geom_dims],
502+
output_core_dims=[dst_geom_dims],
536503
vectorize=True,
537504
dask="parallelized",
538505
output_dtypes=[var_src.dtype],
539506
dask_gufunc_kwargs={
540507
"output_sizes": {
541508
name: value
542509
for name, value in var_dst_dims.items()
543-
if name != "time"
510+
if name in set(self.geom_dims)
544511
}
545512
},
546513
)
547514

548515
new_name = f"{var}__{other.name}"
549-
self.resampled[new_name] = (var_dst_dims, resampled.data)
516+
self.resampled[new_name] = resampled
550517
new_vars.append(new_name)
518+
519+
_record(
520+
self.resampled,
521+
f"spatially resampled {new_vars} from {other.name} "
522+
f"({type(other).__name__}) to {self.name} ({type(self).__name__})",
523+
)
524+
525+
save_to = self.reader_kwargs.get("save_resampled_to")
526+
if save_to:
527+
target = Path(save_to)
528+
target.parent.mkdir(parents=True, exist_ok=True)
529+
self.resampled.to_netcdf(target)
530+
_record(self.resampled, f"saved resampled data to {target}")
531+
551532
return self.resampled[new_vars]

ClimateGraph/data/point_surface.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ class PointSurface(Data):
99
A class that gives particular representation to Point Surface data. This data should be in ("time", "site") dimensions.
1010
"""
1111

12-
type_aliases = ["pt_sfc", "point_surface", "point"]
12+
aliases = ["pt_sfc", "point_surface", "point"]
13+
geom_dims = ("site",)
1314

1415
def _set_geom(self):
1516
"""_set_geom Method for setting the Pyresample Geometry object used for resampling. In this case it is a SwathDefinition object."""

ClimateGraph/data/regular_grid.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ class RegularGrid(Data):
99
A class that implements Regular Grid specific logic. This data should be in ("time", "x", "y", "z") dimensions.
1010
"""
1111

12-
type_aliases = ["regular_grid", "grid", "regulargrid"]
12+
aliases = ["regular_grid", "grid", "regulargrid"]
13+
geom_dims = ("x", "y")
1314

1415
def _set_geom(self):
1516
"""_set_geom Method for setting the Pyresample Geometry object used for resampling. In this case it is a SwathDefinition object because the AreaDefinition isn't working correctly"""

ClimateGraph/domain/domain.py

Lines changed: 6 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import logging
22
from abc import ABC, abstractmethod
3-
from typing import Annotated, Union
43

54
import xarray as xr
6-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel
6+
7+
from ClimateGraph.utils.registry import RegistryMixin
78

89
logging.basicConfig(level=logging.INFO) # TODO: make this settable from yaml file.
910

1011
# TODO: Change the use of BaseModel for actual attributes to improve modularization.
1112

1213

13-
class Domain(ABC):
14+
class Domain(RegistryMixin, ABC):
1415
"""The Domain abstract class.
1516
1617
A class that abstracts the domain definition and interface.
@@ -21,29 +22,6 @@ class Domain(ABC):
2122
aliases: list[str] = list()
2223
config: type["BaseModel"] | None = None
2324

24-
def __init_subclass__(cls, **kwargs):
25-
"""__init_subclass__ This Dunder method is being used to dinamically register all inheriting classes from Domain, this helps with Domain creation."""
26-
super().__init_subclass__(**kwargs)
27-
for name in cls.aliases:
28-
Domain.registry[name] = cls
29-
Domain.registry[cls.__name__.lower()] = cls
30-
31-
@classmethod
32-
def build_config_union(cls) -> Annotated:
33-
"""build_config_union Build an Annotated Union object used for the Pydantic model.
34-
35-
Returns
36-
-------
37-
Annotated
38-
Used for the Pydantic model, dicriminates by type used when creating the Domain objects.
39-
"""
40-
configs = [
41-
cls_.config for cls_ in Domain.registry.values() if cls_.config is not None
42-
]
43-
# `Union[tuple(configs)]` unpacks the tuple at runtime — Ruff's UP007 must not
44-
# rewrite this; doing so strips the Union and breaks Pydantic's discriminator.
45-
return Annotated[Union[tuple(configs)], Field(discriminator="type")] # noqa: UP007
46-
4725
@classmethod
4826
def create(
4927
cls,
@@ -94,35 +72,11 @@ def __init__(self, name: str, domain_config: BaseModel, **kwargs):
9472

9573
@classmethod
9674
def check_domain_class(cls, type: str) -> bool:
97-
"""check_domain_class Method for checking if a string correlates to a Domain subclass, meant to have the same lookup mechanism as get_domain_class
98-
99-
Parameters
100-
----------
101-
type : str
102-
String to lookup in Domain class registry.
103-
104-
Returns
105-
-------
106-
bool
107-
Boolean representing whether the type string corresponds to any Domain subclass.
108-
"""
109-
return type.lower() in cls.registry
75+
return cls.check_class(type)
11076

11177
@classmethod
11278
def get_domain_class(cls, name: str):
113-
"""get_domain_class Method for getting a class object from a string, centralizes the lookup operation for further development of smart lookup.
114-
115-
Parameters
116-
----------
117-
name : str
118-
String to lookup in Domain class registry.
119-
120-
Returns
121-
-------
122-
type
123-
Class object of adequate domain subclass.
124-
"""
125-
return cls.registry[name.lower()]
79+
return cls.get_class(name)
12680

12781
@abstractmethod
12882
def apply(self, data: xr.Dataset | xr.DataArray) -> xr.Dataset | xr.DataArray:

ClimateGraph/domain/domains.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,22 @@ def apply(self, data: xr.Dataset | xr.DataArray):
8888
return masked_data
8989

9090

91+
class AllConfig(BaseModel):
92+
"""AllConfig Pydantic model for the All domain — no filtering applied."""
93+
94+
type: Literal["all"]
95+
96+
97+
class All(Domain):
98+
"""All domain that returns data unchanged — explicit 'use all data' marker."""
99+
100+
config = AllConfig
101+
aliases = ["all"]
102+
103+
def apply(self, data: xr.Dataset | xr.DataArray) -> xr.Dataset | xr.DataArray:
104+
return data
105+
106+
91107
class ShapefileConfig(BaseModel):
92108
"""ShapefileConfig Pydantic model for the Polygon domain definition in the config file. Receives a local path to the shapefile, and a value for filtering."""
93109

0 commit comments

Comments
 (0)