Repository: UXARRAY/uxarray

Issue:
Grid Topology Representation between Dataset, DataArray, and Grid 
We are storing all grid variables through an internal dataset (`uxgrid._ds`) and access attributes (`uxgrid.Mesh2_node_x`) under our `uxgrid` accessor.

This works fine for the `Grid` class, however access to this dataset is not clear for the Dataset and DataArray classes. We would like a better way to access our grid variables. 

One solution would be the following:
* Keep the internal `_ds` and access attributes the same
* Implement a `uxds.topology` or `uxda.topology` access attribute which would act like `uxds.coords` or `uxds.dims`, but would return our `ugrid._ds`. This would allow us to keep the `uxgrid._ds` as an internal grid representation in our Grid and provide a way to access these varibles outside of the Grid class. 

This would be similar to what `xarray` does:
```Python
    @property
    def coords(self) -> DataArrayCoordinates:
        """Dictionary-like container of coordinate arrays."""
        return DataArrayCoordinates(self)
```




Discussion:
philipc2: @erogluorhan @anissa111 

After today's discussion, I've drafted up a simple solution to our issue. Let me know if either of you have any comments/opinions on this implementation. If all is good, we can move forward for review otherwise I can explore other options 
philipc2: @erogluorhan @anissa111 @dcherian 

As I'm working on updating the notebooks in #336 , the one part that still feels incomplete and awkward about the redesign is accessing the grid topology. (as seen [here](https://uxarray--336.org.readthedocs.build/en/336/examples/001-working-with-unstructured-grids.html#:~:text=uxds_complete.uxgrid._ds))

Based off the discussion in #324, I feel like keeping the internal `uxgrid._ds` works, but I'd like to either change the name to something more appropriate `uxgrid.topology` or (preferably) keep `uxgrid._ds` and introduce a similar property to what I had here, but instead it would be attached to the accessor, not the dataset / dataarray objects: `uxgrid.topology` 


philipc2: Changes based off the above discussion are now ready for review. 

Patch:
diff --git a/docs/internal_api/index.rst b/docs/internal_api/index.rst
index 5c47cd5a..a20cd385 100644
--- a/docs/internal_api/index.rst
+++ b/docs/internal_api/index.rst
@@ -18,7 +18,6 @@ Grid Methods
    __init_grid_var_names__
    __from_ds__
    __from_vert__
-   __init_grid_var_attrs__
    _build_edge_node_connectivity
    _build_face_edges_connectivity
    _build_nNodes_per_face
diff --git a/uxarray/core/grid.py b/uxarray/core/grid.py
index b84b1a26..af27df8b 100644
--- a/uxarray/core/grid.py
+++ b/uxarray/core/grid.py
@@ -98,13 +98,7 @@ class Grid:
         else:
             raise RuntimeError("Dataset is not a valid input type.")
 
-        # initialize convenience attributes
-        self.__init_grid_var_attrs__()
-
-        # construct connectivity
-        self._build_edge_node_connectivity()
-
-        # build face dimension, possibly safeguard for large datasets
+        # construct nNodes_per_Face
         self._build_nNodes_per_face()
 
     def __init_grid_var_names__(self):
@@ -126,41 +120,6 @@ class Grid:
             "nMaxMesh2_face_nodes": "nMaxMesh2_face_nodes"
         }
 
