Repository: opendatacube/datacube-core

Issue:
write_geotiff helper function fails if CRS is string, not object
### Expected behaviour
The write_geotiff helper function assumes that you will pass the function a datacube crs object. If you are writing out a geotiff from data that was not loaded using `dc.load`, this crs object is not present. 
E.g. I read a Geotiff file produced by datacube-stats into a Notebook, ran some analysis on it, and wanted to write it back out to Geotiff. I have a crs string from the attributes of my original Geotiff, but no datacube crs object, so the write_geotiff function fails.

### Error
```
AttributeError                            Traceback (most recent call last)
<ipython-input-41-736bab55bae5> in <module>()
      3 Differenceds.attrs['crs'] = (GeotiffData.crs)
      4 
----> 5 write_geotiff(PercentileConfidence, Differenceds)

/g/data/v10/public/modules/dea/20180515/lib/python3.6/site-packages/datacube/helpers.py in write_geotiff(filename, dataset, profile_override, time_index)
     44     profile = DEFAULT_PROFILE.copy()
     45     profile.update({
---> 46         'width': dataset.dims[dataset.crs.dimensions[1]],
     47         'height': dataset.dims[dataset.crs.dimensions[0]],
     48         'transform': dataset.affine,

AttributeError: 'str' object has no attribute 'dimensions'
```


Discussion:
Would
```python
from datacube.utils.geometry import CRS

Differenceds.attrs['crs'] = CRS(GeotiffData.crs)
write_geotiff(PercentileConfidence, Differenceds)
```
solve your problem?
Yes, that's how I have gotten around it. 

It would be really useful if the write_geotiff function was able to handle either the datacube crs object, or a crs string and generate its own on the fly, rather than fail.

Resolving patch:
diff --git a/datacube/helpers.py b/datacube/helpers.py
--- a/datacube/helpers.py
+++ b/datacube/helpers.py
@@ -26,12 +26,17 @@ def write_geotiff(filename, dataset, profile_override=None, time_index=None):
     :param profile_override: option dict, overrides rasterio file creation options.
     :param time_index: DEPRECATED
     """
-    profile_override = profile_override or {}
-
     if time_index is not None:
         raise ValueError('''The write_geotiff function no longer supports passing in `time_index`.
         The same function can be achieved by calling `dataset.isel(time=<time_index>)` before passing
-        in your dataset. It was removed because it made the function much less useful for more advanced cases.''')
+ in your dataset. It was removed because it made the function much less useful for more advanced cases.''')
+
+    profile_override = profile_override or {}
+
+    geobox = getattr(dataset, 'geobox', None)
+
+    if geobox is None:
+        raise ValueError('Can only write datasets with specified `crs` attribute')
 
     try:
         dtypes = {val.dtype for val in dataset.data_vars.values()}
@@ -40,11 +45,13 @@ def write_geotiff(filename, dataset, profile_override=None, time_index=None):
         dtypes = [dataset.dtype]
 
     profile = DEFAULT_PROFILE.copy()
+    height, width = geobox.shape
+
     profile.update({
-        'width': dataset.dims[dataset.crs.dimensions[1]],
-        'height': dataset.dims[dataset.crs.dimensions[0]],
-        'transform': dataset.affine,
-        'crs': dataset.crs.crs_str,
+        'width': width,
+        'height': height,
+        'transform': geobox.affine,
+        'crs': geobox.crs.crs_str,
         'count': len(dataset.data_vars),
         'dtype': str(dtypes.pop())
     })
diff --git a/datacube/utils/xarray_geoextensions.py b/datacube/utils/xarray_geoextensions.py
--- a/datacube/utils/xarray_geoextensions.py
+++ b/datacube/utils/xarray_geoextensions.py
@@ -16,20 +16,38 @@
 from datacube.utils import data_resolution_and_offset, geometry
 
 
+def _norm_crs(crs):
+    if crs is None or isinstance(crs, geometry.CRS):
+        return crs
+    elif isinstance(crs, str):
+        return geometry.CRS(crs)
+    else:
+        raise ValueError('Can not interpret {} as CRS'.format(type(crs)))
+
+
 def _xarray_affine(obj):
-    dims = obj.crs.dimensions
+    crs = _norm_crs(obj.crs)
+    if crs is None:
+        return None
+
+    dims = crs.dimensions
     xres, xoff = data_resolution_and_offset(obj[dims[1]].values)
     yres, yoff = data_resolution_and_offset(obj[dims[0]].values)
     return Affine.translation(xoff, yoff) * Affine.scale(xres, yres)
 
 
 def _xarray_extent(obj):
-    return obj.geobox.extent
+    geobox = obj.geobox
+    return None if geobox is None else geobox.extent
 
 
 def _xarray_geobox(obj):
-    dims = obj.crs.dimensions
-    return geometry.GeoBox(obj[dims[1]].size, obj[dims[0]].size, obj.affine, obj.crs)
+    crs = _norm_crs(obj.crs)
+    if crs is None:
+        return None
+
+    dims = crs.dimensions
+    return geometry.GeoBox(obj[dims[1]].size, obj[dims[0]].size, obj.affine, crs)
 
 
 xarray.Dataset.geobox = property(_xarray_geobox)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -237,6 +237,26 @@ def test_write_geotiff(tmpdir, odc_style_xr_dataset):
         assert (written_data == odc_style_xr_dataset['B10']).all()
 
 
+def test_write_geotiff_str_crs(tmpdir, odc_style_xr_dataset):
+    """Ensure the geotiff helper writer works, and supports crs as a string."""
+    filename = tmpdir + '/test.tif'
+
+    original_crs = odc_style_xr_dataset.crs
+
+    odc_style_xr_dataset.attrs['crs'] = str(original_crs)
+    write_geotiff(filename, odc_style_xr_dataset)
+    assert filename.exists()
+
+    with rasterio.open(str(filename)) as src:
+        written_data = src.read(1)
+
+        assert (written_data == odc_style_xr_dataset['B10']).all()
+
+    del odc_style_xr_dataset.attrs['crs']
+    with pytest.raises(ValueError):
+        write_geotiff(filename, odc_style_xr_dataset)
+
+
 def test_write_geotiff_time_index_deprecated():
     """The `time_index` parameter to `write_geotiff()` was a poorly thought out addition and is now deprecated."""
 
diff --git a/tests/test_xarray_extension.py b/tests/test_xarray_extension.py
new file mode 100644
--- /dev/null
+++ b/tests/test_xarray_extension.py
@@ -0,0 +1,27 @@
+import pytest
+from datacube.utils.xarray_geoextensions import (
+    _norm_crs,
+    _xarray_affine,
+    _xarray_geobox,
+    _xarray_extent,
+)
+from datacube.testutils.geom import epsg4326
+
+
+def test_xr_extension(odc_style_xr_dataset):
+    assert _norm_crs(None) is None
+    assert _norm_crs(epsg4326) is epsg4326
+    assert _norm_crs(str(epsg4326)) == epsg4326
+
+    with pytest.raises(ValueError):
+        _norm_crs([])
+
+    assert odc_style_xr_dataset.geobox.shape == odc_style_xr_dataset.B10.shape
+
+    (sx, zz0, tx, zz1, sy, ty) = odc_style_xr_dataset.affine[:6]
+    assert (zz0, zz1) == (0, 0)
+
+    odc_style_xr_dataset.attrs['crs'] = None
+    assert _xarray_affine(odc_style_xr_dataset) is None
+    assert _xarray_geobox(odc_style_xr_dataset) is None
+    assert _xarray_extent(odc_style_xr_dataset) is None