Repository: opendatacube/datacube-core

Issue:
Calling DatasetType.dimensions when these are not set causes AttributeError
### Expected behaviour
If I have a dataset, and I want to get the dimensions, I should be able to get them. And if they're missing, it should tell me they're missing.

### Actual behaviour

```
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-45-66e0a2a24ac8> in <module>()
      1 # print(ds)
      2 landsat8 = dc.index.products.get_by_name('ls8_level1_usgs')
----> 3 print(landsat8.dimensions)

/usr/local/lib/python3.5/dist-packages/datacube/model/__init__.py in dimensions(self)
    421         """
    422         assert self.metadata_type.name == 'eo'
--> 423         return ('time',) + self.grid_spec.dimensions
    424 
    425     @cached_property

AttributeError: 'NoneType' object has no attribute 'dimensions'
```


### Steps to reproduce the behaviour

 * Added product information for landsat `datacube product add .../docs/config_samples/dataset_types/ls_usgs.yaml`
 * Indexed Landsat8 from S3
 * Get the product with `landsat8 = dc.index.products.get_by_name('ls8_level1_usgs')`
 * try to access the dimensions like `lansat8.dimensions`


### Environment information

* Datacube in Docker, latest build from the `develop` branch as of 4/4/18
* Datacube and Jupyter as defined in this system: https://github.com/crc-si/opendatacube-cloudformation-testing



Discussion:
Obviously code should not crash the way it does, so it's an error. 

On the other hand it's perfectly reasonable for product not to have `grid_spec`, it's not clear what the behaviour should be in this case. When `grid_spec` is set dimensions are taken from `crs` property of the `grid_spec`, and are either `y,x` (most common) or `latitude,longitude`.

Options:

1. Default to projected coordinate `y,x`
2. Default to non-projected `latitude,longitude`
3. Raise exception
4. Return None




Resolving patch:
diff --git a/datacube/model/__init__.py b/datacube/model/__init__.py
--- a/datacube/model/__init__.py
+++ b/datacube/model/__init__.py
@@ -29,6 +29,7 @@
 
 NETCDF_VAR_OPTIONS = {'zlib', 'complevel', 'shuffle', 'fletcher32', 'contiguous'}
 VALID_VARIABLE_ATTRS = {'standard_name', 'long_name', 'units', 'flags_definition'}
+DEFAULT_SPATIAL_DIMS = ('y', 'x')  # Used when product lacks grid_spec
 
 SCHEMA_PATH = Path(__file__).parent / 'schema'
 
@@ -420,7 +421,12 @@ def dimensions(self):
         :type: tuple[str]
         """
         assert self.metadata_type.name == 'eo'
-        return ('time',) + self.grid_spec.dimensions
+        if self.grid_spec is not None:
+            spatial_dims = self.grid_spec.dimensions
+        else:
+            spatial_dims = DEFAULT_SPATIAL_DIMS
+
+        return ('time',) + spatial_dims
 
     @cached_property
     def grid_spec(self):


Test patch:
diff --git a/tests/test_model.py b/tests/test_model.py
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -3,7 +3,7 @@
 from __future__ import absolute_import
 
 import numpy
-from .util import mk_sample_dataset
+from .util import mk_sample_dataset, mk_sample_product
 from datacube.model import GridSpec
 from datacube.utils import geometry
 from datacube.storage.storage import measurement_paths
@@ -53,3 +53,13 @@ def test_dataset_measurement_paths():
 
     for k, v in paths.items():
         assert v == 'file:///tmp/' + k + '.tiff'
+
+
+def test_product_dimensions():
+    product = mk_sample_product('test_product')
+    assert product.grid_spec is None
+    assert product.dimensions == ('time', 'y', 'x')
+
+    product = mk_sample_product('test_product', with_grid_spec=True)
+    assert product.grid_spec is not None
+    assert product.dimensions == ('time', 'y', 'x')
diff --git a/tests/util.py b/tests/util.py
--- a/tests/util.py
+++ b/tests/util.py
@@ -166,7 +166,14 @@ def isclose(a, b, rel_tol=1e-09, abs_tol=0.0):
 
 def mk_sample_product(name,
                       description='Sample',
-                      measurements=['red', 'green', 'blue']):
+                      measurements=['red', 'green', 'blue'],
+                      with_grid_spec=False,
+                      storage=None):
+
+    if storage is None and with_grid_spec is True:
+        storage = {'crs': 'EPSG:3577',
+                   'resolution': {'x': 25, 'y': -25},
+                   'tile_size': {'x': 100000.0, 'y': 100000.0}}
 
     eo_type = MetadataType({
         'name': 'eo',
@@ -203,13 +210,18 @@ def mk_measurement(m):
 
     measurements = [mk_measurement(m) for m in measurements]
 
-    return DatasetType(eo_type, dict(
+    definition = dict(
         name=name,
         description=description,
         metadata_type='eo',
         metadata={},
         measurements=measurements
-    ))
+    )
+
+    if storage is not None:
+        definition['storage'] = storage
+
+    return DatasetType(eo_type, definition)
 
 
 def mk_sample_dataset(bands,