Repository: pydata/xarray

Issue:
Allow `.chunk` for datasets with duplicated dimension names, e.g. Sentinel-3 OLCI files
### What is your issue?

Sentinel-3 OLCI files (e.g. taken from Copernicus Data Space Ecosystem) come with duplicate dimensions which causes xarray `2023.12.0` to raise after https://github.com/pydata/xarray/pull/8491. Specifically `instrument_data.nc` cannot be opened anymore:
```python
import xarray as xr

dataset = xr.open_dataset("instrument_data.nc", decode_cf=True, mask_and_scale=True, chunks="auto")
```
Results in the now expected ValueError:
```
ValueError: This function cannot handle duplicate dimensions, but dimensions {'bands'} appear more than once on this object's dims: ('bands', 'bands')
```
`ncdump -h` prints:
```
netcdf instrument_data {
dimensions:
	bands = 21 ;
	columns = 4865 ;
	detectors = 3700 ;
	rows = 1953 ;
variables:
	float FWHM(bands, detectors) ;
		FWHM:_FillValue = -1.f ;
		FWHM:ancillary_variables = "detector_index lambda0" ;
		FWHM:long_name = "OLCI bandwidth (Full Widths at Half Maximum)" ;
		FWHM:units = "nm" ;
		FWHM:valid_max = 650.f ;
		FWHM:valid_min = 0.f ;
	short detector_index(rows, columns) ;
		detector_index:_FillValue = -1s ;
		detector_index:coordinates = "time_stamp altitude latitude longitude" ;
		detector_index:long_name = "Detector index" ;
		detector_index:valid_max = 3699s ;
		detector_index:valid_min = 0s ;
	byte frame_offset(rows, columns) ;
		frame_offset:_FillValue = -128b ;
		frame_offset:long_name = "Re-sampling along-track frame offset" ;
		frame_offset:valid_max = 15b ;
		frame_offset:valid_min = -15b ;
	float lambda0(bands, detectors) ;
		lambda0:_FillValue = -1.f ;
		lambda0:ancillary_variables = "detector_index FWHM" ;
		lambda0:long_name = "OLCI characterised central wavelength" ;
		lambda0:units = "nm" ;
		lambda0:valid_max = 1040.f ;
		lambda0:valid_min = 390.f ;
	float relative_spectral_covariance(bands, bands) ;
		relative_spectral_covariance:_FillValue = NaNf ;
		relative_spectral_covariance:ancillary_variables = "lambda0" ;
		relative_spectral_covariance:long_name = "Relative spectral covariance matrix" ;
	float solar_flux(bands, detectors) ;
		solar_flux:_FillValue = -1.f ;
		solar_flux:ancillary_variables = "detector_index lambda0" ;
		solar_flux:long_name = "In-band solar irradiance, seasonally corrected" ;
		solar_flux:units = "mW.m-2.nm-1" ;
		solar_flux:valid_max = 2500.f ;
		solar_flux:valid_min = 500.f ;

// global attributes:
		:absolute_orbit_number = 29437U ;
		:ac_subsampling_factor = 64US ;
		:al_subsampling_factor = 1US ;
		:comment = " " ;
		:contact = "eosupport@copernicus.esa.int" ;
		:creation_time = "2023-12-20T07:20:24Z" ;
		:history = "  2023-12-20T07:20:24Z: PUGCoreProcessor JobOrder.3302865.xml" ;
		:institution = "PS2" ;
		:netCDF_version = "4.2 of Jan 13 2023 10:05:23 $" ;
		:processing_baseline = "OL__L1_.003.03.01" ;
		:product_name = "S3B_OL_1_EFR____20231220T045944_20231220T050110_20231220T072024_0085_087_290_1980_PS2_O_NR_003.SEN3" ;
		:references = "S3IPF PDS 004.1 - i2r6 - Product Data Format Specification - OLCI Level 1, S3IPF PDS 002 - i1r8 - Product Data Format Specification - Product Structures, S3IPF DPM 002 - i2r9 - Detailed Processing Model - OLCI Level 1" ;
		:resolution = "[ 270 294 ]" ;
		:source = "IPF-OL-1-EO 06.17" ;
		:start_time = "2023-12-20T04:59:43.719978Z" ;
		:stop_time = "2023-12-20T05:01:09.611725Z" ;
		:title = "OLCI Level 1b Product, Instrument Data Set" ;
}
```

The `relative_spectral_covariance` variable has duplicate dimensions. What do you suggest doing in such cases? 

I guess this is related to https://github.com/pydata/xarray/issues/1378.

Discussion:

Patch:
diff --git a/.github/workflows/ci-additional.yaml b/.github/workflows/ci-additional.yaml
index d8f2b99a..1fc58c4f 100644
--- a/.github/workflows/ci-additional.yaml
+++ b/.github/workflows/ci-additional.yaml
@@ -130,7 +130,7 @@ jobs:
           python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report xarray/
 
       - name: Upload mypy coverage to Codecov
-        uses: codecov/codecov-action@v4.4.1
+        uses: codecov/codecov-action@v4.5.0
         with:
           file: mypy_report/cobertura.xml
           flags: mypy
@@ -184,7 +184,7 @@ jobs:
           python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report xarray/
 
       - name: Upload mypy coverage to Codecov
-        uses: codecov/codecov-action@v4.4.1
+        uses: codecov/codecov-action@v4.5.0
         with:
           file: mypy_report/cobertura.xml
           flags: mypy39
@@ -245,7 +245,7 @@ jobs:
           python -m pyright xarray/
 
       - name: Upload pyright coverage to Codecov
-        uses: codecov/codecov-action@v4.4.1
+        uses: codecov/codecov-action@v4.5.0
         with:
           file: pyright_report/cobertura.xml
           flags: pyright
@@ -304,7 +304,7 @@ jobs:
           python -m pyright xarray/
 
       - name: Upload pyright coverage to Codecov
-        uses: codecov/codecov-action@v4.4.1
+        uses: codecov/codecov-action@v4.5.0
         with:
           file: pyright_report/cobertura.xml
           flags: pyright39
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2975ba88..6f9ba4a4 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -159,7 +159,7 @@ jobs:
           path: pytest.xml
 
       - name: Upload code coverage to Codecov
-        uses: codecov/codecov-action@v4.4.1
+        uses: codecov/codecov-action@v4.5.0
         with:
           file: ./coverage.xml
           flags: unittests
diff --git a/.github/workflows/pypi-release.yaml b/.github/workflows/pypi-release.yaml
index e7aec6e8..d50fff22 100644
--- a/.github/workflows/pypi-release.yaml
+++ b/.github/workflows/pypi-release.yaml
@@ -88,7 +88,7 @@ jobs:
           path: dist
       - name: Publish package to TestPyPI
         if: github.event_name == 'push'
-        uses: pypa/gh-action-pypi-publish@v1.8.14
+        uses: pypa/gh-action-pypi-publish@v1.9.0
         with:
           repository_url: https://test.pypi.org/legacy/
           verbose: true
