Repository: tomvothecoder/xcdat

Issue:
Add attributes and properties to AxisAccessor class and refactor `get_lat_bounds()`
### Is your feature request related to a problem? If yes, Please describe
<!-- Concise description of the problem -->
Related to #39 

### Describe your proposed solution
<!-- Concise description of your proposed solution. -->
Adding class attributes or properties to describe the the axis would be useful, for example if the dataset already has bounds.

- Consider refactoring get_bounds() and child methods (specifically `get_lat_bounds()` which seems overly-complex)
  - Iris's implementation, called [guess_bounds()](https://github.com/SciTools/iris/blob/master/lib/iris/coords.py#L2017). 
  - It looks much simpler in comparison to the CDAT implementation, which is what the current code is based on.
  - `adjust_lat_bounds()` Iris equivalent [iris.coords.coord._discontinguity_in_bounds()](https://github.com/SciTools/iris/blob/83309c32a6c9cfbd4603732a4075bae0f6f07d45/lib/iris/coords.py#L1687)

Acceptance criteria
- [ ] Add attribute `has_bounds()`
  - https://github.com/SciTools/iris/blob/83309c32a6c9cfbd4603732a4075bae0f6f07d45/lib/iris/coords.py#L1908
- 

### Describe alternatives solutions you've considered
<!-- Concise description of any alternative solutions or features you've considered. -->

### Additional context
<!-- Other context, examples, or screenshots about the feature request here. -->


Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo) Report
> Merging [#54](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo) (ebd93d7) into [main](https://codecov.io/gh/tomvothecoder/xcdat/commit/27879bfafa6455e25af45fe61af35c25bba005d8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo) (27879bf) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tomvothecoder/xcdat/pull/54/graphs/tree.svg?width=650&height=150&src=pr&token=UYF6BAURTH&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo)](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo)

```diff
@@            Coverage Diff             @@
##              main       #54    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files            2         5     +3     
  Lines           21       121   +100     
==========================================
+ Hits            21       121   +100     
```


| [Impacted Files](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo) | Coverage Δ | |
|---|---|---|
| [xcdat/axis.py](https://codecov.io/gh/tomvothecoder/xcdat/pull/54/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo#diff-eGNkYXQvYXhpcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [xcdat/log.py](https://codecov.io/gh/tomvothecoder/xcdat/pull/54/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo#diff-eGNkYXQvbG9nLnB5) | `100.00% <100.00%> (ø)` | |
| [xcdat/utils.py](https://codecov.io/gh/tomvothecoder/xcdat/pull/54/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo#diff-eGNkYXQvdXRpbHMucHk=) | `100.00% <100.00%> (ø)` | |
| [xcdat/xcdat.py](https://codecov.io/gh/tomvothecoder/xcdat/pull/54/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo#diff-eGNkYXQveGNkYXQucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo). Last update [a92d281...ebd93d7](https://codecov.io/gh/tomvothecoder/xcdat/pull/54?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tom+Vo).


Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a4322e8..ef7bb4b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -17,5 +17,6 @@
     "python.testing.pytestEnabled": true,
     // Debugger doesn't work if pytest-cov is enabled
     // https://github.com/microsoft/vscode-python/issues/693
-    "python.testing.pytestArgs": ["--no-cov"]
+    "python.testing.pytestArgs": ["--no-cov"],
+    "jupyter.notebookFileRoot": "${workspaceFolder}"
 }
diff --git a/xcdat/axis.py b/xcdat/axis.py
index 67c9ba8..d3b338c 100644
--- a/xcdat/axis.py
+++ b/xcdat/axis.py
@@ -1,8 +1,8 @@
-from typing import List, Optional
+from typing import Optional
 
 import numpy as np
 import xarray as xr
-from typing_extensions import Literal, TypedDict
+from typing_extensions import Literal
 
 from xcdat.xcdat import logger
 
@@ -16,56 +16,50 @@ class AxisAccessor:
 
     Examples
 
+    >>> import xarray as xr
     >>> from xcdat import axis
 
-    Get bounds:
+    Get latitude and longitude bounds (cached properties):
 
-    >>> # Assuming dataset has axes bounds
     >>> ds = xr.open_dataset("file_path")
-    >>> lat_bnds = ds.axis.get_bounds('lat')
-    2021-04-28 11:30:04,196 [INFO]: axis.py(get_bounds:82) >> lat bounds were found in
-    the dataset.
-
-    >>> # Assuming dataset has no axes bounds
-    >>> ds = xr.open_dataset("file_path")
-    >>> lat_bnds = ds.axis.get_bounds("lat")
-    2021-04-28 11:31:51,052 [WARNING]: axis.py(get_bounds:86) >> lat bounds were not
-    found in the dataset, generating bounds.
+    >>> lat_bnds = ds.axis.lat_bnds
+    >>> lon_bnds = ds.axis.lon_bnds
 
-    Get bounds explicitly and do not generate bounds if none found:
+    Get axis bounds explicitly:
 
-    >>> # Assuming dataset has axes bounds
     >>> ds = xr.open_dataset("file_path")
-    >>> lat_bnds = ds.axis.get_bounds('lat', allow_generating=False)
-    2021-04-28 11:30:04,196 [INFO]: axis.py(get_bounds:82) >> lat bounds were found in the dataset.
+    >>> lat_bnds = ds.axis.get_bounds('lat')
+    >>> lon_bnds = ds.axis.get_bounds('lon')
+
+    Get axis bounds explicitly and do not generate bounds if it doesn't exist:
 
-    >>> # Assuming dataset has no axes bounds
     >>> ds = xr.open_dataset("file_path")
     >>> lat_bnds = ds.axis.get_bounds('lat', allow_generating=False)
-    ValueError: lat bounds were not found in the dataset, bounds must be generated
     """
 
     # Type annotation definitions
     Axis = Literal["lat", "lon"]
-    AxesMapValue = TypedDict(
-        "AxesMapValue", {"coords": List[str], "bounds_vars": List[str]}
-    )
-    AxesMap = TypedDict("AxesMap", {"lat": AxesMapValue, "lon": AxesMapValue})
-
-    # Mapping of generic axes names to coordinates and bounds variables in the Dataset
-    axes_map: AxesMap = {
-        "lat": {
-            "coords": ["lat", "latitude"],
-            "bounds_vars": ["lat_bnds", "latitude_bnds"],
-        },
-        "lon": {
-            "coords": ["lon", "longitude"],
-            "bounds_vars": ["lon_bnds", "longitude_bnds"],
-        },
-    }
 
     def __init__(self, xarray_obj: xr.Dataset):
-        self._dataset = xarray_obj
+        self._dataset: xr.Dataset = xarray_obj
+
+    @property
+    def lat_bnds(self) -> Optional[xr.DataArray]:
+        """Get latitude boundaries and caches value.
+
+        :return: Latitude boundaries
+        :rtype: Optional[xr.DataArray]
+        """
+        return self.get_bounds("lat")
+
+    @property
+    def lon_bnds(self) -> Optional[xr.DataArray]:
+        """Get longitude boundaries and caches value.
+
+        :return: Longitude boundaries
+        :rtype: Optional[xr.DataArray]
+        """
+        return self.get_bounds("lon")
 
     def get_bounds(
         self, axis: Axis, allow_generating: bool = True
@@ -85,14 +79,13 @@ class AxisAccessor:
         :return: Axis bounds or None
         :rtype: Optional[xr.DataArray]
         """
-        bounds_vars = AxisAccessor.axes_map[axis]["bounds_vars"]
-        matching_bounds: Optional[xr.DataArray] = None
+        if axis not in ["lat", "lon"]:
+            raise ValueError("Axis must be 'lat' or 'lon")
 
-        for var in bounds_vars:
-            matching_bounds = self._dataset.data_vars.get(var)
-            if matching_bounds is not None:
-                logger.info(f"{axis} bounds were found in the dataset.")
-                return matching_bounds
+        matching_bounds = self._dataset.data_vars.get(f"{axis}_bnds")
+        if matching_bounds is not None:
+            logger.info(f"{axis} bounds were found in the dataset.")
+            return matching_bounds
 
         if matching_bounds is None and allow_generating:
             logger.warning(
@@ -104,7 +97,7 @@ class AxisAccessor:
                 f"{axis} bounds were not found in the dataset, bounds must be generated"
             )
 
-        # Need a default return, but will never reach based on conditionals above
+        # Need a default return, but will never be reached based on conditionals.
         return None  # pragma: no cover
 
     def _gen_bounds(self, axis: Axis, width: float = 1) -> xr.DataArray:
@@ -120,36 +113,38 @@ class AxisAccessor:
         :return: The generated axis bounds DataArray
         :rtype: xr.DataArray
         """
-        axis_coords = self._extract_axis_coords(axis)
-        bounds = self._gen_base_bounds(axis_coords.data, width)
+        axis_coords: xr.DataArray = self._get_coords(axis)
 
+        # The units for axis coordinates must be in degree(s), or the bounds
+        # won't be generated correctly.
         units_attr = axis_coords.attrs.get("units")
         if units_attr is None:
-            raise TypeError(f"Dataset {axis} coordinates has no attribute 'units'.")
+            raise TypeError(f"Dataset {axis} coordinates has no units attribute.")
         if "degree" not in units_attr:
             raise ValueError(
-                f"Dataset {axis} coordinates value for 'units' ('{units_attr}'), does not contain 'degree' substring."
+                f"Dataset {axis} units attribute ('{units_attr}') does not contain 'degree'."
             )
 
-        # Perform adjustments as needed based on boundary values
+        # Bounds are generated and adjusted to ensure circularity or within an
+        # allowed range.
+        bounds = self._gen_base_bounds(axis_coords.data, width)
         if axis == "lat":
             bounds = self._adjust_lat_bounds(bounds)
         if axis == "lon" and len(bounds.shape) == 2:
             bounds = self._adjust_lon_bounds(bounds)
 
-        # Compose final DataArray to set as a variable
         var_name = f"{axis}_bnds"
-        self._dataset[f"{axis}_bnds"] = xr.DataArray(
+        self._dataset[var_name] = xr.DataArray(
             name=var_name,
             data=bounds,
             coords={axis: axis_coords.data},
             dims=[axis, "bnds"],
             attrs={"units": axis_coords.units, "is_generated": True},
         )
-        return self._dataset[f"{axis}_bnds"]
+        return self._dataset[var_name]
 
-    def _extract_axis_coords(self, axis: Axis) -> xr.DataArray:
-        """Extracts the axis' coordinates variable for the specified axis.
+    def _get_coords(self, axis: Axis) -> xr.DataArray:
+        """Get the coordinates for an axis.
 
         :param axis: "lat" or "lon" axis
         :type axis: Axis
@@ -157,13 +152,9 @@ class AxisAccessor:
         :return: Matching coordinates
         :rtype: xr.DataArray
         """
-        axis_coord_names = AxisAccessor.axes_map[axis]["coords"]
-        matching_coords = None
-
-        for name in axis_coord_names:
-            matching_coords = self._dataset.coords.get(name)
-            if matching_coords is not None:
-                return matching_coords
+        matching_coords = self._dataset.coords.get(axis)
+        if matching_coords is not None:
+            return matching_coords
 
         raise TypeError(f"No matching coordinates for axis: {axis}")
 
@@ -172,7 +163,7 @@ class AxisAccessor:
         data: np.ndarray,
         width: float,
     ) -> np.ndarray:
-        """Generates an axis coordinates variable's base 2D bounds.
+        """Generates the base 2D bounds for an axis.
 
         :param data: Axis coordinate data
         :type data: np.ndarray
@@ -220,10 +211,10 @@ class AxisAccessor:
         """
         # Example: [[-1, -0.5], [0.5, 1]]
         # [[min_bound_left, min_bound_right][max_bound_left, max_bound_right]]
-        min_bound_left = bounds[0, 0]  # = -1
-        min_right_right = bounds[0, 1]  # = -0.5
-        max_bound_left = bounds[-1, 0]  # = 0.5
-        max_bound_right = bounds[-1, 1]  # = 1
+        min_bound_left = bounds[0, 0]
+        min_right_right = bounds[0, 1]
+        max_bound_left = bounds[-1, 0]
+        max_bound_right = bounds[-1, 1]
 
         # Variables are used to ensure circularity
         max_degree_interval = 360.0
@@ -237,11 +228,10 @@ class AxisAccessor:
         bounds_diff_with_max_degree = abs(bounds_diff - max_degree_interval)
 
         # For example, for 0.001 < 0.01, the bounds are below the threshold and considered
-        # near the max degree interval so adjustment is necessary
+        # near the max degree interval, so adjustment is necessary
         bounds_near_max_degree: bool = (
             bounds_diff_with_max_degree < max_degree_threshold
         )
-
         if bounds_near_max_degree:
             min_bound_int_limit = abs(min_right_right - min_bound_left) * 0.01
             min_left_val_near_int: bool = (


Test patch:
diff --git a/tests/test_axis.py b/tests/test_axis.py
index 67ca969..b38c31a 100644
--- a/tests/test_axis.py
+++ b/tests/test_axis.py
@@ -48,12 +48,6 @@ class TestAxisAccessor:
         # Create Dataset using coordinates (for testing short axes names)
         self.ds = xr.Dataset(coords={"lat": lat, "lon": lon})
 
-        # Create Dataset using coordinates (for testing long axes names)
-        self.ds_2 = xr.Dataset(
-            coords={"latitude": self.ds.lat, "longitude": self.ds.lon}
-        )
-        self.ds_2 = self.ds_2.swap_dims({"lat": "latitude", "lon": "longitude"})
-
     def test__init__(self):
         obj = AxisAccessor(self.ds)
         assert obj._dataset.identical(self.ds)
@@ -61,6 +55,15 @@ class TestAxisAccessor:
     def test_decorator_call(self):
         assert self.ds.axis._dataset.identical(self.ds)
 
+    def test_lat_bnds(self):
+        obj = AxisAccessor(self.ds)
+
+        assert obj.lat_bnds.identical(self.lat_bnds)  # type:ignore
+
+    def test_lon_bnds(self):
+        obj = AxisAccessor(self.ds)
+        assert obj.lon_bnds.identical(self.lon_bnds)  # type:ignore
+
     def test_get_bounds_when_bounds_exist_in_dataset(self):
         obj = AxisAccessor(self.ds)
         obj._dataset = obj._dataset.assign(
@@ -92,7 +95,7 @@ class TestAxisAccessor:
     def test_get_bounds_raises_error_with_incorrect_axis_argument(self):
         obj = AxisAccessor(self.ds)
 
-        with pytest.raises(KeyError):
+        with pytest.raises(ValueError):
             obj.get_bounds("incorrect_axis_argument")  # type: ignore
 
     def test__gen_bounds_raises_errors(self):
@@ -119,39 +122,24 @@ class TestAxisAccessor:
         assert lon_bnds.equals(self.lon_bnds)
         assert obj._dataset.lon_bnds.is_generated
 
-    def test__gen_bounds_for_long_axis_name(self):
-        obj = AxisAccessor(self.ds_2)
-
-        lat_bnds = obj._gen_bounds("lat")
-        assert lat_bnds.equals(self.lat_bnds)
-        assert obj._dataset.lat_bnds.is_generated
-
-        lon_bnds = obj._gen_bounds("lon")
-        assert lon_bnds.equals(self.lon_bnds)
-        assert obj._dataset.lon_bnds.is_generated
-
     def test__extract_axis_coords(self):
         obj = AxisAccessor(self.ds)
 
         # Check lat axis coordinates exist
-        lat = obj._extract_axis_coords("lat")
+        lat = obj._get_coords("lat")
         assert lat is not None
 
         # Check lon axis coordinates exist
-        lon = obj._extract_axis_coords("lon")
+        lon = obj._get_coords("lon")
         assert lon is not None
 
     def test__extract_axis_coords_raises_errors(self):
         obj = AxisAccessor(self.ds)
 
-        # Raises error if incorrect `axis` param is passed
-        with pytest.raises(KeyError):
-            obj._extract_axis_coords("incorrect_axis")  # type: ignore
-
         # Raises error if `axis` param has no matching coords in the Dataset
         with pytest.raises(TypeError):
             obj._dataset = obj._dataset.drop_vars("lat")
-            obj._extract_axis_coords("lat")
+            obj._get_coords("lat")
 
     def test__gen_base_bounds(self):
         obj = AxisAccessor(self.ds)