Repository: opendatacube/datacube-core

Issue:
In `group_datasets` output dataset order is not guaranteed to stay the same across runs
## Summary

1. Order of datasets impacts final image result
2. Datasets to be "fused" into one raster are sorted by time stamp
3. Several datasets can have exactly the same timestamp
4. Hence order of datasets within a group has an element of randomness

This was first discovered and addressed in 2016, see https://github.com/opendatacube/datacube-core/commit/58cebbe084069a9e9ee48848f364c32de4193f16 . That fixed dataset order when grouping by solar day, but there is still an issue when there are a number of datasets with exactly the same timestamp. This happens with ingested products where tiles from the same scene have exactly the same timestamp. Since these tiles are not overlapping the difference in order is not apparent in the usual case of loading data in the native projection. However applying even a simple resolution change will result in slightly different rasters being loaded when order of datasets is different, this is because "fusing" operation is order dependant.

## When non-overlapping tiles overlap

Example: you have `10x2` raster split into two tiles: `5x2 | 5x2`, you need to load it with a shrink factor of `x2`, i.e. `2.5x1 | 2.5x1`. Since there is no such thing as `2.5` pixel wide raster you are likely to end up with two `3x1` rasters, where edge pixels now overlap and so order of fusing starts to matter. I think also Sentinel data might have exactly the same timestamp for different observations. 




Discussion:

Resolving patch:
diff --git a/datacube/api/core.py b/datacube/api/core.py
--- a/datacube/api/core.py
+++ b/datacube/api/core.py
@@ -1,5 +1,5 @@
 import uuid
-from collections import namedtuple, OrderedDict
+from collections import OrderedDict
 from itertools import groupby
 from math import ceil
 
@@ -15,8 +15,6 @@
 from ..index import index_connect
 from ..drivers import new_datasource
 
-Group = namedtuple('Group', ['key', 'datasets'])
-
 
 class Datacube(object):
     """
@@ -350,14 +348,32 @@ def group_datasets(datasets, group_by):
 
         .. seealso:: :meth:`find_datasets`, :meth:`load_data`, :meth:`query_group_by`
         """
+        if isinstance(group_by, str):
+            group_by = query_group_by(group_by=group_by)
+
         dimension, group_func, units, sort_key = group_by
-        datasets.sort(key=sort_key)
-        groups = [Group(key, tuple(group)) for key, group in groupby(datasets, group_func)]
 
-        data = numpy.empty(len(groups), dtype=object)
-        for index, group in enumerate(groups):
-            data[index] = group.datasets
-        coords = [sort_key(v.datasets[0]) for v in groups]
+        def ds_sorter(ds):
+            return sort_key(ds), getattr(ds, 'id', 0)
+
+        def mk_group(group):
+            dss = tuple(sorted(group, key=ds_sorter))
+            # TODO: decouple axis_value from group sorted order
+            axis_value = sort_key(dss[0])
+            return (axis_value, dss)
+
+        datasets = sorted(datasets, key=group_func)
+
+        groups = [mk_group(group)
+                  for _, group in groupby(datasets, group_func)]
+
+        groups.sort(key=lambda x: x[0])
+
+        coords = [coord for coord, _ in groups]
+        data = numpy.empty(len(coords), dtype=object)
+        for i, (_, dss) in enumerate(groups):
+            data[i] = dss
+
         sources = xarray.DataArray(data, dims=[dimension], coords=[coords])
         sources[dimension].attrs['units'] = units
         return sources
diff --git a/datacube/api/query.py b/datacube/api/query.py
--- a/datacube/api/query.py
+++ b/datacube/api/query.py
@@ -158,6 +158,9 @@ def query_geopolygon(geopolygon=None, **kwargs):
 
 
 def query_group_by(group_by='time', **kwargs):
+    if not isinstance(group_by, str):
+        return group_by
+
     time_grouper = GroupBy(dimension='time',
                            group_by_func=lambda ds: ds.center_time,
                            units='seconds since 1970-01-01 00:00:00',


Test patch:
diff --git a/tests/api/test_core.py b/tests/api/test_core.py
--- a/tests/api/test_core.py
+++ b/tests/api/test_core.py
@@ -1,3 +1,5 @@
+from uuid import UUID
+from types import SimpleNamespace
 from datacube.api.query import GroupBy
 
 from datacube import Datacube
@@ -6,17 +8,26 @@
 
 def test_grouping_datasets():
     def group_func(d):
-        return d['time']
+        return d.time
     dimension = 'time'
     units = None
     datasets = [
-        {'time': datetime.datetime(2016, 1, 1), 'value': 'foo'},
-        {'time': datetime.datetime(2016, 1, 1), 'value': 'flim'},
-        {'time': datetime.datetime(2016, 2, 1), 'value': 'bar'}
+        SimpleNamespace(time=datetime.datetime(2016, 1, 1), value='foo', id=UUID(int=10)),
+        SimpleNamespace(time=datetime.datetime(2016, 2, 1), value='bar', id=UUID(int=1)),
+        SimpleNamespace(time=datetime.datetime(2016, 1, 1), value='flim', id=UUID(int=9)),
     ]
 
     group_by = GroupBy(dimension, group_func, units, sort_key=group_func)
     grouped = Datacube.group_datasets(datasets, group_by)
+    dss = grouped.isel(time=0).values[()]
+    assert isinstance(dss, tuple)
+    assert len(dss) == 2
+    assert [ds.value for ds in dss] == ['flim', 'foo']
+
+    dss = grouped.isel(time=1).values[()]
+    assert isinstance(dss, tuple)
+    assert len(dss) == 1
+    assert [ds.value for ds in dss] == ['bar']
 
     assert str(grouped.time.dtype) == 'datetime64[ns]'
     assert grouped.loc['2016-01-01':'2016-01-15']
diff --git a/tests/api/test_query.py b/tests/api/test_query.py
--- a/tests/api/test_query.py
+++ b/tests/api/test_query.py
@@ -17,7 +17,7 @@
 
 import pytest
 
-from datacube.api.query import Query, _datetime_to_timestamp, query_group_by, solar_day
+from datacube.api.query import Query, _datetime_to_timestamp, query_group_by, solar_day, GroupBy
 from datacube.model import Range
 from datacube.utils import parse_time
 
@@ -84,6 +84,10 @@ def test_query_kwargs():
     with pytest.raises(LookupError):
         query_group_by(group_by='magic')
 
+    gb = query_group_by('time')
+    assert isinstance(gb, GroupBy)
+    assert query_group_by(group_by=gb) is gb
+
 
 def format_test(start_out, end_out):
     return Range(pandas.to_datetime(start_out, utc=True).to_pydatetime(),
diff --git a/tests/test_load_data.py b/tests/test_load_data.py
--- a/tests/test_load_data.py
+++ b/tests/test_load_data.py
@@ -76,7 +76,7 @@ def test_load_data(tmpdir):
     assert ds.time is not None
     assert ds.time == ds2.time
 
-    sources = Datacube.group_datasets([ds], group_by)
+    sources = Datacube.group_datasets([ds], 'time')
     sources2 = Datacube.group_datasets([ds, ds2], group_by)
 
     mm = ['aa']