Esempio n. 1
0
 def step1a(input_slice):
     nonlocal geo_coding
     geo_coding = GeoCoding.from_dataset(input_slice)
     subset = select_spatial_subset(input_slice,
                                    xy_bbox=output_geom.xy_bbox,
                                    xy_border=output_geom.xy_res,
                                    ij_border=1,
                                    geo_coding=geo_coding)
     if subset is None:
         monitor('no spatial overlap with input')
     elif subset is not input_slice:
         geo_coding = GeoCoding.from_dataset(subset)
     return subset
Esempio n. 2
0
 def test_is_geo_crs_and_is_lon_normalized(self):
     x = xr.DataArray(np.linspace(10.0, 20.0, 21),
                      dims='columns',
                      name='lon')
     y = xr.DataArray(np.linspace(53.0, 58.0, 11), dims='rows', name='lat')
     y, x = xr.broadcast(y, x)
     gc = GeoCoding(x, y)
     self.assertEqual(False, gc.is_geo_crs)
     self.assertEqual(False, gc.is_lon_normalized)
     gc = GeoCoding(x, y, is_geo_crs=True)
     self.assertEqual(True, gc.is_geo_crs)
     self.assertEqual(False, gc.is_lon_normalized)
     gc = GeoCoding(x, y, is_lon_normalized=True)
     self.assertEqual(True, gc.is_geo_crs)
     self.assertEqual(True, gc.is_lon_normalized)
Esempio n. 3
0
    def _test_ij_bbox_antimeridian(self, conservative: bool):
        def denorm(x):
            return x if x <= 180 else x - 360

        lon = xr.DataArray(np.linspace(175.0, 185.0, 21), dims='columns')
        lat = xr.DataArray(np.linspace(53.0, 58.0, 11), dims='rows')
        lat, lon = xr.broadcast(lat, lon)
        gc = GeoCoding(x=lon,
                       y=lat,
                       x_name='lon',
                       y_name='lat',
                       is_lon_normalized=True)
        ij_bbox = gc.ij_bbox_conservative if conservative else gc.ij_bbox
        self.assertEqual((-1, -1, -1, -1), ij_bbox((0, -50, 30, 0)))
        self.assertEqual((0, 0, 20, 10),
                         ij_bbox((denorm(160), 50, denorm(200), 60)))
        self.assertEqual((0, 0, 20, 6),
                         ij_bbox((denorm(160), 50, denorm(200), 56)))
        self.assertEqual((10, 0, 20, 6),
                         ij_bbox((denorm(180), 50, denorm(200), 56)))
        self.assertEqual((10, 0, 16, 6),
                         ij_bbox((denorm(180), 50, denorm(183), 56)))
        self.assertEqual((10, 1, 16, 6),
                         ij_bbox((denorm(180), 53.5, denorm(183), 56)))
        self.assertEqual((8, 0, 18, 8),
                         ij_bbox((denorm(180), 53.5, denorm(183), 56),
                                 ij_border=2))
        self.assertEqual((12, 1, 20, 6),
                         ij_bbox((denorm(181), 53.5, denorm(200), 56)))
        self.assertEqual((12, 1, 18, 6),
                         ij_bbox((denorm(181), 53.5, denorm(184), 56)))
Esempio n. 4
0
def _select_variables(dataset,
                      var_names: Union[str, Sequence[str]] = None,
                      geo_coding: GeoCoding = None,
                      xy_names: Tuple[str, str] = None) -> Mapping[str, xr.DataArray]:
    """
    Select variables from *dataset*.

    :param dataset: Source dataset.
    :param var_names: Optional variable name or sequence of variable names.
    :param geo_coding: Optional dataset geo-coding.
    :param xy_names: Optional tuple of the x- and y-coordinate variables in *dataset*. Ignored if *geo_coding* is given.
    :return: The selected variables as a variable name to ``xr.DataArray`` mapping
    """
    geo_coding = geo_coding if geo_coding is not None else GeoCoding.from_dataset(dataset, xy_names=xy_names)
    src_x = geo_coding.x
    x_name, y_name = geo_coding.xy_names
    if var_names is None:
        var_names = [var_name for var_name, var in dataset.data_vars.items()
                     if var_name not in (x_name, y_name) and _is_2d_var(var, src_x)]
    elif isinstance(var_names, str):
        var_names = (var_names,)
    elif len(var_names) == 0:
        raise ValueError(f'empty var_names')
    src_vars = {}
    for var_name in var_names:
        src_var = dataset[var_name]
        if not _is_2d_var(src_var, src_x):
            raise ValueError(
                f"cannot reproject variable {var_name!r} as its shape or dimensions "
                f"do not match those of {x_name!r} and {y_name!r}")
        src_vars[var_name] = src_var
    return src_vars
