Skip to content

Commit 40bab39

Browse files
committed
Allow specifying dimension & coordinate info for labelled dask array
1 parent 74a55c7 commit 40bab39

2 files changed

Lines changed: 254 additions & 58 deletions

File tree

extra_data/keydata.py

Lines changed: 158 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Sequence
12
from warnings import warn
23

34
import h5py
@@ -11,6 +12,25 @@
1112
)
1213

1314

15+
def _expand_ellipsis(ndim, indexing):
16+
"""Expand numpy indexing to one slice/index per dimension"""
17+
# expanding Ellipsis
18+
if any(x is Ellipsis for x in indexing):
19+
ellipsis_idx = indexing.index(Ellipsis)
20+
# Count non-Ellipsis
21+
n_before = len(indexing[:ellipsis_idx])
22+
n_after = len(indexing[ellipsis_idx + 1:])
23+
n_ellipsis = max(0, ndim - n_before - n_after)
24+
25+
# Replace Ellipsis with appropriate number of colons
26+
indexing = (indexing[:ellipsis_idx] +
27+
(slice(None),) * n_ellipsis +
28+
indexing[ellipsis_idx + 1:])
29+
30+
# Pad with slice(None) if indexing is shorter than ndim
31+
return indexing + (slice(None),) * (ndim - len(indexing))
32+
33+
1434
def expand_indexing(shape, indexing):
1535
"""
1636
Expand numpy indexing into explicit coordinate arrays for each dimension.
@@ -32,23 +52,7 @@ def expand_indexing(shape, indexing):
3252
if not isinstance(indexing, tuple):
3353
indexing = (indexing,)
3454

35-
ndim = len(shape)
36-
37-
# expanding Ellipsis
38-
if any(x is Ellipsis for x in indexing):
39-
ellipsis_idx = indexing.index(Ellipsis)
40-
# Count non-Ellipsis
41-
n_before = len(indexing[:ellipsis_idx])
42-
n_after = len(indexing[ellipsis_idx + 1:])
43-
n_ellipsis = max(0, ndim - n_before - n_after)
44-
45-
# Replace Ellipsis with appropriate number of colons
46-
indexing = (indexing[:ellipsis_idx] +
47-
(slice(None),) * n_ellipsis +
48-
indexing[ellipsis_idx + 1:])
49-
50-
# Pad with slice(None) if indexing is shorter than ndim
51-
indexing += (slice(None),) * (ndim - len(indexing))
55+
indexing = _expand_ellipsis(len(shape), indexing)
5256

5357
# Process each index and expand coordinates
5458
result = []
@@ -104,7 +108,7 @@ class KeyData:
104108
"""
105109
def __init__(
106110
self, source, key, *, train_ids, files, section, dtype, eshape,
107-
inc_suspect_trains=True,
111+
edims=None, roi=(), inc_suspect_trains=True,
108112
):
109113
self.source = source
110114
self.key = key
@@ -114,6 +118,10 @@ def __init__(
114118
self.dtype = dtype
115119
self.entry_shape = eshape
116120
self.ndim = len(eshape) + 1
121+
if edims is None:
122+
edims = {f"dim_{i}": None for i in range(len(eshape))}
123+
self._entry_dims = edims
124+
self._roi = roi
117125
self.inc_suspect_trains = inc_suspect_trains
118126

119127
def _find_chunks(self):
@@ -274,6 +282,8 @@ def _without_virtual_overview(self):
274282
section=self.section,
275283
dtype=self.dtype,
276284
eshape=self.entry_shape,
285+
edims=self._entry_dims,
286+
roi=self._roi,
277287
inc_suspect_trains=self.inc_suspect_trains,
278288
)
279289

@@ -342,6 +352,8 @@ def _only_tids(self, tids, files=None):
342352
section=self.section,
343353
dtype=self.dtype,
344354
eshape=self.entry_shape,
355+
edims=self._entry_dims,
356+
roi=self._roi,
345357
inc_suspect_trains=self.inc_suspect_trains,
346358
)
347359

