Repository: dcs4cop/xcube

Issue:
xcube server cannot handle longitude and latitude as dimension names
**Describe the bug**
A cube with the dimensions `latitude` and `longitude` cannot be visualised via xcube viewer, because the server returns an error:  
I would expect xcube server to be able to handle dimensions `longitude` and `latitude` the same way as `lat` and `lon`
![image](https://user-images.githubusercontent.com/23457217/204007863-ebeca3f1-a7e9-41ff-928d-e9d10872155f.png)

Whole traceback
```
2022-11-25 13:16:45,571 - tornado.application - ERROR - Uncaught exception GET /api/datasets?details=1 (192.168.73.186)
HTTPServerRequest(protocol='http', host='doors.api.dev.brockmann-consult.de', method='GET', uri='/api/datasets?details=1', version='HTTP/1.1', remote_ip='192.168.73.186')
Traceback (most recent call last):
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/tornado/web.py", line 1704, in _execute
    result = await result
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/server/webservers/tornado.py", line 335, in get
    await self._call_method('get', args, *kwargs)
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/server/webservers/tornado.py", line 355, in _call_method
    method(*args, **kwargs)
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/webapi/datasets/routes.py", line 77, in get
    response = get_datasets(self.ctx,
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/webapi/datasets/controllers.py", line 145, in get_datasets
    get_dataset(ctx, ds_id,
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/webapi/datasets/controllers.py", line 208, in get_dataset
    dataset_bounds = get_dataset_bounds(ds)
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/core/geom.py", line 716, in get_dataset_bounds
    xy_var_names = get_dataset_xy_var_names(dataset, must_exist=True)
  File "/opt/conda/envs/xcube/lib/python3.9/site-packages/xcube-0.13.0.dev3-py3.9.egg/xcube/core/schema.py", line 305, in get_dataset_xy_var_names
    raise ValueError(f'{dataset_arg_name} has no valid spatial coordinate variables')
ValueError: dataset has no valid spatial coordinate variables
```


Discussion:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 81709690..153bc565 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -23,6 +23,12 @@
   `Can't determine indexer timezone; leaving it unmodified.`
   which was emitted in such cases.
 
+### Fixes
+
+* `xcube serve` will now also accept datasets with coordinate names
+  `longitude` and `latitude`, even if the attribute `long_name` isn't set.
+  (#763)
+
 * Function `xcube.core.resampling.affine.affine_transform_dataset()`
   now assumes that geographic coordinate systems are equal by default and
   hence a resampling based on an affine transformation can be performed.
diff --git a/xcube/core/geom.py b/xcube/core/geom.py
index 1130c4a8..a8596ea3 100644
--- a/xcube/core/geom.py
+++ b/xcube/core/geom.py
@@ -713,7 +713,7 @@ def get_dataset_geometry(dataset: Union[xr.Dataset, xr.DataArray],
 
 
 def get_dataset_bounds(dataset: Union[xr.Dataset, xr.DataArray],
-                       xy_var_names: Tuple[str, str] = None) -> Bounds:
+                       xy_var_names: Optional[Tuple[str, str]] = None) -> Bounds:
     if xy_var_names is None:
         xy_var_names = get_dataset_xy_var_names(dataset, must_exist=True)
     x_name, y_name = xy_var_names
diff --git a/xcube/core/schema.py b/xcube/core/schema.py
index 66c1de7b..4d3c9d29 100644
--- a/xcube/core/schema.py
+++ b/xcube/core/schema.py
@@ -263,9 +263,12 @@ def get_cube_schema(cube: xr.Dataset) -> CubeSchema:
                       chunks=first_chunks)
 
 
-def get_dataset_xy_var_names(coords: Union[xr.Dataset, xr.DataArray, Mapping[Hashable, xr.DataArray]],
+def get_dataset_xy_var_names(coords: Union[xr.Dataset,
+                                           xr.DataArray,
+                                           Mapping[Hashable, xr.DataArray]],
                              must_exist: bool = False,
-                             dataset_arg_name: str = 'dataset') -> Optional[Tuple[str, str]]:
+                             dataset_arg_name: str = 'dataset') \
+        -> Optional[Tuple[str, str]]:
     if hasattr(coords, 'coords'):
         coords = coords.coords
     x_var_name = None
@@ -294,7 +297,9 @@ def get_dataset_xy_var_names(coords: Union[xr.Dataset, xr.DataArray, Mapping[Has
         if x_var_name and y_var_name:
             return str(x_var_name), str(y_var_name)
 
-    for x_var_name, y_var_name in (('lon', 'lat'), ('x', 'y')):
+    for x_var_name, y_var_name in (('lon', 'lat'),
+                                   ('longitude', 'latitude'),
+                                   ('x', 'y')):
         if x_var_name in coords and y_var_name in coords:
             x_var = coords[x_var_name]
             y_var = coords[y_var_name]
@@ -302,7 +307,8 @@ def get_dataset_xy_var_names(coords: Union[xr.Dataset, xr.DataArray, Mapping[Has
                 return x_var_name, y_var_name
 
     if must_exist:
-        raise ValueError(f'{dataset_arg_name} has no valid spatial coordinate variables')
+        raise ValueError(f'{dataset_arg_name}'
+                         f' has no valid spatial coordinate variables')
 
 
 def get_dataset_time_var_name(dataset: Union[xr.Dataset, xr.DataArray],


Test patch:
diff --git a/test/core/test_geom.py b/test/core/test_geom.py
index 4b58bcf8..35e62bd9 100644
--- a/test/core/test_geom.py
+++ b/test/core/test_geom.py
@@ -443,6 +443,16 @@ class GetDatasetBoundsTest(unittest.TestCase):
         bounds = get_dataset_bounds(ds2)
         self.assertEqual((-25.0, -15.0, 15.0, 15.0), bounds)
 
+    def test_longitude_latitude(self):
+        ds1, ds2 = _get_nominal_datasets()
+        ds1 = ds1.rename(dict(lon='longitude', lat='latitude'))
+        ds2 = ds2.rename(dict(lon='longitude', lat='latitude'))
+
+        bounds = get_dataset_bounds(ds1)
+        self.assertEqual((-25.0, -15.0, 15.0, 15.0), bounds)
+        bounds = get_dataset_bounds(ds2)
+        self.assertEqual((-25.0, -15.0, 15.0, 15.0), bounds)
+
     def test_inv_y(self):
         ds1, ds2 = _get_inv_y_datasets()
         bounds = get_dataset_bounds(ds1)
diff --git a/test/core/test_schema.py b/test/core/test_schema.py
index 8269c9ef..954309e6 100644
--- a/test/core/test_schema.py
+++ b/test/core/test_schema.py
@@ -1,10 +1,13 @@
 import unittest
 
 import numpy as np
+import pytest
 import xarray as xr
 
 from xcube.core.new import new_cube
-from xcube.core.schema import CubeSchema, get_dataset_chunks
+from xcube.core.schema import CubeSchema
+from xcube.core.schema import get_dataset_chunks
+from xcube.core.schema import get_dataset_xy_var_names
 
 
 class CubeSchemaTest(unittest.TestCase):
@@ -223,3 +226,80 @@ class GetDatasetChunksTest(unittest.TestCase):
             {'time': 1, 'x': 10, 'y': 25},
             get_dataset_chunks(dataset)
         )
+
+
+class GetDatasetXYVarNamesTest(unittest.TestCase):
+
+    def test_find_by_cf_standard_name_attr(self):
+        dataset = xr.Dataset(coords=dict(
+            u=xr.DataArray([1, 2, 3], attrs=dict(
+                standard_name="projection_x_coordinate"
+            )),
+            v=xr.DataArray([1, 2, 3], attrs=dict(
+                standard_name="projection_y_coordinate"
+            )),
+        ))
+        self.assertEqual(("u", "v"),
+                         get_dataset_xy_var_names(dataset))
+
+    def test_find_by_cf_long_name_attr(self):
+
+        dataset = xr.Dataset(coords=dict(
+            u=xr.DataArray([1, 2, 3], attrs=dict(
+                long_name="x coordinate of projection"
+            )),
+            v=xr.DataArray([1, 2, 3], attrs=dict(
+                long_name="y coordinate of projection"
+            )),
+        ))
+        self.assertEqual(("u", "v"),
+                         get_dataset_xy_var_names(dataset))
+
+        dataset = xr.Dataset(coords=dict(
+            a=xr.DataArray([1, 2, 3], attrs=dict(
+                long_name="longitude"
+            )),
+            b=xr.DataArray([1, 2, 3], attrs=dict(
+                long_name="latitude"
+            )),
+        ))
+        self.assertEqual(("a", "b"),
+                         get_dataset_xy_var_names(dataset))
+
+    def test_find_by_var_name(self):
+
+        dataset = xr.Dataset(coords=dict(
+            x=xr.DataArray([1, 2, 3]),
+            y=xr.DataArray([1, 2, 3]),
+        ))
+        self.assertEqual(("x", "y"),
+                         get_dataset_xy_var_names(dataset))
+
+        dataset = xr.Dataset(coords=dict(
+            lon=xr.DataArray([1, 2, 3]),
+            lat=xr.DataArray([1, 2, 3]),
+        ))
+        self.assertEqual(("lon", "lat"),
+                         get_dataset_xy_var_names(dataset))
+
+        dataset = xr.Dataset(coords=dict(
+            longitude=xr.DataArray([1, 2, 3]),
+            latitude=xr.DataArray([1, 2, 3]),
+        ))
+        self.assertEqual(("longitude", "latitude"),
+                         get_dataset_xy_var_names(dataset))
+
+    def test_not_found(self):
+        dataset = xr.Dataset(coords=dict(
+            hanni=xr.DataArray([1, 2, 3]),
+            nanni=xr.DataArray([1, 2, 3]),
+        ))
+
+        self.assertIsNone(get_dataset_xy_var_names(dataset))
+
+        with pytest.raises(ValueError,
+                           match="dataset has no valid"
+                                 " spatial coordinate variables"):
+            get_dataset_xy_var_names(dataset, must_exist=True)
+
+        self.assertIsNone(get_dataset_xy_var_names(dataset))