Esempio n. 5
0
    def process(self,
                dataset: xr.Dataset,
                geo_coding: GeoCoding,
                output_geom: ImageGeom,
                output_resampling: str,
                include_non_spatial_vars=False) -> xr.Dataset:
        """
        Perform reprojection using tie-points / ground control points.
        """
        reprojection_info = self.get_reprojection_info(dataset)

        in_rectification_mode = reprojection_info.xy_gcp_step is None
        if in_rectification_mode:
            warn_prefix = 'unsupported argument in np-GCP rectification mode'
            if reprojection_info.xy_tp_gcp_step is not None:
                warnings.warn(
                    f'{warn_prefix}: ignoring '
                    f'reprojection_info.xy_tp_gcp_step = {reprojection_info.xy_tp_gcp_step!r}'
                )
            if output_resampling != 'Nearest':
                warnings.warn(f'{warn_prefix}: ignoring '
                              f'dst_resampling = {output_resampling!r}')
            if include_non_spatial_vars:
                warnings.warn(
                    f'{warn_prefix}: ignoring '
                    f'include_non_spatial_vars = {include_non_spatial_vars!r}')

            geo_coding = geo_coding.derive(
                x_name=reprojection_info.xy_names[0],
                y_name=reprojection_info.xy_names[1])

            dataset = rectify_dataset(dataset,
                                      compute_subset=False,
                                      geo_coding=geo_coding,
                                      output_geom=output_geom,
                                      is_y_axis_inverted=True)

            if dataset is not None and geo_coding.is_geo_crs and geo_coding.xy_names != (
                    'lon', 'lat'):
                dataset = dataset.rename({
                    geo_coding.x_name: 'lon',
                    geo_coding.y_name: 'lat'
                })

            return dataset

        else:
            return reproject_xy_to_wgs84(
                dataset,
                src_xy_var_names=reprojection_info.xy_names,
                src_xy_tp_var_names=reprojection_info.xy_tp_names,
                src_xy_crs=reprojection_info.xy_crs,
                src_xy_gcp_step=reprojection_info.xy_gcp_step or 1,
                src_xy_tp_gcp_step=reprojection_info.xy_tp_gcp_step or 1,
                dst_size=output_geom.size,
                dst_region=output_geom.xy_bbox,
                dst_resampling=output_resampling,
                include_non_spatial_vars=include_non_spatial_vars)
Esempio n. 6
0
 def test_from_dataset_1d(self):
     x = xr.DataArray(np.linspace(10.0, 20.0, 21), dims='x')
     y = xr.DataArray(np.linspace(53.0, 58.0, 11), dims='y')
     gc = GeoCoding.from_dataset(xr.Dataset(dict(x=x, y=y)))
     self.assertIsInstance(gc.x, xr.DataArray)
     self.assertIsInstance(gc.y, xr.DataArray)
     self.assertEqual('x', gc.x_name)
     self.assertEqual('y', gc.y_name)
     self.assertEqual(False, gc.is_lon_normalized)
Esempio n. 7
0
    def test_ij_bboxes(self):
        x = xr.DataArray(np.linspace(10.0, 20.0, 21), dims='x')
        y = xr.DataArray(np.linspace(53.0, 58.0, 11), dims='y')
        y, x = xr.broadcast(y, x)
        gc = GeoCoding(x=x, y=y, x_name='x', y_name='y', is_geo_crs=True)

        ij_bboxes = gc.ij_bboxes(np.array([(0.0, -50.0, 30.0, 0.0)]))
        np.testing.assert_almost_equal(
            ij_bboxes, np.array([(-1, -1, -1, -1)], dtype=np.int64))

        ij_bboxes = gc.ij_bboxes(
            np.array([(0.0, 50, 30, 60), (0.0, 50, 30, 56), (15, 50, 30, 56),
                      (15, 50, 18, 56), (15, 53.5, 18, 56)]))
        np.testing.assert_almost_equal(
            ij_bboxes,
            np.array([(0, 0, 20, 10), (0, 0, 20, 6), (10, 0, 20, 6),
                      (10, 0, 16, 6), (10, 1, 16, 6)],
                     dtype=np.int64))
