From e9f9ee63447e65994b09952e17dfc3a30b8ee30c Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Thu, 29 Feb 2024 09:00:42 +0000 Subject: [PATCH 001/129] A few wrinkles to sort out, some changes to pyfive needed, but this is a pre-train interim commit --- activestorage/active.py | 187 ++++++----------- activestorage/active_tools.py | 351 -------------------------------- activestorage/netcdf_to_zarr.py | 126 ------------ 3 files changed, 60 insertions(+), 604 deletions(-) delete mode 100644 activestorage/active_tools.py delete mode 100644 activestorage/netcdf_to_zarr.py diff --git a/activestorage/active.py b/activestorage/active.py index 1cf03eef..04deb549 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -4,19 +4,15 @@ import numpy as np import pathlib import urllib +import pyfive +from pyfive import ZarrArrayStub, OrthogonalIndexer -import h5netcdf import s3fs -#FIXME: Consider using h5py throughout, for more generality -from netCDF4 import Dataset -from zarr.indexing import ( - OrthogonalIndexer, -) from activestorage.config import * from activestorage import reductionist from activestorage.storage import reduce_chunk -from activestorage import netcdf_to_zarr as nz + @contextlib.contextmanager @@ -44,11 +40,12 @@ def load_from_s3(uri, storage_options=None): else: fs = s3fs.S3FileSystem(**storage_options) # use passed-in dictionary with fs.open(uri, 'rb') as s3file: - ds = h5netcdf.File(s3file, 'r', invalid_netcdf=True) + ds = pyfive.File(s3file, 'r', invalid_netcdf=True) print(f"Dataset loaded from S3 via h5netcdf: {ds}") yield ds + class Active: """ Instantiates an interface to active storage which contains either zarr files @@ -83,7 +80,7 @@ def __init__( active_storage_url=None ): """ - Instantiate with a NetCDF4 dataset and the variable of interest within that file. + Instantiate with a NetCDF4 dataset URI and the variable of interest within that file. (We need the variable, because we need variable specific metadata from within that file, however, if that information is available at instantiation, it can be provided using keywords and avoid a metadata read.) @@ -113,57 +110,48 @@ def __init__( self.ncvar = ncvar if self.ncvar is None: raise ValueError("Must set a netCDF variable name to slice") - self.zds = None self._version = 1 self._components = False self._method = None - self._lock = False self._max_threads = max_threads def __getitem__(self, index): """ Provides support for a standard get item. + #FIXME-BNL: Why is the argument index? """ # In version one this is done by explicitly looping over each chunk in the file # and returning the requested slice ourselves. In version 2, we can pass this # through to the default method. ncvar = self.ncvar + # in all casese we need an open netcdf file to get at attributes + # FIXME. We then need to monkey patch the "filename" as rfile onto the dataset + if self.storage_type is None: + nc = pyfive.FILE(self.uri) + elif self.storage_type == "s3": + nc = load_from_s3(self.uri, self.storage_options) + if self.method is None and self._version == 0: # No active operation - lock = self.lock - if lock: - lock.acquire() - if self.storage_type is None: - nc = Dataset(self.uri) data = nc[ncvar][index] nc.close() elif self.storage_type == "s3": - with load_from_s3(self.uri, self.storage_options) as nc: - data = nc[ncvar][index] - data = self._mask_data(data, nc[ncvar]) - - if lock: - lock.release() + + data = nc[ncvar][index] + data = self._mask_data(data, nc[ncvar]) + nc.close() return data elif self._version == 1: - return self._via_kerchunk(index) + return self._get_selection(nc[ncvar], index) elif self._version == 2: - # No active operation either - lock = self.lock - if lock: - lock.acquire() - - data = self._via_kerchunk(index) - - if lock: - lock.release() + data = self._get_selection(nc[ncvar], index) return data else: @@ -223,27 +211,8 @@ def ncvar(self): def ncvar(self, value): self._ncvar = value - @property - def lock(self): - """Return or set a lock that prevents concurrent file reads when accessing the data locally. - - The lock is either a `threading.Lock` instance, an object with - same API and functionality (such as - `dask.utils.SerializableLock`), or is `False` if no lock is - required. - To be effective, the same lock instance must be used across - all process threads. - """ - return self._lock - - @lock.setter - def lock(self, value): - if not value: - value = False - - self._lock = value def _get_active(self, method, *args): """ @@ -255,54 +224,21 @@ def _get_active(self, method, *args): """ raise NotImplementedError - def _via_kerchunk(self, index): - """ - The objective is to use kerchunk to read the slices ourselves. - """ - # FIXME: Order of calls is hardcoded' - if self.zds is None: - print(f"Kerchunking file {self.uri} with variable " - f"{self.ncvar} for storage type {self.storage_type}") - ds, zarray, zattrs = nz.load_netcdf_zarr_generic( - self.uri, - self.ncvar, - self.storage_type, - self.storage_options, - ) - # The following is a hangove from exploration - # and is needed if using the original doing it ourselves - # self.zds = make_an_array_instance_active(ds) - self.zds = ds - - # Retain attributes and other information - if zarray.get('fill_value') is not None: - zattrs['_FillValue'] = zarray['fill_value'] - - self.zarray = zarray - self.zattrs = zattrs - - # FIXME: We do not get the correct byte order on the Zarr - # Array's dtype when using S3, so capture it here. - self._dtype = np.dtype(zarray['dtype']) - - return self._get_selection(index) - - def _get_selection(self, *args): + + + def _get_selection(self, ds, *args): """ - First we need to convert the selection into chunk coordinates, - steps etc, via the Zarr machinery, then we get everything else we can - from zarr and friends and use simple dictionaries and tuples, then - we can go to the storage layer with no zarr. + We need to load the b-tree index first. The current machinery is + using a temporary branch in a fork of pyfive. It will get cleaner + when we tidy that up. """ - compressor = self.zds._compressor - filters = self.zds._filters # Get missing values - _FillValue = self.zattrs.get('_FillValue') - missing_value = self.zattrs.get('missing_value') - valid_min = self.zattrs.get('valid_min') - valid_max = self.zattrs.get('valid_max') - valid_range = self.zattrs.get('valid_range') + _FillValue = ds.attrs.get('_FillValue') + missing_value = ds.attrs.get('missing_value') + valid_min = ds.attrs.get('valid_min') + valid_max = ds.attrs.get('valid_max') + valid_range = ds.attrs.get('valid_range') if valid_max is not None or valid_min is not None: if valid_range is not None: raise ValueError( @@ -319,31 +255,25 @@ def _get_selection(self, *args): valid_min, valid_max, ) + array = ZarrArrayStub(ds.shape, ds.chunks) - indexer = OrthogonalIndexer(*args, self.zds) + + indexer = OrthogonalIndexer(*args, array) out_shape = indexer.shape out_dtype = self.zds._dtype - stripped_indexer = [(a, b, c) for a,b,c in indexer] - drop_axes = indexer.drop_axes # not sure what this does and why, yet. + #stripped_indexer = [(a, b, c) for a,b,c in indexer] + #drop_axes = indexer.drop_axes # not sure what this does and why, yet. - # yes this next line is bordering on voodoo ... - # this returns a nested dictionary with the full file FS reference - # ie all the gubbins: chunks, data structure, types, etc - # if using zarr<=2.13.3 call with _mutable_mapping ie - # fsref = self.zds.chunk_store._mutable_mapping.fs.references - fsref = self.zds.chunk_store.fs.references - return self._from_storage(stripped_indexer, drop_axes, out_shape, - out_dtype, compressor, filters, missing, fsref) + return self._from_storage(ds, indexer, out_shape, out_dtype, missing) - def _from_storage(self, stripped_indexer, drop_axes, out_shape, out_dtype, - compressor, filters, missing, fsref): + def _from_storage(self, ds, indexer, out_shape, out_dtype, missing): method = self.method if method is not None: out = [] counts = [] else: - out = np.empty(out_shape, dtype=out_dtype, order=self.zds._order) + out = np.empty(out_shape, dtype=out_dtype, order=ds.order) counts = None # should never get touched with no method! # Create a shared session object. @@ -370,13 +300,12 @@ def _from_storage(self, stripped_indexer, drop_axes, out_shape, out_dtype, with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: futures = [] # Submit chunks for processing. - for chunk_coords, chunk_selection, out_selection in stripped_indexer: + for chunk_coords, chunk_selection, out_selection in indexer: future = executor.submit( self._process_chunk, - session, fsref, chunk_coords, chunk_selection, - counts, out_selection, - compressor, filters, missing, - drop_axes=drop_axes) + session, ds, chunk_coords, chunk_selection, + counts, out_selection, missing) + #drop_axes=drop_axes) futures.append(future) # Wait for completion. for future in concurrent.futures.as_completed(futures): @@ -446,9 +375,9 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" - def _process_chunk(self, session, fsref, chunk_coords, chunk_selection, counts, - out_selection, compressor, filters, missing, - drop_axes=None): + def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, + out_selection, missing) + #drop_axes=None): """ Obtain part or whole of a chunk. @@ -456,11 +385,15 @@ def _process_chunk(self, session, fsref, chunk_coords, chunk_selection, counts, the output array. Note the need to use counts for some methods + #FIXME: Do, we, it's not actually used? """ - coord = '.'.join([str(c) for c in chunk_coords]) - key = f"{self.ncvar}/{coord}" - rfile, offset, size = tuple(fsref[key]) + + offset, size, filter_mask = ds.get_chunk_details[chunk_coords] + rfile = ds.rfile + #FIXME: need compressor and filters from ds + compressor = None + filters=None # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": @@ -485,8 +418,8 @@ def _process_chunk(self, session, fsref, chunk_coords, chunk_selection, counts, bucket, object, offset, size, compressor, filters, missing, dtype, - self.zds._chunks, - self.zds._order, + ds.chunks, + ds.order, chunk_selection, operation=self._method) else: @@ -504,8 +437,8 @@ def _process_chunk(self, session, fsref, chunk_coords, chunk_selection, counts, bucket, object, offset, size, compressor, filters, missing, dtype, - self.zds._chunks, - self.zds._order, + ds.chunks, + ds.order, chunk_selection, operation=self._method) else: @@ -515,14 +448,14 @@ def _process_chunk(self, session, fsref, chunk_coords, chunk_selection, counts, # although we will version changes. tmp, count = reduce_chunk(rfile, offset, size, compressor, filters, missing, self.zds._dtype, - self.zds._chunks, self.zds._order, + ds.chunks, ds.order, chunk_selection, method=self.method) if self.method is not None: return tmp, count else: - if drop_axes: - tmp = np.squeeze(tmp, axis=drop_axes) + #if drop_axes: + # tmp = np.squeeze(tmp, axis=drop_axes) return tmp, out_selection def _mask_data(self, data, ds_var): diff --git a/activestorage/active_tools.py b/activestorage/active_tools.py deleted file mode 100644 index 59c05508..00000000 --- a/activestorage/active_tools.py +++ /dev/null @@ -1,351 +0,0 @@ -""" -Module to hold Zarr lift-up code. - -We're effectively subclassing zarr.core.Array, but not actually doing so, -instead we're providing tools to hack instances of it -""" -import numpy as np -import zarr - -from packaging import version - -from zarr.core import Array -# import other zarr gubbins used in the methods we override -from zarr.indexing import ( - OrthogonalIndexer, - PartialChunkIterator, - check_fields, - is_contiguous_selection, - -) -from zarr.util import ( - check_array_shape, - is_total_slice, - PartialReadBuffer, -) - -from numcodecs.compat import ensure_ndarray -from zarr.errors import ArrayIndexError - - -def make_an_array_instance_active(instance): - """ - Given a zarr array instance, override some key methods so - we can do active storage things. Note this only works for - normal and _ methods and would not work on __ methods. - - This an ugly hack for development to avoid having to hack - zarr internal in a fork. - """ - - instance.get_orthogonal_selection = as_get_orthogonal_selection.__get__(instance, Array) - instance._get_selection = as_get_selection.__get__(instance, Array) - instance._chunk_getitem = as_chunk_getitem.__get__(instance, Array) - instance._process_chunk = as_process_chunk.__get__(instance, Array) - instance._process_chunk_V = as_process_chunk_V.__get__(instance, Array) - - return instance - -def as_get_orthogonal_selection(self, selection, out=None, - fields=None): - """ - Retrieve data by making a selection for each dimension of the array. For - example, if an array has 2 dimensions, allows selecting specific rows and/or - columns. The selection for each dimension can be either an integer (indexing a - single item), a slice, an array of integers, or a Boolean array where True - values indicate a selection. - Parameters - ---------- - selection : tuple - A selection for each dimension of the array. May be any combination of int, - slice, integer array or Boolean array. - out : ndarray, optional - If given, load the selected data directly into this array. - fields : str or sequence of str, optional - For arrays with a structured dtype, one or more fields can be specified to - extract data for. - Returns - ------- - out : ndarray - A NumPy array containing the data for the requested selection. - Examples - -------- - Setup a 2-dimensional array:: - >>> import zarr - >>> import numpy as np - >>> z = zarr.array(np.arange(100).reshape(10, 10)) - Retrieve rows and columns via any combination of int, slice, integer array and/or - Boolean array:: - >>> z.get_orthogonal_selection(([1, 4], slice(None))) - array([[10, 11, 12, 13, 14, 15, 16, 17, 18, 19], - [40, 41, 42, 43, 44, 45, 46, 47, 48, 49]]) - >>> z.get_orthogonal_selection((slice(None), [1, 4])) - array([[ 1, 4], - [11, 14], - [21, 24], - [31, 34], - [41, 44], - [51, 54], - [61, 64], - [71, 74], - [81, 84], - [91, 94]]) - >>> z.get_orthogonal_selection(([1, 4], [1, 4])) - array([[11, 14], - [41, 44]]) - >>> sel = np.zeros(z.shape[0], dtype=bool) - >>> sel[1] = True - >>> sel[4] = True - >>> z.get_orthogonal_selection((sel, sel)) - array([[11, 14], - [41, 44]]) - For convenience, the orthogonal selection functionality is also available via the - `oindex` property, e.g.:: - >>> z.oindex[[1, 4], :] - array([[10, 11, 12, 13, 14, 15, 16, 17, 18, 19], - [40, 41, 42, 43, 44, 45, 46, 47, 48, 49]]) - >>> z.oindex[:, [1, 4]] - array([[ 1, 4], - [11, 14], - [21, 24], - [31, 34], - [41, 44], - [51, 54], - [61, 64], - [71, 74], - [81, 84], - [91, 94]]) - >>> z.oindex[[1, 4], [1, 4]] - array([[11, 14], - [41, 44]]) - >>> sel = np.zeros(z.shape[0], dtype=bool) - >>> sel[1] = True - >>> sel[4] = True - >>> z.oindex[sel, sel] - array([[11, 14], - [41, 44]]) - Notes - ----- - Orthogonal indexing is also known as outer indexing. - Slices with step > 1 are supported, but slices with negative step are not. - See Also - -------- - get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection, - get_coordinate_selection, set_coordinate_selection, set_orthogonal_selection, - vindex, oindex, __getitem__, __setitem__ - """ - - # refresh metadata - if not self._cache_metadata: - self._load_metadata() - - # check args - check_fields(fields, self._dtype) - - # setup indexer - indexer = OrthogonalIndexer(selection, self) - - return self._get_selection(indexer=indexer, out=out, - fields=fields) - - -def as_get_selection(self, indexer, out=None, - fields=None): - - # We iterate over all chunks which overlap the selection and thus contain data - # that needs to be extracted. Each chunk is processed in turn, extracting the - # necessary data and storing into the correct location in the output array. - - # N.B., it is an important optimisation that we only visit chunks which overlap - # the selection. This minimises the number of iterations in the main for loop. - - # check fields are sensible - out_dtype = check_fields(fields, self._dtype) - - # determine output shape - out_shape = indexer.shape - - # setup output array - if out is None: - out = np.empty(out_shape, dtype=out_dtype, order=self._order) - else: - check_array_shape('out', out, out_shape) - - chunks_info = [] - chunks_locs = [] - - # note: Zarr API has changed with zarr=2.15 - # hasattr(self.chunk_store, "getitems") = True for zarr >= 2.15 - zarr_version = zarr.__version__ - zarr_api_change_version = "2.15" - if version.parse(zarr_version) < version.parse(zarr_api_change_version): - att_getitems = not hasattr(self.chunk_store, "getitems") - elif version.parse(zarr_version) >= version.parse(zarr_api_change_version): - att_getitems = hasattr(self.chunk_store, "getitems") - # iterate over chunks - if att_getitems or any(map(lambda x: x == 0, self.shape)): - # sequentially get one key at a time from storage - for chunk_coords, chunk_selection, out_selection in indexer: - - if version.parse(zarr_version) < version.parse(zarr_api_change_version): - # load chunk selection into output array - pci = self._chunk_getitem(chunk_coords, chunk_selection, out, out_selection, - drop_axes=indexer.drop_axes, fields=fields) - chunks_info.append(pci) - chunks_locs.append(chunk_coords) - elif version.parse(zarr_version) >= version.parse(zarr_api_change_version): - chunk_coords = [chunk_coords] - # load chunk selection into output array - pci = self._chunk_getitems(chunk_coords, chunk_selection, out, out_selection, - drop_axes=indexer.drop_axes, fields=fields) - chunks_info.append(pci) - chunks_locs.append(chunk_coords[0]) - else: - # allow storage to get multiple items at once - lchunk_coords, lchunk_selection, lout_selection = zip(*indexer) - self._chunk_getitems(lchunk_coords, lchunk_selection, out, lout_selection, - drop_axes=indexer.drop_axes, fields=fields) - - if out.shape: - return out, chunks_info, chunks_locs - else: - return out[()], chunks_info, chunks_locs - - -def as_chunk_getitem(self, chunk_coords, chunk_selection, out, out_selection, - drop_axes=None, fields=None): - """Obtain part or whole of a chunk. - Parameters - ---------- - chunk_coords : tuple of ints - Indices of the chunk. - chunk_selection : selection - Location of region within the chunk to extract. - out : ndarray - Array to store result in. - out_selection : selection - Location of region within output array to store results in. - drop_axes : tuple of ints - Axes to squeeze out of the chunk. - fields - TODO - """ - out_is_ndarray = True - try: - out = ensure_ndarray(out) - except TypeError: - out_is_ndarray = False - - assert len(chunk_coords) == len(self._cdata_shape) - - try: - pci_info = self._process_chunk_V(chunk_selection) - except KeyError: - # chunk not initialized - if self._fill_value is not None: - if fields: - fill_value = self._fill_value[fields] - else: - fill_value = self._fill_value - out[out_selection] = fill_value - pci_info = self._process_chunk_V(chunk_selection) - - return pci_info - - -def as_process_chunk( - self, - out, - cdata, - chunk_selection, - drop_axes, - out_is_ndarray, - fields, - out_selection, - partial_read_decode=False, -): - """Take binary data from storage and fill output array""" - if (out_is_ndarray and - not fields and - is_contiguous_selection(out_selection) and - is_total_slice(chunk_selection, self._chunks) and - not self._filters and - self._dtype != object): - - dest = out[out_selection] - write_direct = ( - dest.flags.writeable and - ( - (self._order == 'C' and dest.flags.c_contiguous) or - (self._order == 'F' and dest.flags.f_contiguous) - ) - ) - - if write_direct: - - # optimization: we want the whole chunk, and the destination is - # contiguous, so we can decompress directly from the chunk - # into the destination array - if self._compressor: - if isinstance(cdata, PartialReadBuffer): - cdata = cdata.read_full() - self._compressor.decode(cdata, dest) - else: - chunk = ensure_ndarray(cdata).view(self._dtype) - if np.prod(chunk.shape) > np.prod(self._chunks): - raise ValueError(f"Chunk shape {chunk.shape} exceeds " - f"data chunks shape {self._chunks}") - chunk = chunk.reshape(self._chunks, order=self._order) - np.copyto(dest, chunk) - return - - # decode chunk - try: - if partial_read_decode: - cdata.prepare_chunk() - # size of chunk - tmp = np.empty(self._chunks, dtype=self.dtype) - index_selection = PartialChunkIterator(chunk_selection, self.chunks) - for start, nitems, partial_out_selection in index_selection: - expected_shape = [ - len( - range(*partial_out_selection[i].indices(self.chunks[0] + 1)) - ) - if i < len(partial_out_selection) - else dim - for i, dim in enumerate(self.chunks) - ] - cdata.read_part(start, nitems) - chunk_partial = self._decode_chunk( - cdata.buff, - start=start, - nitems=nitems, - expected_shape=expected_shape, - ) - tmp[partial_out_selection] = chunk_partial - out[out_selection] = tmp[chunk_selection] - return - except ArrayIndexError: - cdata = cdata.read_full() - if self._compressor and isinstance(cdata, np.ndarray): - raise TypeError(f"cdata {cdata} is an ndarray, can not decompress.") - chunk = self._decode_chunk(cdata) - - # select data from chunk - if fields: - chunk = chunk[fields] - tmp = chunk[chunk_selection] - if drop_axes: - tmp = np.squeeze(tmp, axis=drop_axes) - - # store selected data in output - if np.prod(tmp.shape) > np.prod(out.shape): - raise ValueError(f"Storage chunk shape {tmp.shape} exceeds permitted " - f"output data shape {out.shape}.") - out[out_selection] = tmp - -def as_process_chunk_V(self, chunk_selection): - """Run an instance of PCI inside the engine.""" - index_selection = PartialChunkIterator(chunk_selection, self.chunks) - for _, _, _ in index_selection: - return self.chunks, chunk_selection, index_selection diff --git a/activestorage/netcdf_to_zarr.py b/activestorage/netcdf_to_zarr.py deleted file mode 100644 index 4bd0bb82..00000000 --- a/activestorage/netcdf_to_zarr.py +++ /dev/null @@ -1,126 +0,0 @@ -import os -import numpy as np -import zarr -import ujson -import fsspec -import s3fs -import tempfile - -from activestorage.config import * -from kerchunk.hdf import SingleHdf5ToZarr - - -def gen_json(file_url, varname, outf, storage_type, storage_options): - """Generate a json file that contains the kerchunk-ed data for Zarr.""" - # S3 configuration presets - if storage_type == "s3" and storage_options is None: - fs = s3fs.S3FileSystem(key=S3_ACCESS_KEY, - secret=S3_SECRET_KEY, - client_kwargs={'endpoint_url': S3_URL}, - default_fill_cache=False, - default_cache_type="none" - ) - fs2 = fsspec.filesystem('') - with fs.open(file_url, 'rb') as s3file: - h5chunks = SingleHdf5ToZarr(s3file, file_url, - inline_threshold=0) - with fs2.open(outf, 'wb') as f: - content = h5chunks.translate() - f.write(ujson.dumps(content).encode()) - - # S3 passed-in configuration - elif storage_type == "s3" and storage_options is not None: - storage_options = storage_options.copy() - storage_options['default_fill_cache'] = False - storage_options['default_cache_type'] = "none" - fs = s3fs.S3FileSystem(**storage_options) - fs2 = fsspec.filesystem('') - with fs.open(file_url, 'rb') as s3file: - h5chunks = SingleHdf5ToZarr(s3file, file_url, - inline_threshold=0) - with fs2.open(outf, 'wb') as f: - content = h5chunks.translate() - f.write(ujson.dumps(content).encode()) - # not S3 - else: - fs = fsspec.filesystem('') - with fs.open(file_url, 'rb') as local_file: - try: - h5chunks = SingleHdf5ToZarr(local_file, file_url, - inline_threshold=0) - except OSError as exc: - raiser_1 = f"Unable to open file {file_url}. " - raiser_2 = "Check if file is netCDF3 or netCDF-classic" - print(raiser_1 + raiser_2) - raise exc - - # inline threshold adjusts the Size below which binary blocks are - # included directly in the output - # a higher inline threshold can result in a larger json file but - # faster loading time - # for active storage, we don't want anything inline - with fs.open(outf, 'wb') as f: - content = h5chunks.translate() - f.write(ujson.dumps(content).encode()) - - zarray = ujson.loads(content['refs'][f"{varname}/.zarray"]) - zattrs = ujson.loads(content['refs'][f"{varname}/.zattrs"]) - - return outf, zarray, zattrs - - -def open_zarr_group(out_json, varname): - """ - Do the magic opening - - Open a json file read and saved by the reference file system - into a Zarr Group, then extract the Zarr Array you need. - That Array is in the 'data' attribute. - """ - fs = fsspec.filesystem("reference", fo=out_json) - mapper = fs.get_mapper("") # local FS mapper - #mapper.fs.reference has the kerchunk mapping, how does this propagate into the Zarr array? - zarr_group = zarr.open_group(mapper) - - try: - zarr_array = getattr(zarr_group, varname) - except AttributeError as attrerr: - print(f"Zarr Group does not contain variable {varname}. " - f"Zarr Group info: {zarr_group.info}") - raise attrerr - #print("Zarr array info:", zarr_array.info) - - return zarr_array - - -def load_netcdf_zarr_generic(fileloc, varname, storage_type, storage_options, build_dummy=True): - """Pass a netCDF4 file to be shaped as Zarr file by kerchunk.""" - print(f"Storage type {storage_type}") - - # Write the Zarr group JSON to a temporary file. - with tempfile.NamedTemporaryFile() as out_json: - _, zarray, zattrs = gen_json(fileloc, - varname, - out_json.name, - storage_type, - storage_options) - - # open this monster - print(f"Attempting to open and convert {fileloc}.") - ref_ds = open_zarr_group(out_json.name, varname) - - return ref_ds, zarray, zattrs - - -#d = {'version': 1, -# 'refs': { -# '.zgroup': '{"zarr_format":2}', -# '.zattrs': '{"Conventions":"CF-1.6","access-list":"grenvillelister simonwilson jeffcole","awarning":"**** THIS SUITE WILL ARCHIVE NON-DUPLEXED DATA TO MOOSE. FOR CRITICAL MODEL RUNS SWITCH TO DUPLEXED IN: postproc --> Post Processing - common settings --> Moose Archiving --> non_duplexed_set. Follow guidance in http:\\/\\/www-twiki\\/Main\\/MassNonDuplexPolicy","branch-date":"1950-01-01","calendar":"360_day","code-version":"UM 11.6, NEMO vn3.6","creation_time":"2022-10-28 12:28","decription":"Initialised from EN4 climatology","description":"Copy of u-ar696\\/trunk@77470","email":"r.k.schieman@reading.ac.uk","end-date":"2015-01-01","experiment-id":"historical","forcing":"AA,BC,CO2","forcing-info":"blah, blah, blah","institution":"NCAS","macro-parent-experiment-id":"historical","macro-parent-experiment-mip":"CMIP","macro-parent-variant-id":"r1i1p1f3","model-id":"HadGEM3-CG31-MM","name":"\\/work\\/n02\\/n02\\/grenvill\\/cylc-run\\/u-cn134\\/share\\/cycle\\/19500101T0000Z\\/3h_","owner":"rosalynhatcher","project":"Coupled Climate","timeStamp":"2022-Oct-28 12:20:33 GMT","title":"[CANARI] GC3.1 N216 ORCA025 UM11.6","uuid":"51e5ef20-d376-4aa6-938e-4c242886b7b1"}', -# 'lat/.zarray': '{"chunks":[324],"compressor":{"id":"zlib","level":1},"dtype":" Date: Sun, 3 Mar 2024 13:55:55 +0000 Subject: [PATCH 002/129] bnl_test works with pyfive. The tests are borked because they're riddled with zarr dependencies --- activestorage/active.py | 45 ++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 04deb549..c70ddd33 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -5,7 +5,6 @@ import pathlib import urllib import pyfive -from pyfive import ZarrArrayStub, OrthogonalIndexer import s3fs @@ -129,7 +128,7 @@ def __getitem__(self, index): # in all casese we need an open netcdf file to get at attributes # FIXME. We then need to monkey patch the "filename" as rfile onto the dataset if self.storage_type is None: - nc = pyfive.FILE(self.uri) + nc = pyfive.File(self.uri) elif self.storage_type == "s3": nc = load_from_s3(self.uri, self.storage_options) @@ -228,11 +227,16 @@ def _get_active(self, method, *args): def _get_selection(self, ds, *args): """ - We need to load the b-tree index first. The current machinery is - using a temporary branch in a fork of pyfive. It will get cleaner - when we tidy that up. + At this point we have a Dataset object, but all the important information about + how to use it is in the attribute DataoobjectDataset class. Here we gather + metadata from the dataset instance and then continue with the dataobjects instance. """ + + + # stick this here for later, to discuss with David + keepdims = True + # Get missing values _FillValue = ds.attrs.get('_FillValue') missing_value = ds.attrs.get('missing_value') @@ -255,19 +259,20 @@ def _get_selection(self, ds, *args): valid_min, valid_max, ) - array = ZarrArrayStub(ds.shape, ds.chunks) - indexer = OrthogonalIndexer(*args, array) + ds = ds._dataobjects + array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) + indexer = pyfive.OrthogonalIndexer(*args, array) + out_shape = indexer.shape - out_dtype = self.zds._dtype + out_dtype =ds.dtype #stripped_indexer = [(a, b, c) for a,b,c in indexer] - #drop_axes = indexer.drop_axes # not sure what this does and why, yet. - + drop_axes = indexer.drop_axes and keepdims - return self._from_storage(ds, indexer, out_shape, out_dtype, missing) + return self._from_storage(ds, indexer, out_shape, out_dtype, missing, drop_axes) - def _from_storage(self, ds, indexer, out_shape, out_dtype, missing): + def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, drop_axes): method = self.method if method is not None: out = [] @@ -304,8 +309,7 @@ def _from_storage(self, ds, indexer, out_shape, out_dtype, missing): future = executor.submit( self._process_chunk, session, ds, chunk_coords, chunk_selection, - counts, out_selection, missing) - #drop_axes=drop_axes) + counts, out_selection, missing, drop_axes=drop_axes) futures.append(future) # Wait for completion. for future in concurrent.futures.as_completed(futures): @@ -376,8 +380,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, - out_selection, missing) - #drop_axes=None): + out_selection, missing, drop_axes=None): """ Obtain part or whole of a chunk. @@ -389,8 +392,8 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, """ - offset, size, filter_mask = ds.get_chunk_details[chunk_coords] - rfile = ds.rfile + offset, size, filter_mask = ds.get_chunk_details(chunk_coords) + rfile = ds.fh.name #FIXME: need compressor and filters from ds compressor = None filters=None @@ -447,15 +450,15 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, # so neither the returned data or the interface should be considered stable # although we will version changes. tmp, count = reduce_chunk(rfile, offset, size, compressor, filters, - missing, self.zds._dtype, + missing, ds.dtype, ds.chunks, ds.order, chunk_selection, method=self.method) if self.method is not None: return tmp, count else: - #if drop_axes: - # tmp = np.squeeze(tmp, axis=drop_axes) + if drop_axes: + tmp = np.squeeze(tmp, axis=drop_axes) return tmp, out_selection def _mask_data(self, data, ds_var): From 0d7bf5582ef394d9c8a57ff80679aa180b4d8989 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Mon, 4 Mar 2024 11:18:15 +0000 Subject: [PATCH 003/129] Gzip decompression working. Will document issues remaining in #188 --- activestorage/active.py | 25 +++++------ activestorage/hdf2numcodec.py | 78 +++++++++++++++++++++++++++++++++++ bnl/bnl_test.py | 45 ++++++++++++++++++++ 3 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 activestorage/hdf2numcodec.py create mode 100644 bnl/bnl_test.py diff --git a/activestorage/active.py b/activestorage/active.py index c70ddd33..d11ba4eb 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -11,6 +11,7 @@ from activestorage.config import * from activestorage import reductionist from activestorage.storage import reduce_chunk +from activestorage.hdf2numcodec import decode_filters @@ -260,7 +261,7 @@ def _get_selection(self, ds, *args): valid_max, ) - + nfilters = decode_filters(ds._dataobjects.filter_pipeline ,ds.name) ds = ds._dataobjects array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) indexer = pyfive.OrthogonalIndexer(*args, array) @@ -270,10 +271,11 @@ def _get_selection(self, ds, *args): #stripped_indexer = [(a, b, c) for a,b,c in indexer] drop_axes = indexer.drop_axes and keepdims - return self._from_storage(ds, indexer, out_shape, out_dtype, missing, drop_axes) + return self._from_storage(ds, indexer, out_shape, out_dtype, missing, nfilters, drop_axes) - def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, drop_axes): + def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, nfilters, drop_axes): method = self.method + if method is not None: out = [] counts = [] @@ -309,7 +311,7 @@ def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, drop_axes): future = executor.submit( self._process_chunk, session, ds, chunk_coords, chunk_selection, - counts, out_selection, missing, drop_axes=drop_axes) + counts, out_selection, missing, nfilters, drop_axes=drop_axes) futures.append(future) # Wait for completion. for future in concurrent.futures.as_completed(futures): @@ -380,7 +382,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, - out_selection, missing, drop_axes=None): + out_selection, missing, nfilters,drop_axes=None): """ Obtain part or whole of a chunk. @@ -394,9 +396,10 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, offset, size, filter_mask = ds.get_chunk_details(chunk_coords) rfile = ds.fh.name - #FIXME: need compressor and filters from ds + #The compressor is none, as compression is in the filter pipeline + #Note that the filter_pipeline here is the _numcodecs_ one, not the native ds one! compressor = None - filters=None + filters=nfilters # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": @@ -404,9 +407,7 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, parsed_url = urllib.parse.urlparse(rfile) bucket = parsed_url.netloc object = parsed_url.path - # FIXME: We do not get the correct byte order on the Zarr Array's dtype - # when using S3, so use the value captured earlier. - dtype = self._dtype + # for certain S3 servers rfile needs to contain the bucket eg "bucket/filename" # as a result the parser above finds empty string bucket if bucket == "": @@ -420,7 +421,7 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, S3_URL, bucket, object, offset, size, compressor, filters, - missing, dtype, + missing, ds.dtype, ds.chunks, ds.order, chunk_selection, @@ -439,7 +440,7 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, self._get_endpoint_url(), bucket, object, offset, size, compressor, filters, - missing, dtype, + missing, ds.dtype, ds.chunks, ds.order, chunk_selection, diff --git a/activestorage/hdf2numcodec.py b/activestorage/hdf2numcodec.py new file mode 100644 index 00000000..1a007b9b --- /dev/null +++ b/activestorage/hdf2numcodec.py @@ -0,0 +1,78 @@ +import numcodecs + +def decode_filters(filter_pipeline, name): + """ + + Convert HDF5 filter and compression instructions into instructions understood + by numcodecs. Input is a pyfive filter_pipeline object + + We can only support things that are supported by numcodecs, which one might + hope to be a superset of what native pyfive can support. + + See aslso zarr kerchunk _decode_filters. We may need to add much more + support for BLOSC than is currently supported by pyfive. + + """ + filters = [] + + for filter in filter_pipeline: + + filter_id=filter['filter_id'] + properties = filter['client_data_values'] + + if filter_id == GZIP_DEFLATE_FILTER: + filters.append(numcodecs.Zlib(level=properties[0])) + elif filter_id == SHUFFLE_FILTER: + pass + #FIXME: inherited the pass by inspection from Zarr, pretty sure that's wrong. + elif filter_id == 32001: + blosc_compressors = ( + "blosclz", + "lz4", + "lz4hc", + "snappy", + "zlib", + "zstd", + ) + ( + _1, + _2, + bytes_per_num, + total_bytes, + clevel, + shuffle, + compressor, + ) = properties + pars = dict( + blocksize=total_bytes, + clevel=clevel, + shuffle=shuffle, + cname=blosc_compressors[compressor], + ) + filters.append(numcodecs.Blosc(**pars)) + elif filter_id == 32015: + filters.append(numcodecs.Zstd(level=properties[0])) + elif filter_id == 32004: + raise RuntimeError( + f"{name} uses lz4 compression - not supported as yet" + ) + elif filter_id == 32008: + raise RuntimeError( + f"{name} uses bitshuffle compression - not supported as yet" + ) + else: + raise RuntimeError( + f"{name} uses filter id {filter_id} with properties {properties}," + f" not supported as yet" + ) + return filters + +# These are from pyfive's HDF5 filter definitions +# IV.A.2.l The Data Storage - Filter Pipeline message +RESERVED_FILTER = 0 +GZIP_DEFLATE_FILTER = 1 +SHUFFLE_FILTER = 2 +FLETCH32_FILTER = 3 +SZIP_FILTER = 4 +NBIT_FILTER = 5 +SCALEOFFSET_FILTER = 6 diff --git a/bnl/bnl_test.py b/bnl/bnl_test.py new file mode 100644 index 00000000..7354d1b3 --- /dev/null +++ b/bnl/bnl_test.py @@ -0,0 +1,45 @@ +import os +import pytest + +import numpy as np +from netCDF4 import Dataset +from pathlib import Path +import s3fs + +from activestorage.active import Active +from activestorage.config import * + + +def mytest(): + """ + Test again a native model, this time around netCDF4 loadable with h5py + Also, this has _FillValue and missing_value + """ + ncfile, v = "cesm2_native.nc","TREFHT" + ncfile, v = "CMIP6-test.nc", 'tas' + #ncfile, v = "chunked.hdf5", "dataset1" + mypath = Path(__file__).parent + uri = str(mypath/ncfile) + active = Active(uri, v, None) + active._version = 0 + if v == "dataset1": + d = active[2,:] + else: + d = active[4:5, 1:2] + mean_result = np.mean(d) + + active = Active(uri, v, None) + active._version = 2 + active.method = "mean" + active.components = True + if v == "dataset1": + result2 = active[2,:] + else: + result2 = active[4:5, 1:2] + print(result2, ncfile) + # check for active + np.testing.assert_allclose(mean_result, result2["sum"]/result2["n"], rtol=1e-6) + + +if __name__=="__main__": + mytest() \ No newline at end of file From d73fcd84c94b237a98df71183c8d9c18ee3f2ce6 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Mon, 4 Mar 2024 12:54:07 +0000 Subject: [PATCH 004/129] Split compression and filters --- activestorage/__init__.py | 5 +- activestorage/active.py | 17 ++--- activestorage/hdf2numcodec.py | 119 ++++++++++++++++++++-------------- 3 files changed, 80 insertions(+), 61 deletions(-) diff --git a/activestorage/__init__.py b/activestorage/__init__.py index 7e632ea9..f5a6f53d 100644 --- a/activestorage/__init__.py +++ b/activestorage/__init__.py @@ -1,4 +1,5 @@ from .active import Active - -__version__ = "0.0.1" +# 0.0.1 is the initial version using kerchunk +# 0.0.2 is testing out the use of pyfive instead +__version__ = "0.0.2" diff --git a/activestorage/active.py b/activestorage/active.py index d11ba4eb..57080e57 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -233,8 +233,6 @@ def _get_selection(self, ds, *args): metadata from the dataset instance and then continue with the dataobjects instance. """ - - # stick this here for later, to discuss with David keepdims = True @@ -261,7 +259,8 @@ def _get_selection(self, ds, *args): valid_max, ) - nfilters = decode_filters(ds._dataobjects.filter_pipeline ,ds.name) + # hopefully fix pyfive to get a dtype directly + compressor, filters = decode_filters(ds._dataobjects.filter_pipeline , np.dtype(ds.dtype).itemsize, ds.name) ds = ds._dataobjects array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) indexer = pyfive.OrthogonalIndexer(*args, array) @@ -271,9 +270,9 @@ def _get_selection(self, ds, *args): #stripped_indexer = [(a, b, c) for a,b,c in indexer] drop_axes = indexer.drop_axes and keepdims - return self._from_storage(ds, indexer, out_shape, out_dtype, missing, nfilters, drop_axes) + return self._from_storage(ds, indexer, out_shape, out_dtype, missing, compressor, filters, drop_axes) - def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, nfilters, drop_axes): + def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, compressor, filters, drop_axes): method = self.method if method is not None: @@ -311,7 +310,7 @@ def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, nfilters, dr future = executor.submit( self._process_chunk, session, ds, chunk_coords, chunk_selection, - counts, out_selection, missing, nfilters, drop_axes=drop_axes) + counts, out_selection, missing, compressor, filters, drop_axes=drop_axes) futures.append(future) # Wait for completion. for future in concurrent.futures.as_completed(futures): @@ -382,7 +381,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, - out_selection, missing, nfilters,drop_axes=None): + out_selection, missing, compressor, filters, drop_axes=None): """ Obtain part or whole of a chunk. @@ -396,10 +395,6 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, offset, size, filter_mask = ds.get_chunk_details(chunk_coords) rfile = ds.fh.name - #The compressor is none, as compression is in the filter pipeline - #Note that the filter_pipeline here is the _numcodecs_ one, not the native ds one! - compressor = None - filters=nfilters # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": diff --git a/activestorage/hdf2numcodec.py b/activestorage/hdf2numcodec.py index 1a007b9b..faac282e 100644 --- a/activestorage/hdf2numcodec.py +++ b/activestorage/hdf2numcodec.py @@ -1,71 +1,94 @@ import numcodecs -def decode_filters(filter_pipeline, name): +def decode_filters(filter_pipeline, itemsize, name): """ Convert HDF5 filter and compression instructions into instructions understood by numcodecs. Input is a pyfive filter_pipeline object - We can only support things that are supported by numcodecs, which one might - hope to be a superset of what native pyfive can support. + We can only support things that are supported by our storage backends, which + is a rather limited range right now: + - gzip compression, and + - shuffle filter See aslso zarr kerchunk _decode_filters. We may need to add much more - support for BLOSC than is currently supported by pyfive. + support for BLOSC than currently supported. + + Useful notes on filters are here: + - https://docs.unidata.ucar.edu/netcdf-c/current/filters.html and + - https://docs.hdfgroup.org/hdf5/v1_8/group___h5_z.html + + In particular, note that we can only support things that go beyond native HDF5 + if _we_ support them directly. """ - filters = [] + compressors, filters = [], [] for filter in filter_pipeline: filter_id=filter['filter_id'] properties = filter['client_data_values'] + + # We suppor the following if filter_id == GZIP_DEFLATE_FILTER: - filters.append(numcodecs.Zlib(level=properties[0])) + compressors.append(numcodecs.Zlib(level=properties[0])) elif filter_id == SHUFFLE_FILTER: - pass - #FIXME: inherited the pass by inspection from Zarr, pretty sure that's wrong. - elif filter_id == 32001: - blosc_compressors = ( - "blosclz", - "lz4", - "lz4hc", - "snappy", - "zlib", - "zstd", - ) - ( - _1, - _2, - bytes_per_num, - total_bytes, - clevel, - shuffle, - compressor, - ) = properties - pars = dict( - blocksize=total_bytes, - clevel=clevel, - shuffle=shuffle, - cname=blosc_compressors[compressor], - ) - filters.append(numcodecs.Blosc(**pars)) - elif filter_id == 32015: - filters.append(numcodecs.Zstd(level=properties[0])) - elif filter_id == 32004: - raise RuntimeError( - f"{name} uses lz4 compression - not supported as yet" - ) - elif filter_id == 32008: - raise RuntimeError( - f"{name} uses bitshuffle compression - not supported as yet" - ) + filters.append(numcodecs.Shuffle(elementsize=itemsize)) else: - raise RuntimeError( - f"{name} uses filter id {filter_id} with properties {properties}," - f" not supported as yet" - ) - return filters + raise NotImplementedError('We cannot yet support filter id ',filter_id) + + # We might be able, in the future, to support the following + # At the moment the following code cannot be implemented, but we can move + # the loops up as we develop backend support. + + if 0: + + + if filter_id == 32001: + blosc_compressors = ( + "blosclz", + "lz4", + "lz4hc", + "snappy", + "zlib", + "zstd", + ) + ( + _1, + _2, + bytes_per_num, + total_bytes, + clevel, + shuffle, + compressor, + ) = properties + pars = dict( + blocksize=total_bytes, + clevel=clevel, + shuffle=shuffle, + cname=blosc_compressors[compressor], + ) + filters.append(numcodecs.Blosc(**pars)) + elif filter_id == 32015: + filters.append(numcodecs.Zstd(level=properties[0])) + elif filter_id == 32004: + raise RuntimeError( + f"{name} uses lz4 compression - not supported as yet" + ) + elif filter_id == 32008: + raise RuntimeError( + f"{name} uses bitshuffle compression - not supported as yet" + ) + else: + raise RuntimeError( + f"{name} uses filter id {filter_id} with properties {properties}," + f" not supported as yet" + ) + + if len(compressors) > 1: + raise ValueError('We only expected one compression algorithm') + return compressors[0], filters # These are from pyfive's HDF5 filter definitions # IV.A.2.l The Data Storage - Filter Pipeline message From 0c4272f61bc07b1b55696b1df0d13120c2886391 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Mon, 4 Mar 2024 15:19:05 +0000 Subject: [PATCH 005/129] Slight doc improvement in hdf2numcodec --- activestorage/hdf2numcodec.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activestorage/hdf2numcodec.py b/activestorage/hdf2numcodec.py index faac282e..96796855 100644 --- a/activestorage/hdf2numcodec.py +++ b/activestorage/hdf2numcodec.py @@ -4,7 +4,8 @@ def decode_filters(filter_pipeline, itemsize, name): """ Convert HDF5 filter and compression instructions into instructions understood - by numcodecs. Input is a pyfive filter_pipeline object + by numcodecs. Input is a pyfive filter_pipeline object, the itemsize, and the + dataset name for error messages. We can only support things that are supported by our storage backends, which is a rather limited range right now: From c20b25916b3034885790d211e5e88a43e737312f Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Mon, 4 Mar 2024 15:26:51 +0000 Subject: [PATCH 006/129] Need to turn off decoding filter pipeline if there is none. --- activestorage/active.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 57080e57..23d8ba3c 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -260,7 +260,10 @@ def _get_selection(self, ds, *args): ) # hopefully fix pyfive to get a dtype directly - compressor, filters = decode_filters(ds._dataobjects.filter_pipeline , np.dtype(ds.dtype).itemsize, ds.name) + if ds._dataobjects.filter_pipeline is None: + compressor, filters = None, None + else: + compressor, filters = decode_filters(ds._dataobjects.filter_pipeline , np.dtype(ds.dtype).itemsize, ds.name) ds = ds._dataobjects array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) indexer = pyfive.OrthogonalIndexer(*args, array) From 68e49d023968db7448289c2f0202253b551f7773 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 5 Mar 2024 08:12:23 +0000 Subject: [PATCH 007/129] Support for contiguous data. Passing lots of tests now. --- activestorage/active.py | 29 +++++++++++++++++------------ bnl/bnl_test.py | 1 + 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 23d8ba3c..3825e099 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -259,23 +259,28 @@ def _get_selection(self, ds, *args): valid_max, ) + name = ds.name + dtype = np.dtype(ds.dtype) # hopefully fix pyfive to get a dtype directly - if ds._dataobjects.filter_pipeline is None: + array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) + ds = ds._dataobjects + + if ds.filter_pipeline is None: compressor, filters = None, None else: - compressor, filters = decode_filters(ds._dataobjects.filter_pipeline , np.dtype(ds.dtype).itemsize, ds.name) - ds = ds._dataobjects - array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) + compressor, filters = decode_filters(ds.filter_pipeline , dtype.itemsize, name) + indexer = pyfive.OrthogonalIndexer(*args, array) - out_shape = indexer.shape out_dtype =ds.dtype #stripped_indexer = [(a, b, c) for a,b,c in indexer] drop_axes = indexer.drop_axes and keepdims - return self._from_storage(ds, indexer, out_shape, out_dtype, missing, compressor, filters, drop_axes) + # we use array._chunks rather than ds.chunks, as the latter is none in the case of + # unchunked data, and we need to tell the storage the array dimensions in this case. + return self._from_storage(ds, indexer, array._chunks, out_shape, out_dtype, missing, compressor, filters, drop_axes) - def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, compressor, filters, drop_axes): + def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, missing, compressor, filters, drop_axes): method = self.method if method is not None: @@ -312,7 +317,7 @@ def _from_storage(self, ds, indexer, out_shape, out_dtype, missing, compressor, for chunk_coords, chunk_selection, out_selection in indexer: future = executor.submit( self._process_chunk, - session, ds, chunk_coords, chunk_selection, + session, ds, chunks, chunk_coords, chunk_selection, counts, out_selection, missing, compressor, filters, drop_axes=drop_axes) futures.append(future) # Wait for completion. @@ -383,7 +388,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" - def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, + def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, counts, out_selection, missing, compressor, filters, drop_axes=None): """ Obtain part or whole of a chunk. @@ -420,7 +425,7 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, bucket, object, offset, size, compressor, filters, missing, ds.dtype, - ds.chunks, + chunks, ds.order, chunk_selection, operation=self._method) @@ -439,7 +444,7 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, bucket, object, offset, size, compressor, filters, missing, ds.dtype, - ds.chunks, + chunks, ds.order, chunk_selection, operation=self._method) @@ -450,7 +455,7 @@ def _process_chunk(self, session, ds, chunk_coords, chunk_selection, counts, # although we will version changes. tmp, count = reduce_chunk(rfile, offset, size, compressor, filters, missing, ds.dtype, - ds.chunks, ds.order, + chunks, ds.order, chunk_selection, method=self.method) if self.method is not None: diff --git a/bnl/bnl_test.py b/bnl/bnl_test.py index 7354d1b3..c5bb3c3d 100644 --- a/bnl/bnl_test.py +++ b/bnl/bnl_test.py @@ -18,6 +18,7 @@ def mytest(): ncfile, v = "cesm2_native.nc","TREFHT" ncfile, v = "CMIP6-test.nc", 'tas' #ncfile, v = "chunked.hdf5", "dataset1" + ncfile, v = 'daily_data.nc', 'ta' mypath = Path(__file__).parent uri = str(mypath/ncfile) active = Active(uri, v, None) From b745d5e5c521d7eb3ffe1a3c795ce3d8495bd367 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 5 Mar 2024 12:43:45 +0000 Subject: [PATCH 008/129] Remove the file context manager for now --- activestorage/active.py | 35 +++++++++++--------- bnl/bnl_s3test.py | 72 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 bnl/bnl_s3test.py diff --git a/activestorage/active.py b/activestorage/active.py index 3825e099..428a65cd 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -14,8 +14,6 @@ from activestorage.hdf2numcodec import decode_filters - -@contextlib.contextmanager def load_from_s3(uri, storage_options=None): """ Load a netCDF4-like object from S3. @@ -39,10 +37,11 @@ def load_from_s3(uri, storage_options=None): client_kwargs={'endpoint_url': S3_URL}) # eg "http://localhost:9000" for Minio else: fs = s3fs.S3FileSystem(**storage_options) # use passed-in dictionary - with fs.open(uri, 'rb') as s3file: - ds = pyfive.File(s3file, 'r', invalid_netcdf=True) - print(f"Dataset loaded from S3 via h5netcdf: {ds}") - yield ds + + s3file = fs.open(uri, 'rb') + ds = pyfive.File(s3file) + print(f"Dataset loaded from S3 via h5netcdf: {uri}") + return ds @@ -127,25 +126,28 @@ def __getitem__(self, index): ncvar = self.ncvar # in all casese we need an open netcdf file to get at attributes - # FIXME. We then need to monkey patch the "filename" as rfile onto the dataset + # we keep it open because we need it's b-tree if self.storage_type is None: nc = pyfive.File(self.uri) elif self.storage_type == "s3": nc = load_from_s3(self.uri, self.storage_options) + self.filename = self.uri + if self.method is None and self._version == 0: # No active operation if self.storage_type is None: data = nc[ncvar][index] - nc.close() + elif self.storage_type == "s3": data = nc[ncvar][index] data = self._mask_data(data, nc[ncvar]) - nc.close() - + + data.filename = self.uri return data + elif self._version == 1: return self._get_selection(nc[ncvar], index) @@ -402,12 +404,12 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou """ offset, size, filter_mask = ds.get_chunk_details(chunk_coords) - rfile = ds.fh.name + # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": - print("S3 rfile is:", rfile) - parsed_url = urllib.parse.urlparse(rfile) + print("S3 rfile is:", self.filename) + parsed_url = urllib.parse.urlparse(self.filename) bucket = parsed_url.netloc object = parsed_url.path @@ -419,12 +421,13 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou print("S3 bucket:", bucket) print("S3 file:", object) if self.storage_options is None: + # for the moment we need to force ds.dtype to be a numpy type tmp, count = reductionist.reduce_chunk(session, S3_ACTIVE_STORAGE_URL, S3_URL, bucket, object, offset, size, compressor, filters, - missing, ds.dtype, + missing, np.dtype(ds.dtype), chunks, ds.order, chunk_selection, @@ -443,7 +446,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou self._get_endpoint_url(), bucket, object, offset, size, compressor, filters, - missing, ds.dtype, + missing, np.dtype(ds.dtype), chunks, ds.order, chunk_selection, @@ -453,7 +456,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou # see https://github.com/valeriupredoi/PyActiveStorage/issues/33 # so neither the returned data or the interface should be considered stable # although we will version changes. - tmp, count = reduce_chunk(rfile, offset, size, compressor, filters, + tmp, count = reduce_chunk(self.filename, offset, size, compressor, filters, missing, ds.dtype, chunks, ds.order, chunk_selection, method=self.method) diff --git a/bnl/bnl_s3test.py b/bnl/bnl_s3test.py new file mode 100644 index 00000000..dd7a72ba --- /dev/null +++ b/bnl/bnl_s3test.py @@ -0,0 +1,72 @@ + +from activestorage.active import Active +import os +from pathlib import Path +from netCDF4 import Dataset +import numpy as np +import pyfive +import s3fs + +from activestorage.active import load_from_s3 + +S3_BUCKET = "bnl" + +def simple(filename, var): + + S3_URL = 'https://uor-aces-o.s3-ext.jc.rl.ac.uk/' + fs = s3fs.S3FileSystem(anon=True, client_kwargs={'endpoint_url': S3_URL}) + uri = 'bnl/'+filename + + with fs.open(uri,'rb') as s3file2: + f2 = pyfive.File(s3file2) + print(f2[var]) + + with fs.open(uri, 'rb') as s3file: + ds = pyfive.File(s3file) + print(ds[var]) + + #f2 = load_from_s3(uri, storage_options={'client_kwargs':{"endpoint_url":S3_URL}}) + +def test_compression_and_filters_cmip6_forced_s3_from_local_2(ncfile, var): + """ + Test use of datasets with compression and filters applied for a real + CMIP6 dataset (CMIP6-test.nc) - an IPSL file. + + This is for a special anon=True bucket connected to via valid key.secret + """ + storage_options = { + 'key': "f2d55c6dcfc7618b2c34e00b58df3cef", + 'secret': "$/'#M{0{/4rVhp%n^(XeX$q@y#&(NM3W1->~N.Q6VP.5[@bLpi='nt]AfH)>78pT", + 'client_kwargs': {'endpoint_url': "https://uor-aces-o.s3-ext.jc.rl.ac.uk"} + } + active_storage_url = "https://192.171.169.248:8080" + + + mypath = Path(__file__).parent + uri = str(mypath/ncfile) + with Dataset(uri) as nc_data: + nc_min = np.min(nc_data[var][0:2,4:6,7:9]) + print(f"Numpy min from compressed file {nc_min}") + + ofile = os.path.basename(uri) + test_file_uri = os.path.join( + S3_BUCKET, + ofile + ) + print("S3 Test file path:", test_file_uri) + active = Active(test_file_uri, var, storage_type="s3", + storage_options=storage_options, + active_storage_url=active_storage_url) + + active._version = 1 + active._method = "min" + + result = active[0:2,4:6,7:9] + assert nc_min == result + assert result == 239.25946044921875 + + +if __name__=="__main__": + ncfile, var = 'CMIP6-test.nc','tas' + simple(ncfile, var) + test_compression_and_filters_cmip6_forced_s3_from_local_2(ncfile, var) \ No newline at end of file From 74f86620c821b0ba5b48a6611fa3f376be458745 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 5 Mar 2024 13:40:39 +0000 Subject: [PATCH 009/129] A couple more h5py lurkers and one more bug squashed. --- activestorage/active.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 428a65cd..c5b3c477 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -143,8 +143,7 @@ def __getitem__(self, index): data = nc[ncvar][index] data = self._mask_data(data, nc[ncvar]) - - data.filename = self.uri + return data From b628a68f1bd1dcf3f5e7839f1add03690805dc42 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 14:46:46 +0000 Subject: [PATCH 010/129] removed redundant test file --- tests/unit/test_active_tools.py | 191 -------------------------------- 1 file changed, 191 deletions(-) delete mode 100644 tests/unit/test_active_tools.py diff --git a/tests/unit/test_active_tools.py b/tests/unit/test_active_tools.py deleted file mode 100644 index 2e1102f5..00000000 --- a/tests/unit/test_active_tools.py +++ /dev/null @@ -1,191 +0,0 @@ -import os -import numpy as np -import pytest -import zarr - -from activestorage import active_tools as at -from numcodecs import Blosc -from zarr.indexing import ( - OrthogonalIndexer, - is_contiguous_selection, -) -from zarr.util import is_total_slice - - -def assemble_zarr(): - """Create a test zarr object.""" - compressor = Blosc(cname='zstd', clevel=1, shuffle=Blosc.BITSHUFFLE) - z = zarr.create((10000, 10000), chunks=(1000, 1000), dtype='i1', order='C', - compressor=compressor) - - return z - - -def assemble_zarr_uncompressed(): - """Create a test zarr object.""" - z = zarr.create((1000, 1000), chunks=(2, 8), dtype='i1', order='C', - compressor=None) - - return z - - -def assemble_zarr_with_fillvalue(): - """Create a test zarr object.""" - compressor = Blosc(cname='zstd', clevel=1, shuffle=Blosc.BITSHUFFLE) - z = zarr.create((10000, 10000), chunks=(1000, 1000), dtype='i1', order='C', - compressor=compressor) - z._fill_value = -99 - - return z - - -def assemble_zarr_uncompressed_2(): - """Create a test zarr object.""" - z = zarr.create((100, 100), chunks=(10, 10), dtype='i1', order='C', - compressor=None) - - return z - - -def test_as_get_orthogonal_selection(): - """Test Zarr Orthogonal selection.""" - z = assemble_zarr() - z._cache_metadata = None - selection = (slice(0, 2, 1), slice(4, 6, 1)) - sel = at.as_get_orthogonal_selection(z, selection=selection, out=None, - fields=None) - expected_shape = (2, 2) - np.testing.assert_array_equal(sel.shape, expected_shape) - expected_elem = [0, 0] - np.testing.assert_array_equal(sel[0], expected_elem) - - -def test_as_get_selection(): - """Test chunk iterator.""" - z = assemble_zarr() - selection = (slice(0, 2, 1), slice(4, 6, 1)) - indexer = OrthogonalIndexer(selection, z) - ch = at.as_get_selection(z, indexer, out=None, - fields=None) - np.testing.assert_array_equal(ch[0][0], [0, 0]) - np.testing.assert_array_equal(ch[1][0], None) - np.testing.assert_array_equal(ch[2][0], (0, 0)) - - -def test_as_chunk_getitem(): - """Test chunk get item.""" - z = assemble_zarr() - z = at.make_an_array_instance_active(z) - chunk_coords = (0, 3) - chunk_selection = [slice(0, 2, 1)] - out = np.array((2, 2, 2)) - out_selection = slice(0, 2, 1) - ch = at.as_chunk_getitem(z, chunk_coords, chunk_selection, out, out_selection, - drop_axes=None, fields=None) - np.testing.assert_array_equal(ch[0], (1000, 1000)) - np.testing.assert_array_equal(ch[1], [slice(0, 2, 1)]) - # PCI - assert list(ch[2]) == [(0, 2000, (slice(0, 2, 1),))] - - -def test_as_chunk_getitem_with_fillvalue(): - """Test chunk get item.""" - z = assemble_zarr_with_fillvalue() - z = at.make_an_array_instance_active(z) - chunk_coords = (0, 3) - chunk_selection = [slice(0, 2, 1)] - out = np.array((2, 2, 2)) - out_selection = slice(0, 2, 1) - ch = at.as_chunk_getitem(z, chunk_coords, chunk_selection, out, out_selection, - drop_axes=None, fields=None) - np.testing.assert_array_equal(ch[0], (1000, 1000)) - np.testing.assert_array_equal(ch[1], [slice(0, 2, 1)]) - # PCI - assert list(ch[2]) == [(0, 2000, (slice(0, 2, 1),))] - - -def test_process_chunk_uncompressed(): - """Test for processing chunk engine for uncompressed data""" - z = assemble_zarr_uncompressed() - z = at.make_an_array_instance_active(z) - out = np.ones((1, 8)) - cdata = np.ones((1, 2)) - chunk_selection = slice(0, 1, 1) - out_selection = np.array((0)) - ch = at.as_process_chunk(z, - out, - cdata, - chunk_selection, - drop_axes=False, - out_is_ndarray=True, - fields=None, - out_selection=out_selection, - partial_read_decode=False) - - assert is_contiguous_selection(out_selection) - assert not is_total_slice(chunk_selection, z._chunks) - assert out.any() - assert out.shape == (1, 8) - - -def test_process_chunk_uncompressed_write_direct(): - """Test for processing chunk engine for uncompressed data""" - z = assemble_zarr_uncompressed_2() - z = at.make_an_array_instance_active(z) - out = np.ones((10, 10)) - cdata = np.ones((10, 10)) - chunk_selection = (slice(0, 10, 1), slice(10, 20, 1)) - out_selection = np.array((0)) - with pytest.raises(ValueError) as exc: - ch = at.as_process_chunk(z, - out, - cdata, - chunk_selection, - drop_axes=False, - out_is_ndarray=True, - fields=None, - out_selection=out_selection, - partial_read_decode=False) - assert str(exc.value) == "Chunk shape (10, 80) exceeds data chunks shape (10, 10)" - - assert is_contiguous_selection(out_selection) - assert is_total_slice(chunk_selection, z._chunks) - - z._chunks = (2, 8) - out = np.ones((1, 8)) - cdata = np.ones((1, 2)) - chunk_selection = slice(0, 8, 1) - out_selection = np.array((0)) - with pytest.raises(ValueError) as exc: - ch = at.as_process_chunk(z, - out, - cdata, - chunk_selection, - drop_axes=False, - out_is_ndarray=True, - fields=None, - out_selection=out_selection, - partial_read_decode=False) - assert str(exc.value) == "Storage chunk shape (2, 8) exceeds permitted output data shape (1, 8)." - - -def test_process_chunk_uncompressed_with_compressor(): - """Test for processing chunk engine for uncompressed data""" - z = assemble_zarr() - z = at.make_an_array_instance_active(z) - out = np.ones((1, 8)) - cdata = np.ones((1, 2)) - chunk_selection = slice(0, 1, 1) - out_selection = np.array((0)) - raised = f'cdata {cdata} is an ndarray, can not decompress.' - with pytest.raises(TypeError) as exc: - ch = at.as_process_chunk(z, - out, - cdata, - chunk_selection, - drop_axes=False, - out_is_ndarray=True, - fields=None, - out_selection=out_selection, - partial_read_decode=False) - assert str(exc.value) == raised From 1803e7f78ce5ca3f7ec5c1a825cc31b7fb8e5b76 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 14:47:06 +0000 Subject: [PATCH 011/129] removed redundant test cases --- tests/unit/test_storage_types.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index 561c36ff..4a3a4ccb 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -16,17 +16,13 @@ from activestorage.active import Active from activestorage.config import * from activestorage.dummy_data import make_vanilla_ncdata -from activestorage import netcdf_to_zarr import activestorage.reductionist import activestorage.storage -# Capture the real function before it is mocked. -old_netcdf_to_zarr = netcdf_to_zarr.load_netcdf_zarr_generic @mock.patch.object(activestorage.active, "load_from_s3") -@mock.patch.object(activestorage.netcdf_to_zarr, "load_netcdf_zarr_generic") @mock.patch.object(activestorage.active.reductionist, "reduce_chunk") def test_s3(mock_reduce, mock_nz, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" @@ -39,9 +35,6 @@ def test_s3(mock_reduce, mock_nz, mock_load, tmp_path): def load_from_s3(uri): yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) - def load_netcdf_zarr_generic(uri, ncvar, storage_type, storage_options=None): - return old_netcdf_to_zarr(test_file, ncvar, None, None) - def reduce_chunk( session, server, @@ -149,7 +142,6 @@ def test_s3_load_failure(mock_load): @mock.patch.object(activestorage.active, "load_from_s3") -@mock.patch.object(activestorage.netcdf_to_zarr, "load_netcdf_zarr_generic") @mock.patch.object(activestorage.active.reductionist, "reduce_chunk") def test_reductionist_connection(mock_reduce, mock_nz, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" @@ -158,11 +150,7 @@ def test_reductionist_connection(mock_reduce, mock_nz, mock_load, tmp_path): def load_from_s3(uri): yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) - def load_netcdf_zarr_generic(uri, ncvar, storage_type, storage_options=None): - return old_netcdf_to_zarr(test_file, ncvar, None, None) - mock_load.side_effect = load_from_s3 - mock_nz.side_effect = load_netcdf_zarr_generic mock_reduce.side_effect = requests.exceptions.ConnectTimeout() uri = "s3://fake-bucket/fake-object" @@ -178,7 +166,6 @@ def load_netcdf_zarr_generic(uri, ncvar, storage_type, storage_options=None): @mock.patch.object(activestorage.active, "load_from_s3") -@mock.patch.object(activestorage.netcdf_to_zarr, "load_netcdf_zarr_generic") @mock.patch.object(activestorage.active.reductionist, "reduce_chunk") def test_reductionist_bad_request(mock_reduce, mock_nz, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" @@ -187,9 +174,6 @@ def test_reductionist_bad_request(mock_reduce, mock_nz, mock_load, tmp_path): def load_from_s3(uri): yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) - def load_netcdf_zarr_generic(uri, ncvar, storage_type, storage_options=None): - return old_netcdf_to_zarr(test_file, ncvar, None, None) - mock_load.side_effect = load_from_s3 mock_nz.side_effect = load_netcdf_zarr_generic mock_reduce.side_effect = activestorage.reductionist.ReductionistError(400, "Bad request") From f5a375c5f385d8cf57d1851b9ba5b51640ab0133 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 14:47:24 +0000 Subject: [PATCH 012/129] making sure we know Pyfive is run haha --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index c5b3c477..b37ba932 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -40,7 +40,7 @@ def load_from_s3(uri, storage_options=None): s3file = fs.open(uri, 'rb') ds = pyfive.File(s3file) - print(f"Dataset loaded from S3 via h5netcdf: {uri}") + print(f"Dataset loaded from S3 via h5netcdf: {uri} with Pyfive File handler") return ds From d1c928230a20b38d723e34b0dd5d61f647522a91 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 15:01:33 +0000 Subject: [PATCH 013/129] fix bigger data test with native pyfive exception --- tests/test_bigger_data.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/test_bigger_data.py b/tests/test_bigger_data.py index 2f38bddb..2173558d 100644 --- a/tests/test_bigger_data.py +++ b/tests/test_bigger_data.py @@ -9,6 +9,7 @@ from activestorage.active import Active from activestorage.config import * +from pyfive.core import InvalidHDF5File as InvalidHDF5Err import utils @@ -162,28 +163,17 @@ def test_native_emac_model_fails(test_data_path): """ ncfile = str(test_data_path / "emac.nc") uri = utils.write_to_storage(ncfile) - # Use local file to avoid h5py - active = Active(ncfile, "aps_ave") - active._version = 0 - d = active[4:5, 1:2] - if len(d): - mean_result = np.mean(d) - else: - # as it happens it is is possible for a slice to be - # all missing, so for the purpose of this test we - # ignore it, but the general case should not. - pass if USE_S3: active = Active(uri, "aps_ave", utils.get_storage_type()) - with pytest.raises(OSError): + with pytest.raises(InvalidHDF5Err): active[...] else: active = Active(uri, "aps_ave") active._version = 2 active.method = "mean" active.components = True - with pytest.raises(OSError): + with pytest.raises(InvalidHDF5Err): result2 = active[4:5, 1:2] From 69c3dcacbba22759e3714ce81b9d40a93a7533c5 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 16:26:37 +0000 Subject: [PATCH 014/129] fix package test --- tests/test_package.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_package.py b/tests/test_package.py index 3ee2f41c..e6f00c37 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -6,7 +6,7 @@ # test version def test_version(): assert hasattr(activestorage, "__version__") - assert activestorage.__version__ == "0.0.1" + assert activestorage.__version__ == "0.0.2" print(activestorage.__version__) # check activestorage class @@ -14,7 +14,6 @@ def test_activestorage_class_attrs(): assert hasattr(activestorage, "Active") assert hasattr(activestorage, "active") assert hasattr(activestorage, "storage") - assert hasattr(activestorage, "netcdf_to_zarr") # check Active class def test_active_class_attrs(): @@ -25,7 +24,6 @@ def test_active_class_attrs(): assert hasattr(act, "_get_active") assert hasattr(act, "_get_selection") assert hasattr(act, "_process_chunk") - assert hasattr(act, "_via_kerchunk") assert hasattr(act, "components") assert hasattr(act, "method") assert hasattr(act, "ncvar") From c50c3d2edc0d4b4f010b8b36e47747761a73da94 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 16:32:05 +0000 Subject: [PATCH 015/129] mark lock test as xfail --- tests/unit/test_active.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index 2f95fdee..25f988cc 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -81,6 +81,7 @@ def test_active(): init = active.__init__(uri=uri, ncvar=ncvar) +@pytest.mark.xfail(reason="We don't employ locks with Pyfive anymore, yet.") def test_lock(): """Unit test for class:Active.""" uri = "tests/test_data/cesm2_native.nc" From 09b4b3fa3124988150774bdf8eb04977f466dc68 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 5 Mar 2024 16:47:13 +0000 Subject: [PATCH 016/129] toy for testing missing test --- bnl/test_missing_gubbins.py | 145 ++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 bnl/test_missing_gubbins.py diff --git a/bnl/test_missing_gubbins.py b/bnl/test_missing_gubbins.py new file mode 100644 index 00000000..38fdcaec --- /dev/null +++ b/bnl/test_missing_gubbins.py @@ -0,0 +1,145 @@ + +from activestorage import dummy_data as dd +from activestorage import Active +import pyfive +import numpy.ma as ma +import numpy as np +import os +from activestorage.config import * +from pathlib import Path +import s3fs + + +def get_masked_data(ds): + """ This is buried in active itself we should pull it out for wider usage in our testing etc""" + attrs = ds.attrs + missing_value = attrs.get('missing_value') + _FillValue = attrs.get('_FillValue') + valid_min = attrs.get('valid_min') + valid_max = attrs.get('valid_max') + valid_range = attrs.get('valid_range') + + if valid_max is not None or valid_min is not None: + if valid_range is not None: + raise ValueError( + "Invalid combination in the file of valid_min, " + "valid_max, valid_range: " + f"{valid_min}, {valid_max}, {valid_range}" + ) + elif valid_range is not None: + valid_min, valid_max = valid_range + + data = ds[:] + + if _FillValue is not None: + data = np.ma.masked_equal(data, _FillValue) + + if missing_value is not None: + data = np.ma.masked_equal(data, missing_value) + + if valid_max is not None: + data = np.ma.masked_greater(data, valid_max) + + if valid_min is not None: + data = np.ma.masked_less(data, valid_min) + + return data + + + + +def upload_to_s3(server, username, password, bucket, object, rfile): + """Upload a file to an S3 object store.""" + s3_fs = s3fs.S3FileSystem(key=username, secret=password, client_kwargs={'endpoint_url': server}) + # Make sure s3 bucket exists + try: + s3_fs.mkdir(bucket) + except FileExistsError: + pass + + s3_fs.put_file(rfile, os.path.join(bucket, object)) + + +def get_storage_type(): + if USE_S3: + return "s3" + else: + return None + +def write_to_storage(ncfile): + """Write a file to storage and return an appropriate URI or path to access it.""" + if USE_S3: + object = os.path.basename(ncfile) + upload_to_s3(S3_URL, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET, object, ncfile) + return os.path.join("s3://", S3_BUCKET, object) + else: + return ncfile + + +def active_zero(testfile): + """Run Active with no active storage (version=0).""" + active = Active(testfile, "data", get_storage_type()) + active._version = 0 + d = active[0:2, 4:6, 7:9] + assert ma.is_masked(d) + + # FIXME: For the S3 backend, h5netcdf is used to read the metadata. It does + # not seem to load the missing data attributes (missing_value, _FillValue, + # valid_min, valid_max, valid_range, etc). + assert ma.is_masked(d) + + return np.mean(d) + + +def active_two(testfile): + """Run Active with active storage (version=2).""" + active = Active(testfile, "data", get_storage_type()) + active._version = 2 + active.method = "mean" + active.components = True + result2 = active[0:2, 4:6, 7:9] + + active_mean = result2["sum"] / result2["n"] + + return active_mean + + +def load_dataset(testfile): + """Load data as netCDF4.Dataset.""" + ds = pyfive.File(testfile) + actual_data = get_masked_data(ds["data"]) + + ds.close() + + assert ma.is_masked(actual_data) + + return actual_data + + +def test_partially_missing_data(tmp_path): + testfile = str(tmp_path / 'test_partially_missing_data.nc') + r = dd.make_partially_missing_ncdata(testfile) + + # retrieve the actual numpy-ed result + actual_data = load_dataset(testfile) + unmasked_numpy_mean = actual_data[0:2, 4:6, 7:9].data.mean() + masked_numpy_mean = actual_data[0:2, 4:6, 7:9].mean() + assert unmasked_numpy_mean != masked_numpy_mean + print("Numpy masked result (mean)", masked_numpy_mean) + + # write file to storage + testfile = write_to_storage(testfile) + + # numpy masked to check for correct Active behaviour + no_active_mean = active_zero(testfile) + print("No active storage result (mean)", no_active_mean) + + active_mean = active_two(testfile) + print("Active storage result (mean)", active_mean) + + np.testing.assert_array_equal(masked_numpy_mean, active_mean) + np.testing.assert_array_equal(no_active_mean, active_mean) + + +mypath = Path(__file__).parent +test_partially_missing_data(mypath) \ No newline at end of file From 8a7e35bde1ad3fe9281a9bc4f85cd6d40c00a36f Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 17:05:38 +0000 Subject: [PATCH 017/129] turn off some printing for now --- activestorage/active.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index b37ba932..9be07251 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -407,7 +407,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": - print("S3 rfile is:", self.filename) + # print("S3 rfile is:", self.filename) parsed_url = urllib.parse.urlparse(self.filename) bucket = parsed_url.netloc object = parsed_url.path @@ -417,8 +417,8 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou if bucket == "": bucket = os.path.dirname(object) object = os.path.basename(object) - print("S3 bucket:", bucket) - print("S3 file:", object) + # print("S3 bucket:", bucket) + # print("S3 file:", object) if self.storage_options is None: # for the moment we need to force ds.dtype to be a numpy type tmp, count = reductionist.reduce_chunk(session, From cacb94e442fe591be3e860cf0629c324be821721 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 5 Mar 2024 17:08:43 +0000 Subject: [PATCH 018/129] starting to understand the test flakiness --- tests/unit/test_storage_types.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index 4a3a4ccb..30c82b87 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -7,6 +7,7 @@ import os import h5netcdf import numpy as np +import pyfive import pytest import requests.exceptions from unittest import mock @@ -24,16 +25,15 @@ @mock.patch.object(activestorage.active, "load_from_s3") @mock.patch.object(activestorage.active.reductionist, "reduce_chunk") -def test_s3(mock_reduce, mock_nz, mock_load, tmp_path): +def test_s3(mock_reduce, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" # Since this is a unit test, we can't assume that an S3 object store or # active storage server is available. Therefore, we mock out the remote # service interaction and replace with local file operations. - @contextlib.contextmanager - def load_from_s3(uri): - yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) + def load_from_s3(uri, storage_options=None): + return pyfive.File(test_file) def reduce_chunk( session, @@ -67,7 +67,6 @@ def reduce_chunk( ) mock_load.side_effect = load_from_s3 - mock_nz.side_effect = load_netcdf_zarr_generic mock_reduce.side_effect = reduce_chunk uri = "s3://fake-bucket/fake-object" @@ -78,6 +77,10 @@ def reduce_chunk( active._version = 1 active._method = "max" + print("This test has severe flakiness:") + print("Either fails with AssestionError - bTREE stuff,") + print("or it fails with a multitude of KeyErrors.") + print(active) result = active[::] assert result == 999.0 @@ -85,7 +88,6 @@ def reduce_chunk( # S3 loading is not done from Active anymore mock_load.assert_not_called() - mock_nz.assert_called_once_with(uri, "data", "s3", None) # NOTE: This gets called multiple times with various arguments. Match on # the common ones. mock_reduce.assert_called_with( @@ -143,11 +145,11 @@ def test_s3_load_failure(mock_load): @mock.patch.object(activestorage.active, "load_from_s3") @mock.patch.object(activestorage.active.reductionist, "reduce_chunk") -def test_reductionist_connection(mock_reduce, mock_nz, mock_load, tmp_path): +def test_reductionist_connection(mock_reduce, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" @contextlib.contextmanager - def load_from_s3(uri): + def load_from_s3(uri, storage_options=None): yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) mock_load.side_effect = load_from_s3 @@ -167,15 +169,14 @@ def load_from_s3(uri): @mock.patch.object(activestorage.active, "load_from_s3") @mock.patch.object(activestorage.active.reductionist, "reduce_chunk") -def test_reductionist_bad_request(mock_reduce, mock_nz, mock_load, tmp_path): +def test_reductionist_bad_request(mock_reduce, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" @contextlib.contextmanager - def load_from_s3(uri): + def load_from_s3(uri, storage_options=None): yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) mock_load.side_effect = load_from_s3 - mock_nz.side_effect = load_netcdf_zarr_generic mock_reduce.side_effect = activestorage.reductionist.ReductionistError(400, "Bad request") uri = "s3://fake-bucket/fake-object" From ce0479c135651d27f621d9cb39a96d1846c3eb14 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 5 Mar 2024 20:02:16 +0000 Subject: [PATCH 019/129] Addresses issues with missing data and with thread safety (basically we need to read the chunk cache before sending things off into the weeds). --- activestorage/active.py | 119 +++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 70 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index b37ba932..6a0ab3b4 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -44,7 +44,6 @@ def load_from_s3(uri, storage_options=None): return ds - class Active: """ Instantiates an interface to active storage which contains either zarr files @@ -114,6 +113,7 @@ def __init__( self._components = False self._method = None self._max_threads = max_threads + self.missing = None def __getitem__(self, index): """ @@ -125,36 +125,50 @@ def __getitem__(self, index): # through to the default method. ncvar = self.ncvar - # in all casese we need an open netcdf file to get at attributes + # in all cases we need an open netcdf file to get at attributes # we keep it open because we need it's b-tree if self.storage_type is None: nc = pyfive.File(self.uri) elif self.storage_type == "s3": nc = load_from_s3(self.uri, self.storage_options) self.filename = self.uri - - if self.method is None and self._version == 0: - # No active operation - if self.storage_type is None: - data = nc[ncvar][index] - - elif self.storage_type == "s3": + ds = nc[ncvar] + # Get missing values - data = nc[ncvar][index] - data = self._mask_data(data, nc[ncvar]) - - return data + _FillValue = ds.attrs.get('_FillValue') + missing_value = ds.attrs.get('missing_value') + valid_min = ds.attrs.get('valid_min') + valid_max = ds.attrs.get('valid_max') + valid_range = ds.attrs.get('valid_range') + if valid_max is not None or valid_min is not None: + if valid_range is not None: + raise ValueError( + "Invalid combination in the file of valid_min, " + "valid_max, valid_range: " + f"{valid_min}, {valid_max}, {valid_range}" + ) + elif valid_range is not None: + valid_min, valid_max = valid_range + self.missing = _FillValue, missing_value, valid_min, valid_max, + if self.method is None and self._version == 0: + + # No active operation + data = ds[index] + data = self._mask_data(data) + return data + elif self._version == 1: - return self._get_selection(nc[ncvar], index) + + #FIXME: is the difference between version 1 and 2 still honoured? + return self._get_selection(ds, index) elif self._version == 2: - data = self._get_selection(nc[ncvar], index) - return data - + return self._get_selection(ds, index) + else: raise ValueError(f'Version {self._version} not supported') @@ -213,8 +227,6 @@ def ncvar(self, value): self._ncvar = value - - def _get_active(self, method, *args): """ *args defines a slice of data. This method loops over each of the chunks @@ -224,7 +236,6 @@ def _get_active(self, method, *args): an array returned via getitem. """ raise NotImplementedError - def _get_selection(self, ds, *args): @@ -237,29 +248,6 @@ def _get_selection(self, ds, *args): # stick this here for later, to discuss with David keepdims = True - # Get missing values - _FillValue = ds.attrs.get('_FillValue') - missing_value = ds.attrs.get('missing_value') - valid_min = ds.attrs.get('valid_min') - valid_max = ds.attrs.get('valid_max') - valid_range = ds.attrs.get('valid_range') - if valid_max is not None or valid_min is not None: - if valid_range is not None: - raise ValueError( - "Invalid combination in the file of valid_min, " - "valid_max, valid_range: " - f"{valid_min}, {valid_max}, {valid_range}" - ) - elif valid_range is not None: - valid_min, valid_max = valid_range - - missing = ( - _FillValue, - missing_value, - valid_min, - valid_max, - ) - name = ds.name dtype = np.dtype(ds.dtype) # hopefully fix pyfive to get a dtype directly @@ -279,9 +267,9 @@ def _get_selection(self, ds, *args): # we use array._chunks rather than ds.chunks, as the latter is none in the case of # unchunked data, and we need to tell the storage the array dimensions in this case. - return self._from_storage(ds, indexer, array._chunks, out_shape, out_dtype, missing, compressor, filters, drop_axes) + return self._from_storage(ds, indexer, array._chunks, out_shape, out_dtype, compressor, filters, drop_axes) - def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, missing, compressor, filters, drop_axes): + def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, filters, drop_axes): method = self.method if method is not None: @@ -312,6 +300,10 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, missing, comp session = None # Process storage chunks using a thread pool. + # Because we do this, we need to read the dataset b-tree now, not as we go, so + # it is already in cache. If we remove the thread pool from here, we probably + # wouldn't need to do it before the first one. + ds._get_chunk_addresses() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: futures = [] # Submit chunks for processing. @@ -319,7 +311,7 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, missing, comp future = executor.submit( self._process_chunk, session, ds, chunks, chunk_coords, chunk_selection, - counts, out_selection, missing, compressor, filters, drop_axes=drop_axes) + counts, out_selection, compressor, filters, drop_axes=drop_axes) futures.append(future) # Wait for completion. for future in concurrent.futures.as_completed(futures): @@ -390,7 +382,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, counts, - out_selection, missing, compressor, filters, drop_axes=None): + out_selection, compressor, filters, drop_axes=None): """ Obtain part or whole of a chunk. @@ -404,7 +396,6 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou offset, size, filter_mask = ds.get_chunk_details(chunk_coords) - # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": print("S3 rfile is:", self.filename) @@ -426,7 +417,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou S3_URL, bucket, object, offset, size, compressor, filters, - missing, np.dtype(ds.dtype), + self.missing, np.dtype(ds.dtype), chunks, ds.order, chunk_selection, @@ -445,7 +436,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou self._get_endpoint_url(), bucket, object, offset, size, compressor, filters, - missing, np.dtype(ds.dtype), + self.missing, np.dtype(ds.dtype), chunks, ds.order, chunk_selection, @@ -456,7 +447,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou # so neither the returned data or the interface should be considered stable # although we will version changes. tmp, count = reduce_chunk(self.filename, offset, size, compressor, filters, - missing, ds.dtype, + self.missing, ds.dtype, chunks, ds.order, chunk_selection, method=self.method) @@ -467,25 +458,13 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou tmp = np.squeeze(tmp, axis=drop_axes) return tmp, out_selection - def _mask_data(self, data, ds_var): - """ppp""" - # TODO: replace with cfdm.NetCDFIndexer, hopefully. - attrs = ds_var.attrs - missing_value = attrs.get('missing_value') - _FillValue = attrs.get('_FillValue') - valid_min = attrs.get('valid_min') - valid_max = attrs.get('valid_max') - valid_range = attrs.get('valid_range') - - if valid_max is not None or valid_min is not None: - if valid_range is not None: - raise ValueError( - "Invalid combination in the file of valid_min, " - "valid_max, valid_range: " - f"{valid_min}, {valid_max}, {valid_range}" - ) - elif valid_range is not None: - valid_min, valid_max = valid_range + def _mask_data(self, data): + """ + Missing values obtained at initial getitem, and are used here to + mask data, if necessary + """ + + _FillValue, missing_value, valid_min, valid_max = self.missing if _FillValue is not None: data = np.ma.masked_equal(data, _FillValue) From 02d6120dc99ffca2bdff9f4fbbdc379100d4af49 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Wed, 6 Mar 2024 08:25:43 +0000 Subject: [PATCH 020/129] Fixed the regression wrt files with no chunking introduced by thread safety. --- activestorage/active.py | 7 ++++--- bnl/bnl_test.py | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 4e730f0f..90b889f9 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -261,13 +261,12 @@ def _get_selection(self, ds, *args): indexer = pyfive.OrthogonalIndexer(*args, array) out_shape = indexer.shape - out_dtype =ds.dtype #stripped_indexer = [(a, b, c) for a,b,c in indexer] drop_axes = indexer.drop_axes and keepdims # we use array._chunks rather than ds.chunks, as the latter is none in the case of # unchunked data, and we need to tell the storage the array dimensions in this case. - return self._from_storage(ds, indexer, array._chunks, out_shape, out_dtype, compressor, filters, drop_axes) + return self._from_storage(ds, indexer, array._chunks, out_shape, dtype, compressor, filters, drop_axes) def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, filters, drop_axes): method = self.method @@ -303,7 +302,9 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # Because we do this, we need to read the dataset b-tree now, not as we go, so # it is already in cache. If we remove the thread pool from here, we probably # wouldn't need to do it before the first one. - ds._get_chunk_addresses() + + if ds.chunks is not None: + ds._get_chunk_addresses() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: futures = [] # Submit chunks for processing. diff --git a/bnl/bnl_test.py b/bnl/bnl_test.py index c5bb3c3d..ae8e4a32 100644 --- a/bnl/bnl_test.py +++ b/bnl/bnl_test.py @@ -28,7 +28,6 @@ def mytest(): else: d = active[4:5, 1:2] mean_result = np.mean(d) - active = Active(uri, v, None) active._version = 2 active.method = "mean" From 2ffd644b9e1ca055e78d0c79c67f92de4e686b60 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Wed, 6 Mar 2024 09:26:31 +0000 Subject: [PATCH 021/129] Made some changes to Active just so we can use masked data as a standalone method which I don't like. We would be better to pull that out and do this differently. I also fixed the disgusting test ... --- activestorage/active.py | 69 +++++++++++++++++++++++++---------------- tests/test_missing.py | 57 ++++++++++------------------------ 2 files changed, 58 insertions(+), 68 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 90b889f9..c5e9dc8f 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -114,17 +114,11 @@ def __init__( self._method = None self._max_threads = max_threads self.missing = None + self.ds = None - def __getitem__(self, index): - """ - Provides support for a standard get item. - #FIXME-BNL: Why is the argument index? - """ - # In version one this is done by explicitly looping over each chunk in the file - # and returning the requested slice ourselves. In version 2, we can pass this - # through to the default method. + def __load_nc_file(self): + """ Get the netcdf file and it's b-tree""" ncvar = self.ncvar - # in all cases we need an open netcdf file to get at attributes # we keep it open because we need it's b-tree if self.storage_type is None: @@ -133,14 +127,21 @@ def __getitem__(self, index): nc = load_from_s3(self.uri, self.storage_options) self.filename = self.uri - ds = nc[ncvar] - # Get missing values - - _FillValue = ds.attrs.get('_FillValue') - missing_value = ds.attrs.get('missing_value') - valid_min = ds.attrs.get('valid_min') - valid_max = ds.attrs.get('valid_max') - valid_range = ds.attrs.get('valid_range') + self.ds = nc[ncvar] + + def __get_missing_attributes(self): + """" + Load all the missing attributes we need from a netcdf file + """ + + if self.ds is None: + self.__load_nc_file() + + _FillValue = self.ds.attrs.get('_FillValue') + missing_value = self.ds.attrs.get('missing_value') + valid_min = self.ds.attrs.get('valid_min') + valid_max = self.ds.attrs.get('valid_max') + valid_range = self.ds.attrs.get('valid_range') if valid_max is not None or valid_min is not None: if valid_range is not None: raise ValueError( @@ -151,23 +152,36 @@ def __getitem__(self, index): elif valid_range is not None: valid_min, valid_max = valid_range - self.missing = _FillValue, missing_value, valid_min, valid_max, + return _FillValue, missing_value, valid_min, valid_max + + def __getitem__(self, index): + """ + Provides support for a standard get item. + #FIXME-BNL: Why is the argument index? + """ + if self.ds is None: + self.__load_nc_file() + # In version one this is done by explicitly looping over each chunk in the file + # and returning the requested slice ourselves. In version 2, we can pass this + # through to the default method. + + self.missing = self.__get_missing_attributes() if self.method is None and self._version == 0: # No active operation - data = ds[index] + data = self.ds[index] data = self._mask_data(data) return data elif self._version == 1: #FIXME: is the difference between version 1 and 2 still honoured? - return self._get_selection(ds, index) + return self._get_selection(index) elif self._version == 2: - return self._get_selection(ds, index) + return self._get_selection(index) else: raise ValueError(f'Version {self._version} not supported') @@ -238,7 +252,7 @@ def _get_active(self, method, *args): raise NotImplementedError - def _get_selection(self, ds, *args): + def _get_selection(self, *args): """ At this point we have a Dataset object, but all the important information about how to use it is in the attribute DataoobjectDataset class. Here we gather @@ -248,11 +262,11 @@ def _get_selection(self, ds, *args): # stick this here for later, to discuss with David keepdims = True - name = ds.name - dtype = np.dtype(ds.dtype) + name = self.ds.name + dtype = np.dtype(self.ds.dtype) # hopefully fix pyfive to get a dtype directly - array = pyfive.ZarrArrayStub(ds.shape, ds.chunks) - ds = ds._dataobjects + array = pyfive.ZarrArrayStub(self.ds.shape, self.ds.chunks) + ds = self.ds._dataobjects if ds.filter_pipeline is None: compressor, filters = None, None @@ -464,7 +478,8 @@ def _mask_data(self, data): Missing values obtained at initial getitem, and are used here to mask data, if necessary """ - + if self.missing is None: + self.missing = self.__get_missing_attributes() _FillValue, missing_value, valid_min, valid_max = self.missing if _FillValue is not None: diff --git a/tests/test_missing.py b/tests/test_missing.py index 722358af..4edc674a 100644 --- a/tests/test_missing.py +++ b/tests/test_missing.py @@ -7,7 +7,7 @@ import tempfile import unittest -import h5netcdf +import pyfive from netCDF4 import Dataset @@ -247,58 +247,33 @@ def test_validrange(tmp_path): def test_active_mask_data(tmp_path): testfile = str(tmp_path / 'test_partially_missing_data.nc') - # with valid min - r = dd.make_validmin_ncdata(testfile, valid_min=500) - - # retrieve the actual numpy-ed result - actual_data = load_dataset(testfile) + def check_masking(testfile, testname): - # dataset variable - ds = h5netcdf.File(testfile, 'r', invalid_netcdf=True) - dsvar = ds["data"] + valid_masked_data = load_dataset(testfile) + ds = pyfive.File(testfile) + dsvar = ds["data"] + dsdata = dsvar[:] + ds.close() + a = Active(testfile, "data") + data = a._mask_data(dsdata) + np.testing.assert_array_equal(data, valid_masked_data,f'Failed masking for {testname}') - # test the function - data = Active._mask_data(None, actual_data, dsvar) - ds.close() + # with valid min + r = dd.make_validmin_ncdata(testfile, valid_min=500) + check_masking(testfile, "valid min") # with valid range r = dd.make_validrange_ncdata(testfile, valid_range=[750., 850.]) + check_masking(testfile, "valid range") # retrieve the actual numpy-ed result actual_data = load_dataset(testfile) - # dataset variable - ds = h5netcdf.File(testfile, 'r', invalid_netcdf=True) - dsvar = ds["data"] - - # test the function - data = Active._mask_data(None, actual_data, dsvar) - ds.close() - # with missing r = dd.make_missing_ncdata(testfile) - - # retrieve the actual numpy-ed result - actual_data = load_dataset(testfile) - - # dataset variable - ds = h5netcdf.File(testfile, 'r', invalid_netcdf=True) - dsvar = ds["data"] - - # test the function - data = Active._mask_data(None, actual_data, dsvar) - ds.close() + check_masking(testfile,'missing') # with _FillValue r = dd.make_fillvalue_ncdata(testfile) + check_masking(testfile,"_FillValue") - # retrieve the actual numpy-ed result - actual_data = load_dataset(testfile) - - # dataset variable - ds = h5netcdf.File(testfile, 'r', invalid_netcdf=True) - dsvar = ds["data"] - - # test the function - data = Active._mask_data(None, actual_data, dsvar) - ds.close() From 408558f73858fb626fa3c0627a2c6417112d0d88 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 13:28:55 +0000 Subject: [PATCH 022/129] fix test compression --- tests/test_compression.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_compression.py b/tests/test_compression.py index 1cba14b1..86982de9 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -91,6 +91,10 @@ def test_compression_and_filters_cmip6_data(storage_options, active_storage_url) check_dataset_filters(test_file, "tas", "zlib", False) + print("Test file and storage options", test_file, storage_options) + if not utils.get_storage_type(): + storage_options = None + active_storage_url = None active = Active(test_file, 'tas', utils.get_storage_type(), storage_options=storage_options, active_storage_url=active_storage_url) @@ -117,6 +121,10 @@ def test_compression_and_filters_obs4mips_data(storage_options, active_storage_u check_dataset_filters(test_file, "rlut", "zlib", False) + print("Test file and storage options", test_file, storage_options) + if not utils.get_storage_type(): + storage_options = None + active_storage_url = None active = Active(test_file, 'rlut', utils.get_storage_type(), storage_options=storage_options, active_storage_url=active_storage_url) From 3294052033dba987ba45aa37d3d17d1d136ce7bb Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 13:32:45 +0000 Subject: [PATCH 023/129] fix the other compression test --- tests/test_compression_remote_reductionist.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_compression_remote_reductionist.py b/tests/test_compression_remote_reductionist.py index 34e7bfaa..14eb722d 100644 --- a/tests/test_compression_remote_reductionist.py +++ b/tests/test_compression_remote_reductionist.py @@ -55,6 +55,10 @@ def test_compression_and_filters_cmip6_data(storage_options, active_storage_url) test_file_uri = os.path.join(S3_BUCKET, ofile) else: test_file_uri = test_file + print("Test file and storage options", test_file, storage_options) + if not utils.get_storage_type(): + storage_options = None + active_storage_url = None active = Active(test_file_uri, 'tas', utils.get_storage_type(), storage_options=storage_options, active_storage_url=active_storage_url) From ca30bb22f49a86f4f8539e1f083c68234c3e04c5 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 13:45:13 +0000 Subject: [PATCH 024/129] fix mocker tests --- tests/unit/test_storage_types.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index 30c82b87..764c138e 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -3,9 +3,7 @@ # interaction and replace with local file operations. import botocore -import contextlib import os -import h5netcdf import numpy as np import pyfive import pytest @@ -85,8 +83,9 @@ def reduce_chunk( assert result == 999.0 - # S3 loading is not done from Active anymore - mock_load.assert_not_called() + # S3 loading is done from Active + # but we should delegate that outside at some point + # mock_load.assert_not_called() # NOTE: This gets called multiple times with various arguments. Match on # the common ones. @@ -113,9 +112,8 @@ def reduce_chunk( def test_reductionist_version_0(mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3 using version 0.""" - @contextlib.contextmanager def load_from_s3(uri, storage_options=None): - yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) + return pyfive.File(test_file) mock_load.side_effect = load_from_s3 @@ -148,9 +146,8 @@ def test_s3_load_failure(mock_load): def test_reductionist_connection(mock_reduce, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" - @contextlib.contextmanager def load_from_s3(uri, storage_options=None): - yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) + return pyfive.File(test_file) mock_load.side_effect = load_from_s3 mock_reduce.side_effect = requests.exceptions.ConnectTimeout() @@ -172,9 +169,8 @@ def load_from_s3(uri, storage_options=None): def test_reductionist_bad_request(mock_reduce, mock_load, tmp_path): """Test stack when call to Active contains storage_type == s3.""" - @contextlib.contextmanager def load_from_s3(uri, storage_options=None): - yield h5netcdf.File(test_file, 'r', invalid_netcdf=True) + return pyfive.File(test_file) mock_load.side_effect = load_from_s3 mock_reduce.side_effect = activestorage.reductionist.ReductionistError(400, "Bad request") From c1b2823bd753b21085fed2bb5ff6373b657bff8c Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 13:47:04 +0000 Subject: [PATCH 025/129] minor fix to active --- activestorage/active.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index c5e9dc8f..601255ce 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -1,5 +1,4 @@ import concurrent.futures -import contextlib import os import numpy as np import pathlib @@ -40,7 +39,7 @@ def load_from_s3(uri, storage_options=None): s3file = fs.open(uri, 'rb') ds = pyfive.File(s3file) - print(f"Dataset loaded from S3 via h5netcdf: {uri} with Pyfive File handler") + print(f"Dataset loaded from S3 with s3fs and Pyfive: {uri}") return ds From 3c1b4208bc66a00e5ae2cedbc5b1b48ac4963e17 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 13:49:45 +0000 Subject: [PATCH 026/129] ass test todo --- tests/test_harness.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_harness.py b/tests/test_harness.py index f7b9db47..cdead544 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -33,6 +33,8 @@ def test_read0(tmp_path): active = Active(test_file, 'data', utils.get_storage_type()) active._version = 0 d = active[0:2,4:6,7:9] + # TODO Bryan look into this + print(d.data, type(d.data)) nda = np.ndarray.flatten(d.data) assert np.array_equal(nda,np.array([740.,840.,750.,850.,741.,841.,751.,851.])) From 589c772610f572d86cea2ff9158c2ed5b292fc45 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 13:59:21 +0000 Subject: [PATCH 027/129] fix last test --- tests/test_harness.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_harness.py b/tests/test_harness.py index cdead544..315a3b46 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -24,7 +24,7 @@ def create_test_dataset(tmp_path): return test_file -@pytest.mark.xfail(USE_S3, reason="descriptor 'flatten' for 'numpy.ndarray' objects doesn't apply to a 'memoryview' object") +# @pytest.mark.xfail(USE_S3, reason="descriptor 'flatten' for 'numpy.ndarray' objects doesn't apply to a 'memoryview' object") def test_read0(tmp_path): """ Test a normal read slicing the data an interesting way, using version 0 (native interface) @@ -32,10 +32,10 @@ def test_read0(tmp_path): test_file = create_test_dataset(tmp_path) active = Active(test_file, 'data', utils.get_storage_type()) active._version = 0 - d = active[0:2,4:6,7:9] - # TODO Bryan look into this - print(d.data, type(d.data)) - nda = np.ndarray.flatten(d.data) + d = active[0:2, 4:6, 7:9] + # d.data is a memoryview object in both local POSIX and remote S3 storages + # keep the current behaviour of the test to catch possible type changes + nda = np.ndarray.flatten(np.asarray(d.data)) assert np.array_equal(nda,np.array([740.,840.,750.,850.,741.,841.,751.,851.])) def test_read1(tmp_path): From c0220408812cfee9adb20c56fbe4249b56596b78 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 14:23:48 +0000 Subject: [PATCH 028/129] checkout and install pyfive in dev mode --- .github/workflows/run-tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3221a3a8..97aaa651 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - pyfive schedule: - cron: '0 0 * * *' # nightly @@ -34,6 +35,13 @@ jobs: use-mamba: true - run: conda --version - run: python -V + - name: Install development version of bnlawrence/Pyfive:issue6 + run: | + cd .. + git clone https://github.com/bnlawrence/pyfive.git + cd pyfive + git checkout issue6 + pip install -e . - run: pip install -e . - run: pytest -n 2 From 016242e2eb752531522a5685b204ff175d47457d Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 14:34:55 +0000 Subject: [PATCH 029/129] fix Bryans test --- bnl/test_missing_gubbins.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bnl/test_missing_gubbins.py b/bnl/test_missing_gubbins.py index 38fdcaec..65f353a5 100644 --- a/bnl/test_missing_gubbins.py +++ b/bnl/test_missing_gubbins.py @@ -139,7 +139,3 @@ def test_partially_missing_data(tmp_path): np.testing.assert_array_equal(masked_numpy_mean, active_mean) np.testing.assert_array_equal(no_active_mean, active_mean) - - -mypath = Path(__file__).parent -test_partially_missing_data(mypath) \ No newline at end of file From 9bfb099b8c39f2d5c22f0cee78c97fac01b18cce Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 14:39:30 +0000 Subject: [PATCH 030/129] install Pyfive in OSX GHA too --- .github/workflows/run-tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 97aaa651..f69d0eba 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -66,5 +66,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V + - name: Install development version of bnlawrence/Pyfive:issue6 + run: | + cd .. + git clone https://github.com/bnlawrence/pyfive.git + cd pyfive + git checkout issue6 + pip install -e . - run: pip install -e . - run: pytest From 68827ebb8aa56d967e614bb1f89a9b4de090e9f4 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 14:40:49 +0000 Subject: [PATCH 031/129] run the s3 tests too with Minio --- .github/workflows/test_s3_minio.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index c1399441..32d8e1cf 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -6,6 +6,7 @@ on: push: branches: - main # keep this at all times + - pyfive pull_request: schedule: - cron: '0 0 * * *' # nightly @@ -57,6 +58,13 @@ jobs: miniforge-version: "latest" miniforge-variant: Mambaforge use-mamba: true + - name: Install development version of bnlawrence/Pyfive:issue6 + run: | + cd .. + git clone https://github.com/bnlawrence/pyfive.git + cd pyfive + git checkout issue6 + pip install -e . - name: Install PyActiveStorage run: | conda --version From 1d925dcc29e667867de729d959633c2d9dc4a4f6 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 14:42:02 +0000 Subject: [PATCH 032/129] run the s3 tests too with remote reductionist --- .github/workflows/test_s3_remote_reductionist.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test_s3_remote_reductionist.yml b/.github/workflows/test_s3_remote_reductionist.yml index 1edc96c3..c2e9c457 100644 --- a/.github/workflows/test_s3_remote_reductionist.yml +++ b/.github/workflows/test_s3_remote_reductionist.yml @@ -7,6 +7,7 @@ on: push: branches: - main # keep this at all times + - pyfive pull_request: schedule: - cron: '0 0 * * *' # nightly @@ -52,6 +53,13 @@ jobs: miniforge-version: "latest" miniforge-variant: Mambaforge use-mamba: true + - name: Install development version of bnlawrence/Pyfive:issue6 + run: | + cd .. + git clone https://github.com/bnlawrence/pyfive.git + cd pyfive + git checkout issue6 + pip install -e . - name: Install PyActiveStorage run: | conda --version From 400e2303b7d0cf2db96bc1b745c4dffce4055320 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Wed, 6 Mar 2024 16:01:30 +0000 Subject: [PATCH 033/129] Fixed incorrect handling of missing data in reductionist encode_missing --- activestorage/reductionist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activestorage/reductionist.py b/activestorage/reductionist.py index 654b54ba..11a12851 100644 --- a/activestorage/reductionist.py +++ b/activestorage/reductionist.py @@ -116,6 +116,8 @@ def encode_missing(missing): if missing_value: if isinstance(missing_value, collections.abc.Sequence): return {"missing_values": [encode_dvalue(v) for v in missing_value]} + elif isinstance(missing_value, np.ndarray): + return {"missing_values": [encode_dvalue(v) for v in missing_value]} else: return {"missing_value": encode_dvalue(missing_value)} if valid_min and valid_max: From afe35466613034d157109900f5a7c6aa14f7f1b9 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Wed, 6 Mar 2024 16:08:48 +0000 Subject: [PATCH 034/129] it's not a test! --- bnl/bnl_s3test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bnl/bnl_s3test.py b/bnl/bnl_s3test.py index dd7a72ba..9ae4ab35 100644 --- a/bnl/bnl_s3test.py +++ b/bnl/bnl_s3test.py @@ -27,7 +27,7 @@ def simple(filename, var): #f2 = load_from_s3(uri, storage_options={'client_kwargs':{"endpoint_url":S3_URL}}) -def test_compression_and_filters_cmip6_forced_s3_from_local_2(ncfile, var): +def ex_test(ncfile, var): """ Test use of datasets with compression and filters applied for a real CMIP6 dataset (CMIP6-test.nc) - an IPSL file. @@ -58,7 +58,7 @@ def test_compression_and_filters_cmip6_forced_s3_from_local_2(ncfile, var): storage_options=storage_options, active_storage_url=active_storage_url) - active._version = 1 + active._version = 2 active._method = "min" result = active[0:2,4:6,7:9] @@ -66,7 +66,9 @@ def test_compression_and_filters_cmip6_forced_s3_from_local_2(ncfile, var): assert result == 239.25946044921875 + if __name__=="__main__": ncfile, var = 'CMIP6-test.nc','tas' + #ncfile, var = 'test_partially_missing_data.nc','data' simple(ncfile, var) - test_compression_and_filters_cmip6_forced_s3_from_local_2(ncfile, var) \ No newline at end of file + ex_test(ncfile, var) \ No newline at end of file From 44980b7877184a6baeb3dc706e04e001ccd87617 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Wed, 6 Mar 2024 16:51:23 +0000 Subject: [PATCH 035/129] Fixed HDF5 NetCDF attribute issue --- activestorage/active.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 601255ce..b15e6a69 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -133,6 +133,14 @@ def __get_missing_attributes(self): Load all the missing attributes we need from a netcdf file """ + def hfix(x): + ''' + return item if single element list/array + see https://github.com/h5netcdf/h5netcdf/issues/116 + ''' + if not np.isscalar(x) and len(x) == 1: + return x[0] + if self.ds is None: self.__load_nc_file() @@ -151,7 +159,7 @@ def __get_missing_attributes(self): elif valid_range is not None: valid_min, valid_max = valid_range - return _FillValue, missing_value, valid_min, valid_max + return hfix(_FillValue), hfix(missing_value), hfix(valid_min), hfix(valid_max) def __getitem__(self, index): """ From 98a74c539376c62f8c8d5c75a4e907bdd2e3b34a Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Wed, 6 Mar 2024 17:04:50 +0000 Subject: [PATCH 036/129] A little more care was needed ... --- activestorage/active.py | 13 ++++++++----- activestorage/storage.py | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index b15e6a69..81279945 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -138,17 +138,20 @@ def hfix(x): return item if single element list/array see https://github.com/h5netcdf/h5netcdf/issues/116 ''' + if x is None: + return x if not np.isscalar(x) and len(x) == 1: return x[0] + return x if self.ds is None: self.__load_nc_file() - _FillValue = self.ds.attrs.get('_FillValue') + _FillValue = hfix(self.ds.attrs.get('_FillValue')) missing_value = self.ds.attrs.get('missing_value') - valid_min = self.ds.attrs.get('valid_min') - valid_max = self.ds.attrs.get('valid_max') - valid_range = self.ds.attrs.get('valid_range') + valid_min = hfix(self.ds.attrs.get('valid_min')) + valid_max = hfix(self.ds.attrs.get('valid_max')) + valid_range = hfix(self.ds.attrs.get('valid_range')) if valid_max is not None or valid_min is not None: if valid_range is not None: raise ValueError( @@ -159,7 +162,7 @@ def hfix(x): elif valid_range is not None: valid_min, valid_max = valid_range - return hfix(_FillValue), hfix(missing_value), hfix(valid_min), hfix(valid_max) + return _FillValue, missing_value, valid_min, valid_max def __getitem__(self, index): """ diff --git a/activestorage/storage.py b/activestorage/storage.py index 28d91f35..38a7e35d 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -3,7 +3,9 @@ from numcodecs.compat import ensure_ndarray -def reduce_chunk(rfile, offset, size, compression, filters, missing, dtype, shape, order, chunk_selection, method=None): +def reduce_chunk(rfile, + offset, size, compression, filters, missing, dtype, shape, + order, chunk_selection, method=None): """ We do our own read of chunks and decoding etc rfile - the actual file with the data From 4bea4bbee5327bb1cc71698721ac7292a1e7a966 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 17:15:48 +0000 Subject: [PATCH 037/129] new pyfive File API --- tests/test_compression.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_compression.py b/tests/test_compression.py index 86982de9..bd2c34ad 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -17,8 +17,8 @@ def check_dataset_filters(temp_file: str, ncvar: str, compression: str, shuffle: if USE_S3: with load_from_s3(temp_file) as test_data: # NOTE: h5netcdf thinks zlib is gzip - assert test_data.variables[ncvar].compression == "gzip" - assert test_data.variables[ncvar].shuffle == shuffle + assert test_data[ncvar].attrs.get('compression') == "gzip" + assert test_data[ncvar].attrs.get('shuffle') == shuffle else: with Dataset(temp_file) as test_data: test_data_filters = test_data.variables[ncvar].filters() From cf1ff2d735b982993977f96b068fc1e7baf1eb56 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 17:44:26 +0000 Subject: [PATCH 038/129] print out the attrs --- tests/test_compression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_compression.py b/tests/test_compression.py index bd2c34ad..4040a57c 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -16,7 +16,7 @@ def check_dataset_filters(temp_file: str, ncvar: str, compression: str, shuffle: # Sanity check that test data is compressed and filtered as expected. if USE_S3: with load_from_s3(temp_file) as test_data: - # NOTE: h5netcdf thinks zlib is gzip + print("Variable attrs", test_data[ncvar].attrs) assert test_data[ncvar].attrs.get('compression') == "gzip" assert test_data[ncvar].attrs.get('shuffle') == shuffle else: From 2808dc93cc4bb99f8e093d2258291e894fdaabb4 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 6 Mar 2024 17:54:48 +0000 Subject: [PATCH 039/129] print out file attrs --- tests/test_compression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_compression.py b/tests/test_compression.py index 4040a57c..781fe3fe 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -16,7 +16,7 @@ def check_dataset_filters(temp_file: str, ncvar: str, compression: str, shuffle: # Sanity check that test data is compressed and filtered as expected. if USE_S3: with load_from_s3(temp_file) as test_data: - print("Variable attrs", test_data[ncvar].attrs) + print("File attrs", test_data.attrs) assert test_data[ncvar].attrs.get('compression') == "gzip" assert test_data[ncvar].attrs.get('shuffle') == shuffle else: From 38add11b5e036758f594f6acbccba99df1cdf701 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Thu, 7 Mar 2024 11:46:32 +0000 Subject: [PATCH 040/129] V made me do it --- activestorage/active.py | 2 +- tests/test_missing.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 81279945..4ff008ff 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -88,7 +88,7 @@ def __init__( # Assume NetCDF4 for now self.uri = uri if self.uri is None: - raise ValueError(f"Must use a valid file for uri. Got {self.uri}") + raise ValueError(f"Must use a valid file for uri. Got {uri}") # still allow for a passable storage_type # for special cases eg "special-POSIX" ie DDN diff --git a/tests/test_missing.py b/tests/test_missing.py index 4edc674a..5534b959 100644 --- a/tests/test_missing.py +++ b/tests/test_missing.py @@ -196,6 +196,18 @@ def test_validmax(tmp_path): # write file to storage testfile = utils.write_to_storage(testfile) + x = pyfive.File(testfile) + y = Dataset(testfile) + print('bnl',y['data'].getncattr('valid_max')) + print('bnl',x['data'].attrs.get('valid_max')) + import h5py + z = h5py.File(testfile) + print('bnl',z['data'].attrs.get('valid_max')) + import h5netcdf + a = h5netcdf.File(testfile) + print('bnl',a['data'].attrs.get('valid_max')) + + # numpy masked to check for correct Active behaviour no_active_mean = active_zero(testfile) From 277762d322b0de9636e0d709cbf3f0e02a5713e6 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 7 Mar 2024 12:29:51 +0000 Subject: [PATCH 041/129] use correct attributes compression and shuffle --- tests/test_compression.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_compression.py b/tests/test_compression.py index 781fe3fe..0db5ad55 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -17,8 +17,8 @@ def check_dataset_filters(temp_file: str, ncvar: str, compression: str, shuffle: if USE_S3: with load_from_s3(temp_file) as test_data: print("File attrs", test_data.attrs) - assert test_data[ncvar].attrs.get('compression') == "gzip" - assert test_data[ncvar].attrs.get('shuffle') == shuffle + assert test_data[ncvar].compression == "gzip" + assert test_data[ncvar].shuffle == shuffle else: with Dataset(temp_file) as test_data: test_data_filters = test_data.variables[ncvar].filters() From 5bd0fe17b7a1722fe2bf1e8e3f8c8f222ca7cc87 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 7 Mar 2024 20:49:15 +0000 Subject: [PATCH 042/129] use issue60 branch --- .github/workflows/run-tests.yml | 8 ++++---- .github/workflows/test_s3_minio.yml | 4 ++-- .github/workflows/test_s3_remote_reductionist.yml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f69d0eba..f269f7b9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -35,12 +35,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of bnlawrence/Pyfive:issue6 + - name: Install development version of bnlawrence/Pyfive:issue60 run: | cd .. git clone https://github.com/bnlawrence/pyfive.git cd pyfive - git checkout issue6 + git checkout issue60 pip install -e . - run: pip install -e . - run: pytest -n 2 @@ -66,12 +66,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of bnlawrence/Pyfive:issue6 + - name: Install development version of bnlawrence/Pyfive:issue60 run: | cd .. git clone https://github.com/bnlawrence/pyfive.git cd pyfive - git checkout issue6 + git checkout issue60 pip install -e . - run: pip install -e . - run: pytest diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index 32d8e1cf..51bb97b3 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -58,12 +58,12 @@ jobs: miniforge-version: "latest" miniforge-variant: Mambaforge use-mamba: true - - name: Install development version of bnlawrence/Pyfive:issue6 + - name: Install development version of bnlawrence/Pyfive:issue60 run: | cd .. git clone https://github.com/bnlawrence/pyfive.git cd pyfive - git checkout issue6 + git checkout issue60 pip install -e . - name: Install PyActiveStorage run: | diff --git a/.github/workflows/test_s3_remote_reductionist.yml b/.github/workflows/test_s3_remote_reductionist.yml index c2e9c457..55b388ec 100644 --- a/.github/workflows/test_s3_remote_reductionist.yml +++ b/.github/workflows/test_s3_remote_reductionist.yml @@ -53,12 +53,12 @@ jobs: miniforge-version: "latest" miniforge-variant: Mambaforge use-mamba: true - - name: Install development version of bnlawrence/Pyfive:issue6 + - name: Install development version of bnlawrence/Pyfive:issue60 run: | cd .. git clone https://github.com/bnlawrence/pyfive.git cd pyfive - git checkout issue6 + git checkout issue60 pip install -e . - name: Install PyActiveStorage run: | From 74beccd07167a3981456046233813cd49f8ea83c Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Fri, 8 Mar 2024 11:21:12 +0000 Subject: [PATCH 043/129] Unit test for createding reductionist json without s3 --- activestorage/active.py | 67 +++++++++++++++++---------------- tests/test_reductionist_json.py | 40 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 tests/test_reductionist_json.py diff --git a/activestorage/active.py b/activestorage/active.py index 4ff008ff..8c4db9d2 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -42,6 +42,40 @@ def load_from_s3(uri, storage_options=None): print(f"Dataset loaded from S3 with s3fs and Pyfive: {uri}") return ds +def get_missing_attributes(ds): + """" + Load all the missing attributes we need from a netcdf file + """ + + def hfix(x): + ''' + return item if single element list/array + see https://github.com/h5netcdf/h5netcdf/issues/116 + ''' + if x is None: + return x + if not np.isscalar(x) and len(x) == 1: + return x[0] + return x + + _FillValue = hfix(ds.attrs.get('_FillValue')) + missing_value = ds.attrs.get('missing_value') + valid_min = hfix(ds.attrs.get('valid_min')) + valid_max = hfix(ds.attrs.get('valid_max')) + valid_range = hfix(ds.attrs.get('valid_range')) + if valid_max is not None or valid_min is not None: + if valid_range is not None: + raise ValueError( + "Invalid combination in the file of valid_min, " + "valid_max, valid_range: " + f"{valid_min}, {valid_max}, {valid_range}" + ) + elif valid_range is not None: + valid_min, valid_max = valid_range + + return _FillValue, missing_value, valid_min, valid_max + + class Active: """ @@ -129,40 +163,9 @@ def __load_nc_file(self): self.ds = nc[ncvar] def __get_missing_attributes(self): - """" - Load all the missing attributes we need from a netcdf file - """ - - def hfix(x): - ''' - return item if single element list/array - see https://github.com/h5netcdf/h5netcdf/issues/116 - ''' - if x is None: - return x - if not np.isscalar(x) and len(x) == 1: - return x[0] - return x - if self.ds is None: self.__load_nc_file() - - _FillValue = hfix(self.ds.attrs.get('_FillValue')) - missing_value = self.ds.attrs.get('missing_value') - valid_min = hfix(self.ds.attrs.get('valid_min')) - valid_max = hfix(self.ds.attrs.get('valid_max')) - valid_range = hfix(self.ds.attrs.get('valid_range')) - if valid_max is not None or valid_min is not None: - if valid_range is not None: - raise ValueError( - "Invalid combination in the file of valid_min, " - "valid_max, valid_range: " - f"{valid_min}, {valid_max}, {valid_range}" - ) - elif valid_range is not None: - valid_min, valid_max = valid_range - - return _FillValue, missing_value, valid_min, valid_max + return get_missing_attributes(self.ds) def __getitem__(self, index): """ diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py new file mode 100644 index 00000000..299ba6f9 --- /dev/null +++ b/tests/test_reductionist_json.py @@ -0,0 +1,40 @@ +from test_bigger_data import save_cl_file_with_a +import pyfive +from activestorage.active import Active, get_missing_attributes +from activestorage.hdf2numcodec import decode_filters +import numpy as np +from activestorage import reductionist +import json + +class MockActive: + def __init__(self, f,v): + self.f = pyfive.File(f) + ds = self.f[v] + self.dtype = np.dtype(ds.dtype) + self.array = pyfive.ZarrArrayStub(ds.shape, ds.chunks or ds.shape) + self.missing = get_missing_attributes(ds) + ds = ds._dataobjects + self.ds = ds + def __getitem__(self, args): + if self.ds.filter_pipeline is None: + compressor, filters = None, None + else: + compressor, filters = decode_filters(self.ds.filter_pipeline , self.dtype.itemsize, 'a') + if self.ds.chunks is not None: + self.ds._get_chunk_addresses() + + indexer = pyfive.OrthogonalIndexer(args, self.array) + for chunk_coords, chunk_selection, out_selection in indexer: + offset, size, filter_mask = self.ds.get_chunk_details(chunk_coords) + jd = reductionist.build_request_data('a','b','c', + offset, size, compressor, filters, self.missing, self.dtype, + self.array._chunks,self.ds.order,chunk_selection) + js = json.dumps(jd) + return None + +def test_build_request(tmp_path): + + ncfile, v = save_cl_file_with_a(tmp_path), 'cl' + A = MockActive(ncfile,v) + x = A[4:5, 1:2] + # not interested in what is returned, checking that the request builds ok From e91d02c06f461c715266a8d982db35fe9fa78436 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Fri, 8 Mar 2024 11:40:32 +0000 Subject: [PATCH 044/129] Reductionist was incorrectly encoding offset and size, and the test for it had defaults of None (which it can never be). Also modified tests to make easier to extract and debug standalone. --- activestorage/reductionist.py | 4 ++-- tests/test_bigger_data.py | 2 +- tests/test_reductionist_json.py | 2 +- tests/unit/test_reductionist.py | 6 ++++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/activestorage/reductionist.py b/activestorage/reductionist.py index 11a12851..e96d9982 100644 --- a/activestorage/reductionist.py +++ b/activestorage/reductionist.py @@ -139,8 +139,8 @@ def build_request_data(source: str, bucket: str, object: str, offset: int, 'object': object, 'dtype': dtype.name, 'byte_order': encode_byte_order(dtype), - 'offset': offset, - 'size': size, + 'offset': int(offset), + 'size': int(size), 'order': order, } if shape: diff --git a/tests/test_bigger_data.py b/tests/test_bigger_data.py index 2173558d..91adc9f9 100644 --- a/tests/test_bigger_data.py +++ b/tests/test_bigger_data.py @@ -11,7 +11,7 @@ from activestorage.config import * from pyfive.core import InvalidHDF5File as InvalidHDF5Err -import utils +from . import utils @pytest.fixture diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py index 299ba6f9..8053165c 100644 --- a/tests/test_reductionist_json.py +++ b/tests/test_reductionist_json.py @@ -1,4 +1,4 @@ -from test_bigger_data import save_cl_file_with_a +from .test_bigger_data import save_cl_file_with_a import pyfive from activestorage.active import Active, get_missing_attributes from activestorage.hdf2numcodec import decode_filters diff --git a/tests/unit/test_reductionist.py b/tests/unit/test_reductionist.py index 417bad8b..043ccbd6 100644 --- a/tests/unit/test_reductionist.py +++ b/tests/unit/test_reductionist.py @@ -36,8 +36,8 @@ def test_reduce_chunk_defaults(mock_request): s3_url = "https://active.example.com" bucket = "fake-bucket" object = "fake-object" - offset = None - size = None + offset = 0 + size = 0 compression = None filters = None missing = (None, None, None, None) @@ -67,6 +67,8 @@ def test_reduce_chunk_defaults(mock_request): "bucket": bucket, "object": object, "dtype": "int32", + 'offset':0, + 'size':0, "byte_order": sys.byteorder, } mock_request.assert_called_once_with(session, expected_url, expected_data) From 3bb0b8b2c0dceb577cb775cd45c7c40ecf30d04a Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Fri, 8 Mar 2024 11:41:14 +0000 Subject: [PATCH 045/129] Will this break actions? --- bnl/import_test.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 bnl/import_test.py diff --git a/bnl/import_test.py b/bnl/import_test.py new file mode 100644 index 00000000..e805f77c --- /dev/null +++ b/bnl/import_test.py @@ -0,0 +1,7 @@ +from tests import test_reductionist_json +from pathlib import Path + +mypath = Path(__file__).parent + +test_reductionist_json.test_build_request(mypath) + From 7a03fa4e9c7d16bc583a1ca7f03ece7dd036a123 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 13:41:43 +0000 Subject: [PATCH 046/129] ignore bnl for testing --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 728ce2d3..1cfa061e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,7 @@ addopts = # --doctest-modules --ignore=old_code/ --ignore=tests/s3_exploratory + --ignore=bnl --cov=activestorage --cov-report=xml:test-reports/coverage.xml --cov-report=html:test-reports/coverage_html From aa1a4814c4be33a9b7f26624d6cf7b6459e0c0cf Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 13:42:02 +0000 Subject: [PATCH 047/129] correct imports --- tests/test_bigger_data.py | 2 +- tests/test_reductionist_json.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_bigger_data.py b/tests/test_bigger_data.py index 91adc9f9..2173558d 100644 --- a/tests/test_bigger_data.py +++ b/tests/test_bigger_data.py @@ -11,7 +11,7 @@ from activestorage.config import * from pyfive.core import InvalidHDF5File as InvalidHDF5Err -from . import utils +import utils @pytest.fixture diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py index 8053165c..a88ead96 100644 --- a/tests/test_reductionist_json.py +++ b/tests/test_reductionist_json.py @@ -1,9 +1,11 @@ -from .test_bigger_data import save_cl_file_with_a import pyfive from activestorage.active import Active, get_missing_attributes from activestorage.hdf2numcodec import decode_filters import numpy as np + from activestorage import reductionist +from test_bigger_data import save_cl_file_with_a + import json class MockActive: From e5430fdc6509f7f9b8a1049f938cd460fe2ea2e3 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 14:48:08 +0000 Subject: [PATCH 048/129] fix test with correct loaders and imports --- tests/test_missing.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/test_missing.py b/tests/test_missing.py index 5534b959..934d9039 100644 --- a/tests/test_missing.py +++ b/tests/test_missing.py @@ -7,11 +7,15 @@ import tempfile import unittest +# TODO remove in stable +import h5py +import h5netcdf + import pyfive from netCDF4 import Dataset -from activestorage.active import Active +from activestorage.active import Active, load_from_s3 from activestorage.config import * from activestorage import dummy_data as dd @@ -194,17 +198,24 @@ def test_validmax(tmp_path): assert unmasked_numpy_mean != masked_numpy_mean print("Numpy masked result (mean)", masked_numpy_mean) + # load files via external protocols + y = Dataset(testfile) + z = h5py.File(testfile) + a = h5netcdf.File(testfile) + # write file to storage testfile = utils.write_to_storage(testfile) - x = pyfive.File(testfile) - y = Dataset(testfile) + + # load file via our protocols + if USE_S3: + x = load_from_s3(testfile) + else: + x = pyfive.File(testfile) + + # print for Bryan print('bnl',y['data'].getncattr('valid_max')) print('bnl',x['data'].attrs.get('valid_max')) - import h5py - z = h5py.File(testfile) print('bnl',z['data'].attrs.get('valid_max')) - import h5netcdf - a = h5netcdf.File(testfile) print('bnl',a['data'].attrs.get('valid_max')) From dd5766496390a040d151230cf42a812bcf1ffcf8 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 14:52:38 +0000 Subject: [PATCH 049/129] same for this one --- tests/test_reductionist_json.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py index a88ead96..bcf18735 100644 --- a/tests/test_reductionist_json.py +++ b/tests/test_reductionist_json.py @@ -4,13 +4,18 @@ import numpy as np from activestorage import reductionist +from activestorage.active import load_from_s3 +from activestorage.config import * from test_bigger_data import save_cl_file_with_a import json class MockActive: - def __init__(self, f,v): - self.f = pyfive.File(f) + def __init__(self, f, v): + if USE_S3: + self.f = load_from_s3(f) + else: + self.f = pyfive.File(f) ds = self.f[v] self.dtype = np.dtype(ds.dtype) self.array = pyfive.ZarrArrayStub(ds.shape, ds.chunks or ds.shape) From f3171491a10f5fe3f3cc3660a84e70f42a664baa Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 15:03:54 +0000 Subject: [PATCH 050/129] clean test module --- tests/s3_exploratory/test_s3_arrange_files.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/s3_exploratory/test_s3_arrange_files.py b/tests/s3_exploratory/test_s3_arrange_files.py index 18b1015e..8117ce17 100644 --- a/tests/s3_exploratory/test_s3_arrange_files.py +++ b/tests/s3_exploratory/test_s3_arrange_files.py @@ -8,10 +8,6 @@ from activestorage.active import Active from activestorage.dummy_data import make_vanilla_ncdata -import activestorage.storage as st -from activestorage.reductionist import reduce_chunk as reductionist_reduce_chunk -from activestorage.netcdf_to_zarr import gen_json -from kerchunk.hdf import SingleHdf5ToZarr from numpy.testing import assert_allclose, assert_array_equal from pathlib import Path From 4dafa3e73daca3cb29e66b482cdf33cc83101209 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 15:07:49 +0000 Subject: [PATCH 051/129] reduce test activity --- tests/s3_exploratory/test_s3_performance.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/s3_exploratory/test_s3_performance.py b/tests/s3_exploratory/test_s3_performance.py index 41537c4b..c47d2ab7 100644 --- a/tests/s3_exploratory/test_s3_performance.py +++ b/tests/s3_exploratory/test_s3_performance.py @@ -4,10 +4,8 @@ import ujson from pathlib import Path -from kerchunk.hdf import SingleHdf5ToZarr from activestorage.active import Active -from activestorage.netcdf_to_zarr import open_zarr_group from config_minio import * @@ -17,6 +15,7 @@ def test_data_path(): return Path(__file__).resolve().parent / 'test_data' +@pytest.mark.xfail(reason="Pyfive don't use Kerchunk") def test_s3_SingleHdf5ToZarr(): """Check Kerchunk's SingleHdf5ToZarr when S3.""" # SingleHdf5ToZarr is VERY quick and MEM-light @@ -33,6 +32,7 @@ def test_s3_SingleHdf5ToZarr(): inline_threshold=0) +@pytest.mark.xfail(reason="Pyfive don't use Kerchunk") def test_local_SingleHdf5ToZarr(test_data_path): """Check Kerchunk's SingleHdf5ToZarr when NO S3.""" # SingleHdf5ToZarr is VERY quick and MEM-light @@ -44,6 +44,7 @@ def test_local_SingleHdf5ToZarr(test_data_path): inline_threshold=0) +@pytest.mark.xfail(reason="Pyfive don't use Kerchunk") def test_s3_kerchunk_to_json(): """Check Kerchunk's SingleHdf5ToZarr dumped to JSON, when S3.""" s3_file = "s3://pyactivestorage/s3_test_bizarre_large.nc" @@ -64,6 +65,7 @@ def test_s3_kerchunk_to_json(): f.write(ujson.dumps(h5chunks.translate()).encode()) +@pytest.mark.xfail(reason="Pyfive don't use Kerchunk") def test_local_kerchunk_to_json(test_data_path): """Check Kerchunk's SingleHdf5ToZarr dumped to JSON, when NO S3.""" local_file = str(test_data_path / "test_bizarre.nc") @@ -78,6 +80,7 @@ def test_local_kerchunk_to_json(test_data_path): f.write(ujson.dumps(h5chunks.translate()).encode()) +@pytest.mark.xfail(reason="Pyfive don't use Kerchunk") def test_s3_kerchunk_openZarrGroup(): """Check Kerchunk's SingleHdf5ToZarr dumped to JSON, when S3.""" s3_file = "s3://pyactivestorage/s3_test_bizarre_large.nc" @@ -99,6 +102,7 @@ def test_s3_kerchunk_openZarrGroup(): ref_ds = open_zarr_group(outf, "data") +@pytest.mark.xfail(reason="Pyfive don't use Kerchunk") def test_local_kerchunk_openZarrGroup(test_data_path): """Check Kerchunk's SingleHdf5ToZarr dumped to JSON, when NO S3.""" local_file = str(test_data_path / "test_bizarre.nc") From e9aff71f3f393a6d0a543516e630591bf0badd69 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 8 Mar 2024 15:12:39 +0000 Subject: [PATCH 052/129] plop Pyfive in the PR test wkflow too --- .github/workflows/run-test-push.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index db881ab4..718ca4ec 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -30,6 +30,13 @@ jobs: use-mamba: true - run: conda --version - run: python -V + - name: Install development version of bnlawrence/Pyfive:issue60 + run: | + cd .. + git clone https://github.com/bnlawrence/pyfive.git + cd pyfive + git checkout issue60 + pip install -e . - run: pip install -e . - run: pytest -n 2 --junitxml=report-1.xml - uses: codecov/codecov-action@v3 From 08c8b873e95eb888159677792d69afb6f7419a3a Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Mon, 11 Mar 2024 13:20:04 +0000 Subject: [PATCH 053/129] Adding the ability for Active to optionally self report metrics --- activestorage/active.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/activestorage/active.py b/activestorage/active.py index 8c4db9d2..36410450 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -4,6 +4,7 @@ import pathlib import urllib import pyfive +import time import s3fs @@ -148,6 +149,10 @@ def __init__( self._max_threads = max_threads self.missing = None self.ds = None + self.metrics = False + + def toggle_metrics(self): + self.metrics = not self.metrics def __load_nc_file(self): """ Get the netcdf file and it's b-tree""" @@ -331,7 +336,13 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # wouldn't need to do it before the first one. if ds.chunks is not None: + t1 = time.time() ds._get_chunk_addresses() + t2 = time.time() + if self.metrics: + chunk_count = 0 + print(f'Index time: {t2-t1:.1}s ({len(ds._zchunk_index)} chunk entries)') + t1 = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: futures = [] # Submit chunks for processing. @@ -348,6 +359,8 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f except Exception as exc: raise else: + if self.metrics: + chunk_count +=1 if method is not None: result, count = result out.append(result) @@ -393,6 +406,9 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # size. out = out / np.sum(counts).reshape(shape1) + if self.metrics: + t2 = time.time() + print(f'Reduction over {chunk_count} chunks took {t2-t1:.1}s') return out def _get_endpoint_url(self): From 20acfdd679a7309e54b786f74b93575e9421f520 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 12 Mar 2024 08:29:40 +0000 Subject: [PATCH 054/129] Adding some experimental data --- doc/data/home_experiments_bnl.txt | 248 ++++++++++++++++++++++++++++++ doc/figures/sequence.pu | 51 ++++++ 2 files changed, 299 insertions(+) create mode 100644 doc/data/home_experiments_bnl.txt create mode 100644 doc/figures/sequence.pu diff --git a/doc/data/home_experiments_bnl.txt b/doc/data/home_experiments_bnl.txt new file mode 100644 index 00000000..1d42b213 --- /dev/null +++ b/doc/data/home_experiments_bnl.txt @@ -0,0 +1,248 @@ +pyfive branch +default_fill_cache : False +default_cache_type : readahead +default_block_size : 1048576 + +r[0:3, 4:6, 7:9] +r[0:3, 400:600, 7:9] +r[0:3, 400:600, 800:1100] +r[5,:,800] +r[:,300:500,800:1000] +r[1,:,:] + +bnl/ch330a.pc19790301-def.nc, 64 chunk entries + +Reduction over 1 chunks took 0.4s +Overall time (version 1) - 5e+00s +6.5275819301605225 : Regular +6.11167573928833 : Active Regular +4.966563940048218 : Active Remote +5.777529239654541 : Regular +5.894297122955322 : Active Regular +4.513840913772583 : Active Remote +5.653490781784058 : Regular +5.954509019851685 : Active Regular +4.394351005554199 : Active Remote +5.651051044464111 : Regular +5.728168964385986 : Active Regular +4.569552898406982 : Active Remote +5.659423112869263 : Regular +5.652827024459839 : Active Regular +5.071002960205078 : Active Remote + +Reduction over 2 chunks took 0.4s +Overall time (version 1) - 4e+00s +7.908946990966797 : Regular +7.5908496379852295 : Active Regular +4.481859922409058 : Active Remote +8.732571840286255 : Regular +8.1283700466156 : Active Regular +4.558679103851318 : Active Remote +7.707890033721924 : Regular +7.721914052963257 : Active Regular +4.404280185699463 : Active Remote +7.809189796447754 : Regular +7.916913986206055 : Active Regular +4.436033010482788 : Active Remote +7.775968790054321 : Regular +7.735078811645508 : Active Regular +4.649733781814575 : Active Remote + +Reduction over 2 chunks took 0.5s +Overall time (version 1) - 5e+00s +7.754213809967041 : Regular +9.058350801467896 : Active Regular +4.2825469970703125 : Active Remote +7.6808648109436035 : Regular +7.936631202697754 : Active Regular +4.475581884384155 : Active Remote +7.986822843551636 : Regular +7.767880916595459 : Active Regular +5.64799165725708 : Active Remote +8.05326509475708 : Regular +8.558978080749512 : Active Regular +4.387536287307739 : Active Remote +8.05326509475708 : Regular +8.558978080749512 : Active Regular +4.387536287307739 : Active Remote + +Reduction over 4 chunks took 1e+00s +Overall time (version 1) - 5e+00s +11.737549781799316 : Regular +11.829929113388062 : Active Regular +5.3570029735565186 : Active Remote +12.96435260772705 : Regular +11.708273887634277 : Active Regular +4.427199125289917 : Active Remote +12.082044124603271 : Regular +11.663249969482422 : Active Regular +4.448379039764404 : Active Remote +12.160427808761597 : Regular +12.381748914718628 : Active Regular +4.864629030227661 : Active Remote +11.940709114074707 : Regular +12.424716234207153 : Active Regular +4.6097517013549805 : Active Remote + +Reduction over 8 chunks took 0.7s +Overall time (version 1) - 5e+00s +20.35632634162903 : Regular +18.701595783233643 : Active Regular +4.780594825744629 : Active Remote +18.91178870201111 : Regular +20.01336669921875 : Active Regular +4.806182861328125 : Active Remote +19.31538200378418 : Regular +19.239036321640015 : Active Regular +4.886173963546753 : Active Remote +18.862577199935913 : Regular +18.919509172439575 : Active Regular +4.527647972106934 : Active Remote +19.1742422580719 : Regular +19.013291835784912 : Active Regular +4.555526971817017 : Active Remote + +Reduction over 16 chunks took 1e+00s +Overall time (version 1) - 5e+00s +33.903298139572144 : Regular +33.95520091056824 : Active Regular +5.479094982147217 : Active Remote +33.47618818283081 : Regular +33.14509582519531 : Active Regular +4.9755659103393555 : Active Remote +34.320921182632446 : Regular +33.631829023361206 : Active Regular +4.777320861816406 : Active Remote +33.07414221763611 : Regular +33.10297989845276 : Active Regular +4.985634803771973 : Active Remote +33.271361112594604 : Regular +33.41872477531433 : Active Regular +5.1617209911346436 : Active Remote + +Reduction over 64 chunks took 3e+00s +Overall time (version 1) - 7e+00s +135.66737484931946 : Regular +125.51056480407715 : Active Regular +6.838850021362305 : Active Remote +125.06190586090088 : Regular +123.48610496520996 : Active Regular +7.4687440395355225 : Active Remote +124.92097496986389 : Regular +123.4994330406189 : Active Regular +6.4895899295806885 : Active Remote +127.3746292591095 : Regular +129.92956972122192 : Active Regular +7.572640895843506 : Active Remote +123.77195906639099 : Regular +124.58749127388 : Active Regular +7.2448132038116455 : Active Remote + +bnl/ch330a.pc19790301-bnl.nc, 3400 chunk entries + +Overall time (version 1) - 4e+01s +54.81114602088928 : Regular +40.206737995147705 : Active Regular +39.09910821914673 : Active Remote +39.46443700790405 : Regular +39.027403116226196 : Active Regular +37.75058889389038 : Active Remote +37.9528648853302 : Regular +41.495553731918335 : Active Regular +38.07294011116028 : Active Remote +37.73332500457764 : Regular +40.39740324020386 : Active Regular +38.49332809448242 : Active Remote +39.069754123687744 : Regular +40.393052101135254 : Active Regular +39.88633894920349 : Active Remote + +Reduction over 3 chunks took 0.4s +Overall time (version 1) - 4e+01s +40.984943151474 : Regular +39.61718392372131 : Active Regular +39.1941978931427 : Active Remote +39.66099715232849 : Regular +39.43976306915283 : Active Regular +38.039302825927734 : Active Remote +39.62373924255371 : Regular +39.98037099838257 : Active Regular +38.41215372085571 : Active Remote +39.09483599662781 : Regular +39.73953199386597 : Active Regular +41.10827708244324 : Active Remote +40.30459427833557 : Regular +39.665311098098755 : Active Regular +39.48872089385986 : Active Remote + +Reduction over 9 chunks took 0.5s +Overall time (version 1) - 4e+01s +45.93427586555481 : Regular +51.21455001831055 : Active Regular +42.55308794975281 : Active Remote +43.54172086715698 : Regular +43.66517210006714 : Active Regular +38.02682185173035 : Active Remote +43.521965980529785 : Regular +46.44435095787048 : Active Regular +38.40240478515625 : Active Remote +43.9306001663208 : Regular +43.83222007751465 : Active Regular +38.353585958480835 : Active Remote +43.72931981086731 : Regular +45.22025394439697 : Active Regular +39.67265009880066 : Active Remote + +Reduction over 17 chunks took 0.9s +Overall time (version 1) - 4e+01s +47.17348670959473 : Regular +48.371257066726685 : Active Regular +39.67051911354065 : Active Remote +46.177210092544556 : Regular +48.33781385421753 : Active Regular +38.0770800113678 : Active Remote +48.514657974243164 : Regular +49.18296504020691 : Active Regular +39.39418125152588 : Active Remote +50.68878698348999 : Regular +45.65637493133545 : Active Regular +38.83580207824707 : Active Remote +47.00814175605774 : Regular +46.89705014228821 : Active Regular +38.41860294342041 : Active Remote + +Reduction over 60 chunks took 2e+00s +Overall time (version 1) - 4e+01s +71.09002494812012 : Regular +72.1630642414093 : Active Regular +39.93671202659607 : Active Remote +71.39048480987549 : Regular +69.36404490470886 : Active Regular +40.83954119682312 : Active Remote +73.05522775650024 : Regular +70.12043809890747 : Active Regular +42.23869585990906 : Active Remote +71.30378913879395 : Regular +71.10070180892944 : Active Regular +43.04809808731079 : Active Remote +72.75630617141724 : Regular +71.60838198661804 : Active Regular +40.027408838272095 : Active Remote + +Reduction over 340 chunks took 8e+00s +Overall time (version 1) - 5e+01s +216.78450202941895 : Regular +208.11211395263672 : Active Regular +45.26900100708008 : Active Remote +222.73459100723267 : Regular +208.41155195236206 : Active Regular +48.21009302139282 : Active Remote +207.01012992858887 : Regular +212.10812401771545 : Active Regular +47.88032793998718 : Active Remote +209.2855679988861 : Regular +208.8525538444519 : Active Regular +46.665223836898804 : Active Remote +203.70479106903076 : Regular +213.17324495315552 : Active Regular +49.18446397781372 : Active Remote diff --git a/doc/figures/sequence.pu b/doc/figures/sequence.pu new file mode 100644 index 00000000..dfaeccfe --- /dev/null +++ b/doc/figures/sequence.pu @@ -0,0 +1,51 @@ +@startuml +skinparam backgroundColor #EEEBDC +'skinparam handwritten true +skinparam notebackgroundcolor white + +skinparam sequence { + participantBackgroundColor White + BackgroundColor White +} + + +hide footbox +title Key Actors in Active Storage + +box python #lightblue +participant Application +participant Active +end box +box server side #beige +participant Reductionist +participant S3 +end box +Application -> Active: Open File +activate Active +Active -> S3: Read file metadata +S3 -> Active: Metadata blocks +Application -> Active: Active(getitem)\ne.g. mean(X[1,:]) +Active -> S3: Read B-Tree +S3 -> Active: Chunk Index +activate Active +Active -> Active: Identify Chunks +loop +Active -> Reductionist: Reduce Chunk +Reductionist -> S3 : Read chunk +Reductionist -> Active: f(chunk) +end +Active -> Active: f(chunks) +Active -> Application: return\nresult=\nf(getitem) +note left of Application +Multiple getitems +and function calls +can reuse index, +until: +end note +Application -> Active: Close File +deactivate Active + + + + +@enduml \ No newline at end of file From 54967d2ad5157c8c213cbc81a11d80ed58eba5ef Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 12 Mar 2024 21:06:45 +0000 Subject: [PATCH 055/129] Plotting and data code. --- doc/data/home_experiments_bnl.txt | 2 + doc/data/plot_bnl.py | 130 +++++++++++++++ doc/data/work_experiments_bnl.txt | 266 ++++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 doc/data/plot_bnl.py create mode 100644 doc/data/work_experiments_bnl.txt diff --git a/doc/data/home_experiments_bnl.txt b/doc/data/home_experiments_bnl.txt index 1d42b213..bc1af2f2 100644 --- a/doc/data/home_experiments_bnl.txt +++ b/doc/data/home_experiments_bnl.txt @@ -138,8 +138,10 @@ Overall time (version 1) - 7e+00s 124.58749127388 : Active Regular 7.2448132038116455 : Active Remote + bnl/ch330a.pc19790301-bnl.nc, 3400 chunk entries +Reduction over 1 chunks took 0.4s Overall time (version 1) - 4e+01s 54.81114602088928 : Regular 40.206737995147705 : Active Regular diff --git a/doc/data/plot_bnl.py b/doc/data/plot_bnl.py new file mode 100644 index 00000000..fb76301d --- /dev/null +++ b/doc/data/plot_bnl.py @@ -0,0 +1,130 @@ +from pathlib import Path +from matplotlib import pyplot as plt +import numpy as np + +dimensions = np.array([40, 1920, 2560]) +requests = 3*2*2, 3*199*2,3*199*299,1920,40*199*199,1920*2560 +big_chunks = np.array([10,480,640]) +small_chunks = np.array([4, 113, 128]) + + +def get_data(fname, big=True): + + big, small = {}, {} + block = False + with open(fname,'r') as f: + for line in f: + + if line.startswith('bnl'): + if '-def' in line: + inplay = big + else: + inplay = small + + if line.startswith('Reduction over'): + block=True + r1,r2,r3 = [],[],[] + key = line[line.find('over')+5:line.find('chunks')] + print(key) + continue + if line.startswith('Overall'): + continue + if block: + try: + v, t = tuple([x.strip() for x in line.split(':')]) + if t.startswith('Active Regular'): + r2.append(v) + elif t.startswith('Regular'): + r1.append(v) + elif t.startswith('Active Remote'): + r3.append(v) + except: + inplay[key]=r1,r2,r3 + print('Skipping End of Block') + block = False + else: + print('Skipping') + continue + inplay[key]=r1,r2,r3 + print(big) + print(small) + print(big.keys(), small.keys()) + return small, big + +def do_all(hs,hb,ws,wb): + + titles = ['Home - Small Chunks', + 'Home - Big Chunks', + 'Uni - Small Chunks', + 'Uni - Big Chunks'] + + fig, axes = plt.subplots(nrows=2, ncols=2) + fig.set_size_inches(8, 8) + axes = axes.flatten() + + for a, d, t in zip(axes, [hs,hb,ws,wb], titles): + do_plot(a, d, t) + plt.tight_layout() + plt.show() + +def do_plot(ax, data, t): + + def stats4(d1): + dd = np.array([float(v) for v in d1]) + return [np.mean(dd),np.min(dd),np.max(dd)] + + keys = list(data.keys()) + + x = [] + regular, local, remote = [], [], [] + for k in keys: + # three time series for each key + reg, loc, rem = data[k] + sreg, sloc, srem = stats4(reg), stats4(loc), stats4(rem) + regular.append(sreg) + local.append(sloc) + remote.append(srem) + x.append(float(k)) + + delta = 0 + all = True + for d,c in zip([regular, local, remote],['g','r','b']): + x=np.array(x)+delta*0.2 + y = [r[0] for r in d] + err = [[r[0]-r[1] for r in d],[r[2]-r[0] for r in d]] + if c == 'b' or all: + ax.errorbar(x, y, fmt='o', yerr=err, color=c) + delta+=1 + ax.set_title(t) + ax.set_xlabel('Chunks Processed') + ax.set_ylabel('s') + + if t.find('Small') > 0: + cv = np.prod(small_chunks)*4/1e6 + else: + cv = np.prod(big_chunks)*4/1e6 + v = np.prod(dimensions)*4/1e6 + r = v/cv + print(f'Chunk volume {cv}Mb, Dataset volume {v}Mb - ratio {r}') + + + def c2v(x): + return x*cv + def v2c(x): + return x/cv + + + tax = ax.secondary_xaxis(-0.2, functions=(c2v,v2c)) + tax.set_xlabel('Reductionist Read (MB)') + + + +if __name__=="__main__": + mypath = Path(__file__).parent + fname = mypath/'work_experiments_bnl.txt' + ws, wb = get_data(fname) + fname = mypath/'home_experiments_bnl.txt' + hs, hb = get_data(fname) + do_all(hs, hb, ws, wb) + + diff --git a/doc/data/work_experiments_bnl.txt b/doc/data/work_experiments_bnl.txt new file mode 100644 index 00000000..18e2f1d5 --- /dev/null +++ b/doc/data/work_experiments_bnl.txt @@ -0,0 +1,266 @@ +pyfive branch +default_fill_cache : False +default_cache_type : readahead +default_block_size : 1048576 + +r[0:3, 4:6, 7:9] +r[0:3, 400:600, 7:9] +r[0:3, 400:600, 800:1100] +r[5,:,800] +r[:,300:500,800:1000] +r[1,:,:] + +bnl/ch330a.pc19790301-def.nc, 64 chunk entries + +Reduction over 1 chunks took 0.6s +Overall time (version 1) - 3e+00s +8.344115972518921 : Regular +4.220659971237183 : Active Regular +3.1975197792053223 : Active Remote +3.014167070388794 : Regular +3.355847120285034 : Active Regular +2.804471015930176 : Active Remote +2.9698619842529297 : Regular +2.705400228500366 : Active Regular +3.429996967315674 : Active Remote +2.8196191787719727 : Regular +2.7991769313812256 : Active Regular +2.6798598766326904 : Active Remote +2.8660669326782227 : Regular +3.903325080871582 : Active Regular +2.599932909011841 : Active Remote + +Reduction over 2 chunks took 0.3s +Overall time (version 1) - 3e+00s +2.8660669326782227 : Regular +3.903325080871582 : Active Regular +2.599932909011841 : Active Remote +3.29656720161438 : Regular +3.525721788406372 : Active Regular +2.75189471244812 : Active Remote +3.4454238414764404 : Regular +3.665536880493164 : Active Regular +2.571530818939209 : Active Remote +3.538390874862671 : Regular +4.367408990859985 : Active Regular +2.61018705368042 : Active Remote +3.660421848297119 : Regular +3.500704050064087 : Active Regular +2.6997482776641846 : Active Remote + +Reduction over 2 chunks took 0.4s +Overall time (version 1) - 3e+00s +4.397391080856323 : Regular +3.7289319038391113 : Active Regular +2.8331010341644287 : Active Remote +3.8689942359924316 : Regular +3.6623740196228027 : Active Regular +2.6047868728637695 : Active Remote +3.592682123184204 : Regular +3.7491321563720703 : Active Regular +2.693451166152954 : Active Remote +3.557407855987549 : Regular +4.505606174468994 : Active Regular +3.362746000289917 : Active Remote +4.008091926574707 : Regular +3.4869918823242188 : Active Regular +2.7830257415771484 : Active Remote + +Reduction over 4 chunks took 0.5s +Overall time (version 1) - 3e+00s +4.989245176315308 : Regular +5.01286506652832 : Active Regular +2.7912440299987793 : Active Remote +5.019068002700806 : Regular +5.267661094665527 : Active Regular +2.693160057067871 : Active Remote +5.072666883468628 : Regular +4.841257095336914 : Active Regular +3.7721190452575684 : Active Remote +4.9821202754974365 : Regular +5.039299726486206 : Active Regular +3.1108968257904053 : Active Remote +5.485536098480225 : Regular +4.931081295013428 : Active Regular +2.8403587341308594 : Active Remote + +Reduction over 8 chunks took 0.6s +Overall time (version 1) - 3e+00s +8.201773881912231 : Regular +8.027722120285034 : Active Regular +3.062329053878784 : Active Remote +8.066512107849121 : Regular +7.513127326965332 : Active Regular +2.738662004470825 : Active Remote +7.728527069091797 : Regular +8.574406147003174 : Active Regular +3.0061259269714355 : Active Remote +8.332377195358276 : Regular +7.553836107254028 : Active Regular +2.7933387756347656 : Active Remote +9.098789930343628 : Regular +8.0898118019104 : Active Regular +2.934377908706665 : Active Remote + +Reduction over 16 chunks took 0.9s +Overall time (version 1) - 3e+00s +13.034955978393555 : Regular +12.481261968612671 : Active Regular +3.220118999481201 : Active Remote +12.732249736785889 : Regular +14.228748798370361 : Active Regular +3.1314401626586914 : Active Remote +13.192391157150269 : Regular +12.807947874069214 : Active Regular +2.9496419429779053 : Active Remote +13.663172721862793 : Regular +12.501967191696167 : Active Regular +3.0187788009643555 : Active Remote +13.663172721862793 : Regular +12.501967191696167 : Active Regular +3.0187788009643555 : Active Remote + +Reduction over 64 chunks took 4e+00s +Overall time (version 1) - 7e+00s +48.40218806266785 : Regular +46.85928988456726 : Active Regular +6.921466827392578 : Active Remote +48.40218806266785 : Regular +46.85928988456726 : Active Regular +6.921466827392578 : Active Remote +46.171929121017456 : Regular +47.95656108856201 : Active Regular +5.700792074203491 : Active Remote +46.47747492790222 : Regular +46.345592975616455 : Active Regular +5.459475040435791 : Active Remote +51.872729778289795 : Regular +68.54537105560303 : Active Regular +8.05989122390747 : Active Remote + + +bnl/ch330a.pc19790301-bnl.nc, 3400 chunk entries + +Reduction over 1 chunks took 0.4s +Overall time (version 1) - 2e+01s +45.14613080024719 : Regular +27.162260055541992 : Active Regular +23.115849256515503 : Active Remote +23.882447004318237 : Regular +23.903882026672363 : Active Regular +21.457104921340942 : Active Remote +25.383798837661743 : Regular +24.16863775253296 : Active Regular +23.5748450756073 : Active Remote +22.512950897216797 : Regular +23.25707483291626 : Active Regular +25.665203094482422 : Active Remote +26.175257205963135 : Regular +22.014382123947144 : Active Regular +20.443835020065308 : Active Remote + +Reduction over 3 chunks took 0.6s +Overall time (version 1) - 2e+01s +20.11542320251465 : Regular +20.76677107810974 : Active Regular +22.79468083381653 : Active Remote +27.61694622039795 : Regular +26.036365032196045 : Active Regular +24.752694129943848 : Active Remote +24.694957971572876 : Regular +23.442779064178467 : Active Regular +25.0538170337677 : Active Remote +25.87760305404663 : Regular +25.108362913131714 : Active Regular +20.546348094940186 : Active Remote +22.047937870025635 : Regular +21.188127040863037 : Active Regular +22.21766209602356 : Active Remote + +Reduction over 9 chunks took 0.4s +Overall time (version 1) - 2e+01s +25.24668002128601 : Regular +28.922791957855225 : Active Regular +22.928779125213623 : Active Remote +22.633398056030273 : Regular +23.74671173095703 : Active Regular +21.821603775024414 : Active Remote +24.363435983657837 : Regular +27.717058897018433 : Active Regular +21.293541193008423 : Active Remote +25.705342292785645 : Regular +26.323739051818848 : Active Regular +25.679578065872192 : Active Remote +23.84442114830017 : Regular +33.383033990859985 : Active Regular +36.04991698265076 : Active Remote + +Reduction over 17 chunks took 0.6s +Overall time (version 1) - 2e+01s +48.07362198829651 : Regular +37.93207597732544 : Active Regular +22.77363395690918 : Active Remote +28.11515712738037 : Regular +28.385287046432495 : Active Regular +22.595709085464478 : Active Remote +27.156584978103638 : Regular +27.49553680419922 : Active Regular +23.837636947631836 : Active Remote +25.69541597366333 : Regular +25.940948009490967 : Active Regular +24.014315128326416 : Active Remote +27.273848056793213 : Regular +35.16158103942871 : Active Regular +23.360612869262695 : Active Remote + +Reduction over 60 chunks took 3e+00s +Overall time (version 1) - 2e+01s +41.20513677597046 : Regular +38.56750798225403 : Active Regular +24.23797583580017 : Active Remote +40.17050385475159 : Regular +40.017329931259155 : Active Regular +26.986143827438354 : Active Remote +40.651535987854004 : Regular +42.30823493003845 : Active Regular +25.00485610961914 : Active Remote +40.953128814697266 : Regular +40.59316611289978 : Active Regular +23.82962989807129 : Active Remote +39.43385100364685 : Regular +40.663326263427734 : Active Regular +23.716075897216797 : Active Remote + +Reduction over 340 chunks took 8e+00s +Overall time (version 1) - 3e+01s +116.074697971344 : Regular +116.11710214614868 : Active Regular +30.019917964935303 : Active Remote +119.91504836082458 : Regular +116.83721113204956 : Active Regular +29.05119490623474 : Active Remote +114.2572910785675 : Regular +124.5028350353241 : Active Regular +34.20670700073242 : Active Remote +118.08085703849792 : Regular +123.08248209953308 : Active Regular +28.80124282836914 : Active Remote +125.51857686042786 : Regular +143.63352608680725 : Active Regular +35.165050983428955 : Active Remote + + + + + + + + + + + + + + + + From 20b36d173522a5922568945763b1e083a327a403 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Thu, 14 Mar 2024 10:09:37 +0000 Subject: [PATCH 056/129] Added more organised metrics to Active, available after a getitem from .metric_data attribute --- activestorage/active.py | 140 ++++++++++++++++++++++++---------------- 1 file changed, 84 insertions(+), 56 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 36410450..ae9efc0e 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -38,45 +38,62 @@ def load_from_s3(uri, storage_options=None): else: fs = s3fs.S3FileSystem(**storage_options) # use passed-in dictionary + t1=time.time() s3file = fs.open(uri, 'rb') + t2=time.time() ds = pyfive.File(s3file) - print(f"Dataset loaded from S3 with s3fs and Pyfive: {uri}") + t3=time.time() + print(f"Dataset loaded from S3 with s3fs and Pyfive: {uri} ({t2-t1:.2},{t3-t2:.2})") return ds -def get_missing_attributes(ds): - """" - Load all the missing attributes we need from a netcdf file - """ +def _metricise(method): + """ Decorator for class methods loads into metric_data""" + def timed(self, *args, **kw): + ts = time.time() + metric_name='' + if '__metric_name' in kw: + metric_name = kw['__metric_name'] + del kw['__metric_name'] + result = method(self,*args, **kw) + te = time.time() + if metric_name: + self.metric_data[metric_name] = te-ts + return result + return timed - def hfix(x): - ''' - return item if single element list/array - see https://github.com/h5netcdf/h5netcdf/issues/116 - ''' - if x is None: - return x - if not np.isscalar(x) and len(x) == 1: - return x[0] - return x - - _FillValue = hfix(ds.attrs.get('_FillValue')) - missing_value = ds.attrs.get('missing_value') - valid_min = hfix(ds.attrs.get('valid_min')) - valid_max = hfix(ds.attrs.get('valid_max')) - valid_range = hfix(ds.attrs.get('valid_range')) - if valid_max is not None or valid_min is not None: - if valid_range is not None: - raise ValueError( - "Invalid combination in the file of valid_min, " - "valid_max, valid_range: " - f"{valid_min}, {valid_max}, {valid_range}" - ) - elif valid_range is not None: - valid_min, valid_max = valid_range - - return _FillValue, missing_value, valid_min, valid_max +def get_missing_attributes(ds): + """" + Load all the missing attributes we need from a netcdf file + """ + def hfix(x): + ''' + return item if single element list/array + see https://github.com/h5netcdf/h5netcdf/issues/116 + ''' + if x is None: + return x + if not np.isscalar(x) and len(x) == 1: + return x[0] + return x + + _FillValue = hfix(ds.attrs.get('_FillValue')) + missing_value = ds.attrs.get('missing_value') + valid_min = hfix(ds.attrs.get('valid_min')) + valid_max = hfix(ds.attrs.get('valid_max')) + valid_range = hfix(ds.attrs.get('valid_range')) + if valid_max is not None or valid_min is not None: + if valid_range is not None: + raise ValueError( + "Invalid combination in the file of valid_min, " + "valid_max, valid_range: " + f"{valid_min}, {valid_max}, {valid_range}" + ) + elif valid_range is not None: + valid_min, valid_max = valid_range + + return _FillValue, missing_value, valid_min, valid_max class Active: """ @@ -149,11 +166,10 @@ def __init__( self._max_threads = max_threads self.missing = None self.ds = None - self.metrics = False - - def toggle_metrics(self): - self.metrics = not self.metrics + self.metric_data = {} + self.data_read = 0 + @_metricise def __load_nc_file(self): """ Get the netcdf file and it's b-tree""" ncvar = self.ncvar @@ -177,33 +193,41 @@ def __getitem__(self, index): Provides support for a standard get item. #FIXME-BNL: Why is the argument index? """ + self.metric_data = {} if self.ds is None: - self.__load_nc_file() - # In version one this is done by explicitly looping over each chunk in the file - # and returning the requested slice ourselves. In version 2, we can pass this - # through to the default method. + self.__load_nc_file(__metric_name='load nc time') + #self.__metricise('Load','__load_nc_file') self.missing = self.__get_missing_attributes() + + self.data_read = 0 if self.method is None and self._version == 0: - + # No active operation - data = self.ds[index] - data = self._mask_data(data) - return data + return self._get_vanilla(index, __metric_name='vanilla_time') elif self._version == 1: #FIXME: is the difference between version 1 and 2 still honoured? - return self._get_selection(index) + return self._get_selection(index, __metric_name='selection 1 time (s)') elif self._version == 2: - return self._get_selection(index) + return self._get_selection(index, __metric_name='selection 2 time (s)') else: raise ValueError(f'Version {self._version} not supported') + @_metricise + def _get_vanilla(self, index): + """ + Get the data without any active operation + """ + data = self.ds[index] + data = self._mask_data(data) + return data + @property def components(self): """Return or set the components flag. @@ -269,7 +293,7 @@ def _get_active(self, method, *args): """ raise NotImplementedError - + @_metricise def _get_selection(self, *args): """ At this point we have a Dataset object, but all the important information about @@ -286,6 +310,9 @@ def _get_selection(self, *args): array = pyfive.ZarrArrayStub(self.ds.shape, self.ds.chunks) ds = self.ds._dataobjects + self.metric_data['args'] = args + self.metric_data['dataset shape'] = self.ds.shape + self.metric_data['dataset chunks'] = self.ds.chunks if ds.filter_pipeline is None: compressor, filters = None, None else: @@ -338,10 +365,10 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f if ds.chunks is not None: t1 = time.time() ds._get_chunk_addresses() - t2 = time.time() - if self.metrics: - chunk_count = 0 - print(f'Index time: {t2-t1:.1}s ({len(ds._zchunk_index)} chunk entries)') + t2 = time.time() - t1 + self.metric_data['indexing time (s)'] = t2 + self.metric_data['chunk number'] = len(ds._zchunk_index) + chunk_count = 0 t1 = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: futures = [] @@ -359,8 +386,7 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f except Exception as exc: raise else: - if self.metrics: - chunk_count +=1 + chunk_count +=1 if method is not None: result, count = result out.append(result) @@ -406,9 +432,10 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # size. out = out / np.sum(counts).reshape(shape1) - if self.metrics: - t2 = time.time() - print(f'Reduction over {chunk_count} chunks took {t2-t1:.1}s') + t2 = time.time() + self.metric_data['reduction time (s)'] = t2-t1 + self.metric_data['chunks processed'] = chunk_count + self.metric_data['storage read (B)'] = self.data_read return out def _get_endpoint_url(self): @@ -439,6 +466,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou """ offset, size, filter_mask = ds.get_chunk_details(chunk_coords) + self.data_read += size # S3: pass in pre-configured storage options (credentials) if self.storage_type == "s3": From ddcff5cba090508b9f778c1f375e8f04efeb4c83 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Thu, 14 Mar 2024 17:09:16 +0000 Subject: [PATCH 057/129] Active _version1 and _version2 differ again --- activestorage/active.py | 19 +++++++++++++++---- bnl/bnl_s3test.py | 23 +++++++++++++++-------- bnl/bnl_test.py | 39 +++++++++++++++++++++------------------ 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index ae9efc0e..34cb9bb3 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -10,7 +10,7 @@ from activestorage.config import * from activestorage import reductionist -from activestorage.storage import reduce_chunk +from activestorage.storage import reduce_chunk, reduce_opens3_chunk from activestorage.hdf2numcodec import decode_filters @@ -338,7 +338,7 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f counts = None # should never get touched with no method! # Create a shared session object. - if self.storage_type == "s3": + if self.storage_type == "s3" and self._version==2: if self.storage_options is not None: key, secret = None, None if "key" in self.storage_options: @@ -468,8 +468,16 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou offset, size, filter_mask = ds.get_chunk_details(chunk_coords) self.data_read += size - # S3: pass in pre-configured storage options (credentials) - if self.storage_type == "s3": + if self.storage_type == 'S3' and self._version == 1: + + tmp, count = reduce_opens3_chunk(self.ds._fh, offset, size, compressor, filters, + self.missing, ds.dtype, + chunks, ds.order, + chunk_selection, method=self.method + ) + + elif self.storage_type == "s3" and self._version==2: + # S3: pass in pre-configured storage options (credentials) # print("S3 rfile is:", self.filename) parsed_url = urllib.parse.urlparse(self.filename) bucket = parsed_url.netloc @@ -513,6 +521,9 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou ds.order, chunk_selection, operation=self._method) + elif self.storage_type=='ActivePosix' and self.version==2: + # This is where the DDN Fuse and Infinia wrappers go + raise NotImplementedError else: # note there is an ongoing discussion about this interface, and what it returns # see https://github.com/valeriupredoi/PyActiveStorage/issues/33 diff --git a/bnl/bnl_s3test.py b/bnl/bnl_s3test.py index 9ae4ab35..938e98c0 100644 --- a/bnl/bnl_s3test.py +++ b/bnl/bnl_s3test.py @@ -54,16 +54,23 @@ def ex_test(ncfile, var): ofile ) print("S3 Test file path:", test_file_uri) - active = Active(test_file_uri, var, storage_type="s3", - storage_options=storage_options, - active_storage_url=active_storage_url) - active._version = 2 - active._method = "min" + for av in [0,1,2]: - result = active[0:2,4:6,7:9] - assert nc_min == result - assert result == 239.25946044921875 + active = Active(test_file_uri, var, storage_type="s3", + storage_options=storage_options, + active_storage_url=active_storage_url) + + active._version = av + if av > 0: + active._method = "min" + + result = active[0:2,4:6,7:9] + print(active.metric_data) + if av == 0: + result = np.min(result) + assert nc_min == result + assert result == 239.25946044921875 diff --git a/bnl/bnl_test.py b/bnl/bnl_test.py index ae8e4a32..fd9e4d29 100644 --- a/bnl/bnl_test.py +++ b/bnl/bnl_test.py @@ -18,27 +18,30 @@ def mytest(): ncfile, v = "cesm2_native.nc","TREFHT" ncfile, v = "CMIP6-test.nc", 'tas' #ncfile, v = "chunked.hdf5", "dataset1" - ncfile, v = 'daily_data.nc', 'ta' + #ncfile, v = 'daily_data.nc', 'ta' mypath = Path(__file__).parent uri = str(mypath/ncfile) - active = Active(uri, v, None) - active._version = 0 - if v == "dataset1": - d = active[2,:] - else: - d = active[4:5, 1:2] - mean_result = np.mean(d) - active = Active(uri, v, None) - active._version = 2 - active.method = "mean" - active.components = True - if v == "dataset1": - result2 = active[2,:] - else: - result2 = active[4:5, 1:2] - print(result2, ncfile) + results = [] + for av in [0,1,2]: + + active = Active(uri, v, None) + active._version = av + if av > 0: + active.method="mean" + if v == "dataset1": + d = active[2,:] + else: + d = active[4:5, 1:2] + print(active.metric_data) + if av == 0: + d = np.mean(d) + results.append(d) + + # check for active - np.testing.assert_allclose(mean_result, result2["sum"]/result2["n"], rtol=1e-6) + np.testing.assert_allclose(results[0],results[1], rtol=1e-6) + np.testing.assert_allclose(results[1],results[2], rtol=1e-6) + if __name__=="__main__": From df6b5bbbbd72946092746b91898e04a00352b723 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 19 Mar 2024 11:57:52 +0000 Subject: [PATCH 058/129] Active v1 on S3 now works. Stopping default verbosity from reductionist --- activestorage/active.py | 8 ++++---- activestorage/reductionist.py | 5 ++++- activestorage/storage.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 34cb9bb3..78f56e3d 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -468,9 +468,9 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou offset, size, filter_mask = ds.get_chunk_details(chunk_coords) self.data_read += size - if self.storage_type == 'S3' and self._version == 1: + if self.storage_type == 's3' and self._version == 1: - tmp, count = reduce_opens3_chunk(self.ds._fh, offset, size, compressor, filters, + tmp, count = reduce_opens3_chunk(ds.fh, offset, size, compressor, filters, self.missing, ds.dtype, chunks, ds.order, chunk_selection, method=self.method @@ -535,11 +535,11 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou chunk_selection, method=self.method) if self.method is not None: - return tmp, count + return tmp, count else: if drop_axes: tmp = np.squeeze(tmp, axis=drop_axes) - return tmp, out_selection + return tmp, out_selection def _mask_data(self, data): """ diff --git a/activestorage/reductionist.py b/activestorage/reductionist.py index e96d9982..13c3974b 100644 --- a/activestorage/reductionist.py +++ b/activestorage/reductionist.py @@ -9,6 +9,8 @@ import sys import typing +DEBUG = 0 + def get_session(username: str, password: str, cacert: typing.Optional[str]) -> requests.Session: """Create and return a client session object. @@ -53,7 +55,8 @@ def reduce_chunk(session, server, source, bucket, object, """ request_data = build_request_data(source, bucket, object, offset, size, compression, filters, missing, dtype, shape, order, chunk_selection) - print("Reductionist request data dictionary:", request_data) + if DEBUG: + print(f"Reductionist request data dictionary: {request_data}") api_operation = "sum" if operation == "mean" else operation or "select" url = f'{server}/v1/{api_operation}/' response = request(session, url, request_data) diff --git a/activestorage/storage.py b/activestorage/storage.py index 38a7e35d..a6b1a4e4 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -100,3 +100,36 @@ def read_block(open_file, offset, size): data = open_file.read(size) open_file.seek(place) return data + + +def reduce_opens3_chunk(fh, + offset, size, compression, filters, missing, dtype, shape, + order, chunk_selection, method=None): + """ + Same function as reduce_chunk, but this mimics what is done + deep in the bowels of H5py/pyfive. The reason for doing this is + so we can get per chunk metrics + """ + fh.seek(offset) + chunk_buffer = fh.read(size) + chunk = filter_pipeline(chunk_buffer, compression, filters) + # make it a numpy array of bytes + chunk = ensure_ndarray(chunk) + # convert to the appropriate data type + chunk = chunk.view(dtype) + # sort out ordering and convert to the parent hyperslab dimensions + chunk = chunk.reshape(-1, order='A') + chunk = chunk.reshape(shape, order=order) + + tmp = chunk[chunk_selection] + if method: + if missing != (None, None, None, None): + tmp = remove_missing(tmp, missing) + # check on size of tmp; method(empty) returns nan + if tmp.any(): + return method(tmp), tmp.size + else: + return tmp, None + else: + return tmp, None + From ef001eee9e61a7afcf33deb3bb0bae4c05a2e1f2 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Sat, 23 Mar 2024 08:03:22 +0000 Subject: [PATCH 059/129] test runner --- bnl/bnl_timeseries.py | 68 ++++++++++++++++++++++++++++++++++++ bnl/bnl_timeseries_plot.py | 67 +++++++++++++++++++++++++++++++++++ bnl/bnl_timeseries_runner.py | 15 ++++++++ 3 files changed, 150 insertions(+) create mode 100644 bnl/bnl_timeseries.py create mode 100644 bnl/bnl_timeseries_plot.py create mode 100644 bnl/bnl_timeseries_runner.py diff --git a/bnl/bnl_timeseries.py b/bnl/bnl_timeseries.py new file mode 100644 index 00000000..92d64238 --- /dev/null +++ b/bnl/bnl_timeseries.py @@ -0,0 +1,68 @@ +# Simple code to get hemispheric mean of the lowest level of air temperature from +# float UM_m01s30i204_vn1106(time, air_pressure_3, latitude_1, longitude_1) ; +# UM_m01s30i204_vn1106:long_name = "TEMPERATURE ON P LEV/UV GRID" ;" ; +# UM_m01s30i204_vn1106:units = "K" ; +# UM_m01s30i204_vn1106:cell_methods = "time: point" ; +# with +# time = 40 ; +# latitude_1 = 1921 ; +# latitude = 1920 ; +# longitude_1 = 2560 ; +# air_pressure_3 = 11 ; + +# This variable is held in an 18 GB file on S3 storage + +from activestorage.active import Active +import numpy as np +from time import time + +S3_BUCKET = "bnl" +S3_URL = "https://uor-aces-o.s3-ext.jc.rl.ac.uk" +ACTIVE_URL = "https://192.171.169.248:8080" + +def timeseries(location='uni', blocks_MB=1, version=2, threads=100): + + invoke = time() + storage_options = { + 'key': "f2d55c6dcfc7618b2c34e00b58df3cef", + 'secret': "$/'#M{0{/4rVhp%n^(XeX$q@y#&(NM3W1->~N.Q6VP.5[@bLpi='nt]AfH)>78pT", + 'client_kwargs': {'endpoint_url': f"{S3_URL}"}, + 'default_fill_cache':False, + 'default_cache_type':"readahead", + 'default_block_size': blocks_MB * 2**20 + } + + filename = 'ch330a.pc19790301-def.nc' + uri = S3_BUCKET + '/' + filename + var = "UM_m01s30i204_vn1106" + + active = Active(uri, var, storage_type="s3", max_threads=threads, + storage_options=storage_options, + active_storage_url=ACTIVE_URL) + + # set active to use the remote reductionist. + # (we intend to change the invocation method + # to something more obvious and transparent.) + active._version = version + + # and set the operation, again, the API will change + active._method = "mean" + + # get hemispheric mean timeseries: + # (this would be more elegant in cf-python) + ts = [] + md = [] + for i in range(40): + ts.append(active[i,0,0:960,:][0]) + # get some performance diagnostics from pyactive + print(active.metric_data) + if i == 0: + nct = active.metric_data['load nc time'] + md.append(active.metric_data['reduction time (s)']) + + result = np.array(ts) + print(result) + complete = time() + method = {1:'Local',2:'Active'}[version] + titlestring = f"{location}:{method} (T{threads},BS{blocks_MB}): {nct:.3}s,{sum(md):.4}s,{complete-invoke:.4}s" + print('Summary: ',titlestring) diff --git a/bnl/bnl_timeseries_plot.py b/bnl/bnl_timeseries_plot.py new file mode 100644 index 00000000..c654411a --- /dev/null +++ b/bnl/bnl_timeseries_plot.py @@ -0,0 +1,67 @@ +from pathlib import Path +import numpy as np +from matplotlib import pyplot as plt +import os + + +plt.rcParams["figure.figsize"] = (8,12) + +data = {'timeseries1':'Hm1, Active: 5 MB Blks', + 'timeseries2':'Hm1, Active: 1 MB Blks', + 'timeseries2v1':'Hm1, Local: 1 MB Blks', + 'timeseries2-uor-t1-a':'Uni, Active: T1, 1 MB Blks', + 'timeseries2-uor-t1-b':'Uni, Active: T1, 1 MB Blks', + 'timeseries2-uor-t1-c':'Uni, Active: T1, 1 MB Blks', + 'timeseries2-uor-t100-a':'Uni, Active: T100, 1 MB Blks', + 'timeseries2-uor-t100-b':'Uni, Active: T100, 1 MB Blks', + 'timeseries1-uor-t100-a':'Uni, Local: T100, 1 MB Blks', + 'timeseries1-uor-t100-b':'Uni, Local: T100, 1 MB Blks', + 'timeseries2v1-uor-t1':'Uni, Local: T1, 1 MB Blks', + 'timeseries1-uor-t1':'Uni, Active: T1, 5 MB Blks', + } +tt = [] + +mypath = Path(__file__).parent + +logfiles = mypath.glob('timeseries3-H*.log') + +#for d,t in data.items(): + +for ff in logfiles: + + #fname1 = mypath/f'{d}.log' + #fname2 = mypath/f'{d}.metrics.txt' + #os.system(f'grep -a "dataset shape" {fname1} > {fname2}') + #with open(fname2,'r') as f: + with open(ff,'r') as f: + lines = f.readlines() + dicts = [eval(l) for l in lines if l.startswith('{') ] + nct = dicts[0]['load nc time'] + rt = [v['reduction time (s)'] for v in dicts] + summary = lines[-1][9:] + overall_time = float(summary.split(',')[-1][:-2]) + tt.append((overall_time, rt, summary)) + #os.system(f'rm {fname2}') + +tts = sorted(tt) +curve = {k:v for i,v,k in tts} + +for k in curve.keys(): + + if 'Local' in k: + ls = 'dashed' + else: + ls = 'solid' + + plt.plot(curve[k], label=k, linestyle=ls) + + +plt.legend(bbox_to_anchor=(0.7, -0.1), fontsize=8) +plt.title('Comparison of reduction parameters') +plt.ylabel('Time to reduce each timestep (s)') +plt.tight_layout() +plt.savefig('home.png') +plt.show() + + + diff --git a/bnl/bnl_timeseries_runner.py b/bnl/bnl_timeseries_runner.py new file mode 100644 index 00000000..dda35478 --- /dev/null +++ b/bnl/bnl_timeseries_runner.py @@ -0,0 +1,15 @@ +from bnl_timeseries import timeseries +import sys + +location = 'Hm1' +blocks = [1,5] +version = [1,2] +threads = [1,100] +iterations = ['a','b','c'] + +for b in blocks: + for v in version: + for t in threads: + for i in iterations: + with open(f'timeseries3-{location}-{b}-{v}-{t}-{i}.log','w') as sys.stdout: + timeseries(location, b, v, t) From 7c1e704a764f0ef0b825422b9d99ccfc262c9ab5 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 25 Mar 2024 11:31:27 +0000 Subject: [PATCH 060/129] all-zero chunks --- activestorage/storage.py | 5 +++-- tests/unit/test_storage.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/activestorage/storage.py b/activestorage/storage.py index a6b1a4e4..65c648e7 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -44,8 +44,9 @@ def reduce_chunk(rfile, if method: if missing != (None, None, None, None): tmp = remove_missing(tmp, missing) - # check on size of tmp; method(empty) returns nan - if tmp.any(): + # Check on size of tmp; method(empty) fails or gives incorrect + # results + if tmp.size: return method(tmp), tmp.size else: return tmp, None diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 22e6dfbb..1f041218 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -118,3 +118,22 @@ def test_reduced_chunk_fully_masked_data_vmax(): method=np.mean) assert rc[0].size == 0 assert rc[1] is None + + +def test_zero_data(): + """Test method with zero data.""" + rfile = "tests/test_data/zero_chunked.nc" + offset = 8760 + size = 48 + + # no compression + ch_sel = (slice(0, 3, 1), slice(0, 4, 1)) + rc = st.reduce_chunk(rfile, offset, size, + compression=None, filters=None, + missing=(None, None, None, None), + dtype="float32", shape=(3, 4), + order="C", chunk_selection=ch_sel, + method=np.mean) + assert rc[0].size == 1 + assert rc[0] == 0 + assert rc[1] is 12 From 209d3fa80465cb5146c078b9a822d49af9f628b5 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 25 Mar 2024 13:23:01 +0000 Subject: [PATCH 061/129] new test file --- tests/test_data/zero_chunked.nc | Bin 0 -> 8808 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/test_data/zero_chunked.nc diff --git a/tests/test_data/zero_chunked.nc b/tests/test_data/zero_chunked.nc new file mode 100644 index 0000000000000000000000000000000000000000..aad62297f3ef0b30fe34c1ba93302479ba2ccca0 GIT binary patch literal 8808 zcmeHLOKTHR6h1SP#34=}YBfFzj>TO|NZTk?h_p&lgD_xb|_e}KQB8xdUU!o8kz?>$XTThxtMyoYwqeVjS>yWf2GwA|-~{QN-jbkekK zVEe9YZA^}2)I?$H%4oTeub9qTQ%+fyn&MabebNMjLWYOw`8iHcfTnbi7@>v{6Cu=k zFm1}I#0fd0x?pY8JDX~;MeaWf2hc1((SZiFq z`Sr1#qMVj#!AWY6Hp^b&ntc>ISQ5Ab zIMhXR)@l0|3_*qvRQ!3r;+H(XCJOt8)NMO^vWERf>A`}R4j6GK+ z91`{{;cV`Oi1LvAJw%M~!?%IMgovxH#DP0__(e4x^~ncip{bR;>z($!pc94ov8h6h z;V;p>5p<}$Gy|Fe&A`9Lz*5Ec v4ZO_ai!=6_UjUtGy|Fe&46a$KVskqE1pO* literal 0 HcmV?d00001 From 620b4031c623dd483224944583b19561d3fcd7b6 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 25 Mar 2024 13:33:38 +0000 Subject: [PATCH 062/129] fix previously failing Anon bkt test --- tests/test_compression_remote_reductionist.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_compression_remote_reductionist.py b/tests/test_compression_remote_reductionist.py index 14eb722d..f7d527df 100644 --- a/tests/test_compression_remote_reductionist.py +++ b/tests/test_compression_remote_reductionist.py @@ -103,13 +103,8 @@ def test_compression_and_filters_cmip6_forced_s3_from_local(storage_options, act active._version = 1 active._method = "min" - # for now anon=True S3 buckets are not supported by Reductionist - with pytest.raises(RedErr) as rederr: - result = active[0:2,4:6,7:9] - access_denied_err = 'code: \\"AccessDenied\\"' - assert access_denied_err in str(rederr.value) - # assert nc_min == result - # assert result == 239.25946044921875 + result = active[0:2,4:6,7:9] + assert result == 239.25946044921875 def test_compression_and_filters_cmip6_forced_s3_from_local_2(): From b49ffa06b516cf5661558cc01e18f626c55c27bc Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 25 Mar 2024 13:38:57 +0000 Subject: [PATCH 063/129] timeOut for v2, v1 does the pancakes now --- tests/unit/test_storage_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index 764c138e..09e775be 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -157,7 +157,7 @@ def load_from_s3(uri, storage_options=None): make_vanilla_ncdata(test_file) active = Active(uri, "data", "s3") - active._version = 1 + active._version = 2 active._method = "max" with pytest.raises(requests.exceptions.ConnectTimeout): From e99a509f4f774b1135191004d56f993fbf3878bb Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 25 Mar 2024 13:40:17 +0000 Subject: [PATCH 064/129] timeOut for v2, v1 does the pancakes now --- tests/unit/test_storage_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index 09e775be..fee79019 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -180,7 +180,7 @@ def load_from_s3(uri, storage_options=None): make_vanilla_ncdata(test_file) active = Active(uri, "data", "s3") - active._version = 1 + active._version = 2 active._method = "max" with pytest.raises(activestorage.reductionist.ReductionistError): From d1dc658c89f6fd0097671f48dd421b17c01887ae Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 25 Mar 2024 13:42:05 +0000 Subject: [PATCH 065/129] and again v2 --- tests/unit/test_storage_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index fee79019..70b23559 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -72,7 +72,7 @@ def reduce_chunk( make_vanilla_ncdata(test_file) active = Active(uri, "data", "s3") - active._version = 1 + active._version = 2 active._method = "max" print("This test has severe flakiness:") From b43dd213b2a0e9152253575f6b4b229d8eedb7c0 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 25 Mar 2024 13:52:37 +0000 Subject: [PATCH 066/129] another pancake --- tests/test_compression_remote_reductionist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_compression_remote_reductionist.py b/tests/test_compression_remote_reductionist.py index f7d527df..927c4c97 100644 --- a/tests/test_compression_remote_reductionist.py +++ b/tests/test_compression_remote_reductionist.py @@ -62,7 +62,7 @@ def test_compression_and_filters_cmip6_data(storage_options, active_storage_url) active = Active(test_file_uri, 'tas', utils.get_storage_type(), storage_options=storage_options, active_storage_url=active_storage_url) - active._version = 1 + active._version = 2 active._method = "min" if USE_S3: From b5d5925c3420d36f975cbb75cb97d951d3d73696 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Tue, 26 Mar 2024 16:11:31 +0000 Subject: [PATCH 067/129] zero count --- activestorage/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/storage.py b/activestorage/storage.py index 65c648e7..80a575ba 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -49,7 +49,7 @@ def reduce_chunk(rfile, if tmp.size: return method(tmp), tmp.size else: - return tmp, None + return tmp, 0 else: return tmp, None From 8b46b4b034c2db577d46f50d5d3f0e6a9cb7b080 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 27 Mar 2024 13:38:55 +0000 Subject: [PATCH 068/129] fix test --- tests/unit/test_storage.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 1f041218..8e279725 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -60,7 +60,7 @@ def test_reduced_chunk_fully_masked_data_fill(): order="C", chunk_selection=ch_sel, method=np.mean) assert rc[0].size == 0 - assert rc[1] is None + assert rc[1] == 0 def test_reduced_chunk_fully_masked_data_missing(): @@ -79,7 +79,7 @@ def test_reduced_chunk_fully_masked_data_missing(): order="C", chunk_selection=ch_sel, method=np.mean) assert rc[0].size == 0 - assert rc[1] is None + assert rc[1] == 0 def test_reduced_chunk_fully_masked_data_vmin(): @@ -98,7 +98,7 @@ def test_reduced_chunk_fully_masked_data_vmin(): order="C", chunk_selection=ch_sel, method=np.mean) assert rc[0].size == 0 - assert rc[1] is None + assert rc[1] == 0 def test_reduced_chunk_fully_masked_data_vmax(): @@ -117,7 +117,7 @@ def test_reduced_chunk_fully_masked_data_vmax(): order="C", chunk_selection=ch_sel, method=np.mean) assert rc[0].size == 0 - assert rc[1] is None + assert rc[1] == 0 def test_zero_data(): From 2152ac5ec53a732cf7ee044988facfa7b2ff7699 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 22 Jul 2024 13:33:50 +0100 Subject: [PATCH 069/129] add a conda list cmd --- .github/workflows/run-test-push.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index 718ca4ec..52e0b1e0 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -38,5 +38,6 @@ jobs: git checkout issue60 pip install -e . - run: pip install -e . + - run: conda list - run: pytest -n 2 --junitxml=report-1.xml - uses: codecov/codecov-action@v3 From 8b0874350a9d5145cdc98323ea7f40c73f78d6d2 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 20 Jan 2025 17:34:43 +0000 Subject: [PATCH 070/129] start work to port new Pyfive --- activestorage/active.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 78f56e3d..dd74bc5d 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -307,8 +307,8 @@ def _get_selection(self, *args): name = self.ds.name dtype = np.dtype(self.ds.dtype) # hopefully fix pyfive to get a dtype directly - array = pyfive.ZarrArrayStub(self.ds.shape, self.ds.chunks) - ds = self.ds._dataobjects + array = pyfive.indexing.ZarrArrayStub(self.ds.shape, self.ds.chunks) + ds = self.ds.id self.metric_data['args'] = args self.metric_data['dataset shape'] = self.ds.shape @@ -318,7 +318,7 @@ def _get_selection(self, *args): else: compressor, filters = decode_filters(ds.filter_pipeline , dtype.itemsize, name) - indexer = pyfive.OrthogonalIndexer(*args, array) + indexer = pyfive.indexing.OrthogonalIndexer(*args, array) out_shape = indexer.shape #stripped_indexer = [(a, b, c) for a,b,c in indexer] drop_axes = indexer.drop_axes and keepdims @@ -364,10 +364,10 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f if ds.chunks is not None: t1 = time.time() - ds._get_chunk_addresses() + # ds._get_chunk_addresses() t2 = time.time() - t1 self.metric_data['indexing time (s)'] = t2 - self.metric_data['chunk number'] = len(ds._zchunk_index) + # self.metric_data['chunk number'] = len(ds._zchunk_index) chunk_count = 0 t1 = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: @@ -464,8 +464,9 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou #FIXME: Do, we, it's not actually used? """ - - offset, size, filter_mask = ds.get_chunk_details(chunk_coords) + # This should contain all the valid chunks + print(ds._index.keys(), len(ds._index.keys())) + offset, size, filter_mask = ds._index[chunk_coords] self.data_read += size if self.storage_type == 's3' and self._version == 1: From ab648175c612b537218d36064e0918ec0780e191 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Mon, 20 Jan 2025 17:35:16 +0000 Subject: [PATCH 071/129] correct test for new Pyive API --- tests/test_reductionist_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py index bcf18735..3f7afa39 100644 --- a/tests/test_reductionist_json.py +++ b/tests/test_reductionist_json.py @@ -18,7 +18,7 @@ def __init__(self, f, v): self.f = pyfive.File(f) ds = self.f[v] self.dtype = np.dtype(ds.dtype) - self.array = pyfive.ZarrArrayStub(ds.shape, ds.chunks or ds.shape) + self.array = pyfive.indexing.ZarrArrayStub(ds.shape, ds.chunks or ds.shape) self.missing = get_missing_attributes(ds) ds = ds._dataobjects self.ds = ds From 5c8095e5248d1319dbadd69f7e760b3f1d299916 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 21 Jan 2025 10:24:04 +0000 Subject: [PATCH 072/129] Conforming to current index structure a bit better --- activestorage/active.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index dd74bc5d..88f27316 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -5,6 +5,8 @@ import urllib import pyfive import time +from operator import mul +from pyfive.h5d import StoreInfo import s3fs @@ -334,7 +336,7 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f out = [] counts = [] else: - out = np.empty(out_shape, dtype=out_dtype, order=ds.order) + out = np.empty(out_shape, dtype=out_dtype, order=ds._order) counts = None # should never get touched with no method! # Create a shared session object. @@ -464,16 +466,19 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou #FIXME: Do, we, it's not actually used? """ - # This should contain all the valid chunks - print(ds._index.keys(), len(ds._index.keys())) - offset, size, filter_mask = ds._index[chunk_coords] + # map into correct coordinate space for h5py/pyfive + chunk_coords = tuple(map(mul, chunk_coords, chunks)) + # retrieve coordinates from chunk index + storeinfo = ds._index[chunk_coords] + # extract what we need here. + offset, size = storeinfo.byte_offset, storeinfo.size self.data_read += size if self.storage_type == 's3' and self._version == 1: tmp, count = reduce_opens3_chunk(ds.fh, offset, size, compressor, filters, self.missing, ds.dtype, - chunks, ds.order, + chunks, ds_order, chunk_selection, method=self.method ) @@ -500,7 +505,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou size, compressor, filters, self.missing, np.dtype(ds.dtype), chunks, - ds.order, + ds._order, chunk_selection, operation=self._method) else: @@ -519,7 +524,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou size, compressor, filters, self.missing, np.dtype(ds.dtype), chunks, - ds.order, + ds._order, chunk_selection, operation=self._method) elif self.storage_type=='ActivePosix' and self.version==2: @@ -532,7 +537,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou # although we will version changes. tmp, count = reduce_chunk(self.filename, offset, size, compressor, filters, self.missing, ds.dtype, - chunks, ds.order, + chunks, ds._order, chunk_selection, method=self.method) if self.method is not None: From fe880266b46784a0a7ed935d9a8bb77d39d9113c Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 21 Jan 2025 12:32:03 +0000 Subject: [PATCH 073/129] new GH repo and branch for Pyfive in GAs --- .github/workflows/run-test-push.yml | 6 +++--- .github/workflows/run-tests.yml | 12 ++++++------ .github/workflows/test_s3_minio.yml | 6 +++--- .github/workflows/test_s3_remote_reductionist.yml | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index 64f238eb..c92940c8 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -29,12 +29,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of bnlawrence/Pyfive:issue60 + - name: Install development version of NCAS-CMS/Pyfive:h5netcdf run: | cd .. - git clone https://github.com/bnlawrence/pyfive.git + git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout issue60 + git checkout h5netcdf pip install -e . - run: pip install -e . - run: conda list diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6b8a7192..f0719b1b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -34,12 +34,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of bnlawrence/Pyfive:issue60 + - name: Install development version of NCAS-CMS/Pyfive:h5netcdf run: | cd .. - git clone https://github.com/bnlawrence/pyfive.git + git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout issue60 + git checkout h5netcdf pip install -e . - run: conda list - run: pip install -e . @@ -66,12 +66,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of bnlawrence/Pyfive:issue60 + - name: Install development version of NCAS-CMS/Pyfive:h5netcdf run: | cd .. - git clone https://github.com/bnlawrence/pyfive.git + git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout issue60 + git checkout h5netcdf pip install -e . - run: conda list - run: mamba install -c conda-forge git diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index 044c5d21..6638627c 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -56,12 +56,12 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true - - name: Install development version of bnlawrence/Pyfive:issue60 + - name: Install development version of NCAS-CMS/Pyfive:h5netcdf run: | cd .. - git clone https://github.com/bnlawrence/pyfive.git + git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout issue60 + git checkout h5netcdf pip install -e . - name: Install PyActiveStorage run: | diff --git a/.github/workflows/test_s3_remote_reductionist.yml b/.github/workflows/test_s3_remote_reductionist.yml index 31c4ee55..bf1f4608 100644 --- a/.github/workflows/test_s3_remote_reductionist.yml +++ b/.github/workflows/test_s3_remote_reductionist.yml @@ -51,12 +51,12 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true - - name: Install development version of bnlawrence/Pyfive:issue60 + - name: Install development version of NCAS-CMS/Pyfive:h5netcdf run: | cd .. - git clone https://github.com/bnlawrence/pyfive.git + git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout issue60 + git checkout h5netcdf pip install -e . - name: Install PyActiveStorage run: | From e68a0fe20a0791ba1414b9c007119bcecf1ba3bc Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 21 Jan 2025 14:20:41 +0000 Subject: [PATCH 074/129] Use the new pyfive method to get chunk position in file. Includes bad hack for contiguous data. --- activestorage/active.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 88f27316..7b6fc0f4 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -466,11 +466,9 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou #FIXME: Do, we, it's not actually used? """ - # map into correct coordinate space for h5py/pyfive - chunk_coords = tuple(map(mul, chunk_coords, chunks)) + # retrieve coordinates from chunk index - storeinfo = ds._index[chunk_coords] - # extract what we need here. + storeinfo = ds.get_chunk_info_from_chunk_coord(chunk_coords) offset, size = storeinfo.byte_offset, storeinfo.size self.data_read += size From a728ef08cfd47b51266b34b7cfab4928415a011a Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 21 Jan 2025 14:38:26 +0000 Subject: [PATCH 075/129] fix the mocker json test --- tests/test_reductionist_json.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py index 3f7afa39..c7cc09c0 100644 --- a/tests/test_reductionist_json.py +++ b/tests/test_reductionist_json.py @@ -20,7 +20,7 @@ def __init__(self, f, v): self.dtype = np.dtype(ds.dtype) self.array = pyfive.indexing.ZarrArrayStub(ds.shape, ds.chunks or ds.shape) self.missing = get_missing_attributes(ds) - ds = ds._dataobjects + ds = ds.id self.ds = ds def __getitem__(self, args): if self.ds.filter_pipeline is None: @@ -30,12 +30,13 @@ def __getitem__(self, args): if self.ds.chunks is not None: self.ds._get_chunk_addresses() - indexer = pyfive.OrthogonalIndexer(args, self.array) + indexer = pyfive.indexing.OrthogonalIndexer(args, self.array) for chunk_coords, chunk_selection, out_selection in indexer: - offset, size, filter_mask = self.ds.get_chunk_details(chunk_coords) + storeinfo = self.ds.get_chunk_info_from_chunk_coord(chunk_coords) + offset, size = storeinfo.byte_offset, storeinfo.size jd = reductionist.build_request_data('a','b','c', offset, size, compressor, filters, self.missing, self.dtype, - self.array._chunks,self.ds.order,chunk_selection) + self.array._chunks,self.ds._order,chunk_selection) js = json.dumps(jd) return None From 3df5b36580c559c8ac0ebc18aaf533662be0e4f5 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 21 Jan 2025 14:46:02 +0000 Subject: [PATCH 076/129] new fh method --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 7b6fc0f4..29fc124e 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -474,7 +474,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou if self.storage_type == 's3' and self._version == 1: - tmp, count = reduce_opens3_chunk(ds.fh, offset, size, compressor, filters, + tmp, count = reduce_opens3_chunk(ds._fh, offset, size, compressor, filters, self.missing, ds.dtype, chunks, ds_order, chunk_selection, method=self.method From 762d85a79fabe28041432459cc10cdb32ba1b3c1 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 21 Jan 2025 14:54:02 +0000 Subject: [PATCH 077/129] small typo --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 29fc124e..cf397656 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -476,7 +476,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou tmp, count = reduce_opens3_chunk(ds._fh, offset, size, compressor, filters, self.missing, ds.dtype, - chunks, ds_order, + chunks, ds._order, chunk_selection, method=self.method ) From f3c35e06240f4a57bac062e2bba8a1dda49599e3 Mon Sep 17 00:00:00 2001 From: Bryan Lawrence Date: Tue, 21 Jan 2025 15:32:20 +0000 Subject: [PATCH 078/129] Cleared up the compression problem --- activestorage/active.py | 1 - activestorage/hdf2numcodec.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 7b6fc0f4..834e2d44 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -5,7 +5,6 @@ import urllib import pyfive import time -from operator import mul from pyfive.h5d import StoreInfo import s3fs diff --git a/activestorage/hdf2numcodec.py b/activestorage/hdf2numcodec.py index 96796855..fc90cad3 100644 --- a/activestorage/hdf2numcodec.py +++ b/activestorage/hdf2numcodec.py @@ -28,7 +28,7 @@ def decode_filters(filter_pipeline, itemsize, name): for filter in filter_pipeline: filter_id=filter['filter_id'] - properties = filter['client_data_values'] + properties = filter['client_data'] # We suppor the following From 2fabbe336b54010886b88f32c836f98bfb9c3f33 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 21 Jan 2025 17:09:43 +0000 Subject: [PATCH 079/129] reinstate tests --- tests/test_compression_remote_reductionist.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_compression_remote_reductionist.py b/tests/test_compression_remote_reductionist.py index 888dbcff..d447d536 100644 --- a/tests/test_compression_remote_reductionist.py +++ b/tests/test_compression_remote_reductionist.py @@ -29,7 +29,7 @@ # CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -@pytest.mark.xfail(reason='JASMIN messing about with SOF.') +# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') @pytest.mark.parametrize("storage_options, active_storage_url", storage_options_paramlist) def test_compression_and_filters_cmip6_data(storage_options, active_storage_url): """ @@ -83,7 +83,7 @@ def test_compression_and_filters_cmip6_data(storage_options, active_storage_url) # CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -@pytest.mark.xfail(reason='JASMIN messing about with SOF.') +# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') @pytest.mark.parametrize("storage_options, active_storage_url", storage_options_paramlist) def test_compression_and_filters_cmip6_forced_s3_from_local(storage_options, active_storage_url): """ @@ -113,7 +113,7 @@ def test_compression_and_filters_cmip6_forced_s3_from_local(storage_options, act # CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -@pytest.mark.xfail(reason='JASMIN messing about with SOF.') +# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') def test_compression_and_filters_cmip6_forced_s3_from_local_2(): """ Test use of datasets with compression and filters applied for a real @@ -151,7 +151,7 @@ def test_compression_and_filters_cmip6_forced_s3_from_local_2(): # CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -@pytest.mark.xfail(reason='JASMIN messing about with SOF.') +# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') @pytest.mark.skipif(not USE_S3, reason="we need only localhost Reductionist in GA CI") @pytest.mark.skipif(REMOTE_RED, reason="we need only localhost Reductionist in GA CI") def test_compression_and_filters_cmip6_forced_s3_using_local_Reductionist(): From 68851e4c5c880e81bfbc31d4949c66c24ec3cc6d Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 22 Jan 2025 13:11:16 +0000 Subject: [PATCH 080/129] remove bnl references --- tests/test_compression_remote_reductionist.py | 192 ------------------ 1 file changed, 192 deletions(-) delete mode 100644 tests/test_compression_remote_reductionist.py diff --git a/tests/test_compression_remote_reductionist.py b/tests/test_compression_remote_reductionist.py deleted file mode 100644 index d447d536..00000000 --- a/tests/test_compression_remote_reductionist.py +++ /dev/null @@ -1,192 +0,0 @@ -import os -import numpy as np -import pytest - -from netCDF4 import Dataset -from pathlib import Path - -from activestorage.active import Active, load_from_s3 -from activestorage.config import * -from activestorage.dummy_data import make_compressed_ncdata -from activestorage.reductionist import ReductionistError as RedErr - -import utils - - -# Bryan's S3 machine + Bryan's reductionist -STORAGE_OPTIONS_Bryan = { - 'anon': True, - 'client_kwargs': {'endpoint_url': "https://uor-aces-o.s3-ext.jc.rl.ac.uk"}, -} -S3_ACTIVE_URL_Bryan = "https://192.171.169.248:8080" -# TODO include all supported configuration types -storage_options_paramlist = [ - (STORAGE_OPTIONS_Bryan, S3_ACTIVE_URL_Bryan) -] -# bucket needed too for this test only -# otherwise, bucket is extracted automatically from full file uri -S3_BUCKET = "bnl" - - -# CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') -@pytest.mark.parametrize("storage_options, active_storage_url", storage_options_paramlist) -def test_compression_and_filters_cmip6_data(storage_options, active_storage_url): - """ - Test use of datasets with compression and filters applied for a real - CMIP6 dataset (CMIP6-test.nc) - an IPSL file. - - This test will always pass when USE_S3 = False; equally, it will always - fail if USE_S3 = True until Reductionist supports anon=True S3 buckets. - See following test below with a forced storage_type="s3" that mimicks - locally the fail, and catches it. Equally, we catch the same exception when USE_S3=True - - Important info on session data: - S3 Storage options to Reductionist: {'anon': True, 'client_kwargs': {'endpoint_url': 'https://uor-aces-o.s3-ext.jc.rl.ac.uk'}} - S3 anon=True Bucket and File: bnl CMIP6-test.nc - Reductionist request data dictionary: {'source': 'https://uor-aces-o.s3-ext.jc.rl.ac.uk', 'bucket': 'bnl', 'object': 'CMIP6-test.nc', 'dtype': 'float32', 'byte_order': 'little', 'offset': 29385, 'size': 942518, 'order': 'C', 'shape': (15, 143, 144), 'selection': [[0, 2, 1], [4, 6, 1], [7, 9, 1]], 'compression': {'id': 'zlib'}} - """ - test_file = str(Path(__file__).resolve().parent / 'test_data' / 'CMIP6-test.nc') - with Dataset(test_file) as nc_data: - nc_min = np.min(nc_data["tas"][0:2,4:6,7:9]) - print(f"Numpy min from compressed file {nc_min}") - - # TODO remember that the special case for "anon=True" buckets is that - # the actual file uri = "bucket/filename" - if USE_S3: - ofile = os.path.basename(test_file) - test_file_uri = os.path.join(S3_BUCKET, ofile) - else: - test_file_uri = test_file - print("Test file and storage options", test_file, storage_options) - if not utils.get_storage_type(): - storage_options = None - active_storage_url = None - active = Active(test_file_uri, 'tas', utils.get_storage_type(), - storage_options=storage_options, - active_storage_url=active_storage_url) - active._version = 2 - active._method = "min" - - if USE_S3: - # for now anon=True S3 buckets are not supported by Reductionist - with pytest.raises(RedErr) as rederr: - result = active[0:2,4:6,7:9] - access_denied_err = 'code: \\"AccessDenied\\"' - assert access_denied_err in str(rederr.value) - # assert nc_min == result - # assert result == 239.25946044921875 - else: - result = active[0:2,4:6,7:9] - assert nc_min == result - assert result == 239.25946044921875 - - -# CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') -@pytest.mark.parametrize("storage_options, active_storage_url", storage_options_paramlist) -def test_compression_and_filters_cmip6_forced_s3_from_local(storage_options, active_storage_url): - """ - Test use of datasets with compression and filters applied for a real - CMIP6 dataset (CMIP6-test.nc) - an IPSL file. - - This is for a special anon=True bucket ONLY. - """ - test_file = str(Path(__file__).resolve().parent / 'test_data' / 'CMIP6-test.nc') - with Dataset(test_file) as nc_data: - nc_min = np.min(nc_data["tas"][0:2,4:6,7:9]) - print(f"Numpy min from compressed file {nc_min}") - - # TODO remember that the special case for "anon=True" buckets is that - # the actual file uri = "bucket/filename" - ofile = os.path.basename(test_file) - test_file_uri = os.path.join(S3_BUCKET, ofile) - active = Active(test_file_uri, 'tas', storage_type="s3", - storage_options=storage_options, - active_storage_url=active_storage_url) - - active._version = 1 - active._method = "min" - - result = active[0:2,4:6,7:9] - assert result == 239.25946044921875 - - -# CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') -def test_compression_and_filters_cmip6_forced_s3_from_local_2(): - """ - Test use of datasets with compression and filters applied for a real - CMIP6 dataset (CMIP6-test.nc) - an IPSL file. - - This is for a special anon=True bucket connected to via valid key.secret - """ - storage_options = { - 'key': "f2d55c6dcfc7618b2c34e00b58df3cef", - 'secret': "$/'#M{0{/4rVhp%n^(XeX$q@y#&(NM3W1->~N.Q6VP.5[@bLpi='nt]AfH)>78pT", - 'client_kwargs': {'endpoint_url': "https://uor-aces-o.s3-ext.jc.rl.ac.uk"} - } - active_storage_url = "https://192.171.169.248:8080" - test_file = str(Path(__file__).resolve().parent / 'test_data' / 'CMIP6-test.nc') - with Dataset(test_file) as nc_data: - nc_min = np.min(nc_data["tas"][0:2,4:6,7:9]) - print(f"Numpy min from compressed file {nc_min}") - - ofile = os.path.basename(test_file) - test_file_uri = os.path.join( - S3_BUCKET, - ofile - ) - print("S3 Test file path:", test_file_uri) - active = Active(test_file_uri, 'tas', storage_type="s3", - storage_options=storage_options, - active_storage_url=active_storage_url) - - active._version = 1 - active._method = "min" - - result = active[0:2,4:6,7:9] - assert nc_min == result - assert result == 239.25946044921875 - - -# CMIP6_test.nc keeps being unavailable due to BNL bucket unavailable -# @pytest.mark.xfail(reason='JASMIN messing about with SOF.') -@pytest.mark.skipif(not USE_S3, reason="we need only localhost Reductionist in GA CI") -@pytest.mark.skipif(REMOTE_RED, reason="we need only localhost Reductionist in GA CI") -def test_compression_and_filters_cmip6_forced_s3_using_local_Reductionist(): - """ - Test use of datasets with compression and filters applied for a real - CMIP6 dataset (CMIP6-test.nc) - an IPSL file. - - This is for a special anon=True bucket connected to via valid key.secret - and uses the locally deployed Reductionist via container. - """ - print("Reductionist URL", S3_ACTIVE_STORAGE_URL) - storage_options = { - 'key': "f2d55c6dcfc7618b2c34e00b58df3cef", - 'secret': "$/'#M{0{/4rVhp%n^(XeX$q@y#&(NM3W1->~N.Q6VP.5[@bLpi='nt]AfH)>78pT", - 'client_kwargs': {'endpoint_url': "https://uor-aces-o.s3-ext.jc.rl.ac.uk"} - } - - test_file = str(Path(__file__).resolve().parent / 'test_data' / 'CMIP6-test.nc') - with Dataset(test_file) as nc_data: - nc_min = np.min(nc_data["tas"][0:2,4:6,7:9]) - print(f"Numpy min from compressed file {nc_min}") - - ofile = os.path.basename(test_file) - test_file_uri = os.path.join( - S3_BUCKET, - ofile - ) - print("S3 Test file path:", test_file_uri) - active = Active(test_file_uri, 'tas', storage_type="s3", - storage_options=storage_options, - active_storage_url=S3_ACTIVE_STORAGE_URL) - - active._version = 1 - active._method = "min" - - result = active[0:2,4:6,7:9] - assert nc_min == result - assert result == 239.25946044921875 From 1d13c6aaa74c1a26deb0ef38ae69a3167eb6592d Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 22 Jan 2025 13:13:02 +0000 Subject: [PATCH 081/129] remove workflow that runs bnl bucket test --- .../workflows/test_s3_remote_reductionist.yml | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/test_s3_remote_reductionist.yml diff --git a/.github/workflows/test_s3_remote_reductionist.yml b/.github/workflows/test_s3_remote_reductionist.yml deleted file mode 100644 index bf1f4608..00000000 --- a/.github/workflows/test_s3_remote_reductionist.yml +++ /dev/null @@ -1,75 +0,0 @@ -# adapted GA workflow from https://github.com/stackhpc/reductionist-rs -# This runs Active with a remote Reductionist and S3 data stored elsewhere ---- -name: S3/Remote Reductionist - -on: - push: - branches: - - main # keep this at all times - - pyfive - pull_request: - schedule: - - cron: '0 0 * * *' # nightly - -# Required shell entrypoint to have properly configured bash shell -defaults: - run: - shell: bash -l {0} - -jobs: - linux-test: - runs-on: "ubuntu-latest" - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - fail-fast: false - name: Linux Python ${{ matrix.python-version }} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: ${{ matrix.python-version }} - miniforge-version: "latest" - use-mamba: true - - name: Get conda and Python versions - run: | - conda --version - python -V - - name: Export proxy - run: | - echo 'USE_S3 = True' >> activestorage/config.py - echo 'REMOTE_RED = True' >> activestorage/config.py - - name: Ping remote Reductionist - run: curl -k https://192.171.169.248:8080/.well-known/reductionist-schema - - uses: conda-incubator/setup-miniconda@v3 - with: - activate-environment: activestorage-minio - environment-file: environment.yml - python-version: ${{ matrix.python-version }} - miniforge-version: "latest" - use-mamba: true - - name: Install development version of NCAS-CMS/Pyfive:h5netcdf - run: | - cd .. - git clone https://github.com/NCAS-CMS/pyfive.git - cd pyfive - git checkout h5netcdf - pip install -e . - - name: Install PyActiveStorage - run: | - conda --version - python -V - which python - pip install -e . - - name: Run one single test - run: | - pytest tests/test_compression_remote_reductionist.py - - name: Upload HTML report artifact - uses: actions/upload-artifact@v3 - with: - name: html-report - path: test-reports/ - if: always() From 29fd64880f97c7dc42966290047524241ca463ed Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 22 Jan 2025 13:15:48 +0000 Subject: [PATCH 082/129] rplace bnl with relevant info --- tests/test_missing.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_missing.py b/tests/test_missing.py index 934d9039..66aa4830 100644 --- a/tests/test_missing.py +++ b/tests/test_missing.py @@ -212,11 +212,11 @@ def test_validmax(tmp_path): else: x = pyfive.File(testfile) - # print for Bryan - print('bnl',y['data'].getncattr('valid_max')) - print('bnl',x['data'].attrs.get('valid_max')) - print('bnl',z['data'].attrs.get('valid_max')) - print('bnl',a['data'].attrs.get('valid_max')) + # print stuff + print('y-valid-max', y['data'].getncattr('valid_max')) + print('x-valid-max', x['data'].attrs.get('valid_max')) + print('z-valid-max', z['data'].attrs.get('valid_max')) + print('a-valid-max', a['data'].attrs.get('valid_max')) From 4792342cd6005e8659daea3a0612e6d86493eabe Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 22 Jan 2025 13:19:57 +0000 Subject: [PATCH 083/129] remove commented out Bryan server --- tests/test_compression.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_compression.py b/tests/test_compression.py index 0db5ad55..20d41704 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -46,18 +46,14 @@ def create_compressed_dataset(tmp_path: str, compression: str, shuffle: bool): 'client_kwargs': {'endpoint_url': S3_URL}, } S3_ACTIVE_URL_MINIO = S3_ACTIVE_STORAGE_URL -S3_ACTIVE_URL_Bryan = "https://192.171.169.248:8080" # TODO include all supported configuration types # so far test three possible configurations for storage_options: # - storage_options = None, active_storage_url = None (Minio and local Reductionist, preset credentials from config.py) # - storage_options = CLASSIC, active_storage_url = CLASSIC (Minio and local Reductionist, preset credentials from config.py but folded in storage_options and active_storage_url) -# - storage_options = CLASSIC, active_storage_url = Bryan's machine (Minio BUT Reductionist moved on Bryan's machine) -# (this invariably fails due to data URL being //localhost:9000 closed to outside Reductionist storage_options_paramlist = [ (None, None), (STORAGE_OPTIONS_CLASSIC, S3_ACTIVE_URL_MINIO), -# (STORAGE_OPTIONS_CLASSIC, S3_ACTIVE_URL_Bryan) ] From 43ea85355f07812e2a24bf4def2389d0340ce6ae Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 22 Jan 2025 13:20:58 +0000 Subject: [PATCH 084/129] rm ref Bryan --- tests/s3_exploratory/test_s3_reduction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/s3_exploratory/test_s3_reduction.py b/tests/s3_exploratory/test_s3_reduction.py index 3546a528..f2e11071 100644 --- a/tests/s3_exploratory/test_s3_reduction.py +++ b/tests/s3_exploratory/test_s3_reduction.py @@ -19,13 +19,13 @@ def make_tempfile(): """Make dummy data.""" temp_folder = tempfile.mkdtemp() s3_testfile = os.path.join(temp_folder, - 's3_test_bizarre.nc') # Bryan likes this name + 's3_test_bizarre.nc') print(f"S3 Test file is {s3_testfile}") if not os.path.exists(s3_testfile): make_vanilla_ncdata(filename=s3_testfile) local_testfile = os.path.join(temp_folder, - 'local_test_bizarre.nc') # Bryan again + 'local_test_bizarre.nc') print(f"Local Test file is {local_testfile}") if not os.path.exists(local_testfile): make_vanilla_ncdata(filename=local_testfile) From e73712aacea5ad556fe625bbf37bc02af28d18ba Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 11 Feb 2025 13:52:05 +0000 Subject: [PATCH 085/129] use wacasoft branch of Pyfive fork --- .github/workflows/run-test-push.yml | 4 ++-- .github/workflows/run-tests.yml | 8 ++++---- .github/workflows/test_s3_minio.yml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index c92940c8..33471813 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -29,12 +29,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of NCAS-CMS/Pyfive:h5netcdf + - name: Install development version of NCAS-CMS/Pyfive:wacasoft run: | cd .. git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout h5netcdf + git checkout wacasoft pip install -e . - run: pip install -e . - run: conda list diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f0719b1b..8a278038 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -34,12 +34,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of NCAS-CMS/Pyfive:h5netcdf + - name: Install development version of NCAS-CMS/Pyfive:wacasoft run: | cd .. git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout h5netcdf + git checkout wacasoft pip install -e . - run: conda list - run: pip install -e . @@ -66,12 +66,12 @@ jobs: use-mamba: true - run: conda --version - run: python -V - - name: Install development version of NCAS-CMS/Pyfive:h5netcdf + - name: Install development version of NCAS-CMS/Pyfive:wacasoft run: | cd .. git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout h5netcdf + git checkout wacasoft pip install -e . - run: conda list - run: mamba install -c conda-forge git diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index 823f1daf..cceb63d9 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -56,12 +56,12 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true - - name: Install development version of NCAS-CMS/Pyfive:h5netcdf + - name: Install development version of NCAS-CMS/Pyfive:wacasoft run: | cd .. git clone https://github.com/NCAS-CMS/pyfive.git cd pyfive - git checkout h5netcdf + git checkout wacasoft pip install -e . - name: Install PyActiveStorage run: | From 97d6360a16e370724db3654802cd98083945d3f0 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 25 Feb 2025 16:48:36 +0000 Subject: [PATCH 086/129] start api changes --- activestorage/active.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 761cf3b6..ee5db132 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -167,10 +167,10 @@ def __init__( self._max_threads = max_threads self.missing = None self.ds = None + self.netCDF4Dataset = None self.metric_data = {} self.data_read = 0 - @_metricise def __load_nc_file(self): """ Get the netcdf file and it's b-tree""" ncvar = self.ncvar @@ -183,6 +183,11 @@ def __load_nc_file(self): self.filename = self.uri self.ds = nc[ncvar] + return self.ds + + def _netCDF4Dataset(self): + if not self.netCDF4Dataset: + return self.__load_nc_file() def __get_missing_attributes(self): if self.ds is None: @@ -566,3 +571,16 @@ def _mask_data(self, data): data = np.ma.masked_less(data, valid_min) return data + + +class ActiveVariable(): + """ + A netCDF4.Dataset-like variable built on top of the + Active class. It preserves all properties and methods + a regular netCDF4.Dataset has, but some of them are custom + built with Active Storage functionality in mind. + """ + def __init__(self, active): + self.ds = active._netCDF4Dataset() + + From d4f0588eda1a1d4961cfb37aa9e0f4a9a4b817eb Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Tue, 25 Feb 2025 16:48:52 +0000 Subject: [PATCH 087/129] start api changes --- tests/unit/test_active.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index 25f988cc..413ec962 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -3,7 +3,7 @@ import pytest import threading -from activestorage.active import Active +from activestorage.active import Active, ActiveVariable from activestorage.active import load_from_s3 from activestorage.config import * from botocore.exceptions import EndpointConnectionError as botoExc @@ -81,6 +81,14 @@ def test_active(): init = active.__init__(uri=uri, ncvar=ncvar) +def test_activevariable(): + uri = "tests/test_data/cesm2_native.nc" + ncvar = "TREFHT" + active = Active(uri, ncvar=ncvar) + av = ActiveVariable(active) + assert av.ds.shape == (12, 4, 8) + + @pytest.mark.xfail(reason="We don't employ locks with Pyfive anymore, yet.") def test_lock(): """Unit test for class:Active.""" From 3b6f0ced784c1e75dfb79722337c5bca7709d5bb Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 14:47:53 +0000 Subject: [PATCH 088/129] set structure --- activestorage/active.py | 81 +++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 51 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index ee5db132..56c439fa 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -4,10 +4,12 @@ import pathlib import urllib import pyfive +import s3fs import time -from pyfive.h5d import StoreInfo -import s3fs +from pathlib import Path +from pyfive.h5d import StoreInfo +from typing import Optional from activestorage.config import * from activestorage import reductionist @@ -47,21 +49,6 @@ def load_from_s3(uri, storage_options=None): print(f"Dataset loaded from S3 with s3fs and Pyfive: {uri} ({t2-t1:.2},{t3-t2:.2})") return ds -def _metricise(method): - """ Decorator for class methods loads into metric_data""" - def timed(self, *args, **kw): - ts = time.time() - metric_name='' - if '__metric_name' in kw: - metric_name = kw['__metric_name'] - del kw['__metric_name'] - result = method(self,*args, **kw) - te = time.time() - if metric_name: - self.metric_data[metric_name] = te-ts - return result - return timed - def get_missing_attributes(ds): """" @@ -122,13 +109,13 @@ def __new__(cls, *args, **kwargs): def __init__( self, - uri, - ncvar, - storage_type=None, - max_threads=100, - storage_options=None, - active_storage_url=None - ): + dataset: Optional[str | Path | object] , + ncvar: str, + storage_type: str = None, + max_threads: int = 100, + storage_options: dict = None, + active_storage_url: str = None + ) -> None: """ Instantiate with a NetCDF4 dataset URI and the variable of interest within that file. (We need the variable, because we need variable specific metadata from within that @@ -138,15 +125,21 @@ def __init__( :param storage_options: s3fs.S3FileSystem options :param active_storage_url: Reductionist server URL """ - # Assume NetCDF4 for now - self.uri = uri - if self.uri is None: - raise ValueError(f"Must use a valid file for uri. Got {uri}") + input_variable = False + if dataset is None: + raise ValueError(f"Must use a valid file or variable string for dataset. Got {dataset}") + if isinstance(dataset, Path) and not dataset.exists(): + raise ValueError(f"Path to input file {dataset} does not exist.") + if not isinstance(dataset, Path) and not isinstance(dataset, str): + print(f"Treating input {dataset} as variable object.") + input_variable = True + self.uri = dataset + # still allow for a passable storage_type # for special cases eg "special-POSIX" ie DDN if not storage_type and storage_options is not None: - storage_type = urllib.parse.urlparse(uri).scheme + storage_type = urllib.parse.urlparse(dataset).scheme self.storage_type = storage_type # get storage_options @@ -154,8 +147,9 @@ def __init__( self.active_storage_url = active_storage_url # basic check on file - if not os.path.isfile(self.uri) and not self.storage_type: - raise ValueError(f"Must use existing file for uri. {self.uri} not found") + if not input_variable: + if not os.path.isfile(self.uri) and not self.storage_type: + raise ValueError(f"Must use existing file for uri. {self.uri} not found") self.ncvar = ncvar if self.ncvar is None: @@ -201,8 +195,7 @@ def __getitem__(self, index): """ self.metric_data = {} if self.ds is None: - self.__load_nc_file(__metric_name='load nc time') - #self.__metricise('Load','__load_nc_file') + self.__load_nc_file() self.missing = self.__get_missing_attributes() @@ -211,21 +204,20 @@ def __getitem__(self, index): if self.method is None and self._version == 0: # No active operation - return self._get_vanilla(index, __metric_name='vanilla_time') + return self._get_vanilla(index) elif self._version == 1: #FIXME: is the difference between version 1 and 2 still honoured? - return self._get_selection(index, __metric_name='selection 1 time (s)') + return self._get_selection(index) elif self._version == 2: - return self._get_selection(index, __metric_name='selection 2 time (s)') + return self._get_selection(index) else: raise ValueError(f'Version {self._version} not supported') - @_metricise def _get_vanilla(self, index): """ Get the data without any active operation @@ -299,7 +291,7 @@ def _get_active(self, method, *args): """ raise NotImplementedError - @_metricise + def _get_selection(self, *args): """ At this point we have a Dataset object, but all the important information about @@ -571,16 +563,3 @@ def _mask_data(self, data): data = np.ma.masked_less(data, valid_min) return data - - -class ActiveVariable(): - """ - A netCDF4.Dataset-like variable built on top of the - Active class. It preserves all properties and methods - a regular netCDF4.Dataset has, but some of them are custom - built with Active Storage functionality in mind. - """ - def __init__(self, active): - self.ds = active._netCDF4Dataset() - - From bf0c3fd8613fc8132f855b541528ae524201cc9f Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 14:48:05 +0000 Subject: [PATCH 089/129] add test --- tests/unit/test_active.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index 413ec962..fd73c473 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -3,18 +3,19 @@ import pytest import threading -from activestorage.active import Active, ActiveVariable +from activestorage.active import Active from activestorage.active import load_from_s3 from activestorage.config import * from botocore.exceptions import EndpointConnectionError as botoExc from botocore.exceptions import NoCredentialsError as NoCredsExc +from netCDF4 import Dataset def test_uri_none(): """Unit test for class:Active.""" # test invalid uri some_file = None - expected = "Must use a valid file for uri. Got None" + expected = "Must use a valid file or variable string for dataset. Got None" with pytest.raises(ValueError) as exc: active = Active(some_file, ncvar="") assert str(exc.value) == expected @@ -78,15 +79,16 @@ def test_active(): uri = "tests/test_data/cesm2_native.nc" ncvar = "TREFHT" active = Active(uri, ncvar=ncvar) - init = active.__init__(uri=uri, ncvar=ncvar) + init = active.__init__(dataset=uri, ncvar=ncvar) def test_activevariable(): uri = "tests/test_data/cesm2_native.nc" ncvar = "TREFHT" - active = Active(uri, ncvar=ncvar) - av = ActiveVariable(active) - assert av.ds.shape == (12, 4, 8) + ds = Dataset(uri) + av = Active(ds, ncvar) + av._method = "min" + assert av.method([3,444]) == 3 @pytest.mark.xfail(reason="We don't employ locks with Pyfive anymore, yet.") From 35a29d5cadd482e39d75ae41b13a414dfe10c04d Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 15:42:10 +0000 Subject: [PATCH 090/129] actual test with pyfive variable --- activestorage/active.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 56c439fa..fb46b402 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -110,7 +110,7 @@ def __new__(cls, *args, **kwargs): def __init__( self, dataset: Optional[str | Path | object] , - ncvar: str, + ncvar: str = None, storage_type: str = None, max_threads: int = 100, storage_options: dict = None, @@ -152,7 +152,7 @@ def __init__( raise ValueError(f"Must use existing file for uri. {self.uri} not found") self.ncvar = ncvar - if self.ncvar is None: + if self.ncvar is None and not input_variable: raise ValueError("Must set a netCDF variable name to slice") self._version = 1 From 28d97e557a54f4c9ad96b9ab61ff31566c1fc076 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 15:42:18 +0000 Subject: [PATCH 091/129] actual test with pyfive variable --- tests/unit/test_active.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index fd73c473..7de4479c 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -1,5 +1,6 @@ import os import numpy as np +import pyfive import pytest import threading @@ -82,11 +83,20 @@ def test_active(): init = active.__init__(dataset=uri, ncvar=ncvar) -def test_activevariable(): +def test_activevariable_netCDF4(): uri = "tests/test_data/cesm2_native.nc" ncvar = "TREFHT" ds = Dataset(uri) - av = Active(ds, ncvar) + av = Active(ds[ncvar]) + av._method = "min" + assert av.method([3,444]) == 3 + + +def test_activevariable_pyfive(): + uri = "tests/test_data/cesm2_native.nc" + ncvar = "TREFHT" + ds = pyfive.File(uri) + av = Active(ds[ncvar]) av._method = "min" assert av.method([3,444]) == 3 From 25668bd50e01088dddfd2c822f23f0884ff8be11 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 16:18:13 +0000 Subject: [PATCH 092/129] clean up --- activestorage/active.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index fb46b402..440555b2 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -161,8 +161,6 @@ def __init__( self._max_threads = max_threads self.missing = None self.ds = None - self.netCDF4Dataset = None - self.metric_data = {} self.data_read = 0 def __load_nc_file(self): @@ -179,10 +177,6 @@ def __load_nc_file(self): self.ds = nc[ncvar] return self.ds - def _netCDF4Dataset(self): - if not self.netCDF4Dataset: - return self.__load_nc_file() - def __get_missing_attributes(self): if self.ds is None: self.__load_nc_file() @@ -193,7 +187,6 @@ def __getitem__(self, index): Provides support for a standard get item. #FIXME-BNL: Why is the argument index? """ - self.metric_data = {} if self.ds is None: self.__load_nc_file() @@ -307,10 +300,6 @@ def _get_selection(self, *args): # hopefully fix pyfive to get a dtype directly array = pyfive.indexing.ZarrArrayStub(self.ds.shape, self.ds.chunks) ds = self.ds.id - - self.metric_data['args'] = args - self.metric_data['dataset shape'] = self.ds.shape - self.metric_data['dataset chunks'] = self.ds.chunks if ds.filter_pipeline is None: compressor, filters = None, None else: @@ -359,13 +348,6 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # Because we do this, we need to read the dataset b-tree now, not as we go, so # it is already in cache. If we remove the thread pool from here, we probably # wouldn't need to do it before the first one. - - if ds.chunks is not None: - t1 = time.time() - # ds._get_chunk_addresses() - t2 = time.time() - t1 - self.metric_data['indexing time (s)'] = t2 - # self.metric_data['chunk number'] = len(ds._zchunk_index) chunk_count = 0 t1 = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: @@ -430,10 +412,6 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # size. out = out / np.sum(counts).reshape(shape1) - t2 = time.time() - self.metric_data['reduction time (s)'] = t2-t1 - self.metric_data['chunks processed'] = chunk_count - self.metric_data['storage read (B)'] = self.data_read return out def _get_endpoint_url(self): From 8cdd4cbc9bbb5a36a68711d4fa3696292384696c Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 17:23:31 +0000 Subject: [PATCH 093/129] clean up and add bits --- activestorage/active.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 440555b2..6d971511 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -125,6 +125,7 @@ def __init__( :param storage_options: s3fs.S3FileSystem options :param active_storage_url: Reductionist server URL """ + self.ds = None input_variable = False if dataset is None: raise ValueError(f"Must use a valid file or variable string for dataset. Got {dataset}") @@ -133,6 +134,8 @@ def __init__( if not isinstance(dataset, Path) and not isinstance(dataset, str): print(f"Treating input {dataset} as variable object.") input_variable = True + self.ds = dataset + self.filename = None self.uri = dataset @@ -160,11 +163,10 @@ def __init__( self._method = None self._max_threads = max_threads self.missing = None - self.ds = None self.data_read = 0 def __load_nc_file(self): - """ Get the netcdf file and it's b-tree""" + """ Get the netcdf file and its b-tree""" ncvar = self.ncvar # in all cases we need an open netcdf file to get at attributes # we keep it open because we need it's b-tree @@ -173,8 +175,8 @@ def __load_nc_file(self): elif self.storage_type == "s3": nc = load_from_s3(self.uri, self.storage_options) self.filename = self.uri - self.ds = nc[ncvar] + return self.ds def __get_missing_attributes(self): From a2f41ab21c2564651b02cd50827e9c3e14e23760 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 26 Feb 2025 17:23:47 +0000 Subject: [PATCH 094/129] add chunking test case --- tests/unit/test_active.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index 7de4479c..f44ee605 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -86,8 +86,8 @@ def test_active(): def test_activevariable_netCDF4(): uri = "tests/test_data/cesm2_native.nc" ncvar = "TREFHT" - ds = Dataset(uri) - av = Active(ds[ncvar]) + ds = Dataset(uri)[ncvar] + av = Active(ds) av._method = "min" assert av.method([3,444]) == 3 @@ -95,10 +95,11 @@ def test_activevariable_netCDF4(): def test_activevariable_pyfive(): uri = "tests/test_data/cesm2_native.nc" ncvar = "TREFHT" - ds = pyfive.File(uri) - av = Active(ds[ncvar]) + ds = pyfive.File(uri)[ncvar] + av = Active(ds) av._method = "min" assert av.method([3,444]) == 3 + assert av[3:5] == 3 @pytest.mark.xfail(reason="We don't employ locks with Pyfive anymore, yet.") From 549d89a551adaf5d4ceb84a09b91c882e4be4acb Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 26 Feb 2025 19:38:01 +0000 Subject: [PATCH 095/129] axis subsets --- activestorage/active.py | 131 +++++++++++++++++++++++----------- activestorage/reductionist.py | 11 +-- activestorage/storage.py | 38 ++++++---- 3 files changed, 124 insertions(+), 56 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 761cf3b6..95c6084c 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -111,12 +111,12 @@ def __new__(cls, *args, **kwargs): """Store reduction methods.""" instance = super().__new__(cls) instance._methods = { - "min": np.min, - "max": np.max, - "sum": np.sum, + "min": np.ma.min, + "max": np.ma.max, + "sum": np.ma.sum, # For the unweighted mean we calulate the sum and divide # by the number of non-missing elements - "mean": np.sum, + "mean": np.ma.sum, } return instance @@ -124,6 +124,7 @@ def __init__( self, uri, ncvar, + axis=None, storage_type=None, max_threads=100, storage_options=None, @@ -161,6 +162,16 @@ def __init__( if self.ncvar is None: raise ValueError("Must set a netCDF variable name to slice") + # Parse axis (note, if axis is None then we'll work out how + # many dimensions there are at the time of an active + # __getitem__ call). + if axis is not None: + if isinstance(axis, int): + axis = (axis,) + else: + axis = tuple(axis) + + self._axis = axis self._version = 1 self._components = False self._method = None @@ -293,7 +304,7 @@ def _get_active(self, method, *args): an array returned via getitem. """ raise NotImplementedError - + @_metricise def _get_selection(self, *args): """ @@ -311,6 +322,9 @@ def _get_selection(self, *args): array = pyfive.indexing.ZarrArrayStub(self.ds.shape, self.ds.chunks) ds = self.ds.id + if self._axis is None: + self._axis = tuple(range(len(ds.shape))) + self.metric_data['args'] = args self.metric_data['dataset shape'] = self.ds.shape self.metric_data['dataset chunks'] = self.ds.chunks @@ -324,20 +338,30 @@ def _get_selection(self, *args): #stripped_indexer = [(a, b, c) for a,b,c in indexer] drop_axes = indexer.drop_axes and keepdims - # we use array._chunks rather than ds.chunks, as the latter is none in the case of - # unchunked data, and we need to tell the storage the array dimensions in this case. - return self._from_storage(ds, indexer, array._chunks, out_shape, dtype, compressor, filters, drop_axes) + # we use array._chunks rather than ds.chunks, as the latter is + # none in the case of unchunked data, and we need to tell the + # storage the array dimensions in this case. + return self._from_storage(ds, indexer, array._chunks, out_shape, dtype, compressor, filters, drop_axes, self._axis) - def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, filters, drop_axes): + def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, filters, drop_axes, axis): method = self.method - + need_counts = self.components or self._method == "mean" + if method is not None: - out = [] - counts = [] + # Replace the size of each reduced axis with the number of + # chunks along that axis + out_shape = list(out_shape) + for i in axis: + out_shape[i] = indexer.dim_indexers[i].nchunks + + out = np.ma.empty(out_shape, dtype=out_dtype, order=ds._order) + if need_counts: + counts = np.ma.empty( + out_shape, dtype=out_dtype, order=ds._order + ) else: out = np.empty(out_shape, dtype=out_dtype, order=ds._order) - counts = None # should never get touched with no method! - + # Create a shared session object. if self.storage_type == "s3" and self._version==2: if self.storage_options is not None: @@ -378,29 +402,32 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f future = executor.submit( self._process_chunk, session, ds, chunks, chunk_coords, chunk_selection, - counts, out_selection, compressor, filters, drop_axes=drop_axes) + out_selection, compressor, filters, drop_axes=drop_axes) futures.append(future) + # Wait for completion. for future in concurrent.futures.as_completed(futures): try: - result = future.result() + result, count, out_selection = future.result() except Exception as exc: raise - else: - chunk_count +=1 - if method is not None: - result, count = result - out.append(result) - counts.append(count) - else: - # store selected data in output - result, selection = result - out[selection] = result + + chunk_count += 1 + + # Store the selected data + out[out_selection] = result + + # Store the counts for the selected data + if need_counts: + counts[out_selection] = count if method is not None: # Apply the method (again) to aggregate the result - out = method(out) - shape1 = (1,) * len(out_shape) + out = method(out, axis=axis, keepdims=True) + + # Aggregate the counts + if need_counts: + n = np.ma.sum(counts, axis=axis, keepdims=True) if self._components: # Return a dictionary of components containing the @@ -415,9 +442,6 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # reductions require the per-dask-chunk partial # reductions to retain these dimensions so that # partial results can be concatenated correctly.) - out = out.reshape(shape1) - - n = np.sum(counts).reshape(shape1) if self._method == "mean": # For the average, the returned component is # "sum", not "mean" @@ -431,7 +455,11 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # For the average, it is actually the sum that has # been created, so we need to divide by the sample # size. - out = out / np.sum(counts).reshape(shape1) + # + # Note: It's OK if an element of 'n' is zero, + # because it will necessarily correspond to + # a masked value in 'out'. + out = out / n t2 = time.time() self.metric_data['reduction time (s)'] = t2-t1 @@ -453,7 +481,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" - def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, counts, + def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, out_selection, compressor, filters, drop_axes=None): """ Obtain part or whole of a chunk. @@ -461,9 +489,6 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou This is done by taking binary data from storage and filling the output array. - Note the need to use counts for some methods - #FIXME: Do, we, it's not actually used? - """ # retrieve coordinates from chunk index @@ -471,6 +496,8 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou offset, size = storeinfo.byte_offset, storeinfo.size self.data_read += size + axis = self._axis + if self.storage_type == 's3' and self._version == 1: tmp, count = reduce_opens3_chunk(ds._fh, offset, size, compressor, filters, @@ -483,6 +510,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou # S3: pass in pre-configured storage options (credentials) # print("S3 rfile is:", self.filename) parsed_url = urllib.parse.urlparse(self.filename) + bucket = parsed_url.netloc object = parsed_url.path @@ -504,6 +532,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou chunks, ds._order, chunk_selection, + axis, operation=self._method) else: # special case for "anon=True" buckets that work only with e.g. @@ -523,6 +552,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou chunks, ds._order, chunk_selection, + axis, operation=self._method) elif self.storage_type=='ActivePosix' and self.version==2: # This is where the DDN Fuse and Infinia wrappers go @@ -532,17 +562,38 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, cou # see https://github.com/valeriupredoi/PyActiveStorage/issues/33 # so neither the returned data or the interface should be considered stable # although we will version changes. + tmp, count = reduce_chunk(self.filename, offset, size, compressor, filters, self.missing, ds.dtype, chunks, ds._order, - chunk_selection, method=self.method) - + chunk_selection, axis, method=self.method) + if self.method is not None: - return tmp, count + # Replace the index corresponding to each reduced axis + # with its size-1 position in chunk-space. + # + # E.g. if 'out_selection' is (slice(0,12), slice(20,60)), + # 'chunk_coord' is (1, 3), and 'axis' is (1,); then + # 'out_selection' will become (slice(0,12), + # slice(3,4)). If 'axis' were instead (0, 1) then + # 'out_selection' would become (slice(1,2), + # slice(3,4)). + # + # This makes sure that 'out_selection' puts 'tmp' in the + # correct place of the numpy array defined by the method + # that collates the 'tmp's for each chunk (currently + # `_from_storage`). + out_selection = list(out_selection) + for i in axis: + n = chunk_coords[i] + out_selection[i] = slice(n, n+1) + + return tmp, count, tuple(out_selection) else: if drop_axes: tmp = np.squeeze(tmp, axis=drop_axes) - return tmp, out_selection + + return tmp, None, out_selection def _mask_data(self, data): """ diff --git a/activestorage/reductionist.py b/activestorage/reductionist.py index 13c3974b..0de05af7 100644 --- a/activestorage/reductionist.py +++ b/activestorage/reductionist.py @@ -27,7 +27,7 @@ def get_session(username: str, password: str, cacert: typing.Optional[str]) -> r def reduce_chunk(session, server, source, bucket, object, offset, size, compression, filters, missing, dtype, shape, - order, chunk_selection, operation): + order, chunk_selection, axis, operation): """Perform a reduction on a chunk using Reductionist. :param server: Reductionist server URL @@ -49,12 +49,13 @@ def reduce_chunk(session, server, source, bucket, object, 1), slice(1, 3, 1), slice(0, 1, 1)) this defines the part of the chunk which is to be obtained or operated upon. + :param axis: tuple of the axes to reduce (non-negative integers) :param operation: name of operation to perform :returns: the reduced data as a numpy array or scalar :raises ReductionistError: if the request to Reductionist fails """ - request_data = build_request_data(source, bucket, object, offset, size, compression, filters, missing, dtype, shape, order, chunk_selection) + request_data = build_request_data(source, bucket, object, offset, size, compression, filters, missing, dtype, shape, order, chunk_selection, axis) if DEBUG: print(f"Reductionist request data dictionary: {request_data}") api_operation = "sum" if operation == "mean" else operation or "select" @@ -134,7 +135,7 @@ def encode_missing(missing): def build_request_data(source: str, bucket: str, object: str, offset: int, size: int, compression, filters, missing, dtype, shape, - order, selection) -> dict: + order, selection, axis) -> dict: """Build request data for Reductionist API.""" request_data = { 'source': source, @@ -145,6 +146,7 @@ def build_request_data(source: str, bucket: str, object: str, offset: int, 'offset': int(offset), 'size': int(size), 'order': order, + 'axis': axis, } if shape: request_data["shape"] = shape @@ -178,7 +180,8 @@ def decode_result(response): shape = json.loads(response.headers['x-activestorage-shape']) result = np.frombuffer(response.content, dtype=dtype) result = result.reshape(shape) - count = json.loads(response.headers['x-activestorage-count']) + count = json.loads(response.headers['x-activestorage-count']) # TODO this is wrong for now! + count = np.frombuffer(response.content, dtype=dtype) # TODO this is wrong for now! return result, count diff --git a/activestorage/storage.py b/activestorage/storage.py index 80a575ba..e2babbb3 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -3,9 +3,7 @@ from numcodecs.compat import ensure_ndarray -def reduce_chunk(rfile, - offset, size, compression, filters, missing, dtype, shape, - order, chunk_selection, method=None): +def reduce_chunk(rfile, offset, size, compression, filters, missing, dtype, shape, order, chunk_selection, axis, method=None): """ We do our own read of chunks and decoding etc rfile - the actual file with the data @@ -20,6 +18,7 @@ def reduce_chunk(rfile, (slice(0, 2, 1), slice(1, 3, 1), slice(0, 1, 1)) this defines the part of the chunk which is to be obtained or operated upon. + axis - tuple of the axes to reduce (non-negative integers) method - computation desired (in this Python version it's an actual method, in storage implementations we'll change to controlled vocabulary) @@ -41,18 +40,15 @@ def reduce_chunk(rfile, chunk = chunk.reshape(shape, order=order) tmp = chunk[chunk_selection] + tmp = mask_missing(tmp, missing) + if method: - if missing != (None, None, None, None): - tmp = remove_missing(tmp, missing) - # Check on size of tmp; method(empty) fails or gives incorrect - # results - if tmp.size: - return method(tmp), tmp.size - else: - return tmp, 0 + N = np.ma.count(tmp, axis=axis, keepdims=True) + tmp = method(tmp, axis=axis, keepdims=True) else: - return tmp, None + N = None + return tmp, N def filter_pipeline(chunk, compression, filters): """ @@ -73,6 +69,24 @@ def filter_pipeline(chunk, compression, filters): return chunk +def mask_missing(data, missing): + """ + As we are using numpy, we can use a masked array, storage implementations + will have to do this by hand + """ + fill_value, missing_value, valid_min, valid_max = missing + + if fill_value: + data = np.ma.masked_equal(data, fill_value) + if missing_value: + data = np.ma.masked_equal(data, missing_value) + if valid_max: + data = np.ma.masked_greater(data, valid_max) + if valid_min: + data = np.ma.masked_less(data, valid_min) + + return data + def remove_missing(data, missing): """ As we are using numpy, we can use a masked array, storage implementations From 84f6a6882817fad0936eb363cc86bc2a0aff2d85 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 27 Feb 2025 11:43:19 +0000 Subject: [PATCH 096/129] dev --- activestorage/active.py | 92 ++++++++++++++++++---------- activestorage/reductionist.py | 2 +- activestorage/storage.py | 52 +++++----------- tests/unit/test_active_axis.py | 107 +++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 69 deletions(-) create mode 100644 tests/unit/test_active_axis.py diff --git a/activestorage/active.py b/activestorage/active.py index 95c6084c..295abf42 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -322,7 +322,9 @@ def _get_selection(self, *args): array = pyfive.indexing.ZarrArrayStub(self.ds.shape, self.ds.chunks) ds = self.ds.id - if self._axis is None: + if self._axis is None: + # 'axis' is None, so work out how many dimensions there + # are from the variable. self._axis = tuple(range(len(ds.shape))) self.metric_data['args'] = args @@ -345,23 +347,46 @@ def _get_selection(self, *args): def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, filters, drop_axes, axis): method = self.method + + # Whether or not we need to store reduction counts need_counts = self.components or self._method == "mean" - + if method is not None: - # Replace the size of each reduced axis with the number of - # chunks along that axis + # Get the number of chunks per axis + nchunks = [] + dim_indexers = indexer.dim_indexers + for i, d in enumerate(dim_indexers): + try: + nchunks.append(d.nchunks) + except AttributeError: + # If 'd' doesn't have an 'nchunks' attribute then + # it must be for an integer index that results in + # a droped axis. + raise IndexError( + "Can't do an active reduction when the index for " + f"axis {i!r} drops the axis." + ) + + # Replace the size of each reduced axis with the total + # number of chunks along that axis out_shape = list(out_shape) for i in axis: - out_shape[i] = indexer.dim_indexers[i].nchunks - + try: + out_shape[i] = nchunks[i] + except IndexError: + raise ValueError( + "Can't do an active reduction for an " + f"out-of-range axis: {i!r}" + ) + out = np.ma.empty(out_shape, dtype=out_dtype, order=ds._order) + out.mask = True if need_counts: - counts = np.ma.empty( - out_shape, dtype=out_dtype, order=ds._order - ) + counts = np.ma.empty(out_shape, dtype='int64', order=ds._order) + counts.mask = True else: - out = np.empty(out_shape, dtype=out_dtype, order=ds._order) - + out = np.ma.empty(out_shape, dtype=out_dtype, order=ds._order) + # Create a shared session object. if self.storage_type == "s3" and self._version==2: if self.storage_options is not None: @@ -413,22 +438,23 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f raise chunk_count += 1 - + # Store the selected data out[out_selection] = result - + # Store the counts for the selected data if need_counts: counts[out_selection] = count if method is not None: - # Apply the method (again) to aggregate the result + # Apply the method (again) to aggregate the result along + # the reduction axes out = method(out, axis=axis, keepdims=True) - - # Aggregate the counts + + # Aggregate the counts along the reduction axes if need_counts: - n = np.ma.sum(counts, axis=axis, keepdims=True) - + n = np.ma.sum(counts, axis=axis, keepdims=True) + if self._components: # Return a dictionary of components containing the # reduced data and the sample size ('n'). (Rationale: @@ -457,8 +483,8 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f # size. # # Note: It's OK if an element of 'n' is zero, - # because it will necessarily correspond to - # a masked value in 'out'. + # because it will, by definition, correspond + # to a masked value in 'out'. out = out / n t2 = time.time() @@ -481,7 +507,7 @@ def _get_endpoint_url(self): return f"http://{urllib.parse.urlparse(self.filename).netloc}" - def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, + def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, out_selection, compressor, filters, drop_axes=None): """ Obtain part or whole of a chunk. @@ -496,8 +522,9 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, offset, size = storeinfo.byte_offset, storeinfo.size self.data_read += size + # Axes over which to apply a reduction axis = self._axis - + if self.storage_type == 's3' and self._version == 1: tmp, count = reduce_opens3_chunk(ds._fh, offset, size, compressor, filters, @@ -532,7 +559,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, chunks, ds._order, chunk_selection, - axis, + axis, operation=self._method) else: # special case for "anon=True" buckets that work only with e.g. @@ -567,32 +594,33 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, self.missing, ds.dtype, chunks, ds._order, chunk_selection, axis, method=self.method) - + if self.method is not None: - # Replace the index corresponding to each reduced axis - # with its size-1 position in chunk-space. + # For a reduced axis, replace the index in 'out_selection' + # with the corresponding position of the chunk in + # chunks-space. # # E.g. if 'out_selection' is (slice(0,12), slice(20,60)), # 'chunk_coord' is (1, 3), and 'axis' is (1,); then # 'out_selection' will become (slice(0,12), - # slice(3,4)). If 'axis' were instead (0, 1) then + # slice(3,4)). If 'axis' were instead (0, 1), then # 'out_selection' would become (slice(1,2), # slice(3,4)). # # This makes sure that 'out_selection' puts 'tmp' in the # correct place of the numpy array defined by the method - # that collates the 'tmp's for each chunk (currently - # `_from_storage`). + # (currently `_from_storage`) that collates the 'tmp' + # arrays from each chunk. out_selection = list(out_selection) for i in axis: n = chunk_coords[i] - out_selection[i] = slice(n, n+1) - + out_selection[i] = slice(n, n + 1) + return tmp, count, tuple(out_selection) else: if drop_axes: tmp = np.squeeze(tmp, axis=drop_axes) - + return tmp, None, out_selection def _mask_data(self, data): diff --git a/activestorage/reductionist.py b/activestorage/reductionist.py index 0de05af7..f77eaf8e 100644 --- a/activestorage/reductionist.py +++ b/activestorage/reductionist.py @@ -49,7 +49,7 @@ def reduce_chunk(session, server, source, bucket, object, 1), slice(1, 3, 1), slice(0, 1, 1)) this defines the part of the chunk which is to be obtained or operated upon. - :param axis: tuple of the axes to reduce (non-negative integers) + :param axis: tuple of the axes to be reduced (non-negative integers) :param operation: name of operation to perform :returns: the reduced data as a numpy array or scalar :raises ReductionistError: if the request to Reductionist fails diff --git a/activestorage/storage.py b/activestorage/storage.py index e2babbb3..f9b6d2ac 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -18,7 +18,7 @@ def reduce_chunk(rfile, offset, size, compression, filters, missing, dtype, shap (slice(0, 2, 1), slice(1, 3, 1), slice(0, 1, 1)) this defines the part of the chunk which is to be obtained or operated upon. - axis - tuple of the axes to reduce (non-negative integers) + axis - tuple of the axes to be reduced (non-negative integers) method - computation desired (in this Python version it's an actual method, in storage implementations we'll change to controlled vocabulary) @@ -70,40 +70,22 @@ def filter_pipeline(chunk, compression, filters): def mask_missing(data, missing): - """ - As we are using numpy, we can use a masked array, storage implementations - will have to do this by hand - """ - fill_value, missing_value, valid_min, valid_max = missing + """Mask an array. - if fill_value: - data = np.ma.masked_equal(data, fill_value) - if missing_value: - data = np.ma.masked_equal(data, missing_value) - if valid_max: - data = np.ma.masked_greater(data, valid_max) - if valid_min: - data = np.ma.masked_less(data, valid_min) - - return data - -def remove_missing(data, missing): - """ - As we are using numpy, we can use a masked array, storage implementations - will have to do this by hand """ fill_value, missing_value, valid_min, valid_max = missing - if fill_value: + if fill_value is not None: data = np.ma.masked_equal(data, fill_value) - if missing_value: + + if missing_value is not None: data = np.ma.masked_equal(data, missing_value) - if valid_max: + + if valid_max is not None: data = np.ma.masked_greater(data, valid_max) - if valid_min: - data = np.ma.masked_less(data, valid_min) - data = np.ma.compressed(data) + if valid_min is not None: + data = np.ma.masked_less(data, valid_min) return data @@ -119,7 +101,7 @@ def read_block(open_file, offset, size): def reduce_opens3_chunk(fh, offset, size, compression, filters, missing, dtype, shape, - order, chunk_selection, method=None): + order, chunk_selection, axis, method=None): """ Same function as reduce_chunk, but this mimics what is done deep in the bowels of H5py/pyfive. The reason for doing this is @@ -137,14 +119,12 @@ def reduce_opens3_chunk(fh, chunk = chunk.reshape(shape, order=order) tmp = chunk[chunk_selection] + tmp = mask_missing(tmp, missing) + if method: - if missing != (None, None, None, None): - tmp = remove_missing(tmp, missing) - # check on size of tmp; method(empty) returns nan - if tmp.any(): - return method(tmp), tmp.size - else: - return tmp, None + N = np.ma.count(tmp, axis=axis, keepdims=True) + tmp = method(tmp, axis=axis, keepdims=True) else: - return tmp, None + N = None + return tmp, N diff --git a/tests/unit/test_active_axis.py b/tests/unit/test_active_axis.py new file mode 100644 index 00000000..0608f9b8 --- /dev/null +++ b/tests/unit/test_active_axis.py @@ -0,0 +1,107 @@ +import itertools +import netCDF4 +import numpy as np +import pytest + +from activestorage.active import Active + +def axis_combinations(ndim): + """Create axes permutations""" + return [None] + [ + axes + for n in range(1, ndim + 1) + for axes in itertools.permutations(range(ndim), n) + ] + +rfile = "tests/test_data/test1.nc" +ncvar ='tas' +ref = netCDF4.Dataset(rfile)[ncvar][...] + +@pytest.mark.parametrize( + "index", + ( + Ellipsis, + (slice(6, 7), slice(None), slice(None)), + (slice(None), slice(0, 64, 3), slice(None)), + (slice(None), slice(None), slice(0, 128, 4)), + (slice(6, 7), slice(0, 64, 3), slice(0, 128, 4)), + (slice(1,11, 2), slice(0, 64, 3), slice(0, 128, 4)), + (slice(None), [0, 1, 5, 7, 30, 33], slice(None)), + (slice(None), [0, 1, 5, 7, 30, 33, 50, 51, 53], slice(None)), + ) +) +def test_active_axis_reduction(index): + """Unit test for class:Active axis combinations.""" + for axis in axis_combinations(ref.ndim): + for method, numpy_func in zip( + ("mean", "sum", "min", "max"), + (np.ma.mean, np.ma.sum, np.ma.min, np.ma.max) + ): + print (axis, index, method) + + r = numpy_func(ref[index], axis=axis, keepdims=True) + + active = Active(rfile, ncvar, axis=axis) + active.method = method + x = active[index] + + assert x.shape == r.shape + assert (x.mask == r.mask).all() + assert np.ma.allclose(x, r) + + # Test dictionary components output + active.components = True + + rn = np.ma.count(ref[index], axis=axis, keepdims=True) + + x = active[index] + + xn = x["n"] + assert xn.shape == rn.shape + assert (xn == rn).all() + + if method == "mean": + method = "sum" + r = np.ma.sum(ref[index], axis=axis, keepdims=True) + + x = x[method] + assert x.shape == r.shape + assert (x.mask == r.mask).all() + assert np.ma.allclose(x, r) + + +def test_active_axis_format_1(): + """Unit test for class:Active axis format.""" + active1 = Active(rfile, ncvar, axis=[0, 2]) + active1.method = "mean" + + active2 = Active(rfile, ncvar, axis=(-1, -3)) + active2.method = "mean" + + x1 = active2[...] + x2 = active2[...] + + assert x1.shape == x2.shape + assert (x1.mask == x2.mask).all() + assert np.ma.allclose(x1, x2) + + +def test_active_axis_format_2(): + """Unit test for class:Active axis format.""" + # Disallow out-of-range axes + active = Active(rfile, ncvar, axis=(0, 3)) + active.method = "mean" + + with pytest.raises(ValueError): + active[...] + + +def test_active_axis_index(): + """Unit test for class:Active axis format.""" + # Disallow reductions when the index drops an axis (i.e. index + # contains an integer) + active = Active(rfile, ncvar) + active.method = "mean" + + with pytest.raises(IndexError): + active[0] From e471dee0b3ceb3ad63874f248235e69121944699 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 13:00:50 +0000 Subject: [PATCH 097/129] add exception if not pyfive dataset --- activestorage/active.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 6d971511..0b48790f 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -133,9 +133,11 @@ def __init__( raise ValueError(f"Path to input file {dataset} does not exist.") if not isinstance(dataset, Path) and not isinstance(dataset, str): print(f"Treating input {dataset} as variable object.") + if not type(dataset) is pyfive.high_level.Dataset: + raise TypeError(f"Variable object dataset can only be pyfive.high_level.Dataset. Got {dataset}") input_variable = True self.ds = dataset - self.filename = None + self.filename = self.ds self.uri = dataset From 620e2b68bd5958d45aaf1a667136bfde34dcaa61 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 13:01:06 +0000 Subject: [PATCH 098/129] test for that exception --- tests/unit/test_active.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index f44ee605..0175389c 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -87,9 +87,10 @@ def test_activevariable_netCDF4(): uri = "tests/test_data/cesm2_native.nc" ncvar = "TREFHT" ds = Dataset(uri)[ncvar] - av = Active(ds) - av._method = "min" - assert av.method([3,444]) == 3 + exc_str = "Variable object dataset can only be pyfive.high_level.Dataset" + with pytest.raises(TypeError) as exc: + av = Active(ds) + assert exc_str in str(exc) def test_activevariable_pyfive(): From dca443f5c41c62dc70b29c16e5613e3489ee603c Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 13:15:07 +0000 Subject: [PATCH 099/129] start reduce chunk with correct syntax --- activestorage/storage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activestorage/storage.py b/activestorage/storage.py index 80a575ba..64682073 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -27,6 +27,8 @@ def reduce_chunk(rfile, """ #FIXME: for the moment, open the file every time ... we might want to do that, or not + obj_type = type(rfile) + print(f"Reducing chunk of object {obj_type}") with open(rfile,'rb') as open_file: # get the data chunk = read_block(open_file, offset, size) From 1bd40f5eeef36fa417cea6eea6213fa5ca4771b5 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 14:06:49 +0000 Subject: [PATCH 100/129] it bloody works --- activestorage/storage.py | 42 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/activestorage/storage.py b/activestorage/storage.py index 64682073..fcb3e81a 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -1,7 +1,10 @@ """Active storage module.""" import numpy as np +import pyfive from numcodecs.compat import ensure_ndarray +from pyfive.dataobjects import DatasetID + def reduce_chunk(rfile, offset, size, compression, filters, missing, dtype, shape, @@ -29,18 +32,33 @@ def reduce_chunk(rfile, #FIXME: for the moment, open the file every time ... we might want to do that, or not obj_type = type(rfile) print(f"Reducing chunk of object {obj_type}") - with open(rfile,'rb') as open_file: - # get the data - chunk = read_block(open_file, offset, size) - # reverse any compression and filters - chunk = filter_pipeline(chunk, compression, filters) - # make it a numpy array of bytes - chunk = ensure_ndarray(chunk) - # convert to the appropriate data type - chunk = chunk.view(dtype) - # sort out ordering and convert to the parent hyperslab dimensions - chunk = chunk.reshape(-1, order='A') - chunk = chunk.reshape(shape, order=order) + if not obj_type is pyfive.high_level.Dataset: + with open(rfile,'rb') as open_file: + # get the data + chunk = read_block(open_file, offset, size) + # reverse any compression and filters + chunk = filter_pipeline(chunk, compression, filters) + # make it a numpy array of bytes + chunk = ensure_ndarray(chunk) + # convert to the appropriate data type + chunk = chunk.view(dtype) + # sort out ordering and convert to the parent hyperslab dimensions + chunk = chunk.reshape(-1, order='A') + chunk = chunk.reshape(shape, order=order) + else: + class storeinfo: pass + storeinfo.byte_offset = offset + storeinfo.size = size + chunk = rfile.id._get_raw_chunk(storeinfo) + # reverse any compression and filters + chunk = filter_pipeline(chunk, compression, filters) + # make it a numpy array of bytes + chunk = ensure_ndarray(chunk) + # convert to the appropriate data type + chunk = chunk.view(dtype) + # sort out ordering and convert to the parent hyperslab dimensions + chunk = chunk.reshape(-1, order='A') + chunk = chunk.reshape(shape, order=order) tmp = chunk[chunk_selection] if method: From 0bdc3106909a3c4c00b0880e29df33f74414eae7 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 14:06:56 +0000 Subject: [PATCH 101/129] it bloody works --- tests/unit/test_active.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index 0175389c..9d602e8d 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -100,7 +100,11 @@ def test_activevariable_pyfive(): av = Active(ds) av._method = "min" assert av.method([3,444]) == 3 - assert av[3:5] == 3 + av_slice_min = av[3:5] + assert av_slice_min == np.array(258.62814, dtype="float32") + # test with Numpy + np_slice_min = np.min(ds[3:5]) + assert av_slice_min == np_slice_min @pytest.mark.xfail(reason="We don't employ locks with Pyfive anymore, yet.") From 9f46ab9beb2522e762cbb7b7b680974464c3aa42 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 14:17:32 +0000 Subject: [PATCH 102/129] add inline comment --- activestorage/storage.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/activestorage/storage.py b/activestorage/storage.py index fcb3e81a..6e72a488 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -28,11 +28,13 @@ def reduce_chunk(rfile, storage implementations we'll change to controlled vocabulary) """ - - #FIXME: for the moment, open the file every time ... we might want to do that, or not obj_type = type(rfile) print(f"Reducing chunk of object {obj_type}") + if not obj_type is pyfive.high_level.Dataset: + #FIXME: for the moment, open the file every time ... we might want to do that, or not + # we could just use an instance of pyfive.high_level.Dataset.id + # passed directly from active.py, as below with open(rfile,'rb') as open_file: # get the data chunk = read_block(open_file, offset, size) From 0c562e117f216bf30cd528fd25a1e79586875450 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:10:10 +0000 Subject: [PATCH 103/129] correct handling for s3 --- activestorage/active.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/activestorage/active.py b/activestorage/active.py index 0b48790f..76c0230a 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -147,6 +147,12 @@ def __init__( storage_type = urllib.parse.urlparse(dataset).scheme self.storage_type = storage_type + # set correct filename attr + if input_variable and not self.storage_type: + self.filename = self.ds + elif input_variable and self.storage_type == "s3": + self.filename = self.ds.id._filename + # get storage_options self.storage_options = storage_options self.active_storage_url = active_storage_url From 74f0c26ff0af9c91d015553d15a66a97a63ac748 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:10:21 +0000 Subject: [PATCH 104/129] run s3 tests --- .github/workflows/test_s3_minio.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index cceb63d9..1232d768 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -6,7 +6,8 @@ on: push: branches: - main # keep this at all times - - pyfive + # - pyfive # reinstate + - new_api_pyfive pull_request: schedule: - cron: '0 0 * * *' # nightly From 51e27ca616936e3ca4b60d95a936c45a404b1c70 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:22:33 +0000 Subject: [PATCH 105/129] add real world s3 dataset test --- tests/test_real_s3.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_real_s3.py diff --git a/tests/test_real_s3.py b/tests/test_real_s3.py new file mode 100644 index 00000000..630d4c49 --- /dev/null +++ b/tests/test_real_s3.py @@ -0,0 +1,42 @@ +import os +import numpy as np + +from activestorage.active import Active +from activestorage.active import load_from_s3 + +S3_BUCKET = "bnl" + + +def test_s3_dataset(): + """Run somewhat as the 'gold' test.""" + storage_options = { + 'key': "f2d55c6dcfc7618b2c34e00b58df3cef", + 'secret': "$/'#M{0{/4rVhp%n^(XeX$q@y#&(NM3W1->~N.Q6VP.5[@bLpi='nt]AfH)>78pT", + 'client_kwargs': {'endpoint_url': "https://uor-aces-o.s3-ext.jc.rl.ac.uk"}, # old proxy + # 'client_kwargs': {'endpoint_url': "https://uor-aces-o.ext.proxy.jc.rl.ac.uk"}, # new proxy + } + active_storage_url = "https://192.171.169.113:8080" + # bigger_file = "ch330a.pc19790301-bnl.nc" # 18GB 3400 HDF5 chunks + bigger_file = "ch330a.pc19790301-def.nc" # 17GB 64 HDF5 chunks + # bigger_file = "da193a_25_day__198808-198808.nc" # 3GB 30 HDF5 chunks + + test_file_uri = os.path.join( + S3_BUCKET, + bigger_file + ) + print("S3 Test file path:", test_file_uri) + dataset = load_from_s3(test_file_uri, storage_options=storage_options) + av = dataset['UM_m01s16i202_vn1106'] + + # big file bnl: 18GB/3400 HDF5 chunks; def: 17GB/64 HDF5 chunks + active = Active(av, storage_type="s3", + storage_options=storage_options, + active_storage_url=active_storage_url) + active._version = 2 + active._method = "min" + + # result = active[:] + result = active[0:3, 4:6, 7:9] # standardized slice + + print("Result is", result) + assert result == 5098.625 From 2499066ac20fa11d9b4d6a1096b93c25c6d3604b Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:29:11 +0000 Subject: [PATCH 106/129] add note to test --- tests/test_real_s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_real_s3.py b/tests/test_real_s3.py index 630d4c49..2a3f0d50 100644 --- a/tests/test_real_s3.py +++ b/tests/test_real_s3.py @@ -6,7 +6,7 @@ S3_BUCKET = "bnl" - +# TODO Remove after full testing and right before deployment def test_s3_dataset(): """Run somewhat as the 'gold' test.""" storage_options = { From c4950196309aae0316dad75e81ef64a2732357cc Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:29:24 +0000 Subject: [PATCH 107/129] remove leftover --- activestorage/active.py | 1 - 1 file changed, 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 76c0230a..a8e58457 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -137,7 +137,6 @@ def __init__( raise TypeError(f"Variable object dataset can only be pyfive.high_level.Dataset. Got {dataset}") input_variable = True self.ds = dataset - self.filename = self.ds self.uri = dataset From ad3fb546bfce5a806631188408c08d2043dae2fa Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:51:09 +0000 Subject: [PATCH 108/129] unused import --- activestorage/storage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/activestorage/storage.py b/activestorage/storage.py index 6e72a488..56a1ff89 100644 --- a/activestorage/storage.py +++ b/activestorage/storage.py @@ -3,7 +3,6 @@ import pyfive from numcodecs.compat import ensure_ndarray -from pyfive.dataobjects import DatasetID def reduce_chunk(rfile, From 9bf31bd695208cbd941c351d265723ed0368818e Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 27 Feb 2025 15:51:22 +0000 Subject: [PATCH 109/129] unused return --- activestorage/active.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index a8e58457..43a54c21 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -184,8 +184,6 @@ def __load_nc_file(self): self.filename = self.uri self.ds = nc[ncvar] - return self.ds - def __get_missing_attributes(self): if self.ds is None: self.__load_nc_file() From 61cf5dd31888519f78c07bbe4e948fb074c0310b Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:07:09 +0000 Subject: [PATCH 110/129] add correct function docstring --- activestorage/active.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 43a54c21..ae865314 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -173,10 +173,16 @@ def __init__( self.data_read = 0 def __load_nc_file(self): - """ Get the netcdf file and its b-tree""" + """ + Get the netcdf file and its b-tree. + + This private method is used only if the input to Active + is not a pyfive.high_level.Dataset object. In that case, + any file opening is skipped, and ncvar is not used. The + Dataset object will have already contained the b-tree, + and `_filename` attribute. + """ ncvar = self.ncvar - # in all cases we need an open netcdf file to get at attributes - # we keep it open because we need it's b-tree if self.storage_type is None: nc = pyfive.File(self.uri) elif self.storage_type == "s3": From 4cdeb1b17ec8bbe116d261bc0f603a040e2cb22c Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:09:02 +0000 Subject: [PATCH 111/129] removed obsolete inline --- activestorage/active.py | 1 - 1 file changed, 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index ae865314..31546b06 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -310,7 +310,6 @@ def _get_selection(self, *args): name = self.ds.name dtype = np.dtype(self.ds.dtype) - # hopefully fix pyfive to get a dtype directly array = pyfive.indexing.ZarrArrayStub(self.ds.shape, self.ds.chunks) ds = self.ds.id if ds.filter_pipeline is None: From 9aca259ab2410c983801728e0db8567a242d42d2 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:11:19 +0000 Subject: [PATCH 112/129] remove obsolete inline --- activestorage/active.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 31546b06..e7d5c6a4 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -357,9 +357,6 @@ def _from_storage(self, ds, indexer, chunks, out_shape, out_dtype, compressor, f session = None # Process storage chunks using a thread pool. - # Because we do this, we need to read the dataset b-tree now, not as we go, so - # it is already in cache. If we remove the thread pool from here, we probably - # wouldn't need to do it before the first one. chunk_count = 0 t1 = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_threads) as executor: From 2507fe4bc78ed2d47b64c82f871bc4b86e4a4467 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:36:41 +0000 Subject: [PATCH 113/129] Update activestorage/active.py Co-authored-by: David Hassell --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index e7d5c6a4..1cf803a4 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -128,7 +128,7 @@ def __init__( self.ds = None input_variable = False if dataset is None: - raise ValueError(f"Must use a valid file or variable string for dataset. Got {dataset}") + raise ValueError(f"Must use a valid file name or variable object for dataset. Got {dataset!r}") if isinstance(dataset, Path) and not dataset.exists(): raise ValueError(f"Path to input file {dataset} does not exist.") if not isinstance(dataset, Path) and not isinstance(dataset, str): From 17684aa10062bafb0831609dd2ce3dd8402d9562 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:37:19 +0000 Subject: [PATCH 114/129] Update activestorage/active.py Co-authored-by: David Hassell --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 1cf803a4..977041a4 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -134,7 +134,7 @@ def __init__( if not isinstance(dataset, Path) and not isinstance(dataset, str): print(f"Treating input {dataset} as variable object.") if not type(dataset) is pyfive.high_level.Dataset: - raise TypeError(f"Variable object dataset can only be pyfive.high_level.Dataset. Got {dataset}") + raise TypeError(f"Variable object dataset can only be pyfive.high_level.Dataset. Got {dataset!r}") input_variable = True self.ds = dataset self.uri = dataset From b80862d805916ef20e3c7b7db29cdbb65edf5c97 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:37:43 +0000 Subject: [PATCH 115/129] Update activestorage/active.py Co-authored-by: David Hassell --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 977041a4..734321fd 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -130,7 +130,7 @@ def __init__( if dataset is None: raise ValueError(f"Must use a valid file name or variable object for dataset. Got {dataset!r}") if isinstance(dataset, Path) and not dataset.exists(): - raise ValueError(f"Path to input file {dataset} does not exist.") + raise ValueError(f"Path to input file {dataset!r} does not exist.") if not isinstance(dataset, Path) and not isinstance(dataset, str): print(f"Treating input {dataset} as variable object.") if not type(dataset) is pyfive.high_level.Dataset: From ebd54e8a6e1aba511c0f37dc39d40fffd36fb4f7 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 12:44:05 +0000 Subject: [PATCH 116/129] fix test --- tests/unit/test_active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_active.py b/tests/unit/test_active.py index 9d602e8d..1e6fd805 100644 --- a/tests/unit/test_active.py +++ b/tests/unit/test_active.py @@ -16,7 +16,7 @@ def test_uri_none(): """Unit test for class:Active.""" # test invalid uri some_file = None - expected = "Must use a valid file or variable string for dataset. Got None" + expected = "Must use a valid file name or variable object for dataset. Got None" with pytest.raises(ValueError) as exc: active = Active(some_file, ncvar="") assert str(exc.value) == expected From 050bc8fdc6b031ecc6fa3010e9dbac6ab3b3ea56 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 13:11:00 +0000 Subject: [PATCH 117/129] test mock s3 dataset --- tests/unit/test_mock_s3.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_mock_s3.py b/tests/unit/test_mock_s3.py index 63adf8d0..9aea9a43 100644 --- a/tests/unit/test_mock_s3.py +++ b/tests/unit/test_mock_s3.py @@ -1,11 +1,13 @@ import os import s3fs import pathlib +import pyfive import pytest import h5netcdf +import numpy as np from tempfile import NamedTemporaryFile -from activestorage.active import load_from_s3 +from activestorage.active import load_from_s3, Active # needed by the spoofed s3 filesystem @@ -133,7 +135,7 @@ def test_s3file_with_s3fs(s3fs_s3): anon=False, version_aware=True, client_kwargs={"endpoint_url": endpoint_uri} ) - # test load by h5netcdf + # test load by standard h5netcdf with s3.open(os.path.join("MY_BUCKET", file_name), "rb") as f: print("File path", f.path) ncfile = h5netcdf.File(f, 'r', invalid_netcdf=True) @@ -141,9 +143,21 @@ def test_s3file_with_s3fs(s3fs_s3): print(ncfile["ta"]) assert "ta" in ncfile - # test Active + # test active.load_from_s3 storage_options = dict(anon=False, version_aware=True, client_kwargs={"endpoint_url": endpoint_uri}) with load_from_s3(os.path.join("MY_BUCKET", file_name), storage_options) as ac_file: print(ac_file) assert "ta" in ac_file + + # test loading with Pyfive and passing the Dataset to Active + with s3.open(os.path.join("MY_BUCKET", file_name), "rb") as f: + print("File path", f.path) + pie_ds = pyfive.File(f, 'r') + print("File loaded from spoof S3 with Pyfive:", pie_ds) + print("Pyfive dataset:", pie_ds["ta"]) + av = Active(pie_ds["ta"]) + av._method = "min" + assert av.method([3,444]) == 3 + av_slice_min = av[3:5] + assert av_slice_min == np.array(249.6583, dtype="float32") From ead3e60a8e9b54d3f4bc3e70f199e530eb376844 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Fri, 28 Feb 2025 15:42:03 +0000 Subject: [PATCH 118/129] force mamba 2 --- .github/workflows/run-test-push.yml | 1 + .github/workflows/run-tests.yml | 2 ++ .github/workflows/test_s3_minio.yml | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index 33471813..fb2047d3 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -27,6 +27,7 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true + mamba-version: "2.0.5" # https://github.com/conda-incubator/setup-miniconda/issues/392 - run: conda --version - run: python -V - name: Install development version of NCAS-CMS/Pyfive:wacasoft diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8a278038..cc24aba7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -32,6 +32,7 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true + mamba-version: "2.0.5" # https://github.com/conda-incubator/setup-miniconda/issues/392 - run: conda --version - run: python -V - name: Install development version of NCAS-CMS/Pyfive:wacasoft @@ -64,6 +65,7 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true + mamba-version: "2.0.5" # https://github.com/conda-incubator/setup-miniconda/issues/392 - run: conda --version - run: python -V - name: Install development version of NCAS-CMS/Pyfive:wacasoft diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index 1232d768..ca586297 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -7,7 +7,7 @@ on: branches: - main # keep this at all times # - pyfive # reinstate - - new_api_pyfive + - tlc1 pull_request: schedule: - cron: '0 0 * * *' # nightly @@ -34,6 +34,7 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true + mamba-version: "2.0.5" # https://github.com/conda-incubator/setup-miniconda/issues/392 - name: Get conda and Python versions run: | conda --version @@ -57,6 +58,7 @@ jobs: python-version: ${{ matrix.python-version }} miniforge-version: "latest" use-mamba: true + mamba-version: "2.0.5" # https://github.com/conda-incubator/setup-miniconda/issues/392 - name: Install development version of NCAS-CMS/Pyfive:wacasoft run: | cd .. From c588b0da432f56fc092656fb79bb80665565db63 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 5 Mar 2025 15:32:23 +0000 Subject: [PATCH 119/129] up codecov action to v5 --- .github/workflows/run-test-push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index fb2047d3..03d61b6f 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -40,4 +40,4 @@ jobs: - run: pip install -e . - run: conda list - run: pytest -n 2 --junitxml=report-1.xml - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v5 From 93f6362568ece5d3467a3ebaf1985b1dec05a7d7 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 5 Mar 2025 15:39:25 +0000 Subject: [PATCH 120/129] up checkout version and disable artifacts --- .github/workflows/build-and-deploy-on-pypi.yml | 2 +- .github/workflows/create-condalock-file.yml | 2 +- .../workflows/install-from-condalock-file.yml | 2 +- .github/workflows/run-test-push.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- .github/workflows/test_s3_minio.yml | 16 ++++++++-------- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-and-deploy-on-pypi.yml b/.github/workflows/build-and-deploy-on-pypi.yml index cf08f402..fd7ac1d1 100644 --- a/.github/workflows/build-and-deploy-on-pypi.yml +++ b/.github/workflows/build-and-deploy-on-pypi.yml @@ -13,7 +13,7 @@ jobs: name: Build and publish PyActiveStorage on PyPi runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python 3.13 diff --git a/.github/workflows/create-condalock-file.yml b/.github/workflows/create-condalock-file.yml index 86d67d7f..6a10efb5 100644 --- a/.github/workflows/create-condalock-file.yml +++ b/.github/workflows/create-condalock-file.yml @@ -20,7 +20,7 @@ jobs: name: Create and verify conda lock file for latest Python runs-on: 'ubuntu-latest' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 diff --git a/.github/workflows/install-from-condalock-file.yml b/.github/workflows/install-from-condalock-file.yml index 5ff53225..4448895d 100644 --- a/.github/workflows/install-from-condalock-file.yml +++ b/.github/workflows/install-from-condalock-file.yml @@ -25,7 +25,7 @@ jobs: fail-fast: false name: Linux Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 diff --git a/.github/workflows/run-test-push.yml b/.github/workflows/run-test-push.yml index 03d61b6f..716bc232 100644 --- a/.github/workflows/run-test-push.yml +++ b/.github/workflows/run-test-push.yml @@ -17,7 +17,7 @@ jobs: fail-fast: false name: Linux Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index cc24aba7..bb5719c9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false name: Linux Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 @@ -55,7 +55,7 @@ jobs: fail-fast: false name: OSX Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index ca586297..35f2bbd7 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -26,7 +26,7 @@ jobs: fail-fast: false name: Linux Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 @@ -90,10 +90,10 @@ jobs: - name: Stop minio object storage run: tests/s3_exploratory/minio_scripts/minio-stop if: always() - - name: Upload HTML report artifact - uses: actions/upload-artifact@v4 - with: - name: html-report - path: test-reports/ - overwrite: true - if: always() + #- name: Upload HTML report artifact + # uses: actions/upload-artifact@v4 + # with: + # name: html-report + # path: test-reports/ + # overwrite: true + # if: always() From 412607edb959c09b1989033d5e8eb087f2e1522f Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Wed, 5 Mar 2025 15:53:30 +0000 Subject: [PATCH 121/129] unrun GHA --- .github/workflows/test_s3_minio.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test_s3_minio.yml b/.github/workflows/test_s3_minio.yml index 35f2bbd7..4c9a3ef5 100644 --- a/.github/workflows/test_s3_minio.yml +++ b/.github/workflows/test_s3_minio.yml @@ -6,8 +6,7 @@ on: push: branches: - main # keep this at all times - # - pyfive # reinstate - - tlc1 + - pyfive pull_request: schedule: - cron: '0 0 * * *' # nightly From 2e54ea1a7febaee4aac9a8949e298117a174f90e Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 13 Mar 2025 13:33:39 +0000 Subject: [PATCH 122/129] redcutionist not ready, and tests, fixes --- activestorage/active.py | 1 + activestorage/reductionist.py | 22 +++++++++++++++++++--- tests/test_reductionist_json.py | 2 +- tests/unit/test_active_axis.py | 4 ++-- tests/unit/test_reductionist.py | 12 ++++++++---- tests/unit/test_storage.py | 30 ++++++++++++++++++++++-------- 6 files changed, 53 insertions(+), 18 deletions(-) diff --git a/activestorage/active.py b/activestorage/active.py index 295abf42..9269b021 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -549,6 +549,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, # print("S3 bucket:", bucket) # print("S3 file:", object) if self.storage_options is None: + # for the moment we need to force ds.dtype to be a numpy type tmp, count = reductionist.reduce_chunk(session, S3_ACTIVE_STORAGE_URL, diff --git a/activestorage/reductionist.py b/activestorage/reductionist.py index f77eaf8e..9b9560da 100644 --- a/activestorage/reductionist.py +++ b/activestorage/reductionist.py @@ -9,6 +9,8 @@ import sys import typing +REDUCTIONIST_AXIS_READY = False + DEBUG = 0 @@ -146,7 +148,6 @@ def build_request_data(source: str, bucket: str, object: str, offset: int, 'offset': int(offset), 'size': int(size), 'order': order, - 'axis': axis, } if shape: request_data["shape"] = shape @@ -162,6 +163,13 @@ def build_request_data(source: str, bucket: str, object: str, offset: int, if any(missing): request_data["missing"] = encode_missing(missing) + if REDUCTIONIST_AXIS_READY: + request_data['axis'] = axis + elif axis is not None and len(axis) != len(shape): + raise ValueError( + "Can't reduce over axis subset unitl reductionist is ready" + ) + return {k: v for k, v in request_data.items() if v is not None} @@ -178,10 +186,18 @@ def decode_result(response): """Decode a successful response, return as a 2-tuple of (numpy array or scalar, count).""" dtype = response.headers['x-activestorage-dtype'] shape = json.loads(response.headers['x-activestorage-shape']) + + # Result result = np.frombuffer(response.content, dtype=dtype) result = result.reshape(shape) - count = json.loads(response.headers['x-activestorage-count']) # TODO this is wrong for now! - count = np.frombuffer(response.content, dtype=dtype) # TODO this is wrong for now! + + # Counts + count = json.loads(response.headers['x-activestorage-count']) + # TODO: When reductionist is ready, we need to fix 'count' + + # Mask the result + result = np.ma.masked_where(count == 0, result) + return result, count diff --git a/tests/test_reductionist_json.py b/tests/test_reductionist_json.py index c7cc09c0..ec65caeb 100644 --- a/tests/test_reductionist_json.py +++ b/tests/test_reductionist_json.py @@ -36,7 +36,7 @@ def __getitem__(self, args): offset, size = storeinfo.byte_offset, storeinfo.size jd = reductionist.build_request_data('a','b','c', offset, size, compressor, filters, self.missing, self.dtype, - self.array._chunks,self.ds._order,chunk_selection) + self.array._chunks,self.ds._order,chunk_selection, tuple(range(len(self.array._chunks)))) js = json.dumps(jd) return None diff --git a/tests/unit/test_active_axis.py b/tests/unit/test_active_axis.py index 0608f9b8..3c5789a8 100644 --- a/tests/unit/test_active_axis.py +++ b/tests/unit/test_active_axis.py @@ -26,8 +26,8 @@ def axis_combinations(ndim): (slice(None), slice(None), slice(0, 128, 4)), (slice(6, 7), slice(0, 64, 3), slice(0, 128, 4)), (slice(1,11, 2), slice(0, 64, 3), slice(0, 128, 4)), - (slice(None), [0, 1, 5, 7, 30, 33], slice(None)), - (slice(None), [0, 1, 5, 7, 30, 33, 50, 51, 53], slice(None)), + (slice(None), [0, 1, 5, 7, 30, 31], slice(None)), + (slice(None), [0, 1, 5, 7, 30, 31, 50, 51, 53], slice(None)), ) ) def test_active_axis_reduction(index): diff --git a/tests/unit/test_reductionist.py b/tests/unit/test_reductionist.py index 043ccbd6..079d8702 100644 --- a/tests/unit/test_reductionist.py +++ b/tests/unit/test_reductionist.py @@ -44,6 +44,7 @@ def test_reduce_chunk_defaults(mock_request): dtype = np.dtype("int32") shape = None order = None + axis = None chunk_selection = None operation = "min" @@ -56,7 +57,7 @@ def test_reduce_chunk_defaults(mock_request): s3_url, bucket, object, offset, size, compression, filters, missing, dtype, shape, order, - chunk_selection, operation) + chunk_selection, axis, operation) assert tmp == result assert count == 2 @@ -103,6 +104,7 @@ def test_reduce_chunk_compression(mock_request, compression, filters): dtype = np.dtype("int32") shape = (32, ) order = "C" + axis = (0,) chunk_selection = [slice(0, 2, 1)] operation = "min" @@ -113,7 +115,7 @@ def test_reduce_chunk_compression(mock_request, compression, filters): s3_url, bucket, object, offset, size, compression, filters, missing, dtype, shape, order, - chunk_selection, operation) + chunk_selection, axis, operation) assert tmp == result assert count == 2 @@ -192,6 +194,7 @@ def test_reduce_chunk_missing(mock_request, missing): dtype = np.dtype("float32").newbyteorder() shape = (32, ) order = "C" + axis = (0,) chunk_selection = [slice(0, 2, 1)] operation = "min" @@ -200,7 +203,7 @@ def test_reduce_chunk_missing(mock_request, missing): bucket, object, offset, size, compression, filters, missing, dtype, shape, order, - chunk_selection, operation) + chunk_selection, axis, operation) assert tmp == result assert count == 2 @@ -245,6 +248,7 @@ def test_reduce_chunk_not_found(mock_request): missing = [] dtype = np.dtype("int32") shape = (32, ) + axis = (0,) order = "C" chunk_selection = [slice(0, 2, 1)] operation = "min" @@ -254,7 +258,7 @@ def test_reduce_chunk_not_found(mock_request): reductionist.reduce_chunk(session, active_url, s3_url, bucket, object, offset, size, compression, filters, missing, dtype, shape, order, - chunk_selection, operation) + chunk_selection, axis, operation) assert str(exc.value) == 'Reductionist error: HTTP 404: "Not found"' diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 8e279725..44333f15 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -17,6 +17,7 @@ def test_reduce_chunk(): missing=[None, 2050, None, None], dtype="i2", shape=(8, 8), order="C", chunk_selection=slice(0, 2, 1), + axis=(0, 1), method=np.min) assert rc[0] == -1 assert rc[1] == 15 @@ -31,17 +32,20 @@ def test_reduced_chunk_masked_data(): # no compression ch_sel = (slice(0, 62, 1), slice(0, 2, 1), slice(0, 3, 1), slice(0, 2, 1)) - rc = st.reduce_chunk(rfile, offset, size, + + r, c = st.reduce_chunk(rfile, offset, size, compression=None, filters=None, missing=(None, 999.0, None, None), dtype="float32", shape=(62, 2, 3, 2), order="C", chunk_selection=ch_sel, + axis=(0, 1, 2, 3), method=np.mean) # test the output dtype np.testing.assert_raises(AssertionError, - np.testing.assert_array_equal, rc, (249.459564, 680)) + np.testing.assert_array_equal, (r, c), (249.459564, 680)) # test result with correct dtype - np.testing.assert_array_equal(rc, (np.float32(249.459564), 680)) + assert r == np.array([[[[249.45955882352942]]]]) + assert c == 680 def test_reduced_chunk_fully_masked_data_fill(): @@ -53,13 +57,15 @@ def test_reduced_chunk_fully_masked_data_fill(): # no compression ch_sel = (slice(0, 62, 1), slice(0, 2, 1), slice(0, 3, 1), slice(0, 2, 1)) + rc = st.reduce_chunk(rfile, offset, size, compression=None, filters=None, missing=(None, 999.0, None, None), dtype="float32", shape=(62, 2, 3, 2), order="C", chunk_selection=ch_sel, + axis=(0, 1, 2, 3), method=np.mean) - assert rc[0].size == 0 + assert rc[0].size == 1 assert rc[1] == 0 @@ -72,13 +78,15 @@ def test_reduced_chunk_fully_masked_data_missing(): # no compression ch_sel = (slice(0, 62, 1), slice(0, 2, 1), slice(0, 3, 1), slice(0, 2, 1)) + rc = st.reduce_chunk(rfile, offset, size, compression=None, filters=None, missing=(999., None, None, None), dtype="float32", shape=(62, 2, 3, 2), order="C", chunk_selection=ch_sel, + axis=(0, 1, 2, 3), method=np.mean) - assert rc[0].size == 0 + assert rc[0].size == 1 assert rc[1] == 0 @@ -91,13 +99,15 @@ def test_reduced_chunk_fully_masked_data_vmin(): # no compression ch_sel = (slice(0, 62, 1), slice(0, 2, 1), slice(0, 3, 1), slice(0, 2, 1)) + rc = st.reduce_chunk(rfile, offset, size, compression=None, filters=None, missing=(None, None, 1000., None), dtype="float32", shape=(62, 2, 3, 2), order="C", chunk_selection=ch_sel, + axis=(0, 1, 2, 3), method=np.mean) - assert rc[0].size == 0 + assert rc[0].size == 1 assert rc[1] == 0 @@ -110,13 +120,15 @@ def test_reduced_chunk_fully_masked_data_vmax(): # no compression ch_sel = (slice(0, 62, 1), slice(0, 2, 1), slice(0, 3, 1), slice(0, 2, 1)) + rc = st.reduce_chunk(rfile, offset, size, compression=None, filters=None, missing=(None, None, None, 1.), dtype="float32", shape=(62, 2, 3, 2), order="C", chunk_selection=ch_sel, + axis=(0, 1, 2, 3), method=np.mean) - assert rc[0].size == 0 + assert rc[0].size == 1 assert rc[1] == 0 @@ -128,12 +140,14 @@ def test_zero_data(): # no compression ch_sel = (slice(0, 3, 1), slice(0, 4, 1)) + rc = st.reduce_chunk(rfile, offset, size, compression=None, filters=None, missing=(None, None, None, None), dtype="float32", shape=(3, 4), order="C", chunk_selection=ch_sel, + axis=(0, 1), method=np.mean) assert rc[0].size == 1 assert rc[0] == 0 - assert rc[1] is 12 + assert rc[1] == 12 From c6cc75ee6ada185c110619b254615d593127332f Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 13 Mar 2025 15:28:09 +0000 Subject: [PATCH 123/129] fix tests --- tests/unit/test_storage_types.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_storage_types.py b/tests/unit/test_storage_types.py index 70b23559..ac2c1dfa 100644 --- a/tests/unit/test_storage_types.py +++ b/tests/unit/test_storage_types.py @@ -48,6 +48,7 @@ def reduce_chunk( shape, order, chunk_selection, + axis, operation, ): return activestorage.storage.reduce_chunk( @@ -61,6 +62,7 @@ def reduce_chunk( shape, order, chunk_selection, + axis, np.max, ) @@ -71,7 +73,7 @@ def reduce_chunk( test_file = str(tmp_path / "test.nc") make_vanilla_ncdata(test_file) - active = Active(uri, "data", "s3") + active = Active(uri, "data", storage_type="s3") active._version = 2 active._method = "max" @@ -104,6 +106,7 @@ def reduce_chunk( mock.ANY, "C", mock.ANY, + mock.ANY, operation="max", ) @@ -121,7 +124,7 @@ def load_from_s3(uri, storage_options=None): test_file = str(tmp_path / "test.nc") make_vanilla_ncdata(test_file) - active = Active(uri, "data", "s3") + active = Active(uri, "data", storage_type="s3") active._version = 0 result = active[::] @@ -138,7 +141,7 @@ def test_s3_load_failure(mock_load): mock_load.side_effect = FileNotFoundError with pytest.raises(FileNotFoundError): - Active(uri, "data", "s3") + Active(uri, "data", storage_type="s3") @mock.patch.object(activestorage.active, "load_from_s3") @@ -156,7 +159,7 @@ def load_from_s3(uri, storage_options=None): test_file = str(tmp_path / "test.nc") make_vanilla_ncdata(test_file) - active = Active(uri, "data", "s3") + active = Active(uri, "data", storage_type="s3") active._version = 2 active._method = "max" @@ -179,7 +182,7 @@ def load_from_s3(uri, storage_options=None): test_file = str(tmp_path / "test.nc") make_vanilla_ncdata(test_file) - active = Active(uri, "data", "s3") + active = Active(uri, "data", storage_type="s3") active._version = 2 active._method = "max" From 0ad9a848d7de26984a0929f85bcef389bed92ff9 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 13 Mar 2025 15:30:39 +0000 Subject: [PATCH 124/129] add test1.nc --- tests/test_data/test1.nc | Bin 0 -> 318025 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/test_data/test1.nc diff --git a/tests/test_data/test1.nc b/tests/test_data/test1.nc new file mode 100644 index 0000000000000000000000000000000000000000..1aa6fc51d2849c85fd7449872795dc8f138df2d6 GIT binary patch literal 318025 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 z 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 Date: Thu, 13 Mar 2025 15:43:35 +0000 Subject: [PATCH 125/129] fix s3 test --- tests/s3_exploratory/test_s3_reduction.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/s3_exploratory/test_s3_reduction.py b/tests/s3_exploratory/test_s3_reduction.py index f2e11071..175f8e3e 100644 --- a/tests/s3_exploratory/test_s3_reduction.py +++ b/tests/s3_exploratory/test_s3_reduction.py @@ -67,7 +67,7 @@ def test_Active(): print("S3 file uri", s3_testfile_uri) # run Active on s3 file - active = Active(s3_testfile_uri, "data", "s3") + active = Active(s3_testfile_uri, "data", storage_type="s3") active.method = "mean" result1 = active[0:2, 4:6, 7:9] print(result1) @@ -115,7 +115,7 @@ def test_with_valid_netCDF_file(test_data_path): print("S3 file uri", s3_testfile_uri) # run Active on s3 file - active = Active(s3_testfile_uri, "TREFHT", "s3") + active = Active(s3_testfile_uri, "TREFHT", storage_type="s3") active._version = 2 active.method = "mean" active.components = True @@ -148,6 +148,6 @@ def test_reductionist_reduce_chunk(): S3_URL, S3_BUCKET, object, offset, size, None, None, [], np.dtype("int32"), (32, ), "C", - [slice(0, 2, 1), ], "min") + [slice(0, 2, 1), ], None, "min") assert tmp == 134351386 assert count == 2 From 259bba1aeb39df0e0a5158c791d598a88d70987d Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 13 Mar 2025 15:50:17 +0000 Subject: [PATCH 126/129] add missing axis kwarg --- activestorage/active.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 0e31a31e..2f574563 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -514,7 +514,8 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, tmp, count = reduce_opens3_chunk(ds._fh, offset, size, compressor, filters, self.missing, ds.dtype, chunks, ds._order, - chunk_selection, method=self.method + chunk_selection, axis=self.axis, + method=self.method ) elif self.storage_type == "s3" and self._version==2: From 3e725c1deec0f25d634ae68de4a79c38878bbc0e Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 13 Mar 2025 15:55:12 +0000 Subject: [PATCH 127/129] agh am a doofus --- activestorage/active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/active.py b/activestorage/active.py index 2f574563..e9eed0e9 100644 --- a/activestorage/active.py +++ b/activestorage/active.py @@ -514,7 +514,7 @@ def _process_chunk(self, session, ds, chunks, chunk_coords, chunk_selection, tmp, count = reduce_opens3_chunk(ds._fh, offset, size, compressor, filters, self.missing, ds.dtype, chunks, ds._order, - chunk_selection, axis=self.axis, + chunk_selection, axis=axis, method=self.method ) From b474d09e996cdf8288d36f55edf6d6016d4d5000 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 13 Mar 2025 16:11:46 +0000 Subject: [PATCH 128/129] explicitly set storage type kwarg in tests --- tests/test_bigger_data.py | 22 +++++++++++----------- tests/test_byte_order.py | 2 +- tests/test_compression.py | 6 +++--- tests/test_harness.py | 14 +++++++------- tests/test_missing.py | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/test_bigger_data.py b/tests/test_bigger_data.py index 2173558d..f423218d 100644 --- a/tests/test_bigger_data.py +++ b/tests/test_bigger_data.py @@ -106,12 +106,12 @@ def save_cl_file_with_a(tmp_path): def test_cl(tmp_path): ncfile = save_cl_file_with_a(tmp_path) - active = Active(ncfile, "cl", utils.get_storage_type()) + active = Active(ncfile, "cl", storage_type=utils.get_storage_type()) active._version = 0 d = active[4:5, 1:2] mean_result = np.mean(d) - active = Active(ncfile, "cl", utils.get_storage_type()) + active = Active(ncfile, "cl", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True @@ -127,12 +127,12 @@ def test_cl(tmp_path): def test_ps(tmp_path): ncfile = save_cl_file_with_a(tmp_path) - active = Active(ncfile, "ps", utils.get_storage_type()) + active = Active(ncfile, "ps", storage_type=utils.get_storage_type()) active._version = 0 d = active[4:5, 1:2] mean_result = np.mean(d) - active = Active(ncfile, "ps", utils.get_storage_type()) + active = Active(ncfile, "ps", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True @@ -165,7 +165,7 @@ def test_native_emac_model_fails(test_data_path): uri = utils.write_to_storage(ncfile) if USE_S3: - active = Active(uri, "aps_ave", utils.get_storage_type()) + active = Active(uri, "aps_ave", storage_type=utils.get_storage_type()) with pytest.raises(InvalidHDF5Err): active[...] else: @@ -184,12 +184,12 @@ def test_cesm2_native(test_data_path): """ ncfile = str(test_data_path / "cesm2_native.nc") uri = utils.write_to_storage(ncfile) - active = Active(uri, "TREFHT", utils.get_storage_type()) + active = Active(uri, "TREFHT", storage_type=utils.get_storage_type()) active._version = 0 d = active[4:5, 1:2] mean_result = np.mean(d) - active = Active(uri, "TREFHT", utils.get_storage_type()) + active = Active(uri, "TREFHT", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True @@ -209,12 +209,12 @@ def test_daily_data(test_data_path): """ ncfile = str(test_data_path / "daily_data.nc") uri = utils.write_to_storage(ncfile) - active = Active(uri, "ta", utils.get_storage_type()) + active = Active(uri, "ta", storage_type=utils.get_storage_type()) active._version = 0 d = active[4:5, 1:2] mean_result = np.mean(d) - active = Active(uri, "ta", utils.get_storage_type()) + active = Active(uri, "ta", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True @@ -234,13 +234,13 @@ def test_daily_data_masked(test_data_path): """ ncfile = str(test_data_path / "daily_data_masked.nc") uri = utils.write_to_storage(ncfile) - active = Active(uri, "ta", utils.get_storage_type()) + active = Active(uri, "ta", storage_type=utils.get_storage_type()) active._version = 0 d = active[:] d = np.ma.masked_where(d==999., d) mean_result = np.ma.mean(d) - active = Active(uri, "ta", utils.get_storage_type()) + active = Active(uri, "ta", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True diff --git a/tests/test_byte_order.py b/tests/test_byte_order.py index 5e2148e4..811cae05 100644 --- a/tests/test_byte_order.py +++ b/tests/test_byte_order.py @@ -38,7 +38,7 @@ def test_byte_order(tmp_path: str, byte_order: str): """ test_file = create_byte_order_dataset(tmp_path, byte_order) - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active._version = 1 active._method = "min" result = active[0:2,4:6,7:9] diff --git a/tests/test_compression.py b/tests/test_compression.py index 20d41704..3f178d00 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -65,7 +65,7 @@ def test_compression_and_filters(tmp_path: str, compression: str, shuffle: bool) """ test_file = create_compressed_dataset(tmp_path, compression, shuffle) - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active._version = 1 active._method = "min" result = active[0:2,4:6,7:9] @@ -91,7 +91,7 @@ def test_compression_and_filters_cmip6_data(storage_options, active_storage_url) if not utils.get_storage_type(): storage_options = None active_storage_url = None - active = Active(test_file, 'tas', utils.get_storage_type(), + active = Active(test_file, 'tas', storage_type=utils.get_storage_type(), storage_options=storage_options, active_storage_url=active_storage_url) active._version = 1 @@ -121,7 +121,7 @@ def test_compression_and_filters_obs4mips_data(storage_options, active_storage_u if not utils.get_storage_type(): storage_options = None active_storage_url = None - active = Active(test_file, 'rlut', utils.get_storage_type(), + active = Active(test_file, 'rlut', storage_type=utils.get_storage_type(), storage_options=storage_options, active_storage_url=active_storage_url) active._version = 1 diff --git a/tests/test_harness.py b/tests/test_harness.py index 315a3b46..02990538 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -30,7 +30,7 @@ def test_read0(tmp_path): Test a normal read slicing the data an interesting way, using version 0 (native interface) """ test_file = create_test_dataset(tmp_path) - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active._version = 0 d = active[0:2, 4:6, 7:9] # d.data is a memoryview object in both local POSIX and remote S3 storages @@ -43,11 +43,11 @@ def test_read1(tmp_path): Test a normal read slicing the data an interesting way, using version 1 (replicating native interface in our code) """ test_file = create_test_dataset(tmp_path) - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active._version = 0 d0 = active[0:2,4:6,7:9] - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active._version = 1 d1 = active[0:2,4:6,7:9] assert np.array_equal(d0,d1) @@ -57,12 +57,12 @@ def test_active(tmp_path): Shows what we expect an active example test to achieve and provides "the right answer" """ test_file = create_test_dataset(tmp_path) - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active._version = 0 d = active[0:2,4:6,7:9] mean_result = np.mean(d) - active = Active(test_file, 'data', utils.get_storage_type()) + active = Active(test_file, 'data', storage_type=utils.get_storage_type()) active.method = "mean" result2 = active[0:2,4:6,7:9] assert mean_result == result2 @@ -72,12 +72,12 @@ def testActiveComponents(tmp_path): Shows what we expect an active example test to achieve and provides "the right answer" """ test_file = create_test_dataset(tmp_path) - active = Active(test_file, "data", utils.get_storage_type()) + active = Active(test_file, "data", storage_type=utils.get_storage_type()) active._version = 0 d = active[0:2, 4:6, 7:9] mean_result = np.mean(d) - active = Active(test_file, "data", utils.get_storage_type()) + active = Active(test_file, "data", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True diff --git a/tests/test_missing.py b/tests/test_missing.py index 66aa4830..69f93675 100644 --- a/tests/test_missing.py +++ b/tests/test_missing.py @@ -35,7 +35,7 @@ def load_dataset(testfile): def active_zero(testfile): """Run Active with no active storage (version=0).""" - active = Active(testfile, "data", utils.get_storage_type()) + active = Active(testfile, "data", storage_type=utils.get_storage_type()) active._version = 0 d = active[0:2, 4:6, 7:9] @@ -49,7 +49,7 @@ def active_zero(testfile): def active_two(testfile): """Run Active with active storage (version=2).""" - active = Active(testfile, "data", utils.get_storage_type()) + active = Active(testfile, "data", storage_type=utils.get_storage_type()) active._version = 2 active.method = "mean" active.components = True From 16ba98b4c70c41d48593d7515e4c9daa7782d8d8 Mon Sep 17 00:00:00 2001 From: Valeriu Predoi Date: Thu, 13 Mar 2025 16:18:29 +0000 Subject: [PATCH 129/129] and finally the last test I hope --- tests/s3_exploratory/test_s3_performance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/s3_exploratory/test_s3_performance.py b/tests/s3_exploratory/test_s3_performance.py index c47d2ab7..68f2e812 100644 --- a/tests/s3_exploratory/test_s3_performance.py +++ b/tests/s3_exploratory/test_s3_performance.py @@ -124,7 +124,7 @@ def test_Active_s3_v0(): """ # run Active on s3 file s3_file = "s3://pyactivestorage/s3_test_bizarre_large.nc" - active = Active(s3_file, "data", "s3") + active = Active(s3_file, "data", storage_type="s3") active._version = 0 active.components = True result1 = active[0:2, 4:6, 7:9] @@ -136,7 +136,7 @@ def test_Active_s3_v1(): """ # run Active on s3 file s3_file = "s3://pyactivestorage/s3_test_bizarre_large.nc" - active = Active(s3_file, "data", "s3") + active = Active(s3_file, "data", storage_type="s3") active._version = 1 active.method = "mean" active.components = True @@ -149,7 +149,7 @@ def test_Active_s3_v2(): """ # run Active on s3 file s3_file = "s3://pyactivestorage/s3_test_bizarre_large.nc" - active = Active(s3_file, "data", "s3") + active = Active(s3_file, "data", storage_type="s3") active._version = 2 active.method = "mean" active.components = True