@@ -111,6 +111,6 @@ jobs:
           name: releases
           path: dist
       - name: Publish package to PyPI
-        uses: pypa/gh-action-pypi-publish@v1.8.14
+        uses: pypa/gh-action-pypi-publish@v1.9.0
         with:
           verbose: true
diff --git a/.github/workflows/upstream-dev-ci.yaml b/.github/workflows/upstream-dev-ci.yaml
index e2fbabf3..4bc1b693 100644
--- a/.github/workflows/upstream-dev-ci.yaml
+++ b/.github/workflows/upstream-dev-ci.yaml
@@ -146,7 +146,7 @@ jobs:
         run: |
           python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report
       - name: Upload mypy coverage to Codecov
-        uses: codecov/codecov-action@v4.4.1
+        uses: codecov/codecov-action@v4.5.0
         with:
           file: mypy_report/cobertura.xml
           flags: mypy
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 7ec6e08e..e7a48458 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -22,7 +22,8 @@ v2024.06.1 (unreleased)
 
 New Features
 ~~~~~~~~~~~~
-
+- Allow chunking for arrays with duplicated dimension names (:issue:`8759`, :pull:`9099`).
+  By `Martin Raspaud <https://github.com/mraspaud>`_.
 
 Breaking changes
 ~~~~~~~~~~~~~~~~
@@ -73,7 +74,6 @@ Bug fixes
   support arbitrary kwargs such as ``order`` for polynomial interpolation (:issue:`8762`).
   By `Nicolas Karasiak <https://github.com/nkarasiak>`_.
 
-
 Documentation
 ~~~~~~~~~~~~~
 - Add link to CF Conventions on packed data and sentence on type determination in the I/O user guide (:issue:`9041`, :pull:`9045`).