Esempio n. 8
0
def select_spatial_subset(
        dataset: xr.Dataset,
        ij_bbox: Tuple[int, int, int, int] = None,
        ij_border: int = 0,
        xy_bbox: Tuple[float, float, float, float] = None,
        xy_border: float = 0.,
        geo_coding: GeoCoding = None,
        xy_names: Tuple[str, str] = None) -> Optional[xr.Dataset]:
    """
    Select a spatial subset of *dataset* for the bounding box *ij_bbox* or *xy_bbox*.

    :param dataset: Source dataset.
    :param ij_bbox: Bounding box (i_min, i_min, j_max, j_max) in pixel coordinates.
    :param geo_coding: Optional dataset geo-coding.
    :param xy_names: Optional tuple of the x- and y-coordinate variables in *dataset*. Ignored if *geo_coding* is given.
    :return: Spatial dataset subset
    """

    if ij_bbox is None and xy_bbox is None:
        raise ValueError('One of ij_bbox and xy_bbox must be given')
    if ij_bbox and xy_bbox:
        raise ValueError('Only one of ij_bbox and xy_bbox can be given')
    geo_coding = geo_coding if geo_coding is not None else GeoCoding.from_dataset(
        dataset, xy_names=xy_names)
    if xy_bbox:
        ij_bbox = geo_coding.ij_bbox(xy_bbox,
                                     ij_border=ij_border,
                                     xy_border=xy_border)
        if ij_bbox[0] == -1:
            return None
    width, height = geo_coding.size
    i_min, j_min, i_max, j_max = ij_bbox
    if i_min > 0 or j_min > 0 or i_max < width - 1 or j_max < height - 1:
        x_dim, y_dim = geo_coding.dims
        i_slice = slice(i_min, i_max + 1)
        j_slice = slice(j_min, j_max + 1)
        return dataset.isel({x_dim: i_slice, y_dim: j_slice})
    return dataset
Esempio n. 9
0
 def _test_ij_bbox(self, conservative: bool):
     x = xr.DataArray(np.linspace(10.0, 20.0, 21), dims='x')
     y = xr.DataArray(np.linspace(53.0, 58.0, 11), dims='y')
     y, x = xr.broadcast(y, x)
     gc = GeoCoding(x=x, y=y, x_name='x', y_name='y', is_geo_crs=True)
     ij_bbox = gc.ij_bbox_conservative if conservative else gc.ij_bbox
     self.assertEqual((-1, -1, -1, -1), ij_bbox((0, -50, 30, 0)))
     self.assertEqual((0, 0, 20, 10), ij_bbox((0, 50, 30, 60)))
     self.assertEqual((0, 0, 20, 6), ij_bbox((0, 50, 30, 56)))
     self.assertEqual((10, 0, 20, 6), ij_bbox((15, 50, 30, 56)))
     self.assertEqual((10, 0, 16, 6), ij_bbox((15, 50, 18, 56)))
     self.assertEqual((10, 1, 16, 6), ij_bbox((15, 53.5, 18, 56)))
     self.assertEqual((8, 0, 18, 8), ij_bbox((15, 53.5, 18, 56),
                                             ij_border=2))
Esempio n. 10
0
def _compute_ij_images_xarray_dask(src_geo_coding: GeoCoding,
                                   output_geom: ImageGeom,
                                   uv_delta: float) -> da.Array:
    """Compute dask.array.Array destination image with source pixel i,j coords from xarray.DataArray x,y sources """
    dst_width = output_geom.width
    dst_height = output_geom.height
    dst_tile_width = output_geom.tile_width
    dst_tile_height = output_geom.tile_height
    dst_var_shape = 2, dst_height, dst_width
    dst_var_chunks = 2, dst_tile_height, dst_tile_width

    dst_x_min, dst_y_min, dst_x_max, dst_y_max = output_geom.xy_bbox
    dst_xy_res = output_geom.xy_res

    # Compute an empirical xy_border as a function of the number of tiles, because the more tiles we have
    # the smaller the destination xy-bboxes and the higher the risk to not find any source ij-bbox for
    # a given xy-bbox.
    # xy_border will not be larger than half of the coverage of a tile.
    #
    num_tiles_x = dst_width / dst_tile_width
    num_tiles_y = dst_height / dst_tile_height
    max_num_tiles = max(num_tiles_x, num_tiles_y)
    xy_border = min(2 * max_num_tiles * dst_xy_res,
                    min(0.5 * (dst_x_max - dst_x_min), 0.5 * (dst_y_max - dst_y_min)))

    dst_xy_bboxes = output_geom.xy_bboxes
    src_ij_bboxes = src_geo_coding.ij_bboxes(dst_xy_bboxes, xy_border=xy_border, ij_border=1)

    return compute_array_from_func(_compute_ij_images_xarray_dask_block,
                                   dst_var_shape,
                                   dst_var_chunks,
                                   np.float64,
                                   ctx_arg_names=[
                                       'dtype',
                                       'block_id',
                                       'block_shape',
                                       'block_slices',
                                   ],
                                   args=(
                                       src_geo_coding.x,
                                       src_geo_coding.y,
                                       src_ij_bboxes,
                                       dst_x_min,
                                       dst_y_min,
                                       dst_xy_res,
                                       uv_delta
                                   ),
                                   name='ij_pixels',
                                   )