@@ -597,39 +609,43 @@ def xarray(self, extra_dims=None, roi=(), name=None, extra_coords=None):
597609
given, it should map dimension names to coordinate arrays. If True,
598610
default coordinate arrays will be generated.
599611
"""
600-
import xarray
612+
import xarray # Fail before loading data if xarray is missing
601613

602614
ndarr = self.ndarray(roi=roi)
603615

604-
# Train ID index
605-
coords = {'trainId': self.train_id_coordinates()}
606-
dims = ['trainId']
607-
608-
def _dim_name(idx):
609-
if extra_dims is not None:
610-
return extra_dims[idx]
611-
else:
612-
return f'dim_{idx}'
613-
614-
# Dimension labels after the train dimension
615-
if extra_dims is not None and isinstance(extra_coords, dict):
616-
dims += extra_dims
617-
coords |= extra_coords
616+
return self._wrap_xarray(
617+
ndarr, extra_dims=extra_dims, roi=roi, name=name, extra_coords=extra_coords
618+
)
618619

619-
elif isinstance(extra_coords, dict):
620-
coords |= extra_coords
621-
dims += ['dim_%d' % i for i in range(ndarr.ndim - 1)]
620+
def _wrap_xarray(self, arr, extra_dims=None, roi=(), name=None, extra_coords=None):
621+
import xarray
622622

623-
elif extra_coords or extra_dims is not None:
624-
# add default coordinates if extra_coords is True or extra_dims given.
625-
for idx, coord in enumerate(expand_indexing(self.entry_shape, roi)):
626-
dim = _dim_name(idx)
627-
coords[dim] = coord
623+
if (extra_dims is not None) or (extra_coords is not None):
624+
if extra_coords is None:
625+
dim_info = extra_dims
626+
elif (extra_dims is None) and (extra_coords is True):
627+
# Pass a sequence of names so that with_entry_dims will generate
628+
# integer coordinates.
629+
dim_info = list(self._entry_dims.keys())
630+
elif extra_dims is None:
631+
# Allowed to attach coordinates to a subset of existing dimensions
632+
if missing := set(extra_coords) - set(self._entry_dims):
633+
raise ValueError(f"No dimensions named {missing}")
634+
dim_info = {k: extra_coords.get(k, v)
635+
for (k, v) in self._entry_dims.items()}
636+
else: # Both specified
637+
dim_info = {n: extra_coords.get(n, None) for n in extra_dims}
638+
639+
return self.with_entry_dims(dim_info, roi=roi)._wrap_xarray(arr, name=name)
640+
641+
roi = _expand_ellipsis(len(self.entry_shape), self._roi)
642+
dims = ['trainId'] + [
643+
n for n, sel in zip(self._entry_dims, roi) if not isinstance(sel, int)
644+
]
628645

629-
if not isinstance(coord, int):
630-
dims.append(dim)
631-
else:
632-
dims += ['dim_%d' % i for i in range(ndarr.ndim - 1)]
646+
coords = {'trainId': self.train_id_coordinates()} | {
647+
k: v for (k, v) in self._entry_dims.items() if (v is not None)
648+
}
633649

634650
# xarray attributes
635651
attrs = {}
@@ -639,11 +655,12 @@ def _dim_name(idx):
639655
except Exception as e:
640656
warn(f"Exception fetching units: {e}")
641657

642-
if ndarr.dtype.names is not None:
658+
if arr.dtype.names is not None:
643659
# Structured dtype.
644660
return xarray.Dataset(
645-
{field: (dims, ndarr[field]) for field in ndarr.dtype.names},
646-
coords=coords, attrs=attrs)
661+
{field: (dims, arr[field]) for field in arr.dtype.names},
662+
coords=coords, attrs=attrs
663+
)
647664
else:
648665
if name is None:
649666
name = f'{self.source}.{self.key}'
@@ -653,7 +670,8 @@ def _dim_name(idx):
653670

654671
# Primitive dtype.
655672
return xarray.DataArray(
656-
ndarr, dims=dims, coords=coords, name=name, attrs=attrs)
673+
arr, dims=dims, coords=coords, name=name, attrs=attrs
674+
)
657675

658676
def series(self):
659677
"""Load this data as a pandas Series. Only for 1D data.
@@ -671,7 +689,8 @@ def series(self):
671689
data = self.ndarray()
672690
return pd.Series(data, name=name, index=index)
673691