diff --git a/xarray/backends/api.py b/xarray/backends/api.py
index 4b7f1052..ea3639db 100644
--- a/xarray/backends/api.py
+++ b/xarray/backends/api.py
@@ -863,7 +863,8 @@ def open_mfdataset(
         In general, these should divide the dimensions of each dataset. If int, chunk
         each dimension by ``chunks``. By default, chunks will be chosen to load entire
         input files into memory at once. This has a major impact on performance: please
-        see the full documentation for more details [2]_.
+        see the full documentation for more details [2]_. This argument is evaluated
+        on a per-file basis, so chunk sizes that span multiple files will be ignored.
     concat_dim : str, DataArray, Index or a Sequence of these or None, optional
         Dimensions to concatenate files along.  You only need to provide this argument
         if ``combine='nested'``, and if any of the dimensions along which you want to
diff --git a/xarray/core/common.py b/xarray/core/common.py
index 936a6bb2..28ffc80e 100644
--- a/xarray/core/common.py
+++ b/xarray/core/common.py
@@ -1067,7 +1067,8 @@ class DataWithCoords(AttrAccessMixin):
         # TODO support non-string indexer after removing the old API.
 
         from xarray.core.dataarray import DataArray
-        from xarray.core.groupby import ResolvedGrouper, TimeResampler
+        from xarray.core.groupby import ResolvedGrouper
+        from xarray.core.groupers import TimeResampler
         from xarray.core.resample import RESAMPLE_DIM
 
         # note: the second argument (now 'skipna') use to be 'dim'
diff --git a/xarray/core/computation.py b/xarray/core/computation.py
index f09b04b7..f418d382 100644
--- a/xarray/core/computation.py
+++ b/xarray/core/computation.py
@@ -1142,6 +1142,8 @@ def apply_ufunc(
     dask.array.apply_gufunc
     xarray.map_blocks
 
+    Notes
+    -----
     :ref:`dask.automatic-parallelization`
         User guide describing :py:func:`apply_ufunc` and :py:func:`map_blocks`.
 
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index 16b93303..d3390d26 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -6751,9 +6751,9 @@ class DataArray(
         from xarray.core.groupby import (
             DataArrayGroupBy,
             ResolvedGrouper,
-            UniqueGrouper,
             _validate_groupby_squeeze,
         )
+        from xarray.core.groupers import UniqueGrouper
 
         _validate_groupby_squeeze(squeeze)
         rgrouper = ResolvedGrouper(UniqueGrouper(), group, self)
@@ -6833,11 +6833,11 @@ class DataArray(
         .. [1] http://pandas.pydata.org/pandas-docs/stable/generated/pandas.cut.html
         """
         from xarray.core.groupby import (
-            BinGrouper,
             DataArrayGroupBy,
             ResolvedGrouper,
             _validate_groupby_squeeze,
         )
+        from xarray.core.groupers import BinGrouper
 
         _validate_groupby_squeeze(squeeze)
         grouper = BinGrouper(
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index 872cb482..0923c5d4 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -10301,9 +10301,9 @@ class Dataset(
         from xarray.core.groupby import (
             DatasetGroupBy,
             ResolvedGrouper,
-            UniqueGrouper,
             _validate_groupby_squeeze,
         )
+        from xarray.core.groupers import UniqueGrouper
 
         _validate_groupby_squeeze(squeeze)
         rgrouper = ResolvedGrouper(UniqueGrouper(), group, self)
@@ -10384,11 +10384,11 @@ class Dataset(
         .. [1] http://pandas.pydata.org/pandas-docs/stable/generated/pandas.cut.html
         """
         from xarray.core.groupby import (
-            BinGrouper,
             DatasetGroupBy,
             ResolvedGrouper,
             _validate_groupby_squeeze,
         )
+        from xarray.core.groupers import BinGrouper
 
         _validate_groupby_squeeze(squeeze)
         grouper = BinGrouper(
diff --git a/xarray/core/groupby.py b/xarray/core/groupby.py
index 5966c32d..eceb4e62 100644
--- a/xarray/core/groupby.py
+++ b/xarray/core/groupby.py
@@ -1,9 +1,7 @@
 from __future__ import annotations
 
 import copy
-import datetime
 import warnings
-from abc import ABC, abstractmethod
 from collections.abc import Hashable, Iterator, Mapping, Sequence
 from dataclasses import dataclass, field
 from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Union
@@ -12,7 +10,6 @@ import numpy as np
 import pandas as pd
 from packaging.version import Version
 
-from xarray.coding.cftime_offsets import _new_to_legacy_freq
 from xarray.core import dtypes, duck_array_ops, nputils, ops
 from xarray.core._aggregations import (
     DataArrayGroupByAggregations,
@@ -26,7 +23,6 @@ from xarray.core.formatting import format_array_flat
 from xarray.core.indexes import (
     create_default_index_implicit,
     filter_indexes_from_coords,
-    safe_cast_to_index,
 )
 from xarray.core.options import OPTIONS, _get_keep_attrs
 from xarray.core.types import (
@@ -55,14 +51,10 @@ if TYPE_CHECKING:
 
     from xarray.core.dataarray import DataArray
     from xarray.core.dataset import Dataset
-    from xarray.core.resample_cftime import CFTimeGrouper
-    from xarray.core.types import DatetimeLike, SideOptions
+    from xarray.core.groupers import Grouper
+    from xarray.core.types import GroupIndex, GroupKey, T_GroupIndices
     from xarray.core.utils import Frozen
 
-    GroupKey = Any
-    GroupIndex = Union[int, slice, list[int]]
-    T_GroupIndices = list[GroupIndex]
-
 
 def check_reduce_dims(reduce_dims, dimensions):
     if reduce_dims is not ...:
@@ -79,6 +71,8 @@ def check_reduce_dims(reduce_dims, dimensions):
 def _maybe_squeeze_indices(
     indices, squeeze: bool | None, grouper: ResolvedGrouper, warn: bool
 ):
+    from xarray.core.groupers import UniqueGrouper
+
     is_unique_grouper = isinstance(grouper.grouper, UniqueGrouper)
     can_squeeze = is_unique_grouper and grouper.grouper.can_squeeze
     if squeeze in [None, True] and can_squeeze:
@@ -95,32 +89,6 @@ def _maybe_squeeze_indices(
     return indices
 
 
-def unique_value_groups(
-    ar, sort: bool = True
-) -> tuple[np.ndarray | pd.Index, np.ndarray]:
-    """Group an array by its unique values.
-
-    Parameters
-    ----------
-    ar : array-like
-        Input array. This will be flattened if it is not already 1-D.
-    sort : bool, default: True
-        Whether or not to sort unique values.
-
-    Returns
-    -------
-    values : np.ndarray
-        Sorted, unique values as returned by `np.unique`.
-    indices : list of lists of int
-        Each element provides the integer indices in `ar` with values given by
-        the corresponding value in `unique_values`.
-    """
-    inverse, values = pd.factorize(ar, sort=sort)
-    if isinstance(values, pd.MultiIndex):
-        values.names = ar.names
-    return values, inverse
-
-
 def _codes_to_group_indices(inverse: np.ndarray, N: int) -> T_GroupIndices:
     assert inverse.ndim == 1
     groups: T_GroupIndices = [[] for _ in range(N)]
@@ -308,96 +276,6 @@ def _ensure_1d(group: T_Group, obj: T_DataWithCoords) -> tuple[
     )
 
 
-def _apply_loffset(
-    loffset: str | pd.DateOffset | datetime.timedelta | pd.Timedelta,
-    result: pd.Series | pd.DataFrame,
-):
-    """
-    (copied from pandas)
-    if loffset is set, offset the result index
-
-    This is NOT an idempotent routine, it will be applied
-    exactly once to the result.
-
-    Parameters
-    ----------
-    result : Series or DataFrame
-        the result of resample
-    """
-    # pd.Timedelta is a subclass of datetime.timedelta so we do not need to
-    # include it in instance checks.
-    if not isinstance(loffset, (str, pd.DateOffset, datetime.timedelta)):
-        raise ValueError(
-            f"`loffset` must be a str, pd.DateOffset, datetime.timedelta, or pandas.Timedelta object. "
-            f"Got {loffset}."
-        )
-
-    if isinstance(loffset, str):
-        loffset = pd.tseries.frequencies.to_offset(loffset)
-
-    needs_offset = (
-        isinstance(loffset, (pd.DateOffset, datetime.timedelta))
-        and isinstance(result.index, pd.DatetimeIndex)
-        and len(result.index) > 0
-    )
-
-    if needs_offset:
-        result.index = result.index + loffset
-
-
-class Grouper(ABC):
-    """Base class for Grouper objects that allow specializing GroupBy instructions."""
-
-    @property
-    def can_squeeze(self) -> bool:
-        """TODO: delete this when the `squeeze` kwarg is deprecated. Only `UniqueGrouper`
-        should override it."""
-        return False
-
-    @abstractmethod
-    def factorize(self, group) -> EncodedGroups:
-        """
-        Takes the group, and creates intermediates necessary for GroupBy.
-        These intermediates are
-        1. codes - Same shape as `group` containing a unique integer code for each group.
-        2. group_indices - Indexes that let us index out the members of each group.
-        3. unique_coord - Unique groups present in the dataset.
-        4. full_index - Unique groups in the output. This differs from `unique_coord` in the
-           case of resampling and binning, where certain groups in the output are not present in
-           the input.
-        """
-        pass
-
-
-class Resampler(Grouper):
-    """Base class for Grouper objects that allow specializing resampling-type GroupBy instructions.
-    Currently only used for TimeResampler, but could be used for SpaceResampler in the future.
-    """
-
-    pass
-
-
-@dataclass
-class EncodedGroups:
-    """
-    Dataclass for storing intermediate values for GroupBy operation.
-    Returned by factorize method on Grouper objects.
-
-    Parameters
-    ----------
-    codes: integer codes for each group
-    full_index: pandas Index for the group coordinate
-    group_indices: optional, List of indices of array elements belonging
-                   to each group. Inferred if not provided.
-    unique_coord: Unique group values present in dataset. Inferred if not provided
-    """
-
-    codes: DataArray
-    full_index: pd.Index
-    group_indices: T_GroupIndices | None = field(default=None)
-    unique_coord: IndexVariable | _DummyGroup | None = field(default=None)
-
-
 @dataclass
 class ResolvedGrouper(Generic[T_DataWithCoords]):
     """
@@ -481,248 +359,12 @@ class ResolvedGrouper(Generic[T_DataWithCoords]):
         if encoded.unique_coord is None:
             unique_values = self.full_index[np.unique(encoded.codes)]
             self.unique_coord = IndexVariable(
-                self.group.name, unique_values, attrs=self.group.attrs
+                self.codes.name, unique_values, attrs=self.group.attrs
             )
         else:
             self.unique_coord = encoded.unique_coord
 
 
-@dataclass
-class UniqueGrouper(Grouper):
-    """Grouper object for grouping by a categorical variable."""
-
-    _group_as_index: pd.Index | None = None
-
-    @property
-    def is_unique_and_monotonic(self) -> bool:
-        if isinstance(self.group, _DummyGroup):
-            return True
-        index = self.group_as_index
-        return index.is_unique and index.is_monotonic_increasing
-
-    @property
-    def group_as_index(self) -> pd.Index:
-        if self._group_as_index is None:
-            self._group_as_index = self.group.to_index()
-        return self._group_as_index
-
-    @property
-    def can_squeeze(self) -> bool:
-        is_dimension = self.group.dims == (self.group.name,)
-        return is_dimension and self.is_unique_and_monotonic
-
-    def factorize(self, group1d) -> EncodedGroups:
-        self.group = group1d
-
-        if self.can_squeeze:
-            return self._factorize_dummy()
-        else:
-            return self._factorize_unique()
-
-    def _factorize_unique(self) -> EncodedGroups:
-        # look through group to find the unique values
-        sort = not isinstance(self.group_as_index, pd.MultiIndex)
-        unique_values, codes_ = unique_value_groups(self.group_as_index, sort=sort)
-        if (codes_ == -1).all():
-            raise ValueError(
-                "Failed to group data. Are you grouping by a variable that is all NaN?"
-            )
-        codes = self.group.copy(data=codes_)
-        unique_coord = IndexVariable(
-            self.group.name, unique_values, attrs=self.group.attrs
-        )
-        full_index = unique_coord
-
-        return EncodedGroups(
-            codes=codes, full_index=full_index, unique_coord=unique_coord
-        )
-
-    def _factorize_dummy(self) -> EncodedGroups:
-        size = self.group.size
-        # no need to factorize
-        # use slices to do views instead of fancy indexing
-        # equivalent to: group_indices = group_indices.reshape(-1, 1)
-        group_indices: T_GroupIndices = [slice(i, i + 1) for i in range(size)]
-        size_range = np.arange(size)
-        if isinstance(self.group, _DummyGroup):
-            codes = self.group.to_dataarray().copy(data=size_range)
-        else:
-            codes = self.group.copy(data=size_range)
-        unique_coord = self.group
-        full_index = IndexVariable(
-            self.group.name, unique_coord.values, self.group.attrs
-        )
-        return EncodedGroups(
-            codes=codes,
-            group_indices=group_indices,
-            full_index=full_index,
-            unique_coord=unique_coord,
-        )
-
-
-@dataclass
-class BinGrouper(Grouper):
-    """Grouper object for binning numeric data."""
-
-    bins: Any  # TODO: What is the typing?
-    cut_kwargs: Mapping = field(default_factory=dict)
-    binned: Any = None
-    name: Any = None
-
-    def __post_init__(self) -> None:
-        if duck_array_ops.isnull(self.bins).all():
-            raise ValueError("All bin edges are NaN.")
-
-    def factorize(self, group) -> EncodedGroups:
-        from xarray.core.dataarray import DataArray
-
-        data = group.data
-
-        binned, self.bins = pd.cut(data, self.bins, **self.cut_kwargs, retbins=True)
-
-        binned_codes = binned.codes
-        if (binned_codes == -1).all():
-            raise ValueError(
-                f"None of the data falls within bins with edges {self.bins!r}"
-            )
-
-        new_dim_name = f"{group.name}_bins"
-
-        full_index = binned.categories
-        uniques = np.sort(pd.unique(binned_codes))
-        unique_values = full_index[uniques[uniques != -1]]
-
-        codes = DataArray(
-            binned_codes, getattr(group, "coords", None), name=new_dim_name
-        )
-        unique_coord = IndexVariable(new_dim_name, pd.Index(unique_values), group.attrs)
-        return EncodedGroups(
-            codes=codes, full_index=full_index, unique_coord=unique_coord
-        )
-
-
-@dataclass
-class TimeResampler(Resampler):
-    """Grouper object specialized to resampling the time coordinate."""
-
-    freq: str
-    closed: SideOptions | None = field(default=None)
-    label: SideOptions | None = field(default=None)
-    origin: str | DatetimeLike = field(default="start_day")
-    offset: pd.Timedelta | datetime.timedelta | str | None = field(default=None)
-    loffset: datetime.timedelta | str | None = field(default=None)
-    base: int | None = field(default=None)
-
-    index_grouper: CFTimeGrouper | pd.Grouper = field(init=False)
-    group_as_index: pd.Index = field(init=False)
-
-    def __post_init__(self):
-        if self.loffset is not None:
-            emit_user_level_warning(
-                "Following pandas, the `loffset` parameter to resample is deprecated.  "
-                "Switch to updating the resampled dataset time coordinate using "
-                "time offset arithmetic.  For example:\n"
-                "    >>> offset = pd.tseries.frequencies.to_offset(freq) / 2\n"
-                '    >>> resampled_ds["time"] = resampled_ds.get_index("time") + offset',
-                FutureWarning,
-            )
-
-        if self.base is not None:
-            emit_user_level_warning(
-                "Following pandas, the `base` parameter to resample will be deprecated in "
-                "a future version of xarray.  Switch to using `origin` or `offset` instead.",
-                FutureWarning,
-            )
-
-        if self.base is not None and self.offset is not None:
-            raise ValueError("base and offset cannot be present at the same time")
-
-    def _init_properties(self, group: T_Group) -> None:
-        from xarray import CFTimeIndex
-        from xarray.core.pdcompat import _convert_base_to_offset
-
-        group_as_index = safe_cast_to_index(group)
-
-        if self.base is not None:
-            # grouper constructor verifies that grouper.offset is None at this point
-            offset = _convert_base_to_offset(self.base, self.freq, group_as_index)
-        else:
-            offset = self.offset
-
-        if not group_as_index.is_monotonic_increasing:
-            # TODO: sort instead of raising an error
-            raise ValueError("index must be monotonic for resampling")
-
-        if isinstance(group_as_index, CFTimeIndex):
-            from xarray.core.resample_cftime import CFTimeGrouper
-
-            index_grouper = CFTimeGrouper(
-                freq=self.freq,
-                closed=self.closed,
-                label=self.label,
-                origin=self.origin,
-                offset=offset,
-                loffset=self.loffset,
-            )
-        else:
-            index_grouper = pd.Grouper(
-                # TODO remove once requiring pandas >= 2.2
-                freq=_new_to_legacy_freq(self.freq),
-                closed=self.closed,
-                label=self.label,
-                origin=self.origin,
-                offset=offset,
-            )
-        self.index_grouper = index_grouper
-        self.group_as_index = group_as_index
-
-    def _get_index_and_items(self) -> tuple[pd.Index, pd.Series, np.ndarray]:
-        first_items, codes = self.first_items()
-        full_index = first_items.index
-        if first_items.isnull().any():
-            first_items = first_items.dropna()
-
-        full_index = full_index.rename("__resample_dim__")
-        return full_index, first_items, codes
-
-    def first_items(self) -> tuple[pd.Series, np.ndarray]:
-        from xarray import CFTimeIndex
-
-        if isinstance(self.group_as_index, CFTimeIndex):
-            return self.index_grouper.first_items(self.group_as_index)
-        else:
-            s = pd.Series(np.arange(self.group_as_index.size), self.group_as_index)
-            grouped = s.groupby(self.index_grouper)
-            first_items = grouped.first()
-            counts = grouped.count()
-            # This way we generate codes for the final output index: full_index.
-            # So for _flox_reduce we avoid one reindex and copy by avoiding
-            # _maybe_restore_empty_groups
-            codes = np.repeat(np.arange(len(first_items)), counts)
-            if self.loffset is not None:
-                _apply_loffset(self.loffset, first_items)
-            return first_items, codes
-
-    def factorize(self, group) -> EncodedGroups:
-        self._init_properties(group)
-        full_index, first_items, codes_ = self._get_index_and_items()
-        sbins = first_items.values.astype(np.int64)
-        group_indices: T_GroupIndices = [
-            slice(i, j) for i, j in zip(sbins[:-1], sbins[1:])
-        ]
-        group_indices += [slice(sbins[-1], None)]
-
-        unique_coord = IndexVariable(group.name, first_items.index, group.attrs)
-        codes = group.copy(data=codes_)
-
-        return EncodedGroups(
-            codes=codes,
-            group_indices=group_indices,
-            full_index=full_index,
-            unique_coord=unique_coord,
-        )
-
-
 def _validate_groupby_squeeze(squeeze: bool | None) -> None:
     # While we don't generally check the type of every arg, passing
     # multiple dimensions as multiple arguments is common enough, and the
@@ -1084,6 +726,8 @@ class GroupBy(Generic[T_Xarray]):
         """Our index contained empty groups (e.g., from a resampling or binning). If we
         reduced on that dimension, we want to restore the full index.
         """
+        from xarray.core.groupers import BinGrouper, TimeResampler
+
         (grouper,) = self.groupers
         if (
             isinstance(grouper.grouper, (BinGrouper, TimeResampler))
@@ -1118,6 +762,7 @@ class GroupBy(Generic[T_Xarray]):
         from flox.xarray import xarray_reduce
 
         from xarray.core.dataset import Dataset
+        from xarray.core.groupers import BinGrouper
 
         obj = self._original_obj
         (grouper,) = self.groupers
@@ -1513,14 +1158,8 @@ class DataArrayGroupByBase(GroupBy["DataArray"], DataArrayGroupbyArithmetic):
         (grouper,) = self.groupers
         group = grouper.group1d
 
-        groupby_coord = (
-            f"{group.name}_bins"
-            if isinstance(grouper.grouper, BinGrouper)
-            else group.name
-        )
-
         def lookup_order(dimension):
-            if dimension == groupby_coord:
+            if dimension == grouper.name:
                 (dimension,) = group.dims
             if dimension in self._obj.dims:
                 axis = self._obj.get_axis_num(dimension)
diff --git a/xarray/core/groupers.py b/xarray/core/groupers.py
new file mode 100644
index 00000000..e33cd3ad
--- /dev/null
+++ b/xarray/core/groupers.py
@@ -0,0 +1,392 @@
+"""
+This module provides Grouper objects that encapsulate the
+"factorization" process - conversion of value we are grouping by
+to integer codes (one per group).
+"""
+
+from __future__ import annotations
+
+import datetime
+from abc import ABC, abstractmethod
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+import numpy as np
+import pandas as pd
+
+from xarray.coding.cftime_offsets import _new_to_legacy_freq
+from xarray.core import duck_array_ops
+from xarray.core.dataarray import DataArray
+from xarray.core.groupby import T_Group, _DummyGroup
+from xarray.core.indexes import safe_cast_to_index
+from xarray.core.resample_cftime import CFTimeGrouper
+from xarray.core.types import DatetimeLike, SideOptions, T_GroupIndices
+from xarray.core.utils import emit_user_level_warning
+from xarray.core.variable import IndexVariable
+
+__all__ = [
+    "EncodedGroups",
+    "Grouper",
+    "Resampler",
+    "UniqueGrouper",
+    "BinGrouper",
+    "TimeResampler",
+]
+
+RESAMPLE_DIM = "__resample_dim__"
+
+
+@dataclass
+class EncodedGroups:
+    """
+    Dataclass for storing intermediate values for GroupBy operation.
+    Returned by factorize method on Grouper objects.
+
+    Parameters
+    ----------
+    codes: integer codes for each group
+    full_index: pandas Index for the group coordinate
+    group_indices: optional, List of indices of array elements belonging
+                   to each group. Inferred if not provided.
+    unique_coord: Unique group values present in dataset. Inferred if not provided
+    """
+
+    codes: DataArray
+    full_index: pd.Index
+    group_indices: T_GroupIndices | None = field(default=None)
+    unique_coord: IndexVariable | _DummyGroup | None = field(default=None)
+
+
+class Grouper(ABC):
+    """Base class for Grouper objects that allow specializing GroupBy instructions."""
+
+    @property
+    def can_squeeze(self) -> bool:
+        """TODO: delete this when the `squeeze` kwarg is deprecated. Only `UniqueGrouper`
+        should override it."""
+        return False
+
+    @abstractmethod
+    def factorize(self, group) -> EncodedGroups:
+        """
+        Takes the group, and creates intermediates necessary for GroupBy.
+        These intermediates are
+        1. codes - Same shape as `group` containing a unique integer code for each group.
+        2. group_indices - Indexes that let us index out the members of each group.
+        3. unique_coord - Unique groups present in the dataset.
+        4. full_index - Unique groups in the output. This differs from `unique_coord` in the
+           case of resampling and binning, where certain groups in the output are not present in
+           the input.
+
+        Returns an instance of EncodedGroups.
+        """
+        pass
+
+
+class Resampler(Grouper):
+    """Base class for Grouper objects that allow specializing resampling-type GroupBy instructions.
+    Currently only used for TimeResampler, but could be used for SpaceResampler in the future.
+    """
+
+    pass
+
+
+@dataclass
+class UniqueGrouper(Grouper):
+    """Grouper object for grouping by a categorical variable."""
+
+    _group_as_index: pd.Index | None = None
+
+    @property
+    def is_unique_and_monotonic(self) -> bool:
+        if isinstance(self.group, _DummyGroup):
+            return True
+        index = self.group_as_index
+        return index.is_unique and index.is_monotonic_increasing
+
+    @property
+    def group_as_index(self) -> pd.Index:
+        if self._group_as_index is None:
+            self._group_as_index = self.group.to_index()
+        return self._group_as_index
+
+    @property
+    def can_squeeze(self) -> bool:
+        """This is a deprecated method and will be removed eventually."""
+        is_dimension = self.group.dims == (self.group.name,)
+        return is_dimension and self.is_unique_and_monotonic
+
+    def factorize(self, group1d) -> EncodedGroups:
+        self.group = group1d
+
+        if self.can_squeeze:
+            return self._factorize_dummy()
+        else:
+            return self._factorize_unique()
+
+    def _factorize_unique(self) -> EncodedGroups:
+        # look through group to find the unique values
+        sort = not isinstance(self.group_as_index, pd.MultiIndex)
+        unique_values, codes_ = unique_value_groups(self.group_as_index, sort=sort)
+        if (codes_ == -1).all():
+            raise ValueError(
+                "Failed to group data. Are you grouping by a variable that is all NaN?"
+            )
+        codes = self.group.copy(data=codes_)
+        unique_coord = IndexVariable(
+            self.group.name, unique_values, attrs=self.group.attrs
+        )
+        full_index = unique_coord
+
+        return EncodedGroups(
+            codes=codes, full_index=full_index, unique_coord=unique_coord
+        )
+
+    def _factorize_dummy(self) -> EncodedGroups:
+        size = self.group.size
+        # no need to factorize
+        # use slices to do views instead of fancy indexing
+        # equivalent to: group_indices = group_indices.reshape(-1, 1)
+        group_indices: T_GroupIndices = [slice(i, i + 1) for i in range(size)]
+        size_range = np.arange(size)
+        if isinstance(self.group, _DummyGroup):
+            codes = self.group.to_dataarray().copy(data=size_range)
+        else:
+            codes = self.group.copy(data=size_range)
+        unique_coord = self.group
+        full_index = IndexVariable(
+            self.group.name, unique_coord.values, self.group.attrs
+        )
+        return EncodedGroups(
+            codes=codes,
+            group_indices=group_indices,
+            full_index=full_index,
+            unique_coord=unique_coord,
+        )
+
+
+@dataclass
+class BinGrouper(Grouper):
+    """Grouper object for binning numeric data."""
+
+    bins: int | Sequence | pd.IntervalIndex
+    cut_kwargs: Mapping = field(default_factory=dict)
+    binned: Any = None
+    name: Any = None
+
+    def __post_init__(self) -> None:
+        if duck_array_ops.isnull(self.bins).all():
+            raise ValueError("All bin edges are NaN.")
+
+    def factorize(self, group) -> EncodedGroups:
+        from xarray.core.dataarray import DataArray
+
+        data = group.data
+
+        binned, self.bins = pd.cut(data, self.bins, **self.cut_kwargs, retbins=True)
+
+        binned_codes = binned.codes
+        if (binned_codes == -1).all():
+            raise ValueError(
+                f"None of the data falls within bins with edges {self.bins!r}"
+            )
+
+        new_dim_name = f"{group.name}_bins"
+
+        full_index = binned.categories
+        uniques = np.sort(pd.unique(binned_codes))
+        unique_values = full_index[uniques[uniques != -1]]
+
+        codes = DataArray(
+            binned_codes, getattr(group, "coords", None), name=new_dim_name
+        )
+        unique_coord = IndexVariable(new_dim_name, pd.Index(unique_values), group.attrs)
+        return EncodedGroups(
+            codes=codes, full_index=full_index, unique_coord=unique_coord
+        )
+
+
+@dataclass
+class TimeResampler(Resampler):
+    """Grouper object specialized to resampling the time coordinate."""
+
+    freq: str
+    closed: SideOptions | None = field(default=None)
+    label: SideOptions | None = field(default=None)
+    origin: str | DatetimeLike = field(default="start_day")
+    offset: pd.Timedelta | datetime.timedelta | str | None = field(default=None)
+    loffset: datetime.timedelta | str | None = field(default=None)
+    base: int | None = field(default=None)
+
+    index_grouper: CFTimeGrouper | pd.Grouper = field(init=False)
+    group_as_index: pd.Index = field(init=False)
+
+    def __post_init__(self):
+        if self.loffset is not None:
+            emit_user_level_warning(
+                "Following pandas, the `loffset` parameter to resample is deprecated.  "
+                "Switch to updating the resampled dataset time coordinate using "
+                "time offset arithmetic.  For example:\n"
+                "    >>> offset = pd.tseries.frequencies.to_offset(freq) / 2\n"
+                '    >>> resampled_ds["time"] = resampled_ds.get_index("time") + offset',
+                FutureWarning,
+            )
+
+        if self.base is not None:
+            emit_user_level_warning(
+                "Following pandas, the `base` parameter to resample will be deprecated in "
+                "a future version of xarray.  Switch to using `origin` or `offset` instead.",
+                FutureWarning,
+            )
+
+        if self.base is not None and self.offset is not None:
+            raise ValueError("base and offset cannot be present at the same time")
+
+    def _init_properties(self, group: T_Group) -> None:
+        from xarray import CFTimeIndex
+        from xarray.core.pdcompat import _convert_base_to_offset
+
+        group_as_index = safe_cast_to_index(group)
+
+        if self.base is not None:
+            # grouper constructor verifies that grouper.offset is None at this point
+            offset = _convert_base_to_offset(self.base, self.freq, group_as_index)
+        else:
+            offset = self.offset
+
+        if not group_as_index.is_monotonic_increasing:
+            # TODO: sort instead of raising an error
+            raise ValueError("index must be monotonic for resampling")
+
+        if isinstance(group_as_index, CFTimeIndex):
+            from xarray.core.resample_cftime import CFTimeGrouper
+
+            index_grouper = CFTimeGrouper(
+                freq=self.freq,
+                closed=self.closed,
+                label=self.label,
+                origin=self.origin,
+                offset=offset,
+                loffset=self.loffset,
+            )
+        else:
+            index_grouper = pd.Grouper(
+                # TODO remove once requiring pandas >= 2.2
+                freq=_new_to_legacy_freq(self.freq),
+                closed=self.closed,
+                label=self.label,
+                origin=self.origin,
+                offset=offset,
+            )
+        self.index_grouper = index_grouper
+        self.group_as_index = group_as_index
+
+    def _get_index_and_items(self) -> tuple[pd.Index, pd.Series, np.ndarray]:
+        first_items, codes = self.first_items()
+        full_index = first_items.index
+        if first_items.isnull().any():
+            first_items = first_items.dropna()
+
+        full_index = full_index.rename("__resample_dim__")
+        return full_index, first_items, codes
+
+    def first_items(self) -> tuple[pd.Series, np.ndarray]:
+        from xarray import CFTimeIndex
+
+        if isinstance(self.group_as_index, CFTimeIndex):
+            return self.index_grouper.first_items(self.group_as_index)
+        else:
+            s = pd.Series(np.arange(self.group_as_index.size), self.group_as_index)
+            grouped = s.groupby(self.index_grouper)
+            first_items = grouped.first()
+            counts = grouped.count()
+            # This way we generate codes for the final output index: full_index.
+            # So for _flox_reduce we avoid one reindex and copy by avoiding
+            # _maybe_restore_empty_groups
+            codes = np.repeat(np.arange(len(first_items)), counts)
+            if self.loffset is not None:
+                _apply_loffset(self.loffset, first_items)
+            return first_items, codes
+
+    def factorize(self, group) -> EncodedGroups:
+        self._init_properties(group)
+        full_index, first_items, codes_ = self._get_index_and_items()
+        sbins = first_items.values.astype(np.int64)
+        group_indices: T_GroupIndices = [
+            slice(i, j) for i, j in zip(sbins[:-1], sbins[1:])
+        ]
+        group_indices += [slice(sbins[-1], None)]
+
+        unique_coord = IndexVariable(group.name, first_items.index, group.attrs)
+        codes = group.copy(data=codes_)
+
+        return EncodedGroups(
+            codes=codes,
+            group_indices=group_indices,
+            full_index=full_index,
+            unique_coord=unique_coord,
+        )
+
+
+def _apply_loffset(
+    loffset: str | pd.DateOffset | datetime.timedelta | pd.Timedelta,
+    result: pd.Series | pd.DataFrame,
+):
+    """
+    (copied from pandas)
+    if loffset is set, offset the result index
+
+    This is NOT an idempotent routine, it will be applied
+    exactly once to the result.
+
+    Parameters
+    ----------
+    result : Series or DataFrame
+        the result of resample
+    """
+    # pd.Timedelta is a subclass of datetime.timedelta so we do not need to
+    # include it in instance checks.
+    if not isinstance(loffset, (str, pd.DateOffset, datetime.timedelta)):
+        raise ValueError(
+            f"`loffset` must be a str, pd.DateOffset, datetime.timedelta, or pandas.Timedelta object. "
+            f"Got {loffset}."
+        )
+
+    if isinstance(loffset, str):
+        loffset = pd.tseries.frequencies.to_offset(loffset)
+
+    needs_offset = (
+        isinstance(loffset, (pd.DateOffset, datetime.timedelta))
+        and isinstance(result.index, pd.DatetimeIndex)
+        and len(result.index) > 0
+    )
+
+    if needs_offset:
+        result.index = result.index + loffset
+
+
+def unique_value_groups(
+    ar, sort: bool = True
+) -> tuple[np.ndarray | pd.Index, np.ndarray]:
+    """Group an array by its unique values.
+
+    Parameters
+    ----------
+    ar : array-like
+        Input array. This will be flattened if it is not already 1-D.
+    sort : bool, default: True
+        Whether or not to sort unique values.
+
+    Returns
+    -------
+    values : np.ndarray
+        Sorted, unique values as returned by `np.unique`.
+    indices : list of lists of int
+        Each element provides the integer indices in `ar` with values given by
+        the corresponding value in `unique_values`.
+    """
+    inverse, values = pd.factorize(ar, sort=sort)
+    if isinstance(values, pd.MultiIndex):
+        values.names = ar.names
+    return values, inverse
diff --git a/xarray/core/resample.py b/xarray/core/resample.py
index 9181bb4e..ceab0a89 100644
--- a/xarray/core/resample.py
+++ b/xarray/core/resample.py
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
     from xarray.core.dataarray import DataArray
     from xarray.core.dataset import Dataset
 
-RESAMPLE_DIM = "__resample_dim__"
+from xarray.core.groupers import RESAMPLE_DIM
 
 
 class Resample(GroupBy[T_Xarray]):
diff --git a/xarray/core/types.py b/xarray/core/types.py
index 41078d29..588d15dc 100644
--- a/xarray/core/types.py
+++ b/xarray/core/types.py
@@ -84,14 +84,20 @@ if TYPE_CHECKING:
         # anything with a dtype attribute
         _SupportsDType[np.dtype[Any]],
     ]
-    try:
-        from cftime import datetime as CFTimeDatetime
-    except ImportError:
-        CFTimeDatetime = Any
-    DatetimeLike = Union[pd.Timestamp, datetime.datetime, np.datetime64, CFTimeDatetime]
+
 else:
     DTypeLikeSave: Any = None
 
+# https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
+try:
+    from cftime import datetime as CFTimeDatetime
+except ImportError:
+    CFTimeDatetime = np.datetime64
+
+DatetimeLike: TypeAlias = Union[
+    pd.Timestamp, datetime.datetime, np.datetime64, CFTimeDatetime
+]
+
 
 class Alignable(Protocol):
     """Represents any Xarray type that supports alignment.
@@ -182,7 +188,8 @@ GroupByCompatible = Union["Dataset", "DataArray"]
 Dims = Union[str, Collection[Hashable], "ellipsis", None]
 
 # FYI in some cases we don't allow `None`, which this doesn't take account of.
-T_ChunkDim: TypeAlias = Union[int, Literal["auto"], None, tuple[int, ...]]
+# FYI the `str` is for a size string, e.g. "16MB", supported by dask.
+T_ChunkDim: TypeAlias = Union[str, int, Literal["auto"], None, tuple[int, ...]]
 # We allow the tuple form of this (though arguably we could transition to named dims only)
 T_Chunks: TypeAlias = Union[T_ChunkDim, Mapping[Any, T_ChunkDim]]
 T_NormalizedChunks = tuple[tuple[int, ...], ...]
@@ -283,3 +290,7 @@ QuantileMethods = Literal[
 
 NetcdfWriteModes = Literal["w", "a"]
 ZarrWriteModes = Literal["w", "w-", "a", "a-", "r+", "r"]
+
+GroupKey = Any
+GroupIndex = Union[int, slice, list[int]]
+T_GroupIndices = list[GroupIndex]
diff --git a/xarray/namedarray/core.py b/xarray/namedarray/core.py
index 960ab9d4..fe47bf50 100644
--- a/xarray/namedarray/core.py
+++ b/xarray/namedarray/core.py
@@ -812,7 +812,12 @@ class NamedArray(NamedArrayAggregations, Generic[_ShapeType_co, _DType_co]):
             chunks = either_dict_or_kwargs(chunks, chunks_kwargs, "chunk")
 
         if is_dict_like(chunks):
-            chunks = {self.get_axis_num(dim): chunk for dim, chunk in chunks.items()}
+            # This method of iteration allows for duplicated dimension names, GH8579
+            chunks = {
+                dim_number: chunks[dim]
+                for dim_number, dim in enumerate(self.dims)
+                if dim in chunks
+            }
 
         chunkmanager = guess_chunkmanager(chunked_array_type)
 


Test patch:
diff --git a/xarray/tests/test_dask.py b/xarray/tests/test_dask.py
index 517fc0c2..20491eca 100644
--- a/xarray/tests/test_dask.py
+++ b/xarray/tests/test_dask.py
@@ -337,13 +337,16 @@ class TestDataArrayAndDataset(DaskTestCase):
             self.data, coords={"x": range(4)}, dims=("x", "y"), name="foo"
         )
 
-    def test_chunk(self):
+    def test_chunk(self) -> None:
         for chunks, expected in [
             ({}, ((2, 2), (2, 2, 2))),
             (3, ((3, 1), (3, 3))),
             ({"x": 3, "y": 3}, ((3, 1), (3, 3))),
             ({"x": 3}, ((3, 1), (2, 2, 2))),
             ({"x": (3, 1)}, ((3, 1), (2, 2, 2))),
+            ({"x": "16B"}, ((1, 1, 1, 1), (2, 2, 2))),
+            ("16B", ((1, 1, 1, 1), (1,) * 6)),
+            ("16MB", ((4,), (6,))),
         ]:
             # Test DataArray
             rechunked = self.lazy_array.chunk(chunks)
@@ -635,6 +638,13 @@ class TestDataArrayAndDataset(DaskTestCase):
 
         assert count[0] == 1
 
+    def test_duplicate_dims(self):
+        data = np.random.normal(size=(4, 4))
+        arr = DataArray(data, dims=("x", "x"))
+        chunked_array = arr.chunk({"x": 2})
+        assert chunked_array.chunks == ((2, 2), (2, 2))
+        assert chunked_array.chunksizes == {"x": (2, 2)}
+
     def test_stack(self):
         data = da.random.normal(size=(2, 3, 4), chunks=(1, 3, 4))
         arr = DataArray(data, dims=("w", "x", "y"))
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index ece2ddf8..659c7c16 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -110,13 +110,14 @@ class TestDataArray:
         assert expected == repr(data_array)
 
     def test_repr_multiindex(self) -> None:
+        obj_size = np.dtype("O").itemsize
         expected = dedent(
-            """\
+            f"""\
             <xarray.DataArray (x: 4)> Size: 32B
             array([0, 1, 2, 3], dtype=uint64)
             Coordinates:
-              * x        (x) object 32B MultiIndex
-              * level_1  (x) object 32B 'a' 'a' 'b' 'b'
+              * x        (x) object {4 * obj_size}B MultiIndex
+              * level_1  (x) object {4 * obj_size}B 'a' 'a' 'b' 'b'
               * level_2  (x) int64 32B 1 2 1 2"""
         )
         assert expected == repr(self.mda)
@@ -129,15 +130,16 @@ class TestDataArray:
         mda_long = DataArray(
             list(range(32)), coords={"x": mindex_long}, dims="x"
         ).astype(np.uint64)
+        obj_size = np.dtype("O").itemsize
         expected = dedent(
-            """\
+            f"""\
             <xarray.DataArray (x: 32)> Size: 256B
             array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
                    17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31],
                   dtype=uint64)
             Coordinates:
-              * x        (x) object 256B MultiIndex
-              * level_1  (x) object 256B 'a' 'a' 'a' 'a' 'a' 'a' ... 'd' 'd' 'd' 'd' 'd' 'd'
+              * x        (x) object {32 * obj_size}B MultiIndex
+              * level_1  (x) object {32 * obj_size}B 'a' 'a' 'a' 'a' 'a' 'a' ... 'd' 'd' 'd' 'd' 'd' 'd'
               * level_2  (x) int64 256B 1 2 3 4 5 6 7 8 1 2 3 4 ... 5 6 7 8 1 2 3 4 5 6 7 8"""
         )
         assert expected == repr(mda_long)
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 81b27da8..f6829861 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -335,13 +335,14 @@ class TestDataset:
 
     def test_repr_multiindex(self) -> None:
         data = create_test_multiindex()
+        obj_size = np.dtype("O").itemsize
         expected = dedent(
-            """\
-            <xarray.Dataset> Size: 96B
+            f"""\
+            <xarray.Dataset> Size: {8 * obj_size + 32}B
             Dimensions:  (x: 4)
             Coordinates:
-              * x        (x) object 32B MultiIndex
-              * level_1  (x) object 32B 'a' 'a' 'b' 'b'
+              * x        (x) object {4 * obj_size}B MultiIndex
+              * level_1  (x) object {4 * obj_size}B 'a' 'a' 'b' 'b'
               * level_2  (x) int64 32B 1 2 1 2
             Data variables:
                 *empty*"""
@@ -357,12 +358,12 @@ class TestDataset:
         midx_coords = Coordinates.from_pandas_multiindex(midx, "x")
         data = Dataset({}, midx_coords)
         expected = dedent(
-            """\
-            <xarray.Dataset> Size: 96B
+            f"""\
+            <xarray.Dataset> Size: {8 * obj_size + 32}B
             Dimensions:                  (x: 4)
             Coordinates:
-              * x                        (x) object 32B MultiIndex
-              * a_quite_long_level_name  (x) object 32B 'a' 'a' 'b' 'b'
+              * x                        (x) object {4 * obj_size}B MultiIndex
+              * a_quite_long_level_name  (x) object {4 * obj_size}B 'a' 'a' 'b' 'b'
               * level_2                  (x) int64 32B 1 2 1 2
             Data variables:
                 *empty*"""
diff --git a/xarray/tests/test_formatting.py b/xarray/tests/test_formatting.py
index 2c40ac88..b9d5f401 100644
--- a/xarray/tests/test_formatting.py
+++ b/xarray/tests/test_formatting.py
@@ -12,8 +12,6 @@ from xarray.core import formatting
 from xarray.core.datatree import DataTree  # TODO: Remove when can do xr.DataTree
 from xarray.tests import requires_cftime, requires_dask, requires_netCDF4
 
-ON_WINDOWS = sys.platform == "win32"
-
 
 class TestFormatting:
     def test_get_indexer_at_least_n_items(self) -> None:
@@ -1071,74 +1069,34 @@ Dimensions without coordinates: x
         """.strip()
     assert actual == expected
 
-
-@pytest.mark.skipif(
-    ON_WINDOWS,
-    reason="Default numpy's dtypes vary according to OS",
-)
-def test_array_repr_dtypes_unix() -> None:
-
     # Signed integer dtypes
 
-    ds = xr.DataArray(np.array([0]), dims="x")
+    array = np.array([0])
+    ds = xr.DataArray(array, dims="x")
     actual = repr(ds)
-    expected = """
-<xarray.DataArray (x: 1)> Size: 8B
-array([0])
+    expected = f"""
+<xarray.DataArray (x: 1)> Size: {array.dtype.itemsize}B
+{repr(array)}
 Dimensions without coordinates: x
         """.strip()
     assert actual == expected
 
-    ds = xr.DataArray(np.array([0], dtype="int32"), dims="x")
+    array = np.array([0], dtype="int32")
+    ds = xr.DataArray(array, dims="x")
     actual = repr(ds)
-    expected = """
+    expected = f"""
 <xarray.DataArray (x: 1)> Size: 4B
-array([0], dtype=int32)
+{repr(array)}
 Dimensions without coordinates: x
         """.strip()
     assert actual == expected
 
-    ds = xr.DataArray(np.array([0], dtype="int64"), dims="x")
+    array = np.array([0], dtype="int64")
+    ds = xr.DataArray(array, dims="x")
     actual = repr(ds)
-    expected = """
-<xarray.DataArray (x: 1)> Size: 8B
-array([0])
-Dimensions without coordinates: x
-        """.strip()
-    assert actual == expected
-
-
-@pytest.mark.skipif(
-    not ON_WINDOWS,
-    reason="Default numpy's dtypes vary according to OS",
-)
-def test_array_repr_dtypes_on_windows() -> None:
-
-    # Integer dtypes
-
-    ds = xr.DataArray(np.array([0]), dims="x")
-    actual = repr(ds)
-    expected = """
-<xarray.DataArray (x: 1)> Size: 4B
-array([0])
-Dimensions without coordinates: x
-        """.strip()
-    assert actual == expected
-
-    ds = xr.DataArray(np.array([0], dtype="int32"), dims="x")
-    actual = repr(ds)
-    expected = """
-<xarray.DataArray (x: 1)> Size: 4B
-array([0])
-Dimensions without coordinates: x
-        """.strip()
-    assert actual == expected
-
-    ds = xr.DataArray(np.array([0], dtype="int64"), dims="x")
-    actual = repr(ds)
-    expected = """
+    expected = f"""
 <xarray.DataArray (x: 1)> Size: 8B
-array([0], dtype=int64)
+{repr(array)}
 Dimensions without coordinates: x
         """.strip()
     assert actual == expected