Esempio n. 11
0
def _compute_output_geom(
        dataset: xr.Dataset,
        geo_coding: GeoCoding = None,
        xy_names: Tuple[str, str] = None,
        xy_oversampling: float = 1.0,
        xy_eps: float = 1e-10,
        ij_denom: Union[int, Tuple[int, int]] = None) -> ImageGeom:
    i_denom, j_denom = ((ij_denom, ij_denom)
                        if isinstance(ij_denom, int) else ij_denom) or (1, 1)
    geo_coding = geo_coding if geo_coding is not None else GeoCoding.from_dataset(
        dataset, xy_names=xy_names)
    src_x, src_y = geo_coding.xy
    dim_y, dim_x = src_x.dims
    src_x_x_diff = src_x.diff(dim=dim_x)
    src_x_y_diff = src_x.diff(dim=dim_y)
    src_y_x_diff = src_y.diff(dim=dim_x)
    src_y_y_diff = src_y.diff(dim=dim_y)
    src_x_x_diff_sq = np.square(src_x_x_diff)
    src_x_y_diff_sq = np.square(src_x_y_diff)
    src_y_x_diff_sq = np.square(src_y_x_diff)
    src_y_y_diff_sq = np.square(src_y_y_diff)
    src_x_diff = np.sqrt(src_x_x_diff_sq + src_y_x_diff_sq)
    src_y_diff = np.sqrt(src_x_y_diff_sq + src_y_y_diff_sq)
    src_x_res = float(src_x_diff.where(src_x_diff > xy_eps).min())
    src_y_res = float(src_y_diff.where(src_y_diff > xy_eps).min())
    src_xy_res = min(src_x_res, src_y_res) / (math.sqrt(2.0) * xy_oversampling)
    src_x_min = float(src_x.min())
    src_x_max = float(src_x.max())
    src_y_min = float(src_y.min())
    src_y_max = float(src_y.max())
    dst_width = 1 + math.floor((src_x_max - src_x_min) / src_xy_res)
    dst_height = 1 + math.floor((src_y_max - src_y_min) / src_xy_res)
    dst_width = i_denom * ((dst_width + i_denom - 1) // i_denom)
    dst_height = j_denom * ((dst_height + j_denom - 1) // j_denom)

    # Reduce height if it takes the maximum latitude over 90° (see Issue #303).
    if src_y_min + dst_height * src_xy_res > 90.0:
        dst_height -= 1

    return ImageGeom((dst_width, dst_height),
                     x_min=src_x_min,
                     y_min=src_y_min,
                     xy_res=src_xy_res,
                     is_geo_crs=geo_coding.is_geo_crs)
Esempio n. 12
0
    def process(self,
                dataset: xr.Dataset,
                geo_coding: GeoCoding,
                output_geom: ImageGeom,
                output_resampling: str,
                include_non_spatial_vars=False) -> xr.Dataset:
        """
        Perform reprojection using tie-points / ground control points.
        """
        reprojection_info = self.get_reprojection_info(dataset)

        in_rectification_mode = reprojection_info.xy_gcp_step is None
        if in_rectification_mode:
            warn_prefix = 'unsupported argument in np-GCP rectification mode'
            if reprojection_info.xy_tp_gcp_step is not None:
                warnings.warn(
                    f'{warn_prefix}: ignoring '
                    f'reprojection_info.xy_tp_gcp_step = {reprojection_info.xy_tp_gcp_step!r}'
                )
            if output_resampling != 'Nearest':
                warnings.warn(f'{warn_prefix}: ignoring '
                              f'dst_resampling = {output_resampling!r}')
            if include_non_spatial_vars:
                warnings.warn(
                    f'{warn_prefix}: ignoring '
                    f'include_non_spatial_vars = {include_non_spatial_vars!r}')

            geo_coding = geo_coding.derive(
                x_name=reprojection_info.xy_names[0],
                y_name=reprojection_info.xy_names[1])

            dataset = rectify_dataset(dataset,
                                      compute_subset=False,
                                      geo_coding=geo_coding,
                                      output_geom=output_geom,
                                      is_y_reversed=True)
            if output_geom.is_tiled:
                # The following condition may become true, if we have used rectified_dataset(input, ..., is_y_reverse=True)
                # In this case y-chunksizes will also be reversed. So that the first chunk is smaller than any other.
                # Zarr will reject such datasets, when written.
                if dataset.chunks.get('lat')[0] < dataset.chunks.get(
                        'lat')[-1]:
                    dataset = dataset.chunk({
                        'lat': output_geom.tile_height,
                        'lon': output_geom.tile_width
                    })
            if dataset is not None and geo_coding.is_geo_crs and geo_coding.xy_names != (
                    'lon', 'lat'):
                dataset = dataset.rename({
                    geo_coding.x_name: 'lon',
                    geo_coding.y_name: 'lat'
                })

            return dataset

        else:
            return reproject_xy_to_wgs84(
                dataset,
                src_xy_var_names=reprojection_info.xy_names,
                src_xy_tp_var_names=reprojection_info.xy_tp_names,
                src_xy_crs=reprojection_info.xy_crs,
                src_xy_gcp_step=reprojection_info.xy_gcp_step or 1,
                src_xy_tp_gcp_step=reprojection_info.xy_tp_gcp_step or 1,
                dst_size=output_geom.size,
                dst_region=output_geom.xy_bbox,
                dst_resampling=output_resampling,
                include_non_spatial_vars=include_non_spatial_vars)
Esempio n. 13
0
def rectify_dataset(dataset: xr.Dataset,
                    var_names: Union[str, Sequence[str]] = None,
                    geo_coding: GeoCoding = None,
                    xy_names: Tuple[str, str] = None,
                    output_geom: ImageGeom = None,
                    is_y_reversed: bool = False,
                    tile_size: Union[int, Tuple[int, int]] = None,
                    output_ij_names: Tuple[str, str] = None,
                    load_xy: bool = False,
                    load_vars: bool = False,
                    compute_subset: bool = True,
                    uv_delta: float = 1e-3) -> Optional[xr.Dataset]:
    """
    Reproject *dataset* using its per-pixel x,y coordinates or the given *geo_coding*.

    The function expects *dataset* to have either one- or two-dimensional coordinate variables
    that provide spatial x,y coordinates for every data variable with the same spatial dimensions.

    For example, a dataset may comprise variables with spatial dimensions ``var(..., y_dim, x_dim)``, then one
    the function expects coordinates to be provided in two forms:

    1. One-dimensional ``x_var(x_dim)`` and ``y_var(y_dim)`` (coordinate) variables.
    2. Two-dimensional ``x_var(y_dim, x_dim)`` and ``y_var(y_dim, x_dim)`` (coordinate) variables.

    If *output_geom* is given and defines a tile size or *tile_size* is given, and the number of tiles
    is greater than one in the output's x- or y-direction, then the returned dataset will be composed of lazy,
    chunked dask arrays. Otherwise the returned dataset will be composed of ordinary numpy arrays.

    :param dataset: Source dataset.
    :param var_names: Optional variable name or sequence of variable names.
    :param geo_coding: Optional dataset geo-coding.
    :param xy_names: Optional tuple of the x- and y-coordinate variables in *dataset*. Ignored if *geo_coding* is given.
    :param output_geom: Optional output geometry. If not given, output geometry will be computed
        to spatially fit *dataset* and to retain its spatial resolution.
    :param is_y_reversed: Whether the y-axis labels in the output should be in reverse order.
    :param tile_size: Optional tile size for the output.
    :param output_ij_names: If given, a tuple of variable names in which to store the computed source pixel
        coordinates in the returned output.
    :param load_xy: Compute x,y coordinates and load into memory before the actual rectification process.
        May improve runtime performance at the cost of higher memory consumption.
    :param load_vars: Compute source variables and load into memory before the actual rectification process.
        May improve runtime performance at the cost of higher memory consumption.
    :param compute_subset: Whether to compute a spatial subset from *dataset* using *output_geom*. If set,
        The function may return ``None`` in case there is no overlap.
    :param uv_delta: A normalized value that is used to determine whether x,y coordinates in the output are contained
        in the triangles defined by the input x,y coordinates.
        The higher this value, the more inaccurate the rectification will be.
    :return: a reprojected dataset, or None if the requested output does not intersect with *dataset*.
    """
    src_geo_coding = geo_coding if geo_coding is not None else GeoCoding.from_dataset(dataset, xy_names=xy_names)
    src_x, src_y = src_geo_coding.xy
    src_attrs = dict(dataset.attrs)

    if output_geom is None:
        output_geom = ImageGeom.from_dataset(dataset, geo_coding=src_geo_coding)
    elif compute_subset:
        dataset_subset = select_spatial_subset(dataset,
                                               xy_bbox=output_geom.xy_bbox,
                                               ij_border=1,
                                               xy_border=output_geom.xy_res,
                                               geo_coding=src_geo_coding)
        if dataset_subset is None:
            return None
        if dataset_subset is not dataset:
            src_geo_coding = GeoCoding.from_dataset(dataset_subset)
            src_x, src_y = src_geo_coding.x, src_geo_coding.y
            dataset = dataset_subset

    if tile_size is not None:
        output_geom = output_geom.derive(tile_size=tile_size)

    src_vars = _select_variables(dataset, var_names, geo_coding=src_geo_coding)

    if load_xy:
        # This is NOT faster:
        src_x = src_x.compute()
        src_y = src_y.compute()
        src_geo_coding = src_geo_coding.derive(x=src_x, y=src_y)

    if output_geom.is_tiled:
        get_dst_src_ij_images = _compute_ij_images_xarray_dask
        get_dst_var_image = _compute_var_image_xarray_dask
    else:
        get_dst_src_ij_images = _compute_ij_images_xarray_numpy
        get_dst_var_image = _compute_var_image_xarray_numpy

    dst_src_ij_array = get_dst_src_ij_images(src_geo_coding,
                                             output_geom,
                                             uv_delta)

    if is_y_reversed:
        # Note that the following reverse operation may change any y-axis' chunking.
        # This is the case if the y-chunksize does not integer-divide y-size, e.g.
        # y-chunksizes (512, 512, 512, 273) will change into (273, 512, 512, 512).
        dst_src_ij_array = dst_src_ij_array[:, ::-1]

    dst_dims = src_geo_coding.xy_names[::-1]
    dst_ds_coords = output_geom.coord_vars(xy_names=src_geo_coding.xy_names,
                                           is_lon_normalized=src_geo_coding.is_lon_normalized,
                                           is_y_reversed=is_y_reversed)
    dst_vars = dict()
    for src_var_name, src_var in src_vars.items():
        if load_vars:
            # This is NOT faster:
            src_var = src_var.compute()

        dst_var_dims = src_var.dims[0:-2] + dst_dims
        dst_var_coords = {d: src_var.coords[d] for d in dst_var_dims if d in src_var.coords}
        dst_var_coords.update({d: dst_ds_coords[d] for d in dst_var_dims if d in dst_ds_coords})
        dst_var_array = get_dst_var_image(src_var,
                                          dst_src_ij_array,
                                          fill_value=np.nan)
        dst_var = xr.DataArray(dst_var_array,
                               dims=dst_var_dims,
                               coords=dst_var_coords,
                               attrs=src_var.attrs)
        dst_vars[src_var_name] = dst_var

    if output_ij_names:
        output_i_name, output_j_name = output_ij_names
        dst_ij_coords = {d: dst_ds_coords[d] for d in dst_dims if d in dst_ds_coords}
        dst_vars[output_i_name] = xr.DataArray(dst_src_ij_array[0], dims=dst_dims, coords=dst_ij_coords)
        dst_vars[output_j_name] = xr.DataArray(dst_src_ij_array[1], dims=dst_dims, coords=dst_ij_coords)

    return xr.Dataset(dst_vars, coords=dst_ds_coords, attrs=src_attrs)