-    def __init_grid_var_attrs__(self) -> None:
-        """Initialize attributes for directly accessing UGRID dimensions and
-        variables.
-
-        Examples
-        ----------
-        Assuming the mesh node coordinates for longitude are stored with an input
-        name of 'mesh_node_x', we store this variable name in the `grid_var_names`
-        dictionary with the key 'Mesh2_node_x'. In order to access it, we do:
-
-        >>> x = grid._ds[grid.grid_var_names["Mesh2_node_x"]]
-
-        With the help of this function, we can directly access it through the
-        use of a standardized name based on the UGRID conventions
-
-        >>> x = grid.Mesh2_node_x
-        """
-
-        # Iterate over dict to set access attributes
-        for key, value in self.grid_var_names.items():
-            # Set Attributes for Data Variables
-            if self._ds.data_vars is not None:
-                if value in self._ds.data_vars:
-                    setattr(self, key, self._ds[value])
-
-            # Set Attributes for Coordinates
-            if self._ds.coords is not None:
-                if value in self._ds.coords:
-                    setattr(self, key, self._ds[value])
-
-            # Set Attributes for Dimensions
-            if self._ds.dims is not None:
-                if value in self._ds.dims:
-                    setattr(self, key, len(self._ds[value]))
-
     def __from_vert__(self, dataset):
         """Create a grid with faces constructed from vertices specified by the
         given argument.
@@ -283,6 +242,198 @@ class Grid:
 
         dataset.close()
 
+    def __repr__(self):
+        """Constructs a string representation of the contents of a ``Grid``."""
+
+        prefix = "<uxarray.Grid>\n"
+        original_grid_str = f"Original Grid Type: {self.mesh_type}\n"
+        dims_heading = "Grid Dimensions:\n"
+        dims_str = ""
+        for key, value in zip(self._ds.dims.keys(), self._ds.dims.values()):
+            dims_str += f"  * {key}: {value}\n"
+
+        coord_heading = "Grid Coordinate Variables:\n"
+        coords_str = ""
+        if "Mesh2_node_x" in self._ds:
+            coords_str += f"  * Mesh2_node_x: {self.Mesh2_node_x.shape}\n"
+            coords_str += f"  * Mesh2_node_y: {self.Mesh2_node_y.shape}\n"
+        if "Mesh2_face_x" in self._ds:
+            coords_str += f"  * Mesh2_face_x: {self.Mesh2_face_x.shape}\n"
+            coords_str += f"  * Mesh2_face_y: {self.Mesh2_face_y.shape} \n"
+
+        connectivity_heading = "Grid Connectivity Variables:\n"
+        connectivity_str = ""
+        if "Mesh2_face_nodes" in self._ds:
+            connectivity_str += f"  * Mesh2_face_nodes: {self.Mesh2_face_nodes.shape}\n"
+        if "Mesh2_edge_nodes" in self._ds:
+            connectivity_str += f"  * Mesh2_edge_nodes: {self.Mesh2_edge_nodes.shape}\n"
+        if "Mesh2_face_edges" in self._ds:
+            connectivity_str += f"  * Mesh2_face_edges: {self.Mesh2_face_edges.shape}\n"
+        connectivity_str += f"  * nNodes_per_face: {self.nNodes_per_face.shape}\n"
+
+        return prefix + original_grid_str + dims_heading + dims_str + coord_heading + coords_str + \
+            connectivity_heading + connectivity_str
+
+    # attribute properties
+
+    @property
+    def parsed_attrs(self):
+        """Dictionary of parsed attributes from the source grid."""
+        return self._ds.attrs
+
+    @property
+    def Mesh2(self):
+        """UGRID Attribute ``Mesh2``, which indicates the topology data of a 2D
+        unstructured mesh."""
+        return self._ds[self.grid_var_names["Mesh2"]]
+
+    # dimension properties
+
+    @property
+    def nMesh2_node(self):
+        """UGRID Dimension ``nMesh2_node``, which represents the total number
+        of nodes."""
+        return self._ds[self.grid_var_names["Mesh2_node_x"]].shape[0]
+
+    @property
+    def nMesh2_face(self):
+        """UGRID Dimension ``nMesh2_face``, which represents the total number
+        of faces."""
+        return self._ds[self.grid_var_names["Mesh2_face_nodes"]].shape[0]
+
+    @property
+    def nMesh2_edge(self):
+        """UGRID Dimension ``nMesh2_face``, which represents the total number
+        of edges."""
+
+        if "Mesh2_edge_nodes" not in self._ds:
+            self._build_edge_node_connectivity(repopulate=True)
+
+        return self._ds['Mesh2_edge_nodes'].shape[0]
+
+    @property
+    def nMaxMesh2_face_nodes(self):
+        """UGRID Dimension ``nMaxMesh2_face_nodes``, which represents the
+        maximum number of faces nodes that a face may contain."""
+        return self.Mesh2_face_nodes.shape[1]
+
+    @property
+    def nMaxMesh2_face_edges(self):
+        """Dimension ``nMaxMesh2_face_edges``, which represents the maximum
+        number of edges per face.
+
+        Equivalent to ``nMaxMesh2_face_nodes``
+        """
+
+        if "Mesh2_face_edges" not in self._ds:
+            self._build_face_edges_connectivity()
+
+        return self._ds["Mesh2_face_edges"].shape[1]
+
+    @property
+    def nNodes_per_face(self):
+        """Dimension Variable ``nNodes_per_face``, which contains the number of
+        non-fill-value nodes per face.
+
+        Dimensions (``nMesh2_nodes``) and DataType ``INT_DTYPE``.
+        """
+        return self._ds["nNodes_per_face"]
+
+    # coordinate properties
+
+    @property
+    def Mesh2_node_x(self):
+        """UGRID Coordinate Variable ``Mesh2_node_x``, which contains the
+        longitude of each node.
+
+        Dimensions (``nMesh2_node``)
+        """
+        return self._ds[self.grid_var_names["Mesh2_node_x"]]
+
+    @property
+    def Mesh2_face_x(self):
+        """UGRID Coordinate Variable ``Mesh2_face_x``, which contains the
+        longitude of each face center.
+
+        Dimensions (``nMesh2_face``)
+        """
+        if "Mesh2_face_x" in self._ds:
+            return self._ds["Mesh2_face_x"]
+        else:
+            return None
+
+    @property
+    def Mesh2_node_y(self):
+        """UGRID Coordinate Variable ``Mesh2_node_y``, which contains the
+        latitude of each node.
+
+        Dimensions (``nMesh2_node``)
+        """
+        return self._ds[self.grid_var_names["Mesh2_node_y"]]
+
+    @property
+    def Mesh2_face_y(self):
+        """UGRID Coordinate Variable ``Mesh2_face_y``, which contains the
+        latitude of each face center.
+
+        Dimensions (``nMesh2_face``)
+        """
+        if "Mesh2_face_y" in self._ds:
+            return self._ds["Mesh2_face_y"]
+        else:
+            return None
+
+    @property
+    def Mesh2_node_z(self):
+        """UGRID Coordinate Variable ``Mesh2_node_z``, which contains the
+        latitude of each node.
+
+        Dimensions (``nMesh2_node``)
+        """
+        if self.grid_var_names["Mesh2_node_z"] in self._ds:
+            return self._ds[self.grid_var_names["Mesh2_node_z"]]
+        else:
+            return None
+
+    # connectivity properties
+
+    @property
+    def Mesh2_face_nodes(self):
+        """UGRID Connectivity Variable ``Mesh2_face_nodes``, which maps each
+        face to its corner nodes.
+
+        Dimensions (``nMesh2_face``, ``nMaxMesh2_face_nodes``) and
+        DataType ``INT_DTYPE``.
+        """
+
+        return self._ds[self.grid_var_names["Mesh2_face_nodes"]]
+
+    @property
+    def Mesh2_edge_nodes(self):
+        """UGRID Connectivity Variable ``Mesh2_edge_nodes``, which maps every
+        edge to the two nodes that it connects.
+
+        Dimensions (``nMesh2_edge``, ``Two``) and DataType
+        ``INT_DTYPE``.
+        """
+        if "Mesh2_edge_nodes" not in self._ds:
+            self._build_edge_node_connectivity()
+
+        return self._ds['Mesh2_edge_nodes']
+
+    @property
+    def Mesh2_face_edges(self):
+        """UGRID Connectivity Variable ``Mesh2_face_edges``, which maps every
+        face to its edges.
+
+        Dimensions (``nMesh2_face``, ``nMaxMesh2_face_nodes``) and
+        DataType ``INT_DTYPE``.
+        """
+        if "Mesh2_face_edges" not in self._ds:
+            self._build_face_edges_connectivity()
+
+        return self._ds["Mesh2_face_edges"]
+
     def copy(self):
         """Returns a deep copy of this grid."""
         return Grid(xr.Dataset(self._ds),
@@ -593,10 +744,6 @@ class Grid:
                     fill_value_mask
             })
 
-        # set standardized attributes
-        setattr(self, "Mesh2_edge_nodes", self._ds['Mesh2_edge_nodes'])
-        setattr(self, "nMesh2_edge", edge_nodes_unique.shape[0])
-
     def _build_face_edges_connectivity(self):
         """Constructs the UGRID connectivity variable (``Mesh2_face_edges``)
         and stores it within the internal (``Grid._ds``) and through the
