Repository: opendatacube/datacube-core

Issue:
Proposal: Extend Measurements class to add base_attrs method
What are your thoughts on extending the [Measurements class](https://github.com/opendatacube/datacube-core/blob/develop/datacube/model/__init__.py#L314-L328) to have a `base_attrs()` method that could replace functionality [here](https://github.com/opendatacube/datacube-core/blob/develop/datacube/api/core.py#L443-L454)?

Then, it could be dropped in the [measurements property](https://github.com/opendatacube/datacube-core/blob/develop/datacube/model/__init__.py#L414-L420) of the DatasetType.



Discussion:
Just for some context, we've created an index class that maps existing internal data structures to Data Cube models. This change would allow users that use a custom index to subclass `Measurement` and include custom fields that would be added to output Datasets without additional changes. 

Resolving patch:
diff --git a/datacube/api/core.py b/datacube/api/core.py
--- a/datacube/api/core.py
+++ b/datacube/api/core.py
@@ -439,17 +439,8 @@ def work_measurements(measurement, data_func):
 
         for measurement in measurements:
             data = results.pop(0)
-
-            attrs = {
-                'nodata': measurement.get('nodata'),
-                'units': measurement.get('units', '1'),
-                'crs': geobox.crs
-            }
-            if 'flags_definition' in measurement:
-                attrs['flags_definition'] = measurement['flags_definition']
-            if 'spectral_definition' in measurement:
-                attrs['spectral_definition'] = measurement['spectral_definition']
-
+            attrs = measurement.dataarray_attrs()
+            attrs['crs'] = geobox.crs
             dims = tuple(coords.keys()) + tuple(geobox.dimensions)
             result[measurement['name']] = (dims, data, attrs)
 
diff --git a/datacube/model/__init__.py b/datacube/model/__init__.py
--- a/datacube/model/__init__.py
+++ b/datacube/model/__init__.py
@@ -316,21 +316,32 @@ def metadata_doc_without_lineage(self):
         return without_lineage_sources(self.metadata_doc, self.metadata_type)
 
 
-class Measurement(object):
-    REQUIRED_KEYS = ['name', 'dtype', 'nodata', 'units']
-    OPTIONAL_KEYS = ['aliases', 'spectral_definition', 'flags_definition']
+class Measurement(dict):
+    REQUIRED_KEYS = ('name', 'dtype', 'nodata', 'units')
+    OPTIONAL_KEYS = ('aliases', 'spectral_definition', 'flags_definition')
+    FILTER_ATTR_KEYS = ('name', 'dtype')
 
     def __init__(self, **measurement_dict):
-        for key in self.REQUIRED_KEYS:
-            setattr(self, key, measurement_dict[key])
+        missing_keys = set(self.REQUIRED_KEYS) - set(measurement_dict)
+        if missing_keys:
+            raise ValueError("Measurent required keys missing: {}".format(missing_keys))
 
-        for key in self.OPTIONAL_KEYS:
-            if key in measurement_dict:
-                setattr(self, key, measurement_dict[key])
+        measurement_data = {key: value for key, value in measurement_dict.items()
+                            if key in self.REQUIRED_KEYS + self.OPTIONAL_KEYS}
+
+        super().__init__(measurement_data)
 
     def __repr__(self):
-        attrs = ", ".join("{}={}".format(key, value) for key, value in vars(self).items())
-        return "Measurement({})".format(attrs)
+        return "Measurement({})".format(super(Measurement, self).__repr__())
+
+    def copy(self):
+        """Required as the super class `dict` method returns a `dict`
+           and does not preserve Measurement class"""
+        return Measurement(**self)
+
+    def dataarray_attrs(self):
+        """This returns attributes filtered for display in a dataarray."""
+        return {key: value for key, value in self.items() if key not in self.FILTER_ATTR_KEYS}
 
 
 @schema_validated(SCHEMA_PATH / 'metadata-type-schema.yaml')
@@ -422,7 +433,7 @@ def measurements(self):
 
         :type: dict[str, dict]
         """
-        return OrderedDict((m['name'], m) for m in self.definition.get('measurements', []))
+        return OrderedDict((m['name'], Measurement(**m)) for m in self.definition.get('measurements', []))
 
     @property
     def dimensions(self):
diff --git a/datacube/scripts/ingest.py b/datacube/scripts/ingest.py
--- a/datacube/scripts/ingest.py
+++ b/datacube/scripts/ingest.py
@@ -14,7 +14,7 @@
 import datacube
 from datacube.api.core import Datacube
 from datacube.index.index import Index
-from datacube.model import DatasetType, Range, GeoPolygon
+from datacube.model import DatasetType, Range, GeoPolygon, Measurement
 from datacube.model.utils import make_dataset, xr_apply, datasets_to_doc
 from datacube.ui import click as ui
 from datacube.utils import read_documents
@@ -58,7 +58,7 @@ def morph_dataset_type(source_type, config, index, storage_format):
 
     def merge_measurement(measurement, spec):
         measurement.update({k: spec.get(k, measurement[k]) for k in ('name', 'nodata', 'dtype')})
-        return measurement
+        return Measurement(**measurement)
 
     output_type.definition['measurements'] = [merge_measurement(output_type.measurements[spec['src_varname']], spec)
                                               for spec in config['measurements']]
@@ -110,7 +110,7 @@ def get_filename(config, tile_index, sources, **kwargs):
 def get_measurements(source_type, config):
     def merge_measurement(measurement, spec):
         measurement.update({k: spec.get(k) or measurement[k] for k in ('nodata', 'dtype', 'resampling_method')})
-        return measurement
+        return Measurement(**measurement)
 
     return [merge_measurement(source_type.measurements[spec['src_varname']].copy(), spec)
             for spec in config['measurements']]


Test patch: