|
11 | 11 | import os |
12 | 12 | import sys |
13 | 13 | 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 |
14 | 19 |
|
15 | 20 |
|
16 | 21 | def available_cpu_cores(): |
@@ -49,3 +54,111 @@ def isinstance_no_import(obj, mod: str, cls: str): |
49 | 54 | return False |
50 | 55 |
|
51 | 56 | 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