@@ -621,10 +768,6 @@ class Grid:
                     "Maps every edge to the two nodes that it connects",
             })
 
-        # set standardized attributes
-        setattr(self, "nMaxMesh2_face_edges", inverse_indices.shape[1])
-        setattr(self, "Mesh2_face_edges", self._ds["Mesh2_face_edges"])
-
     def _populate_cartesian_xyz_coord(self):
         """A helper function that populates the xyz attribute in
         UXarray.Grid._ds. This function is called when we need to use the
@@ -774,6 +917,3 @@ class Grid:
             data=nNodes_per_face,
             dims=["nMesh2_face"],
             attrs={"long_name": "number of non-fill value nodes for each face"})
-
-        # standardized attribute
-        setattr(self, "nNodes_per_face", self._ds["nNodes_per_face"])


Test patch:
diff --git a/test/constants.py b/test/constants.py
index 4a54e1b1..acf33fec 100644
--- a/test/constants.py
+++ b/test/constants.py
@@ -5,7 +5,7 @@ NNODES_ov_RLL10deg_CSne4 = 683
 NNODES_outCSne8 = 386
 NNODES_outCSne30 = 5402
 NNODES_outRLL1deg = 64442
-DATAVARS_outCSne30 = 4
+DATAVARS_outCSne30 = 3
 TRI_AREA = 1.047
 # 4*Pi is 12.56
 MESH30_AREA = 12.566
diff --git a/test/test_api.py b/test/test_api.py
index dd066e67..74c92360 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -12,31 +12,30 @@ except ImportError:
 
 current_path = Path(os.path.dirname(os.path.realpath(__file__)))
 
-gridfile_ne30 = current_path / "meshfiles" / "ugrid" / "outCSne30" / "outCSne30.ug"
-dsfile_var2_ne30 = current_path / "meshfiles" / "ugrid" / "outCSne30" / "outCSne30_var2.nc"
-dsfiles_mf_ne30 = str(
-    current_path) + "/meshfiles/ugrid/outCSne30/outCSne30_*.nc"
 
-gridfile_geoflow = current_path / "meshfiles" / "ugrid" / "geoflow-small" / "grid.nc"
+class TestAPI(TestCase):
 
+    geoflow_data_path = current_path / "meshfiles" / "ugrid" / "geoflow-small"
+    gridfile_geoflow = current_path / "meshfiles" / "ugrid" / "geoflow-small" / "grid.nc"
+    geoflow_data_v1 = geoflow_data_path / "v1.nc"
+    geoflow_data_v2 = geoflow_data_path / "v2.nc"
+    geoflow_data_v3 = geoflow_data_path / "v3.nc"
 
-class TestAPI(TestCase):
+    gridfile_ne30 = current_path / "meshfiles" / "ugrid" / "outCSne30" / "outCSne30.ug"
+    dsfile_var2_ne30 = current_path / "meshfiles" / "ugrid" / "outCSne30" / "outCSne30_var2.nc"
+    dsfiles_mf_ne30 = str(
+        current_path) + "/meshfiles/ugrid/outCSne30/outCSne30_*.nc"
 
     def test_open_geoflow_dataset(self):
         """Loads a single dataset with its grid topology file using uxarray's
         open_dataset call."""
 
-        # Base data path
-        base_path = "test/meshfiles/ugrid/geoflow-small/"
-
-        # Path to Grid file
-        grid_path = base_path + "grid.nc"
-
         # Paths to Data Variable files
-        var_names = ['v1.nc', 'v2.nc', 'v3.nc']
-        data_paths = [base_path + name for name in var_names]
+        data_paths = [
+            self.geoflow_data_v1, self.geoflow_data_v2, self.geoflow_data_v3
+        ]
 
-        uxds_v1 = ux.open_dataset(grid_path, data_paths[0])
+        uxds_v1 = ux.open_dataset(self.gridfile_geoflow, data_paths[0])
 
     #     TODO: Add asserts
 
@@ -44,31 +43,34 @@ class TestAPI(TestCase):
         """Loads a single dataset with its grid topology file using uxarray's
         open_dataset call."""
 
-        uxds_var2_ne30 = ux.open_dataset(gridfile_ne30, dsfile_var2_ne30)
+        uxds_var2_ne30 = ux.open_dataset(self.gridfile_ne30,
+                                         self.dsfile_var2_ne30)
 
         nt.assert_equal(uxds_var2_ne30.uxgrid.Mesh2_node_x.size,
                         constants.NNODES_outCSne30)
         nt.assert_equal(len(uxds_var2_ne30.uxgrid._ds.data_vars),
                         constants.DATAVARS_outCSne30)
-        nt.assert_equal(uxds_var2_ne30.uxgrid.source_grid, gridfile_ne30)
-        nt.assert_equal(uxds_var2_ne30.source_datasets, str(dsfile_var2_ne30))
+        nt.assert_equal(uxds_var2_ne30.uxgrid.source_grid, self.gridfile_ne30)
+        nt.assert_equal(uxds_var2_ne30.source_datasets,
+                        str(self.dsfile_var2_ne30))
 
     def test_open_mf_dataset(self):
         """Loads multiple datasets with their grid topology file using
         uxarray's open_dataset call."""
 