674-
def dask_array(self, labelled=False):
692+
def dask_array(self, labelled=False, *, roi=(), name=None, extra_dims=None,
693+
extra_coords=None):
675694
"""Make a Dask array for this data.
676695
677696
Dask is a system for lazy parallel computation. This method doesn't
@@ -691,6 +710,26 @@ def dask_array(self, labelled=False):
691710
labelled: bool
692711
If True, label the train IDs for the data, returning an
693712
xarray.DataArray object wrapping a Dask array.
713+
roi: numpy.s_[], slice, or tuple of slices
714+
The region of interest. This expression selects data in all
715+
dimensions apart from the first (trains) dimension. If the data
716+
holds a 1D array for each entry, roi=np.s_[:8] would get the first 8
717+
values from every train. If the data is 2D or more at each entry,
718+
selection looks like roi=np.s_[:8, 5:10] .
719+
name: str
720+
Name the array itself. The default is the source and key joined by a
721+
dot. Ignored if labelled is False, and for structured data when a
722+
dataset is returned.
723+
extra_dims: list of str
724+
Name extra dimensions in the array. The first dimension is
725+
automatically called 'train'. The default for extra dimensions is
726+
dim_0, dim_1, ... Ignored if labelled is False.
727+
extra_coords: bool or dict
728+
Add coordinates to the returned DataArray. If roi is used, the
729+
coordinates will match the selected region of interest. If a dict is
730+
given, it should map dimension names to coordinate arrays. If True,
731+
default coordinate arrays will be generated. Ignored if labelled is
732+
False.
694733
"""
695734
import dask.array as da
696735

@@ -725,17 +764,79 @@ def dask_array(self, labelled=False):
725764
shape = (0,) + self.entry_shape
726765
dask_arr = da.zeros(shape=shape, dtype=self.dtype, chunks=shape)
727766

767+
dask_arr = dask_arr[:, *roi]
768+
728769
if labelled:
729-
# Dimension labels
730-
dims = ['trainId'] + ['dim_%d' % i for i in range(dask_arr.ndim - 1)]
770+
return self._wrap_xarray(
771+
dask_arr,
772+
extra_dims=extra_dims,
773+
roi=roi, name=name,
774+
extra_coords=extra_coords
775+
)
776+
else:
777+
return dask_arr
778+
779+
def with_entry_dims(self, dims=None, roi=None, **kwargs):
780+
"""Attach dimension names, ROI and optional coordinate labels
731781
732-
# Train ID index
733-
coords = {'trainId': self.train_id_coordinates()}
782+
Parameters
783+
----------
734784
735-
import xarray
736-
return xarray.DataArray(dask_arr, dims=dims, coords=coords)
785+
dims: list or dict
786+
Either a list of dimension names, or a dictionary mapping dimension
787+
names to coordinate labels. Every dimension except for the trains/
788+
entries dimension must be named, but coordinate labels are optional.
789+
Uses None in the dict format to omit coordinate labels. If a ROI
790+
is specified, coordinate labels should be only for that selection.
791+
roi: numpy.s_[], slice, or tuple of slices
792+
The region of interest. This expression selects data in all
793+
dimensions apart from the first (trains) dimension. If the data
794+
holds a 1D array for each entry, roi=np.s_[:8] would get the first 8
795+
values from every train. If the data is 2D or more at each entry,
796+
selection looks like roi=np.s_[:8, 5:10] .
797+
kwargs:
798+
The dictionary format for dims can be passed as keyword arguments
799+
instead. Don't mix keyword arguments with a dims list/dict.
800+
"""
801+
if not isinstance(roi, tuple):
802+
roi = roi,
803+
804+
if dims is None:
805+
if not kwargs:
806+
raise TypeError("No dimension information specified")
807+
coords = kwargs
808+
elif kwargs:
809+
raise TypeError("Mixing positional and keyword arguments is not supported")
810+
elif isinstance(dims, str):
811+
coords = {dims: None}
812+
elif isinstance(dims, Sequence):
813+
# If we are given names, generate integer indexes
814+
coords = {name: coord for (name, coord) in
815+
zip(dims, expand_indexing(self.entry_shape, roi))}
816+
elif isinstance(dims, dict):
817+
coords = dims
737818
else:
738-
return dask_arr
819+
raise TypeError(
820+
f"Unexpected type for dimension/coordinate info: {type(dims)}"
821+
)
822+
823+
if len(coords) != len(self.entry_shape):
824+
raise TypeError(
825+
f"Expected names for {len(self.entry_shape)} dimensions, got {len(coords)}"
826+
)
827+
828+
return KeyData(
829+
self.source, self.key,
830+
train_ids=self.train_ids,
831+
files=self.files,
832+
section=self.section,
833+
dtype=self.dtype,
834+
eshape=self.entry_shape,
835+
edims=coords,
836+
roi=roi,
837+
inc_suspect_trains=self.inc_suspect_trains,
838+
)
839+
739840

740841
# Getting data by train: --------------------------------------------------
741842

0 commit comments

Comments
 (0)