Skip to content

Commit a36547f

Browse files
committed
Add utility function to efficiently unstack xarrays
1 parent 74a55c7 commit a36547f

2 files changed

Lines changed: 116 additions & 7 deletions

File tree

extra_data/components.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .exceptions import SourceNameError
1414
from .reader import DataCollection, by_id, by_index
1515
from .read_machinery import DataChunk, roi_shape, split_trains
16-
from .utils import default_num_threads
16+
from .utils import default_num_threads, unstack_regular
1717
from .writer import FileWriter
1818
from .write_cxi import XtdfCXIWriter, JUNGFRAUCXIWriter
1919

@@ -1342,10 +1342,7 @@ def xarray(self, *, pulses=None, fill_value=None, roi=(), astype=None,
13421342
out = self._wrap_xarray(arr, subtrain_index)
13431343

13441344
if unstack_pulses:
1345-
# Separate train & pulse dimensions, and arrange dimensions
1346-
# so that the data is contiguous in memory.
1347-
dim_order = ['module'] + out.indexes['train_pulse'].names + self.dimensions[2:]
1348-
return out.unstack('train_pulse').transpose(*dim_order)
1345+
return unstack_regular(out, 'train_pulse', fallback=True)
13491346

13501347
return out
13511348

@@ -1600,8 +1597,7 @@ def _get_pulse_data(self, source, key, tid):
16001597

16011598
# Separate train & pulse dimensions, and arrange dimensions
16021599
# so that the data is contiguous in memory.
1603-
dim_order = train_pulse_ids.names + dims[1:]
1604-
return arr.unstack('train_pulse').transpose(*dim_order)
1600+
return unstack_regular(arr, 'train_pulse', fallback=True)
16051601

16061602
def _select_pulse_ids(self, pulse_ids):
16071603
"""Select pulses by ID

extra_data/utils.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
import os
1212
import sys
1313
from shutil import get_terminal_size
14+
from typing import TYPE_CHECKING
15+
16+
import numpy as np
17+
if TYPE_CHECKING:
18+
import pandas
1419

1520

1621
def available_cpu_cores():
@@ -49,3 +54,111 @@ def isinstance_no_import(obj, mod: str, cls: str):
4954
return False
5055

5156
return isinstance(obj, getattr(m, cls))
57+
58+
59+
def _multiindex_regular_labels(mix: "pandas.MultiIndex"):
60+
"""Return a tuple of indexes if mix is a cartesian product, else None"""
61+
import pandas as pd
62+
if mix.has_duplicates:
63+
return None
64+
65+
k1_sel, k1_subix = mix.get_loc_level(mix[0][0])
66+
rpt_len = len(k1_subix)
67+
rpt, rem = divmod(len(mix), rpt_len)
68+
if rem != 0:
69+
return None
70+
71+
if isinstance(k1_subix, pd.MultiIndex):
72+
inner_labels = _multiindex_regular_labels(k1_subix)
73+
if inner_labels is None:
74+
return None
75+
else:
76+
inner_labels = (k1_subix,)
77+
78+
# Check that the outermost level has each value rpt_len times
79+
outer_codes = mix.codes[0].reshape(-1, rpt_len)
80+
if (outer_codes != outer_codes[:, 0, np.newaxis]).any():
81+
return None
82+
83+
# Check that each inner level is repeating regularly
84+
for level in range(1, mix.nlevels):
85+
codes = mix.codes[level].reshape(-1, rpt_len)
86+
if (codes != codes[0]).any():
87+
return None
88+
89+
outer_labels = mix.levels[0][outer_codes[:, 0]]
90+
return (outer_labels,) + inner_labels
91+
92+
93+
def _unstack_regular_once(arr, dim, fallback=False, fill_value=None):
94+
import pandas as pd
95+
import xarray as xr
96+
97+
mix = arr.indexes[dim]
98+
assert isinstance(mix, pd.MultiIndex)
99+
if (mix_labels := _multiindex_regular_labels(mix)) is None:
100+
# Not a cartesian product -> cannot reshape
101+
if fallback:
102+
return _unstack_fallback(arr, dim, fill_value)
103+
raise ValueError(f"MultiIndex for {dim!r} is not a cartesian product")
104+
105+
mix_shape = tuple(len(l) for l in mix_labels)
106+
dim_ix = arr.dims.index(dim)
107+
new_shape = arr.shape[:dim_ix] + mix_shape + arr.shape[dim_ix + 1:]
108+
data = arr.values.reshape(new_shape)
109+
110+
coords = {
111+
k: v for (k, v) in arr.coords.items() if dim not in v.dims # Unchanged
112+
} | dict(
113+
zip(mix.names, mix_labels) # Unstacked coordinates
114+
) | {
115+
# Other coordinates along unstacked dimension
116+
k: (mix.names, v.values.reshape(mix_shape)) for (k, v) in arr.coords.items()
117+
if (dim in v.dims and k != dim and k not in mix.names)
118+
}
119+
120+
return xr.DataArray(
121+
data,
122+
dims=arr.dims[:dim_ix] + mix.names + arr.dims[dim_ix + 1:],
123+
coords=coords,
124+
)
125+
126+
127+
def _unstack_fallback(arr, dim, fill_value=None):
128+
from xarray.core.dtypes import NA
129+
if fill_value is None:
130+
fill_value = NA
131+
132+
res = arr.unstack(dim, fill_value=fill_value)
133+
134+
# Restore the obvious axis order
135+
dim_ix = arr.dims.index(dim)
136+
new_dims = res.dims[arr.ndim:]
137+
return res.transpose(arr.dims[:dim_ix] + new_dims + arr.dims[dim_ix + 1:])
138+
139+
140+
def unstack_regular(arr, dim=None, *, fallback=False, fill_value=None):
141+
"""Unstack an xarray.DataArray efficiently when no fill values are needed.
142+
143+
Where the stacked index is a full cartesian product, we can make a view of
144+
the original data instead of copying it, which is much more efficient. In
145+
this case, we also don't have to convert integers to floats to allow for
146+
NaN values.
147+
148+
If ``fallback=True``, this also accepts arrays where fill values are needed,
149+
and uses xarray's implementation. Otherwise, it raises ValueError if
150+
unstacking would require inserting fill values.
151+
152+
The unstacked dimensions are expanded in-place in the dimension order,
153+
rather than being moved to the end.
154+
"""
155+
import pandas as pd
156+
157+
if dim is None:
158+
dim = [d for d in arr.dims if isinstance(arr.indexes.get(d), pd.MultiIndex)]
159+
if isinstance(dim, str):
160+
dim = [dim]
161+
for d in dim:
162+
arr = _unstack_regular_once(arr, d, fallback, fill_value)
163+
164+
return arr

0 commit comments

Comments
 (0)