-        uxds_mf_ne30 = ux.open_mfdataset(gridfile_ne30, dsfiles_mf_ne30)
+        uxds_mf_ne30 = ux.open_mfdataset(self.gridfile_ne30,
+                                         self.dsfiles_mf_ne30)
 
         nt.assert_equal(uxds_mf_ne30.uxgrid.Mesh2_node_x.size,
                         constants.NNODES_outCSne30)
         nt.assert_equal(len(uxds_mf_ne30.uxgrid._ds.data_vars),
                         constants.DATAVARS_outCSne30)
-        nt.assert_equal(uxds_mf_ne30.uxgrid.source_grid, gridfile_ne30)
-        nt.assert_equal(uxds_mf_ne30.source_datasets, dsfiles_mf_ne30)
+        nt.assert_equal(uxds_mf_ne30.uxgrid.source_grid, self.gridfile_ne30)
+        nt.assert_equal(uxds_mf_ne30.source_datasets, self.dsfiles_mf_ne30)
 
     def test_open_grid(self):
         """Loads only a grid topology file using uxarray's open_grid call."""
-        uxgrid = ux.open_grid(gridfile_geoflow)
+        uxgrid = ux.open_grid(self.gridfile_geoflow)
 
         nt.assert_almost_equal(uxgrid.calculate_total_face_area(),
                                constants.MESH30_AREA,
@@ -78,7 +80,8 @@ class TestAPI(TestCase):
         """Loads a single dataset with its grid topology file using uxarray's
         open_dataset call and make a copy of the object."""
 
-        uxds_var2_ne30 = ux.open_dataset(gridfile_ne30, dsfile_var2_ne30)
+        uxds_var2_ne30 = ux.open_dataset(self.gridfile_ne30,
+                                         self.dsfile_var2_ne30)
 
         # make a shallow and deep copy of the dataset object
         uxds_var2_ne30_copy_deep = uxds_var2_ne30.copy(deep=True)
@@ -100,17 +103,12 @@ class TestAPI(TestCase):
         """Loads an unstructured grid and data using uxarray's open_dataset
         call and make a copy of the dataarray object."""
 
-        # Base data path
-        base_path = "test/meshfiles/ugrid/geoflow-small/"
-
-        # Path to Grid file
-        grid_path = base_path + "grid.nc"
-
         # Paths to Data Variable files
-        var_names = ['v1.nc', 'v2.nc', 'v3.nc']
-        data_paths = [base_path + name for name in var_names]
+        data_paths = [
+            self.geoflow_data_v1, self.geoflow_data_v2, self.geoflow_data_v3
+        ]
 
-        uxds_v1 = ux.open_dataset(grid_path, data_paths[0])
+        uxds_v1 = ux.open_dataset(self.gridfile_geoflow, data_paths[0])
 
         # get the uxdataarray object
         v1_uxdata_array = uxds_v1['v1']
diff --git a/test/test_grid.py b/test/test_grid.py
index 29f110e1..38223b3f 100644
--- a/test/test_grid.py
+++ b/test/test_grid.py
@@ -220,8 +220,8 @@ class TestGrid(TestCase):
         assert (vgrid.nMesh2_face == 3)
         assert (vgrid.nMesh2_node == 12)
 
-    def test_init_grid_var_attrs(self):
-        """Tests to see if accessing variables through set attributes is equal
+    def test_grid_properties(self):
+        """Tests to see if accessing variables through set properties is equal
         to using the dict."""
 
         # Dataset with standard UGRID variable names