dclimate-client-py 0.1.1__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.
@@ -0,0 +1,2 @@
1
+ # Created by pytest automatically.
2
+ *
@@ -0,0 +1,4 @@
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
@@ -0,0 +1,8 @@
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
@@ -0,0 +1,32 @@
1
+ # public API
2
+ from .client import (
3
+ load_s3,
4
+ geo_temporal_query,
5
+ )
6
+ from .dclimate_client import dClimateClient
7
+ from .geotemporal_data import GeotemporalData
8
+ from .encryption_codec import (
9
+ EncryptionCodec,
10
+ )
11
+ from .datasets import (
12
+ list_dataset_catalog,
13
+ fetch_cid_from_url,
14
+ DatasetCatalog,
15
+ CatalogCollection,
16
+ CatalogDataset,
17
+ DatasetVariantConfig,
18
+ )
19
+
20
+ __all__ = [
21
+ "dClimateClient",
22
+ "load_s3",
23
+ "geo_temporal_query",
24
+ "list_dataset_catalog",
25
+ "fetch_cid_from_url",
26
+ "GeotemporalData",
27
+ "EncryptionCodec",
28
+ "DatasetCatalog",
29
+ "CatalogCollection",
30
+ "CatalogDataset",
31
+ "DatasetVariantConfig",
32
+ ]
@@ -0,0 +1 @@
1
+ {"cpc-precip-conus": "bafyr4iff6vuvpkb4zexkx7exoafsjvfnno4ofidyc37gsto2aaxixg5zia", "cpc-precip-global": "bafyr4ihe35o55qz47y2dbau6ikvfkbrthqn57pdhem5jdxhvqf5u7qlrbm", "chirps-final-p05": "bafyr4ic2n63fkxdvwgh2uzealcrbeubncivcqaptttfrr4vp5ecbe5e65a"}
@@ -0,0 +1,203 @@
1
+ """
2
+ Functions that will map to endpoints in the flask app
3
+ """
4
+
5
+ import datetime
6
+ import typing
7
+ import xarray as xr
8
+
9
+ from .dclimate_zarr_errors import (
10
+ ConflictingGeoRequestError,
11
+ ConflictingAggregationRequestError,
12
+ InvalidExportFormatError,
13
+ InvalidSelectionError,
14
+ )
15
+ from .geotemporal_data import GeotemporalData, DEFAULT_POINT_LIMIT
16
+ from .s3_retrieval import get_dataset_from_s3
17
+ from .ipfs_retrieval import (
18
+ _get_dataset_by_ipfs_cid,
19
+ )
20
+ from .datasets import (
21
+ resolve_dataset_source,
22
+ get_concatenable_variants,
23
+ find_dataset_by_name,
24
+ find_collection_by_name,
25
+ fetch_cid_from_url,
26
+ DATASET_CATALOG_INTERNAL,
27
+ DatasetCatalog,
28
+ )
29
+ from .concatenate import concatenate_datasets
30
+
31
+ def load_s3(
32
+ dataset_name: str,
33
+ bucket_name: str,
34
+ ) -> GeotemporalData:
35
+ """
36
+ Load a Geotemporal dataset from an S3 bucket.
37
+
38
+ Parameters
39
+ ----------
40
+
41
+ dataset_name: str
42
+ The name of the dataset in the bucket.
43
+ bucket_name: str
44
+ S3 bucket name where the dataset is going to be fetched
45
+ """
46
+ ds = get_dataset_from_s3(dataset_name, bucket_name)
47
+ return GeotemporalData(ds, dataset_name=dataset_name)
48
+
49
+
50
+ def geo_temporal_query(
51
+ dataset_name: str,
52
+ source: typing.Literal["s3"] = "s3",
53
+ bucket_name: str = None,
54
+ var_name: str = None,
55
+ gateway_uri_stem: str | None = None,
56
+ rpc_uri_stem: str | None = None,
57
+ forecast_reference_time: str = None,
58
+ point_kwargs: dict = None,
59
+ circle_kwargs: dict = None,
60
+ rectangle_kwargs: dict = None,
61
+ polygon_kwargs: dict = None,
62
+ multiple_points_kwargs: dict = None,
63
+ spatial_agg_kwargs: dict = None,
64
+ temporal_agg_kwargs: dict = None,
65
+ rolling_agg_kwargs: dict = None,
66
+ time_range: typing.Optional[typing.List[datetime.datetime]] = None,
67
+ # as_of: typing.Optional[datetime.datetime] = None, # Removed as_of
68
+ point_limit: int = DEFAULT_POINT_LIMIT,
69
+ output_format: str = "array",
70
+ ) -> typing.Union[dict, bytes]:
71
+ """Filter an XArray dataset
72
+
73
+ Filter an XArray dataset by specified spatial and/or temporal bounds and aggregate
74
+ according to spatial and/or temporal logic, if desired. Before aggregating check
75
+ that the filtered data fits within specified point and area maximums to avoid
76
+ computationally expensive retrieval and processing operations. When bounds or
77
+ aggregation logic are not provided, pass the dataset along untouched.
78
+
79
+ Return either a numpy array of data values or a NetCDF file.
80
+
81
+ Only one of point, circle, rectangle, or polygon kwargs may be provided. Only one of
82
+ temporal or rolling aggregation kwargs may be provided, although they can be chained
83
+ with spatial aggregations if desired.
84
+
85
+ Args:
86
+ dataset_name (str): Name used to identify the dataset within the STAC catalog (for IPFS)
87
+ or the dataset name in the bucket (for S3).
88
+ source: (typing.Literal["ipfs", "s3"]): how to pull data. Defaults to "ipfs".
89
+ bucket_name (str): S3 bucket name where the datasets are going to be fetched (required if source="s3").
90
+ var_name (str, optional): Specific data variable to use within the dataset.
91
+ gateway_uri_stem (str | None, optional): Custom IPFS HTTP Gateway URI stem for IPFS source.
92
+ rpc_uri_stem (str | None, optional): Custom IPFS RPC API URI stem for IPFS source.
93
+ forecast_reference_time (str): Isoformatted string representing the desire date
94
+ to return all available forecasts for
95
+ circle_kwargs (dict, optional): a dictionary of parameters relevant to a
96
+ circular query
97
+ rectangle_kwargs (dict, optional): a dictionary of parameters relevant to a
98
+ rectangular query
99
+ polygon_kwargs (dict, optional): a dictionary of parameters relevant to a
100
+ polygonal query
101
+ multiple_points_kwargs (dict, optional): Parameters for querying multiple specific points.
102
+ point_kwargs (dict, optional): Parameters for querying a single point.
103
+ spatial_agg_kwargs (dict, optional): a dictionary of parameters relevant to a
104
+ spatial aggregation operation
105
+ temporal_agg_kwargs (dict, optional): a dictionary of parameters relevant to a
106
+ temporal aggregation operation
107
+ rolling_agg_kwargs (dict, optional): a dictionary of parameters relevant to a
108
+ rolling aggregation operation
109
+ time_range (typing.Optional[typing.List[datetime.datetime]], optional):
110
+ time range in which to subset data.
111
+ Defaults to None.
112
+ # REMOVED as_of (typing.Optional[datetime.datetime], optional):
113
+ # pull in most recent data created before this time. If None, just get most
114
+ # recent. Defaults to None.
115
+ point_limit (int, optional): maximum number of data points user can request.
116
+ Defaults to DEFAULT_POINT_LIMIT.
117
+ output_format (str, optional): Current supported formats are `array` and
118
+ `netcdf`. Defaults to "array", which provides a dict of data
119
+ values and coordinates.
120
+
121
+ Returns:
122
+ typing.Union[dict, bytes]: Output data as dict (default) or NetCDF bytes.
123
+ """
124
+ # Check for incompatible request parameters
125
+ if (
126
+ len(
127
+ [
128
+ kwarg_dict
129
+ for kwarg_dict in [
130
+ circle_kwargs,
131
+ rectangle_kwargs,
132
+ polygon_kwargs,
133
+ multiple_points_kwargs,
134
+ point_kwargs,
135
+ ]
136
+ if kwarg_dict is not None
137
+ ]
138
+ )
139
+ > 1
140
+ ):
141
+ raise ConflictingGeoRequestError(
142
+ "User requested more than one type of geographic query, but only one can "
143
+ "be submitted at a time"
144
+ )
145
+ if spatial_agg_kwargs and point_kwargs:
146
+ raise ConflictingGeoRequestError(
147
+ "User requested spatial aggregation methods on a single point, "
148
+ "but these are mutually exclusive parameters. Only one may be requested at "
149
+ "a time."
150
+ )
151
+ if temporal_agg_kwargs and rolling_agg_kwargs:
152
+ raise ConflictingAggregationRequestError(
153
+ "User requested both rolling and temporal aggregation, but these are "
154
+ "mutually exclusive operations. Only one may be requested at a time."
155
+ )
156
+ if output_format not in ["array", "netcdf"]:
157
+ raise InvalidExportFormatError(
158
+ "User requested an invalid export format. Only 'array' or 'netcdf' "
159
+ "permitted."
160
+ )
161
+
162
+ # Set defaults to avoid Nones accidentally passed by users causing a TypeError
163
+ if not point_limit:
164
+ point_limit = DEFAULT_POINT_LIMIT
165
+
166
+ # Load the dataset based on the source
167
+ if source == "s3":
168
+ if not bucket_name:
169
+ raise ValueError("bucket_name is required when source is 's3'")
170
+ data = load_s3(dataset_name, bucket_name)
171
+ else:
172
+ raise ValueError(
173
+ "Invalid source specified. Must be 's3'. "
174
+ "IPFS source is deprecated - use dClimateClient instead."
175
+ )
176
+
177
+ # If specific variable is requested, use that
178
+ if var_name is not None:
179
+ data = data.use(var_name)
180
+
181
+ # Filter data down temporally, then spatially, and check that the size of resulting
182
+ # dataset fits within the limit. While a user can get the entire DS by providing no
183
+ # filters, this will almost certainly cause the size checks to fail
184
+
185
+ data = data.query(
186
+ forecast_reference_time=forecast_reference_time,
187
+ point_kwargs=point_kwargs,
188
+ circle_kwargs=circle_kwargs,
189
+ rectangle_kwargs=rectangle_kwargs,
190
+ polygon_kwargs=polygon_kwargs,
191
+ multiple_points_kwargs=multiple_points_kwargs,
192
+ spatial_agg_kwargs=spatial_agg_kwargs,
193
+ temporal_agg_kwargs=temporal_agg_kwargs,
194
+ rolling_agg_kwargs=rolling_agg_kwargs,
195
+ time_range=time_range,
196
+ point_limit=point_limit,
197
+ )
198
+
199
+ # Export
200
+ if output_format == "netcdf":
201
+ return data.to_netcdf()
202
+ else: # "array"
203
+ return data.as_dict()
@@ -0,0 +1,207 @@
1
+ """
2
+ Concatenation utilities for combining dataset variants.
3
+
4
+ Implements smart concatenation logic similar to dclimate-client-js,
5
+ avoiding duplicate data by finding split indices where new data begins.
6
+ """
7
+
8
+ import logging
9
+ import typing
10
+ from datetime import datetime
11
+ import numpy as np
12
+ import xarray as xr
13
+
14
+ from .dclimate_zarr_errors import NoDataFoundError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def find_split_index(
20
+ combined_coords: typing.Any,
21
+ next_coords: typing.Any,
22
+ last_coord_value: typing.Any,
23
+ ) -> int:
24
+ """
25
+ Find the index in next_coords where values are strictly greater than last_coord_value.
26
+
27
+ This prevents duplicate data when concatenating datasets.
28
+
29
+ Args:
30
+ combined_coords: Coordinates from the combined dataset (for reference)
31
+ next_coords: Coordinates from the next variant to concatenate
32
+ last_coord_value: The last coordinate value from the combined dataset
33
+
34
+ Returns:
35
+ Index in next_coords where to start slicing (first value > last_coord_value)
36
+
37
+ Raises:
38
+ NoDataFoundError: If no new data found in next variant
39
+ """
40
+ # Convert coordinates to numpy array for easier comparison
41
+ next_array = np.array(next_coords)
42
+
43
+ # Handle different data types
44
+ if isinstance(last_coord_value, (datetime, np.datetime64)):
45
+ # Convert to numpy datetime64 for comparison
46
+ if isinstance(last_coord_value, datetime):
47
+ last_coord_value = np.datetime64(last_coord_value)
48
+
49
+ # Find where next coords are strictly after last coord
50
+ mask = next_array > last_coord_value
51
+ indices = np.where(mask)[0]
52
+
53
+ if len(indices) == 0:
54
+ raise NoDataFoundError(
55
+ f"No new data found in next variant after {last_coord_value}"
56
+ )
57
+
58
+ return int(indices[0])
59
+
60
+ elif isinstance(last_coord_value, (int, float, np.number)):
61
+ # Numeric comparison
62
+ mask = next_array > last_coord_value
63
+ indices = np.where(mask)[0]
64
+
65
+ if len(indices) == 0:
66
+ raise NoDataFoundError(
67
+ f"No new data found in next variant after {last_coord_value}"
68
+ )
69
+
70
+ return int(indices[0])
71
+
72
+ elif isinstance(last_coord_value, str):
73
+ # String comparison (lexicographic)
74
+ mask = next_array > last_coord_value
75
+ indices = np.where(mask)[0]
76
+
77
+ if len(indices) == 0:
78
+ raise NoDataFoundError(
79
+ f"No new data found in next variant after '{last_coord_value}'"
80
+ )
81
+
82
+ return int(indices[0])
83
+
84
+ else:
85
+ # For other types, try direct comparison
86
+ try:
87
+ mask = next_array > last_coord_value
88
+ indices = np.where(mask)[0]
89
+
90
+ if len(indices) == 0:
91
+ raise NoDataFoundError(
92
+ f"No new data found in next variant after {last_coord_value}"
93
+ )
94
+
95
+ return int(indices[0])
96
+ except Exception as e:
97
+ raise TypeError(
98
+ f"Cannot compare coordinate type {type(last_coord_value).__name__}: {e}"
99
+ )
100
+
101
+
102
+ async def concatenate_datasets(
103
+ datasets: typing.List[xr.Dataset],
104
+ dimension: str = "time",
105
+ ) -> xr.Dataset:
106
+ """
107
+ Concatenate multiple xarray datasets along a dimension, avoiding duplicate data.
108
+
109
+ Implements smart concatenation logic:
110
+ 1. Start with first dataset (highest priority)
111
+ 2. For each subsequent dataset:
112
+ - Find the last coordinate value in combined dataset
113
+ - Find split index in next dataset where coords > last coord
114
+ - Slice next dataset to only include new data
115
+ - Concatenate sliced dataset
116
+
117
+ Args:
118
+ datasets: List of xarray datasets to concatenate (in priority order)
119
+ dimension: Dimension to concatenate along (default: "time")
120
+
121
+ Returns:
122
+ Concatenated xarray dataset
123
+
124
+ Raises:
125
+ ValueError: If datasets list is empty or dimension not found
126
+ NoDataFoundError: If a variant contains no new data
127
+ """
128
+ if not datasets:
129
+ raise ValueError("Cannot concatenate empty list of datasets")
130
+
131
+ if len(datasets) == 1:
132
+ logger.info("Only one dataset provided, returning it without concatenation")
133
+ return datasets[0]
134
+
135
+ # Check that all datasets have the dimension
136
+ for i, ds in enumerate(datasets):
137
+ if dimension not in ds.dims:
138
+ raise ValueError(
139
+ f"Dataset {i} does not have dimension '{dimension}'. "
140
+ f"Available dimensions: {list(ds.dims.keys())}"
141
+ )
142
+
143
+ logger.info(f"Concatenating {len(datasets)} datasets along '{dimension}' dimension")
144
+
145
+ # Start with the first dataset (highest priority)
146
+ combined = datasets[0]
147
+ logger.debug(
148
+ f"Starting with dataset 1/{len(datasets)}, "
149
+ f"{dimension} range: {combined[dimension].values[0]} to {combined[dimension].values[-1]}"
150
+ )
151
+
152
+ # Concatenate each subsequent dataset
153
+ for i, next_ds in enumerate(datasets[1:], start=2):
154
+ # Get the last coordinate value from combined dataset
155
+ last_coord_value = combined[dimension].values[-1]
156
+
157
+ # Get coordinates from next dataset
158
+ next_coords = next_ds[dimension].values
159
+
160
+ logger.debug(
161
+ f"Processing dataset {i}/{len(datasets)}, "
162
+ f"{dimension} range: {next_coords[0]} to {next_coords[-1]}"
163
+ )
164
+
165
+ # Find where to split the next dataset
166
+ try:
167
+ split_index = find_split_index(
168
+ combined[dimension].values,
169
+ next_coords,
170
+ last_coord_value,
171
+ )
172
+
173
+ logger.debug(
174
+ f"Found split index {split_index} (out of {len(next_coords)} coords) "
175
+ f"for dataset {i}, new data starts at: {next_coords[split_index]}"
176
+ )
177
+
178
+ # Slice the next dataset to only include new data
179
+ sliced_next = next_ds.isel({dimension: slice(split_index, None)})
180
+
181
+ logger.debug(
182
+ f"Sliced dataset {i} to {len(sliced_next[dimension])} new coords"
183
+ )
184
+
185
+ # Concatenate with combined dataset
186
+ new_combined = xr.concat(
187
+ [combined, sliced_next],
188
+ dim=dimension,
189
+ )
190
+ logger.debug(
191
+ f"After concatenating dataset {i}, total {dimension} coords: {len(combined[dimension])}"
192
+ )
193
+
194
+ except NoDataFoundError as e:
195
+ logger.warning(
196
+ f"Skipping dataset {i} as it contains no new data: {e}"
197
+ )
198
+ # Continue to next dataset
199
+ continue
200
+
201
+ logger.info(
202
+ f"Concatenation complete. Final dataset has {len(combined[dimension])} "
203
+ f"{dimension} coordinates ranging from {combined[dimension].values[0]} "
204
+ f"to {combined[dimension].values[-1]}"
205
+ )
206
+
207
+ return combined