Repository: dcs4cop/xcube (python)

Issue:
xcube server to return default color bar
**Is your feature request related to a problem? Please describe.**

xcube server will return any color bar associated with a variable, independent whether the color bar name is known or not, e.g. `"Plasma"` instead of `"plasma"`. xcube viewer will show a missing color bar image in this case: 

![image](https://user-images.githubusercontent.com/206773/69242538-56ac6400-0ba1-11ea-9a11-2c3635218bff.png)

**Describe the solution you'd like**

Let xcube server report the actual default color bar used in the case, i.e. "viridis".

**Describe alternatives you've considered**



Pull request description:
Forman 238 default color bar name
For `xcube serve`, any undefined color bar name will default to `"viridis"`.

Closes #238

Changes:

* New function `xcube.util.cmaps.get_cmap()` that makes sure, a defined color mapping is returned, defaulting to `"viridis"`.
* Code that uses the new function:
  - function `xcube.webapi.context.ServiceContext.get_color_mapping()`
  - file `D:\Projects\xcube\xcube\webapi\defaults.py` (DEFAULT_CMAP_CBAR = 'viridis')
  - class `xcube.util.tiledimage.ColorMappedRgbaImage`
  - function `xcube.webapi.controllers.tiles.get_legend()`



Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 8564a844..ccf7fe14 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -38,6 +38,8 @@
   `lat_bnds` is decreasing for `lat_bnds[:, 0]` and `lat_bnds[:, 1]`, but `lat_bnds[i, 0] < lat_bnds[i, 1]`
   for all `i`. xcube is now more tolerant w.r.t. to such wrong ordering of cell boundaries and will 
   compute the correct spatial extent. (#233)
+* For `xcube serve`, any undefined color bar name will default to `"viridis"`. (#238)
+    
  
 ### Fixes
 
diff --git a/xcube/util/cmaps.py b/xcube/util/cmaps.py
index 4d75b18a..7e4ff7eb 100644
--- a/xcube/util/cmaps.py
+++ b/xcube/util/cmaps.py
@@ -101,6 +101,34 @@ _CBARS_LOADED = False
 _LOCK = Lock()
 
 
+def get_cmap(cmap_name: str, default_cmap_name='viridis', num_colors: int = None):
+    """
+    Get color mapping for color bar name *cmap_name*.
+
+    If *num_colors* is a positive integer,
+    a resampled mapping will be returned that contains *num_colors* color entries.
+
+    If *cmap_name* is not defined, *default_cmap_name* is used.
+    If *default_cmap_name* is undefined too, a ValueError is raised.
+
+    Otherwise, a tuple (actual_cmap_name, cmap) is returned.
+
+    :param cmap_name: Color bar name.
+    :param num_colors: Number of colours in returned color mapping.
+    :param default_cmap_name: Default color bar name.
+    :return: A tuple (actual_cmap_name, cmap).
+    """
+    ensure_cmaps_loaded()
+    try:
+        cmap = cm.get_cmap(cmap_name, num_colors)
+    except ValueError as e:
+        _LOG.warning(str(e))
+        _LOG.warning(f'color map name {cmap_name!r} undefined, falling back to {default_cmap_name!r}')
+        cmap_name = default_cmap_name
+        cmap = cm.get_cmap(cmap_name, num_colors)
+    return cmap_name, cmap
+
+
 def get_cmaps():
     """
     Return a JSON-serializable tuple containing records of the form:
diff --git a/xcube/util/tiledimage.py b/xcube/util/tiledimage.py
index 28232b3d..922261e3 100644
--- a/xcube/util/tiledimage.py
+++ b/xcube/util/tiledimage.py
@@ -25,14 +25,13 @@ import uuid
 from abc import ABCMeta, abstractmethod
 from typing import Tuple, Sequence, Union, Any, Callable, Optional
 
-import matplotlib.cm as cm
 import numba
 import numpy as np
 from PIL import Image
 
 from xcube.constants import GLOBAL_GEO_EXTENT
 from xcube.util.cache import Cache
-from xcube.util.cmaps import ensure_cmaps_loaded
+from xcube.util.cmaps import get_cmap
 from xcube.util.perf import measure_time_cm
 from xcube.util.tilegrid import TileGrid, GeoExtent
 
@@ -43,6 +42,10 @@ except ImportError:
 
 __author__ = "Norman Fomferra (Brockmann Consult GmbH)"
 
+DEFAULT_COLOR_MAP_NAME = 'viridis'
+DEFAULT_COLOR_MAP_VALUE_RANGE = 0.0, 1.0
+DEFAULT_COLOR_MAP_NUM_COLORS = 256
+
 _LOG = logging.getLogger('xcube')
 
 X = int
@@ -441,9 +444,9 @@ class ColorMappedRgbaImage(DecoratorImage):
     def __init__(self,
                  source_image: TiledImage,
                  image_id: str = None,
-                 value_range: Tuple[float, float] = (0.0, 1.0),
-                 cmap_name: str = None,
-                 num_colors: int = 256,
+                 value_range: Tuple[float, float] = DEFAULT_COLOR_MAP_VALUE_RANGE,
+                 cmap_name: str = DEFAULT_COLOR_MAP_NAME,
+                 num_colors: int = DEFAULT_COLOR_MAP_NUM_COLORS,
                  no_data_value: Union[int, float] = None,
                  encode: bool = False,
                  format: str = None,
@@ -451,19 +454,12 @@ class ColorMappedRgbaImage(DecoratorImage):
                  trace_perf: bool = False):
         super().__init__(source_image, image_id=image_id, format=format, mode='RGBA', tile_cache=tile_cache,
                          trace_perf=trace_perf)
-        self._value_range = value_range
-        self._cmap_name = cmap_name if cmap_name else 'jet'
-        ensure_cmaps_loaded()
-
-        try:
-            self._cmap = cm.get_cmap(self._cmap_name, num_colors)
-        except ValueError as e:
-            _LOG.warning(str(e))
-            _LOG.warning('reverting to colour map "jet"')
-            self._cmap_name = 'jet'
-            self._cmap = cm.get_cmap(self._cmap_name, num_colors)
-
-        self._cmap.set_bad('k', 0)
+        self._value_range = value_range or DEFAULT_COLOR_MAP_VALUE_RANGE
+        cmap_name, cmap = get_cmap(cmap_name or DEFAULT_COLOR_MAP_NAME,
+                                   default_cmap_name=DEFAULT_COLOR_MAP_NAME,
+                                   num_colors=num_colors or DEFAULT_COLOR_MAP_NUM_COLORS)
+        self._cmap_name = cmap_name
+        self._cmap = cmap
         self._no_data_value = no_data_value
         self._encode = encode
 
@@ -545,9 +541,9 @@ class ColorMappedRgbaImage2(OpImage):
                  array: np.ndarray,
                  tile_size: Size2D,
                  image_id: str = None,
-                 cmap_range: Tuple[float, float] = (0.0, 1.0),
-                 cmap_name: str = None,
-                 num_colors: int = 256,
+                 cmap_range: Tuple[float, float] = DEFAULT_COLOR_MAP_VALUE_RANGE,
+                 cmap_name: str = DEFAULT_COLOR_MAP_NAME,
+                 num_colors: int = DEFAULT_COLOR_MAP_NUM_COLORS,
                  no_data_value: Union[int, float] = None,
                  encode: bool = False,
                  format: str = None,
@@ -569,20 +565,18 @@ class ColorMappedRgbaImage2(OpImage):
         valid_range = valid_range if valid_range is not None else (-np.inf, np.inf)
         valid_range = tuple(map(float, valid_range))
         no_data_value = float(no_data_value) if no_data_value is not None else float(np.nan)
-        cmap_range = cmap_range if cmap_range is not None else (0.0, 1.0)
+        cmap_range = cmap_range if cmap_range is not None else DEFAULT_COLOR_MAP_VALUE_RANGE
         cmap_range = tuple(map(float, cmap_range))
-        cmap_name = cmap_name if cmap_name else 'jet'
         self._array = array
         self._valid_range = valid_range
         self._no_data_value = no_data_value
         self._encode = encode
         self._flip_y = flip_y
-        self._num_colors = num_colors
-        ensure_cmaps_loaded()
         self._cmap_range = cmap_range
-        cmap = cm.get_cmap(cmap_name, num_colors)
-        # cmap.set_bad('k', 0)
-        self._colors = cmap(np.linspace(0, 1, num_colors))
+        self._num_colors = num_colors or DEFAULT_COLOR_MAP_NUM_COLORS
+        cmap_name, cmap = get_cmap(cmap_name or DEFAULT_COLOR_MAP_NAME,
+                                   default_cmap_name=DEFAULT_COLOR_MAP_NAME)
+        self._colors = cmap(np.linspace(0, 1, num_colors or DEFAULT_COLOR_MAP_NUM_COLORS))
 
     def compute_tile(self,
                      tile_x: int, tile_y: int,
diff --git a/xcube/webapi/context.py b/xcube/webapi/context.py
index b803adac..01810f8a 100644
--- a/xcube/webapi/context.py
+++ b/xcube/webapi/context.py
@@ -36,6 +36,7 @@ import zarr
 from xcube.core.dsio import guess_dataset_format
 from xcube.core.verify import assert_cube
 from xcube.constants import FORMAT_NAME_ZARR, FORMAT_NAME_NETCDF4, FORMAT_NAME_LEVELS
+from xcube.util.cmaps import get_cmap
 from xcube.util.perf import measure_time
 from xcube.version import version
 from xcube.util.cache import MemoryCacheStore, Cache
@@ -227,7 +228,7 @@ class ServiceContext:
                             cmap_cbar = color_mapping.get('ColorFile', cmap_cbar)
                         else:
                             cmap_cbar = color_mapping.get('ColorBar', cmap_cbar)
-
+                            cmap_cbar, _ = get_cmap(cmap_cbar)
                         return cmap_cbar, cmap_vmin, cmap_vmax
             else:
                 ds = self.get_dataset(ds_id, expected_var_names=[var_name])
diff --git a/xcube/webapi/controllers/tiles.py b/xcube/webapi/controllers/tiles.py
index ef8e3bff..52867aea 100644
--- a/xcube/webapi/controllers/tiles.py
+++ b/xcube/webapi/controllers/tiles.py
@@ -10,7 +10,7 @@ import matplotlib.colors
 import matplotlib.figure
 import numpy as np
 
-from xcube.util.cmaps import get_norm
+from xcube.util.cmaps import get_norm, get_cmap
 from xcube.util.perf import measure_time_cm
 from xcube.util.tiledimage import NdarrayImage, TransformArrayImage, ColorMappedRgbaImage, ColorMappedRgbaImage2
 from xcube.util.tilegrid import TileGrid
@@ -163,9 +163,9 @@ def get_legend(ctx: ServiceContext,
         cmap_h = cmap_h or DEFAULT_CMAP_HEIGHT
 
     try:
-        cmap = cm.get_cmap(cmap_cbar)
+        _, cmap = get_cmap(cmap_cbar)
     except ValueError:
-        raise ServiceResourceNotFoundError(f"color bar {cmap_cbar} not found")
+        raise ServiceResourceNotFoundError(f"color bar {cmap_cbar!r} not found")
 
     fig = matplotlib.figure.Figure(figsize=(cmap_w, cmap_h))
     ax1 = fig.add_subplot(1, 1, 1)
diff --git a/xcube/webapi/defaults.py b/xcube/webapi/defaults.py
index b6c85282..18879189 100644
--- a/xcube/webapi/defaults.py
+++ b/xcube/webapi/defaults.py
@@ -33,7 +33,7 @@ DEFAULT_LOG_PREFIX = os.path.abspath('xcube_server.log')
 DEFAULT_TILE_COMP_MODE = 0
 DEFAULT_TRACE_PERF = False
 
-DEFAULT_CMAP_CBAR = 'jet'
+DEFAULT_CMAP_CBAR = 'viridis'
 DEFAULT_CMAP_VMIN = 0.
 DEFAULT_CMAP_VMAX = 1.
 DEFAULT_CMAP_WIDTH = 1


Test patch:
diff --git a/test/util/test_cmaps.py b/test/util/test_cmaps.py
index 9ca5c100..583ca74d 100644
--- a/test/util/test_cmaps.py
+++ b/test/util/test_cmaps.py
@@ -1,13 +1,32 @@
 import os
 from unittest import TestCase
-from matplotlib.colors import LinearSegmentedColormap, ListedColormap
+
 import matplotlib.cm as cm
+from matplotlib.colors import LinearSegmentedColormap, ListedColormap, Colormap
 
-from xcube.util.cmaps import get_cmaps, ensure_cmaps_loaded, _get_custom_colormap
+from xcube.util.cmaps import get_cmaps, ensure_cmaps_loaded, _get_custom_colormap, get_cmap
 
 
 class CmapsTest(TestCase):
 
+    def test_get_cmap(self):
+        ensure_cmaps_loaded()
+
+        cmap_name, cmap = get_cmap('plasma')
+        self.assertEqual('plasma', cmap_name)
+        self.assertIsInstance(cmap, Colormap)
+
+        cmap_name, cmap = get_cmap('PLASMA')
+        self.assertEqual('viridis', cmap_name)
+        self.assertIsInstance(cmap, Colormap)
+
+        cmap_name, cmap = get_cmap('PLASMA', default_cmap_name='magma')
+        self.assertEqual('magma', cmap_name)
+        self.assertIsInstance(cmap, Colormap)
+
+        with self.assertRaises(ValueError):
+            get_cmap('PLASMA', default_cmap_name='MAGMA')
+
     def test_get_cmaps_returns_singleton(self):
         cmaps = get_cmaps()
         self.assertIs(cmaps, get_cmaps())
@@ -90,7 +109,6 @@ class CmapsTest(TestCase):
 
 
 def main():
-
     cmaps = get_cmaps()
 
     html_head = '<!DOCTYPE html>\n' + \
diff --git a/test/webapi/controllers/test_tiles.py b/test/webapi/controllers/test_tiles.py
index 4e0b4ae2..0c5b31ea 100644
--- a/test/webapi/controllers/test_tiles.py
+++ b/test/webapi/controllers/test_tiles.py
@@ -4,7 +4,7 @@ from test.webapi.helpers import new_test_service_context, RequestParamsMock
 from xcube.webapi.context import ServiceContext
 from xcube.webapi.controllers.tiles import get_dataset_tile, get_ne2_tile, get_dataset_tile_grid, get_ne2_tile_grid, \
     get_legend
-from xcube.webapi.errors import ServiceBadRequestError, ServiceResourceNotFoundError
+from xcube.webapi.errors import ServiceBadRequestError
 
 
 class TilesControllerTest(unittest.TestCase):
@@ -89,9 +89,9 @@ class TilesControllerTest(unittest.TestCase):
         image = get_legend(ctx, 'demo', 'conc_chl', RequestParamsMock())
         self.assertEqual("<class 'bytes'>", str(type(image)))
 
-        with self.assertRaises(ServiceResourceNotFoundError) as cm:
-            get_legend(ctx, 'demo', 'conc_chl', RequestParamsMock(cbar='sun-shine'))
-        self.assertEqual('color bar sun-shine not found', cm.exception.reason)
+        # This is fine, because we fall back to "viridis".
+        image = get_legend(ctx, 'demo', 'conc_chl', RequestParamsMock(cbar='sun-shine'))
+        self.assertEqual("<class 'bytes'>", str(type(image)))
 
         with self.assertRaises(ServiceBadRequestError) as cm:
             get_legend(ctx, 'demo', 'conc_chl', RequestParamsMock(vmin='sun-shine'))
diff --git a/test/webapi/test_context.py b/test/webapi/test_context.py
index 38748224..1103b56b 100644
--- a/test/webapi/test_context.py
+++ b/test/webapi/test_context.py
@@ -69,7 +69,7 @@ class ServiceContextTest(unittest.TestCase):
         cm = ctx.get_color_mapping('demo', 'kd489')
         self.assertEqual(('jet', 0., 6.), cm)
         cm = ctx.get_color_mapping('demo', '_')
-        self.assertEqual(('jet', 0., 1.), cm)
+        self.assertEqual(('viridis', 0., 1.), cm)
 
     def test_get_global_place_groups(self):
         ctx = new_test_service_context()