Repository: zarr-developers/VirtualiZarr

Issue:
`open_virtual_dataset` error with TEMPO dmr++
Trying to open a DMR++ file from the Tropospheric Emissions: Monitoring of Pollution (TEMPO) satellite-based air quality instrument results in the following `TypeError`:

```python
result = open_virtual_dataset("https://data.asdc.earthdata.nasa.gov/asdc-prod-protected/TEMPO/TEMPO_NO2_L2_V03/2024.07.25/TEMPO_NO2_L2_V03_20240725T114404Z_S002G06.nc.dmrpp", indexes={}, filetype="dmrpp", reader_options={"storage_options": fs.storage_options})
```
![temp](https://github.com/user-attachments/assets/e6816921-2b9d-482f-b3aa-e768d1328468)




Discussion:
ayushnag: @TomNicholas this is ready to be reviewed/merged. (cc: @danielfromearth)

One oddity is that I cannot test the dmrpp reader with real world NASA files because [of this line in the kerchunk ZArray logic](https://github.com/zarr-developers/VirtualiZarr/blob/ab23caaed6b530d66e41947c8a525d194f6b76e6/virtualizarr/zarr.py#L77) which changes `fill_val` for a float dtype array. The dmrpp reader sets `fill_val` to None by default as the ZArray constructor also has. However since the kerchunk code has extra logic, otherwise identical virtual datasets have a different `fill_val` when opened with virtualizarr `netcdf` vs `dmrpp`. Perhaps that logic should be in the ZArray constructor or only added when writing to kerchunk
TomNicholas: > One oddity is that I cannot test the dmrpp reader with real world NASA files because [of this line in the kerchunk ZArray logic](https://github.com/zarr-developers/VirtualiZarr/blob/ab23caaed6b530d66e41947c8a525d194f6b76e6/virtualizarr/zarr.py#L77) which changes fill_val for a float dtype array. The dmrpp reader sets fill_val to None by default as the ZArray constructor also has. However since the kerchunk code has extra logic, otherwise identical virtual datasets have a different fill_val when opened with virtualizarr netcdf vs dmrpp. Perhaps that logic should be in the ZArray constructor or only added when writing to kerchunk

Thanks for flagging that - I can't remember exactly what the idea was but it seems like a bug or at least some workaround specific to kerchunk. Would you mind raising a new issue to track this oddity?
danielfromearth: @ayushnag, this is awesome, and the results are looking great for TEMPO!

![temp](https://github.com/user-attachments/assets/58fa90ae-5eb8-48ab-bcf7-420094b10bd8)

Future roadmap to incorporate this with DataTree too, right, so subgroups are loaded together? :)


ayushnag: Glad to see it! 

Yes, DataTree integration is on the roadmap. In fact, the signature of `_split_groups()` currently returns `dict[Path, ET.Element]` which matches up to `DataTree.from_dict`  (each `ET.Element` can be converted to an `xr.Dataset`)

> @ayushnag, this is awesome, and the results are looking great for TEMPO!
> 
> ![temp](https://private-user-images.githubusercontent.com/114174502/383335131-58fa90ae-5eb8-48ab-bcf7-420094b10bd8.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MzA5MjkwMzcsIm5iZiI6MTczMDkyODczNywicGF0aCI6Ii8xMTQxNzQ1MDIvMzgzMzM1MTMxLTU4ZmE5MGFlLTVlYjgtNDhhYi1iY2Y3LTQyMDA5NGIxMGJkOC5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjQxMTA2JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI0MTEwNlQyMTMyMTdaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT0xMDg0NGE0MzFjMGMyMWI3MTNlM2Y5MTU3M2RmOTQ4NDQwN2U3OWVhOWUxMDMzOWVlY2YwZmQ3ZTEyYzYwNjYzJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.Jc9xK3uMN2hXbK83s3jBNmsblG6f2h_v0grm32voD6I)
> 
> Future roadmap is to incorporate this with DataTree too, right, so subgroups are loaded together? :)

Patch:
diff --git a/docs/releases.rst b/docs/releases.rst
index cadbc85..56f3ac9 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -21,6 +21,8 @@ Deprecations
 Bug fixes
 ~~~~~~~~~
 
+- Handle root and nested groups with ``dmrpp`` backend (:pull:`265`)
+  By `Ayush Nag <https://github.com/ayushnag>`_.
 - Fixed bug with writing of `dimension_names` into zarr metadata.
   (:pull:`286`) By `Tom Nicholas <https://github.com/TomNicholas>`_.
 - Fixed bug causing CF-compliant variables not to be identified as coordinates (:pull:`191`)
diff --git a/virtualizarr/backend.py b/virtualizarr/backend.py
index 32403d0..fab010c 100644
--- a/virtualizarr/backend.py
+++ b/virtualizarr/backend.py
@@ -127,10 +127,10 @@ def open_virtual_dataset(
         File path to open as a set of virtualized zarr arrays.
     filetype : FileType, default None
         Type of file to be opened. Used to determine which kerchunk file format backend to use.
-        Can be one of {'netCDF3', 'netCDF4', 'HDF', 'TIFF', 'GRIB', 'FITS', 'zarr_v3', 'kerchunk'}.
+        Can be one of {'netCDF3', 'netCDF4', 'HDF', 'TIFF', 'GRIB', 'FITS', 'dmrpp', 'zarr_v3', 'kerchunk'}.
         If not provided will attempt to automatically infer the correct filetype from header bytes.
     group : str, default is None
-        Path to the HDF5/netCDF4 group in the given file to open. Given as a str, supported by filetypes “netcdf4” and “hdf5”.
+        Path to the HDF5/netCDF4 group in the given file to open. Given as a str, supported by filetypes “netcdf4”, “hdf5”, and "dmrpp".
     drop_variables: list[str], default is None
         Variables in the file to drop before returning.
     loadable_variables: list[str], default is None
diff --git a/virtualizarr/readers/dmrpp.py b/virtualizarr/readers/dmrpp.py
index c9095c7..5859ca9 100644
--- a/virtualizarr/readers/dmrpp.py
+++ b/virtualizarr/readers/dmrpp.py
@@ -1,7 +1,6 @@
-import os
 import warnings
-from collections import defaultdict
 from collections.abc import Mapping
+from pathlib import Path
 from typing import Any, Iterable, Optional
 from xml.etree import ElementTree as ET
 
@@ -36,15 +35,15 @@ class DMRPPVirtualBackend(VirtualBackend):
                 "Specifying `loadable_variables` or auto-creating indexes with `indexes=None` is not supported for dmrpp files."
             )
 
-        if group:
-            raise NotImplementedError()
-
         fpath = _FsspecFSFromFilepath(
             filepath=filepath, reader_options=reader_options
         ).open_file()
 
-        parser = DMRParser(fpath.read(), data_filepath=filepath.strip(".dmrpp"))
-        vds = parser.parse_dataset()
+        parser = DMRParser(
+            root=ET.parse(fpath).getroot(),
+            data_filepath=filepath.removesuffix(".dmrpp"),
+        )
+        vds = parser.parse_dataset(group=group, indexes=indexes)
 
         return vds.drop_vars(drop_variables)
 
@@ -60,12 +59,12 @@ class DMRParser:
     """
 
     # DAP and DMRPP XML namespaces
-    _ns = {
+    _NS = {
         "dap": "http://xml.opendap.org/ns/DAP/4.0#",
-        "dmr": "http://xml.opendap.org/dap/dmrpp/1.0.0#",
+        "dmrpp": "http://xml.opendap.org/dap/dmrpp/1.0.0#",
     }
     # DAP data types to numpy data types
-    _dap_np_dtype = {
+    _DAP_NP_DTYPE = {
         "Byte": "uint8",
         "UByte": "uint8",
         "Int8": "int8",
@@ -82,24 +81,24 @@ class DMRParser:
         "String": "object",
     }
     # Default zlib compression value
-    _default_zlib_value = 6
+    _DEFAULT_ZLIB_VALUE = 6
     # Encoding keys that should be removed from attributes and placed in xarray encoding dict
-    _encoding_keys = {"_FillValue", "missing_value", "scale_factor", "add_offset"}
+    _ENCODING_KEYS = {"_FillValue", "missing_value", "scale_factor", "add_offset"}
 
-    def __init__(self, dmr: str, data_filepath: Optional[str] = None):
+    def __init__(self, root: ET.Element, data_filepath: Optional[str] = None):
         """
-        Initialize the DMRParser with the given DMR data and data file path.
+        Initialize the DMRParser with the given DMR++ file contents and source data file path.
 
         Parameters
         ----------
-        dmr : str
-            The DMR file contents as a string.
+        dmrpp_str : str
+            The dmrpp file contents as a string.
 
         data_filepath : str, optional
             The path to the actual data file that will be set in the chunk manifests.
-            If None, the data file path is taken from the DMR file.
+            If None, the data file path is taken from the DMR++ file.
         """
-        self.root = ET.fromstring(dmr)
+        self.root = root
         self.data_filepath = (
             data_filepath if data_filepath is not None else self.root.attrib["name"]
         )
@@ -145,170 +144,57 @@ class DMRParser:
             Data variables:
                 d_8_chunks  (phony_dim_0, phony_dim_1, phony_dim_2) float32 4MB ManifestA...
         """
+        group_tags = self.root.findall("dap:Group", self._NS)
         if group is not None:
-            # group = "/" + group.strip("/")  # ensure group is in form "/a/b"
-            group = os.path.normpath(group).removeprefix(
-                "/"
-            )  # ensure group is in form "a/b/c"
-        if self._is_hdf5(self.root):
-            return self._parse_hdf5_dataset(self.root, group, indexes)
-        if self.data_filepath.endswith(".nc"):
-            return self._parse_netcdf4_dataset(self.root, group, indexes)
-        raise ValueError("DMR file must be HDF5 or netCDF4 based")
-
-    def _parse_netcdf4_dataset(
-        self,
-        root: ET.Element,
-        group: Optional[str] = None,
-        indexes: Mapping[str, Index] = {},
-    ) -> Dataset:
-        """
-        Parse the dataset from the netcdf4 based dmrpp with groups, starting at the given group.
-        Set root to the given group.
-
-        Parameters
-        ----------
-        root : ET.Element
-            The root element of the DMR file.
-
-        group : str
-            The group to parse. If None, and no groups are present, the dataset is parsed.
-            If None and groups are present, the first group is parsed.
+            group = Path(group)
+            if not group.is_absolute():
+                group = Path("/") / group
+            if len(group_tags) == 0:
+                warnings.warn("No groups found in DMR++ file; ignoring group parameter")
+            else:
+                all_groups = self._split_groups(self.root)
+                if group in all_groups:
+                    return self._parse_dataset(all_groups[group], indexes)
+                else:
+                    raise ValueError(f"Group {group} not found in DMR++ file")
+        return self._parse_dataset(self.root, indexes)
 
-        Returns
-        -------
-        xr.Dataset
-        """
-        group_tags = root.findall("dap:Group", self._ns)
-        if len(group_tags) == 0:
-            if group is not None:
-                # no groups found and group specified -> warning
-                warnings.warn(
-                    "No groups found in NetCDF4 DMR file; ignoring group parameter"
-                )
-            # no groups found and no group specified -> parse dataset
-            return self._parse_dataset(root, indexes)
-        all_groups = self._split_netcdf4(root)
-        if group is None:
-            # groups found and no group specified -> parse first group
-            return self._parse_dataset(group_tags[0], indexes)
-        if group in all_groups:
-            # groups found and group specified -> parse specified group
-            return self._parse_dataset(all_groups[group], indexes)
-        else:
-            # groups found and specified group not found -> error
-            raise ValueError(f"Group {group} not found in NetCDF4 DMR file")
-
-    def _split_netcdf4(self, root: ET.Element) -> dict[str, ET.Element]:
+    def find_node_fqn(self, fqn: str) -> ET.Element:
         """
-        Split the input <Group> element into several <Dataset> ET.Elements by netcdf4 group
-        E.g. {"left": <Dataset>, "right": <Dataset>}
+        Find the element in the root element by converting the fully qualified name to an xpath query.
 
-        Parameters
-        ----------
-        root : ET.Element
-            The root element of the DMR file.
-
-        Returns
-        -------
-        dict[str, ET.Element]
-        """
-        group_tags = root.findall("dap:Group", self._ns)
-        all_groups: dict[str, ET.Element] = defaultdict(
-            lambda: ET.Element(root.tag, root.attrib)
-        )
-        for group_tag in group_tags:
-            all_groups[os.path.normpath(group_tag.attrib["name"])] = group_tag
-        return all_groups
-
-    def _is_hdf5(self, root: ET.Element) -> bool:
-        """Check if the DMR file is HDF5 based."""
-        if root.find(".//dap:Attribute[@name='fullnamepath']", self._ns) is not None:
-            return True
-        if root.find("./dap:Attribute[@name='HDF5_GLOBAL']", self._ns) is not None:
-            return True
-        return False
-
-    def _parse_hdf5_dataset(
-        self,
-        root: ET.Element,
-        group: Optional[str] = None,
-        indexes: Mapping[str, Index] = {},
-    ) -> Dataset:
-        """
-        Parse the dataset from the HDF5 based dmrpp with groups, starting at the given group.
-        Set root to the given group.
+        E.g. fqn = "/a/b" --> root.find("./*[@name='a']/*[@name='b']")
+        See more about OPeNDAP fully qualified names (FQN) here: https://docs.opendap.org/index.php/DAP4:_Specification_Volume_1#Fully_Qualified_Names
 
         Parameters
         ----------
-        root : ET.Element
-            The root element of the DMR file.
-
-        group : str
-            The group to parse. If None, and no groups are present, the dataset is parsed.
-            If None and groups are present, the first group is parsed.
-
-        indexes : Mapping[str, Index], default is {}
-            Indexes to use on the returned xarray Dataset.
-            Default is {} which will avoid creating any indexes
+        fqn : str
+            The fully qualified name of an element. E.g. "/a/b"
 
         Returns
         -------
-        xr.Dataset
-        """
-        all_groups = self._split_hdf5(root=root)
-        if len(all_groups) == 0:
-            raise ValueError("No groups found in HDF based dmrpp file")
-        if group is None:
-            # pick a random group if no group is specified
-            group = next(iter(all_groups))
-        attrs = {}
-        for attr_tag in root.iterfind("dap:Attribute", self._ns):
-            if attr_tag.attrib["type"] != "Container":
-                attrs.update(self._parse_attribute(attr_tag))
-        if group in all_groups:
-            # replace aliased variable names with original names: gt1r_heights -> heights
-            orignames = self._find_original_names(all_groups[group])
-            vds = self._parse_dataset(all_groups[group], indexes)
-            # Only one group so found attrs are global attrs
-            if len(all_groups) == 1:
-                vds.attrs.update(attrs)
-            return vds.rename(orignames)
-        raise ValueError(f"Group {group} not found in HDF5 dmrpp file")
-
-    def _find_original_names(self, root: ET.Element) -> dict[str, str]:
-        """
-        Find the original variable names from the HDF based groups. E.g. gt1r_heights -> heights
+        ET.Element
+            The matching node found within the root element.
 
-        E.g. if the variable name is 'gt1r_heights', the original name is 'heights' from the group 'gt1r'.
-
-        Parameters
-        ----------
-        root : ET.Element
-            The root element of the DMR file.
-
-        Returns
-        -------
-        dict[str, str]
+        Raises
+        ------
+        ValueError
+            If the fully qualified name is not found in the root element.
         """
+        if fqn == "/":
+            return self.root
+        elements = fqn.strip("/").split("/")  # /a/b/ --> ['a', 'b']
+        xpath_segments = [f"*[@name='{element}']" for element in elements]
+        xpath_query = "./" + "/".join(xpath_segments)  # "./[*[@name='a']/*[@name='b']"
+        element = self.root.find(xpath_query, self._NS)
+        if element is None:
+            raise ValueError(f"Path {fqn} not found in provided root")
+        return element
 
-        orignames: dict[str, str] = {}
-        vars_tags: list[ET.Element] = []
-        for dap_dtype in self._dap_np_dtype:
-            vars_tags += root.findall(f"dap:{dap_dtype}", self._ns)
-        for var_tag in vars_tags:
-            origname_tag = var_tag.find(
-                "./dap:Attribute[@name='origname']/dap:Value", self._ns
-            )
-            if origname_tag is not None and origname_tag.text is not None:
-                orignames[var_tag.attrib["name"]] = origname_tag.text
-        return orignames
-
-    def _split_hdf5(self, root: ET.Element) -> dict[str, ET.Element]:
+    def _split_groups(self, root: ET.Element) -> dict[Path, ET.Element]:
         """
-        Split the input <Dataset> element into several <Dataset> ET.Elements by HDF5 group
-        E.g. {"gtr1/heights": <Dataset>, "gtr1/temperatures": <Dataset>}. Builds up new <Dataset> elements
-        each with dimensions, variables, and attributes.
+        Split the input <Dataset> element into several <Dataset> ET.Elements by <Group> name.
+        E.g. {"/": <Dataset>, "left": <Dataset>, "right": <Dataset>}
 
         Parameters
         ----------
@@ -317,86 +203,81 @@ class DMRParser:
 
         Returns
         -------
-        dict[str, ET.Element]
-        """
-        # Add all variable, dimension, and attribute tags to their respective groups
-        groups_roots: dict[str, ET.Element] = defaultdict(
-            lambda: ET.Element(root.tag, root.attrib)
-        )
-        group_dims: dict[str, set[str]] = defaultdict(
-            set
-        )  # {"gt1r/heights": {"dim1", "dim2", ...}}
-        vars_tags: list[ET.Element] = []
-        for dap_dtype in self._dap_np_dtype:
-            vars_tags += root.findall(f"dap:{dap_dtype}", self._ns)
-        # Variables
-        for var_tag in vars_tags:
-            fullname_tag = var_tag.find(
-                "./dap:Attribute[@name='fullnamepath']/dap:Value", self._ns
-            )
-            if fullname_tag is not None and fullname_tag.text is not None:
-                # '/gt1r/heights/ph_id_pulse' -> 'gt1r/heights'
-                group_name = os.path.dirname(fullname_tag.text).removeprefix("/")
-                groups_roots[group_name].append(var_tag)
-                dim_tags = var_tag.findall("dap:Dim", self._ns)
-                dims = self._parse_multi_dims(dim_tags)
-                group_dims[group_name].update(dims.keys())
-        # Dimensions
-        for dim_tag in root.iterfind("dap:Dimension", self._ns):
-            for g, d in group_dims.items():
-                if dim_tag.attrib["name"] in d:
-                    groups_roots[g].append(dim_tag)
-        # Attributes
-        container_attr_tag = root.find("dap:Attribute[@name='HDF5_GLOBAL']", self._ns)
-        if container_attr_tag is None:
-            attrs_tags = root.findall("dap:Attribute", self._ns)
-            for attr_tag in attrs_tags:
-                fullname_tag = attr_tag.find(
-                    "./dap:Attribute[@name='fullnamepath']/dap:Value", self._ns
-                )
-                if fullname_tag is not None and fullname_tag.text is not None:
-                    group_name = os.path.dirname(fullname_tag.text).removeprefix("/")
-                    # Add all attributes to the new dataset
-                    groups_roots[group_name].extend(attr_tag)
-        else:
-            groups_roots[next(iter(groups_roots))].extend(container_attr_tag)
-        return groups_roots
+        dict[Path, ET.Element]
+        """
+        all_groups: dict[Path, ET.Element] = {}
+        dataset_tags = [
+            d for d in root if d.tag != "{" + self._NS["dap"] + "}" + "Group"
+        ]
+        if len(dataset_tags) > 0:
+            all_groups[Path("/")] = ET.Element(root.tag, root.attrib)
+            all_groups[Path("/")].extend(dataset_tags)
+        all_groups.update(self._split_groups_recursive(root, Path("/")))
+        return all_groups
+
+    def _split_groups_recursive(
+        self, root: ET.Element, current_path=Path("")
+    ) -> dict[Path, ET.Element]:
+        group_dict: dict[Path, ET.Element] = {}
+        for g in root.iterfind("dap:Group", self._NS):
+            new_path = current_path / Path(g.attrib["name"])
+            dataset_tags = [
+                d for d in g if d.tag != "{" + self._NS["dap"] + "}" + "Group"
+            ]
+            group_dict[new_path] = ET.Element(g.tag, g.attrib)
+            group_dict[new_path].extend(dataset_tags)
+            group_dict.update(self._split_groups_recursive(g, new_path))
+        return group_dict
 
     def _parse_dataset(
         self, root: ET.Element, indexes: Mapping[str, Index] = {}
     ) -> Dataset:
         """
-        Parse the dataset using the root element of the DMR file.
+        Parse the dataset using the root element of the DMR++ file.
 
         Parameters
         ----------
         root : ET.Element
-            The root element of the DMR file.
+            The root element of the DMR++ file.
 
         Returns
         -------
         xr.Dataset
         """
         # Dimension names and sizes
-        dim_tags = root.findall("dap:Dimension", self._ns)
-        dataset_dims = self._parse_multi_dims(dim_tags)
+        dims: dict[str, int] = {}
+        dimension_tags = self._find_dimension_tags(root)
+        for dim in dimension_tags:
+            dims.update(self._parse_dim(dim))
         # Data variables and coordinates
-        coord_names = self._find_coord_names(root)
-        # if no coord_names are found or coords don't include dims, dims are used as coords
-        if len(coord_names) == 0 or len(coord_names) < len(dataset_dims):
-            coord_names = set(dataset_dims.keys())
+        coord_names: set[str] = set()
+        coord_tags = root.findall(
+            ".//dap:Attribute[@name='coordinates']/dap:Value", self._NS
+        )
+        for c in coord_tags:
+            if c.text is not None:
+                coord_names.update(c.text.split(" "))
         # Seperate and parse coords + data variables
         coord_vars: dict[str, Variable] = {}
         data_vars: dict[str, Variable] = {}
         for var_tag in self._find_var_tags(root):
-            variable = self._parse_variable(var_tag, dataset_dims)
-            if var_tag.attrib["name"] in coord_names:
+            variable = self._parse_variable(var_tag)
+            # Either coordinates are explicitly defined or 1d variable with same name as dimension is a coordinate
+            if var_tag.attrib["name"] in coord_names or (
+                len(variable.dims) == 1 and variable.dims[0] == var_tag.attrib["name"]
+            ):
                 coord_vars[var_tag.attrib["name"]] = variable
             else:
                 data_vars[var_tag.attrib["name"]] = variable
         # Attributes
         attrs: dict[str, str] = {}
-        for attr_tag in self.root.iterfind("dap:Attribute", self._ns):
+        # Look for an attribute tag called "HDF5_GLOBAL" and unpack it
+        hdf5_global_attrs = root.find("dap:Attribute[@name='HDF5_GLOBAL']", self._NS)
+        if hdf5_global_attrs is not None:
+            # Remove the container attribute and add its children to the root dataset
+            root.remove(hdf5_global_attrs)
+            root.extend(hdf5_global_attrs)
+        for attr_tag in root.iterfind("dap:Attribute", self._NS):
             attrs.update(self._parse_attribute(attr_tag))
         return Dataset(
             data_vars=data_vars,
@@ -406,58 +287,28 @@ class DMRParser:
 
     def _find_var_tags(self, root: ET.Element) -> list[ET.Element]:
         """
-        Find all variable tags in the DMR file. Also known as array tags.
+        Find all variable tags in the DMR++ file. Also known as array tags.
         Tags are labeled with the DAP data type. E.g. <Float32>, <Int16>, <String>
 
         Parameters
         ----------
         root : ET.Element
-            The root element of the DMR file.
+            The root element of the DMR++ file.
 
         Returns
         -------
         list[ET.Element]
         """
         vars_tags: list[ET.Element] = []
-        for dap_dtype in self._dap_np_dtype:
-            vars_tags += root.findall(f"dap:{dap_dtype}", self._ns)
+        for dap_dtype in self._DAP_NP_DTYPE:
+            vars_tags += root.findall(f"dap:{dap_dtype}", self._NS)
         return vars_tags
 
-    def _find_coord_names(self, root: ET.Element) -> set[str]:
-        """
-        Find the name of all coordinates in root. Checks inside all variables and global attributes.
-
-        Parameters
-        ----------
-        root : ET.Element
-            The root element of the DMR file.
-
-        Returns
-        -------
-        set[str] : The set of unique coordinate names.
-        """
-        # Check for coordinate names within each variable attributes
-        coord_names: set[str] = set()
-        for var_tag in self._find_var_tags(root):
-            coord_tag = var_tag.find(
-                "./dap:Attribute[@name='coordinates']/dap:Value", self._ns
-            )
-            if coord_tag is not None and coord_tag.text is not None:
-                coord_names.update(coord_tag.text.split(" "))
-            for map_tag in var_tag.iterfind("dap:Map", self._ns):
-                coord_names.add(map_tag.attrib["name"].removeprefix("/"))
-        # Check for coordinate names in a global attribute
-        coord_tag = var_tag.find("./dap:Attribute[@name='coordinates']", self._ns)
-        if coord_tag is not None and coord_tag.text is not None:
-            coord_names.update(coord_tag.text.split(" "))
-        return coord_names
-
-    def _parse_dim(self, root: ET.Element) -> dict[str, int | None]:
+    def _parse_dim(self, root: ET.Element) -> dict[str, int]:
         """
         Parse single <Dim> or <Dimension> tag
 
         If the tag has no name attribute, it is a phony dimension. E.g. <Dim size="300"/> --> {"phony_dim": 300}
-        If the tag has no size attribute, it is an unlimited dimension. E.g. <Dim name="time"/> --> {"time": None}
         If the tag has both name and size attributes, it is a regular dimension. E.g. <Dim name="lat" size="1447"/> --> {"lat": 1447}
 
         Parameters
@@ -472,98 +323,84 @@ class DMRParser:
         """
         if "name" not in root.attrib and "size" in root.attrib:
             return {"phony_dim": int(root.attrib["size"])}
-        if "name" in root.attrib and "size" not in root.attrib:
-            return {os.path.basename(root.attrib["name"]): None}
         if "name" in root.attrib and "size" in root.attrib:
-            return {os.path.basename(root.attrib["name"]): int(root.attrib["size"])}
+            return {Path(root.attrib["name"]).name: int(root.attrib["size"])}
         raise ValueError("Not enough information to parse Dim/Dimension tag")
 
-    def _parse_multi_dims(
-        self, dim_tags: list[ET.Element], global_dims: dict[str, int] = {}
-    ) -> dict:
+    def _find_dimension_tags(self, root: ET.Element) -> list[ET.Element]:
         """
-        Parse multiple <Dim> or <Dimension> tags. Generally <Dim> tags are found within dmrpp variable tags.
+        Find the all tags with dimension information.
 
-        Returns best possible matching of {dimension: shape} present in the list and global_dims. E.g tags=(Dim("lat", None), Dim("lon", None)) and global_dims={"lat": 100, "lon": 100, "time": 5} --> {"lat": 100, "lon": 100}
-
-        E.g. tags=(Dim("time", None), Dim("", 200)) and global_dims={"lat": 100, "lon": 100, "time": 5} --> {"time": 5, "phony_dim0": 200}
-
-        This function is often used to fill in missing sizes from the global_dims. E.g. Variable tags may contain only dimension names and not sizes. If the {name: size} matching is known from the global_dims, it is used to fill in the missing sizes.
+        First attempts to find Dimension tags, then falls back to Dim tags.
+        If Dim tags are found, the fully qualified name is used to find the corresponding Dimension tag.
 
         Parameters
         ----------
-        dim_tags : tuple[ET.Element]
-            A tuple of ElementTree Elements representing dimensions in the DMR file.
-
-        global_dims : dict
-            A dictionary of dimension names and sizes. E.g. {"time": 1, "lat": 1447, "lon": 2895}
+        root : ET.Element
+            An ElementTree Element from a DMR++ file.
 
         Returns
         -------
-        dict
-            E.g. {"time": 1, "lat": 1447, "lon": 2895}
+        list[ET.Element]
         """
-        dims: dict[str, int | None] = {}
-        for dim_tag in dim_tags:
-            dim: dict[str, int | None] = self._parse_dim(dim_tag)
-            if "phony_dim" in dim:
-                dims["phony_dim_" + str(len(dims))] = dim["phony_dim"]
-            else:
-                dims.update(dim)
-        for name, size in list(dims.items()):
-            if name in global_dims and size is None:
-                dims[name] = global_dims[name]
-        return dims
-
-    def _parse_variable(
-        self, var_tag: ET.Element, dataset_dims: dict[str, int]
-    ) -> Variable:
+        dimension_tags = root.findall("dap:Dimension", self._NS)
+        if not dimension_tags:
+            # Dim tags contain a fully qualified name that references a Dimension tag elsewhere in the DMR++
+            dim_tags = root.findall("dap:Dim", self._NS)
+            for d in dim_tags:
+                dimension_tag = self.find_node_fqn(d.attrib["name"])
+                if dimension_tag is not None:
+                    dimension_tags.append(dimension_tag)
+        return dimension_tags
+
+    def _parse_variable(self, var_tag: ET.Element) -> Variable:
         """
-        Parse a variable from a DMR tag.
+        Parse a variable from a DMR++ tag.
 
         Parameters
         ----------
         var_tag : ET.Element
-            An ElementTree Element representing a variable in the DMR file. Will have DAP dtype as tag.
-
-        dataset_dims : dict
-            A dictionary of dimension names and sizes. E.g. {"time": 1, "lat": 1447, "lon": 2895}
-            Must contain at least all the dimensions used by the variable. Necessary since the variable
-            metadata only contains the dimension names and not the sizes.
+            An ElementTree Element representing a variable in the DMR++ file. Will have DAP dtype as tag. E.g. <Float32>
 
         Returns
         -------
         xr.Variable
         """
-        # Dimension names
-        dim_tags = var_tag.findall("dap:Dim", self._ns)
-        dim_shapes = self._parse_multi_dims(dim_tags, dataset_dims)
+        # Dimension info
+        dims: dict[str, int] = {}
+        dimension_tags = self._find_dimension_tags(var_tag)
+        if not dimension_tags:
+            raise ValueError("Variable has no dimensions")
+        for dim in dimension_tags:
+            dims.update(self._parse_dim(dim))
         # convert DAP dtype to numpy dtype
         dtype = np.dtype(
-            self._dap_np_dtype[var_tag.tag.removeprefix("{" + self._ns["dap"] + "}")]
+            self._DAP_NP_DTYPE[var_tag.tag.removeprefix("{" + self._NS["dap"] + "}")]
         )
         # Chunks and Filters
         filters = None
-        shape: tuple[int, ...] = tuple(dim_shapes.values())
+        shape: tuple[int, ...] = tuple(dims.values())
         chunks_shape = shape
-        chunks_tag = var_tag.find("dmr:chunks", self._ns)
+        chunks_tag = var_tag.find("dmrpp:chunks", self._NS)
         if chunks_tag is not None:
             # Chunks
-            found_chunk_dims = self._parse_chunks_dimensions(chunks_tag)
-            chunks_shape = found_chunk_dims if found_chunk_dims is not None else shape
+            chunk_dim_text = chunks_tag.findtext(
+                "dmrpp:chunkDimensionSizes", namespaces=self._NS
+            )
+            if chunk_dim_text is not None:
+                # 1 1447 2895 -> (1, 1447, 2895)
+                chunks_shape = tuple(map(int, chunk_dim_text.split()))
+            else:
+                chunks_shape = shape
             chunkmanifest = self._parse_chunks(chunks_tag, chunks_shape)
             # Filters
             filters = self._parse_filters(chunks_tag, dtype)
         # Attributes
         attrs: dict[str, Any] = {}
-        for attr_tag in var_tag.iterfind("dap:Attribute", self._ns):
+        for attr_tag in var_tag.iterfind("dap:Attribute", self._NS):
             attrs.update(self._parse_attribute(attr_tag))
         # Fill value is placed in encoding and thus removed from attributes
-        fill_value = attrs.pop("_FillValue", 0.0)
-        # Remove attributes only used for parsing logic
-        attrs.pop("fullnamepath", None)
-        attrs.pop("origname", None)
-        attrs.pop("coordinates", None)
+        fill_value = attrs.pop("_FillValue", None)
         # create ManifestArray and ZArray
         zarray = ZArray(
             chunks=chunks_shape,
@@ -574,14 +411,13 @@ class DMRParser:
             shape=shape,
         )
         marr = ManifestArray(zarray=zarray, chunkmanifest=chunkmanifest)
-        encoding = {k: attrs.get(k) for k in self._encoding_keys if k in attrs}
-        return Variable(
-            dims=dim_shapes.keys(), data=marr, attrs=attrs, encoding=encoding
-        )
+        encoding = {k: attrs.get(k) for k in self._ENCODING_KEYS if k in attrs}
+        return Variable(dims=dims.keys(), data=marr, attrs=attrs, encoding=encoding)
 
     def _parse_attribute(self, attr_tag: ET.Element) -> dict[str, Any]:
         """
-        Parse an attribute from a DMR attr tag. Converts the attribute value to a native python type.
+        Parse an attribute from a DMR++ attr tag. Converts the attribute value to a native python type.
+        Raises an exception if nested attributes are passed. Container attributes must be unwrapped in the parent function.
 
         Parameters
         ----------
@@ -595,8 +431,13 @@ class DMRParser:
         attr: dict[str, Any] = {}
         values = []
         if "type" in attr_tag.attrib and attr_tag.attrib["type"] == "Container":
-            return attr
-        dtype = np.dtype(self._dap_np_dtype[attr_tag.attrib["type"]])
+            # DMR++ build information that is not part of the dataset
+            if attr_tag.attrib["name"] == "build_dmrpp_metadata":
+                return {}
+            raise ValueError(
+                "Nested attributes cannot be assigned to a variable or dataset"
+            )
+        dtype = np.dtype(self._DAP_NP_DTYPE[attr_tag.attrib["type"]])
         # if multiple Value tags are present, store as "key": "[v1, v2, ...]"
         for value_tag in attr_tag:
             # cast attribute to native python type using dmr provided dtype
@@ -605,6 +446,7 @@ class DMRParser:
                 if dtype != np.object_
                 else value_tag.text
             )
+            # "*" may represent nan values in DMR++
             if val == "*":
                 val = np.nan
             values.append(val)
@@ -615,7 +457,7 @@ class DMRParser:
         self, chunks_tag: ET.Element, dtype: np.dtype
     ) -> list[dict] | None:
         """
-        Parse filters from a DMR chunks tag.
+        Parse filters from a DMR++ chunks tag.
 
         Parameters
         ----------
@@ -643,7 +485,7 @@ class DMRParser:
                             "id": "zlib",
                             "level": int(
                                 chunks_tag.attrib.get(
-                                    "deflateLevel", self._default_zlib_value
+                                    "deflateLevel", self._DEFAULT_ZLIB_VALUE
                                 )
                             ),
                         }
@@ -651,33 +493,11 @@ class DMRParser:
             return filters
         return None
 
-    def _parse_chunks_dimensions(
-        self, chunks_tag: ET.Element
-    ) -> tuple[int, ...] | None:
-        """
-        Parse the chunk dimensions from a DMR chunks tag. Returns None if no chunk dimensions are found.
-
-        Parameters
-        ----------
-        chunks_tag : ET.Element
-            An ElementTree Element with a <chunks> tag.
-
-        Returns
-        -------
-        tuple[int, ...] | None
-
-        """
-        chunk_dim_tag = chunks_tag.find("dmr:chunkDimensionSizes", self._ns)
-        if chunk_dim_tag is not None and chunk_dim_tag.text is not None:
-            # 1 1447 2895 -> (1, 1447, 2895)
-            return tuple(map(int, chunk_dim_tag.text.split()))
-        return None
-
     def _parse_chunks(
         self, chunks_tag: ET.Element, chunks_shape: tuple[int, ...]
     ) -> ChunkManifest:
         """
-        Parse the chunk manifest from a DMR chunks tag.
+        Parse the chunk manifest from a DMR++ chunks tag.
 
         Parameters
         ----------
@@ -696,7 +516,7 @@ class DMRParser:
             [0 for i in range(len(chunks_shape))] if chunks_shape else [0]
         )
         chunk_key_template = ".".join(["{}" for i in range(len(default_num))])
-        for chunk_tag in chunks_tag.iterfind("dmr:chunk", self._ns):
+        for chunk_tag in chunks_tag.iterfind("dmrpp:chunk", self._NS):
             chunk_num = default_num
             if "chunkPositionInArray" in chunk_tag.attrib:
                 # "[0,1023,10235]" -> ["0","1023","10235"]


Test patch:
diff --git a/virtualizarr/tests/test_readers/test_dmrpp.py b/virtualizarr/tests/test_readers/test_dmrpp.py
index d2b19d6..cbafc40 100644
--- a/virtualizarr/tests/test_readers/test_dmrpp.py
+++ b/virtualizarr/tests/test_readers/test_dmrpp.py
@@ -1,22 +1,356 @@
+import textwrap
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+import numpy as np
 import pytest
 import xarray as xr
+import xarray.testing as xrt
 
 from virtualizarr import open_virtual_dataset
+from virtualizarr.manifests.manifest import ChunkManifest
+from virtualizarr.readers.dmrpp import DMRParser
 from virtualizarr.tests import network
 
 urls = [
     (
-        "netcdf4",
-        "https://github.com/OPENDAP/bes/raw/3e518f6dc2f625b0b83cfb6e6fd5275e4d6dcef1/modules/dmrpp_module/data/dmrpp/chunked_threeD.h5",
-        "dmrpp",
-        "https://github.com/OPENDAP/bes/raw/3e518f6dc2f625b0b83cfb6e6fd5275e4d6dcef1/modules/dmrpp_module/data/dmrpp/chunked_threeD.h5.dmrpp",
+        "https://its-live-data.s3-us-west-2.amazonaws.com/test-space/cloud-experiments/dmrpp/20240826090000-JPL-L4_GHRSST-SSTfnd-MUR25-GLOB-v02.0-fv04.2.nc",
+        "https://its-live-data.s3-us-west-2.amazonaws.com/test-space/cloud-experiments/dmrpp/20240826090000-JPL-L4_GHRSST-SSTfnd-MUR25-GLOB-v02.0-fv04.2.nc.dmrpp",
     )
+    # TODO: later add MUR, SWOT, TEMPO and others by using kerchunk JSON to read refs (rather than reading the whole netcdf file)
 ]
 
 
+@pytest.fixture
+def basic_dmrpp() -> DMRParser:
+    xml_str = """\
+    <?xml version="1.0" encoding="ISO-8859-1"?>
+    <Dataset xmlns="http://xml.opendap.org/ns/DAP/4.0#" xmlns:dmrpp="http://xml.opendap.org/dap/dmrpp/1.0.0#" dapVersion="4.0" dmrVersion="1.0" name="test.dmrpp">
+        <Dimension name="x" size="720"/>
+        <Dimension name="y" size="1440"/>
+        <Dimension name="z" size="3"/>
+        <Int32 name="x">
+            <Dim name="/x"/>
+            <Attribute name="long_name" type="String">
+                <Value>grid x-axis</Value>
+            </Attribute>
+            <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                <dmrpp:chunk offset="41268" nBytes="4"/>
+            </dmrpp:chunks>
+        </Int32>
+        <Int32 name="y">
+            <Dim name="/y"/>
+            <Attribute name="long_name" type="String">
+                <Value>grid y-axis</Value>
+            </Attribute>
+            <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                <dmrpp:chunk offset="41272" nBytes="4"/>
+            </dmrpp:chunks>
+        </Int32>
+        <Int32 name="z">
+            <Dim name="/z"/>
+            <Attribute name="long_name" type="String">
+                <Value>grid z-axis</Value>
+            </Attribute>
+            <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                <dmrpp:chunk offset="41276" nBytes="4"/>
+            </dmrpp:chunks>
+        </Int32>
+        <Float32 name="data">
+            <Dim name="/x"/>
+            <Dim name="/y"/>
+            <Attribute name="long_name" type="String">
+                <Value>analysed sea surface temperature</Value>
+            </Attribute>
+            <Attribute name="items" type="Int16">
+                <Value>1</Value>
+                <Value>2</Value>
+                <Value>3</Value>
+            </Attribute>
+            <Attribute name="_FillValue" type="Int16">
+                <Value>-32768</Value>
+            </Attribute>
+            <Attribute name="add_offset" type="Float64">
+                <Value>298.14999999999998</Value>
+            </Attribute>
+            <Attribute name="scale_factor" type="Float64">
+                <Value>0.001</Value>
+            </Attribute>
+            <Attribute name="coordinates" type="String">
+                <Value>x y z</Value>
+            </Attribute>
+            <dmrpp:chunks compressionType="shuffle deflate" deflateLevel="5" fillValue="-32768" byteOrder="LE">
+                <dmrpp:chunkDimensionSizes>360 720</dmrpp:chunkDimensionSizes>
+                <dmrpp:chunk offset="40762" nBytes="4083" chunkPositionInArray="[0,0]"/>
+                <dmrpp:chunk offset="44845" nBytes="4083" chunkPositionInArray="[0,720]"/>
+                <dmrpp:chunk offset="48928" nBytes="4083" chunkPositionInArray="[0,1440]"/>
+                <dmrpp:chunk offset="53011" nBytes="4083" chunkPositionInArray="[360, 0]"/>
+                <dmrpp:chunk offset="57094" nBytes="4083" chunkPositionInArray="[360, 720]"/>
+                <dmrpp:chunk offset="61177" nBytes="4083" chunkPositionInArray="[360, 1440]"/>
+                <dmrpp:chunk offset="65260" nBytes="4083" chunkPositionInArray="[720, 0]"/>
+                <dmrpp:chunk offset="69343" nBytes="4083" chunkPositionInArray="[720, 720]"/>
+                <dmrpp:chunk offset="73426" nBytes="4083" chunkPositionInArray="[720, 1440]"/>
+            </dmrpp:chunks>
+        </Float32>
+        <Float32 name="mask">
+            <Dim name="/x"/>
+            <Dim name="/y"/>
+            <Dim name="/z"/>
+            <Attribute name="long_name" type="String">
+                <Value>mask</Value>
+            </Attribute>
+            <dmrpp:chunks compressionType="shuffle" fillValue="-2147483647" byteOrder="LE">
+                <dmrpp:chunk offset="41276" nBytes="4"/>
+            </dmrpp:chunks>
+        </Float32>
+        <Attribute name="Conventions" type="String">
+            <Value>CF-1.6</Value>
+        </Attribute>
+        <Attribute name="title" type="String">
+            <Value>Sample Dataset</Value>
+        </Attribute>
+    </Dataset>
+    """
+    return DMRParser(root=ET.fromstring(textwrap.dedent(xml_str)))
+
+
+@pytest.fixture
+def nested_groups_dmrpp() -> DMRParser:
+    xml_str = """\
+    <?xml version="1.0" encoding="ISO-8859-1"?>
+    <Dataset xmlns="http://xml.opendap.org/ns/DAP/4.0#" xmlns:dmrpp="http://xml.opendap.org/dap/dmrpp/1.0.0#" dapVersion="4.0" dmrVersion="1.0" name="test.dmrpp">
+        <Dimension name="a" size="10"/>
+        <Dimension name="b" size="10"/>
+        <Int32 name="a">
+            <Dim name="/a"/>
+            <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                <dmrpp:chunk offset="41268" nBytes="4"/>
+            </dmrpp:chunks>
+        </Int32>
+        <Int32 name="b">
+            <Dim name="/b"/>
+            <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                <dmrpp:chunk offset="41268" nBytes="4"/>
+            </dmrpp:chunks>
+        </Int32>
+        <Group name="group1">
+            <Dimension name="x" size="720"/>
+            <Dimension name="y" size="1440"/>
+            <Int32 name="x">
+                <Dim name="/group1/x"/>
+                <Attribute name="test" type="String">
+                    <Value>test</Value>
+                </Attribute>
+                <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                    <dmrpp:chunk offset="41268" nBytes="4"/>
+                </dmrpp:chunks>
+            </Int32>
+            <Int32 name="y">
+                <Dim name="/group1/y"/>
+                <Attribute name="test" type="String">
+                    <Value>test</Value>
+                </Attribute>
+                <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                    <dmrpp:chunk offset="41268" nBytes="4"/>
+                </dmrpp:chunks>
+            </Int32>
+            <Group name="group2">
+                <Int32 name="area">
+                    <Dim name="/group1/x"/>
+                    <Dim name="/group1/y"/>
+                    <dmrpp:chunks fillValue="-2147483647" byteOrder="LE">
+                        <dmrpp:chunk offset="41268" nBytes="4"/>
+                    </dmrpp:chunks>
+                </Int32>
+            </Group>
+        </Group>
+    </Dataset>
+    """
+    return DMRParser(root=ET.fromstring(textwrap.dedent(xml_str)))
+
+
 @network
-@pytest.mark.parametrize("data_type, data_url, dmrpp_type, dmrpp_url", urls)
-def test_dmrpp_reader(data_type, data_url, dmrpp_type, dmrpp_url):
-    result = open_virtual_dataset(dmrpp_url, indexes={}, filetype=dmrpp_type)
+@pytest.mark.parametrize("data_url, dmrpp_url", urls)
+@pytest.mark.skip(reason="Fill_val mismatch")
+def test_NASA_dmrpp(data_url, dmrpp_url):
+    result = open_virtual_dataset(dmrpp_url, indexes={}, filetype="dmrpp")
     expected = open_virtual_dataset(data_url, indexes={})
     xr.testing.assert_identical(result, expected)
+
+
+@pytest.mark.parametrize(
+    "dmrpp_fixture, fqn_path, expected_xpath",
+    [
+        ("basic_dmrpp", "/", "."),
+        ("basic_dmrpp", "/data", "./*[@name='data']"),
+        ("basic_dmrpp", "/data/items", "./*[@name='data']/*[@name='items']"),
+        (
+            "nested_groups_dmrpp",
+            "/group1/group2/area",
+            "./*[@name='group1']/*[@name='group2']/*[@name='area']",
+        ),
+    ],
+)
+def test_find_node_fqn(request, dmrpp_fixture, fqn_path, expected_xpath):
+    parser_instance = request.getfixturevalue(dmrpp_fixture)
+    result = parser_instance.find_node_fqn(fqn_path)
+    expected = parser_instance.root.find(expected_xpath, parser_instance._NS)
+    assert result == expected
+
+
+@pytest.mark.parametrize(
+    "dmrpp_fixture, group_path",
+    [
+        ("basic_dmrpp", "/"),
+        ("nested_groups_dmrpp", "/"),
+        ("nested_groups_dmrpp", "/group1"),
+        ("nested_groups_dmrpp", "/group1/group2"),
+    ],
+)
+def test_split_groups(request, dmrpp_fixture, group_path):
+    dmrpp_instance = request.getfixturevalue(dmrpp_fixture)
+    # get all tags in a dataset (so all tags excluding nested groups)
+    dataset_tags = lambda x: [
+        d for d in x if d.tag != "{" + dmrpp_instance._NS["dap"] + "}" + "Group"
+    ]
+    # check that contents of the split groups dataset match contents of the original dataset
+    result_tags = dataset_tags(
+        dmrpp_instance._split_groups(dmrpp_instance.root)[Path(group_path)]
+    )
+    expected_tags = dataset_tags(dmrpp_instance.find_node_fqn(group_path))
+    assert result_tags == expected_tags
+
+
+def test_parse_dataset(basic_dmrpp, nested_groups_dmrpp):
+    vds = basic_dmrpp.parse_dataset()
+    assert vds.sizes == {"x": 720, "y": 1440, "z": 3}
+    assert vds.data_vars.keys() == {"data", "mask"}
+    assert vds.data_vars["data"].dims == ("x", "y")
+    assert vds.attrs == {"Conventions": "CF-1.6", "title": "Sample Dataset"}
+    assert vds.coords.keys() == {"x", "y", "z"}
+    vds_root_implicit = nested_groups_dmrpp.parse_dataset()
+    vds_root = nested_groups_dmrpp.parse_dataset(group="/")
+    xrt.assert_identical(vds_root_implicit, vds_root)
+    assert vds_root.sizes == {"a": 10, "b": 10}
+    assert vds_root.coords.keys() == {"a", "b"}
+    vds_g1 = nested_groups_dmrpp.parse_dataset(group="/group1")
+    assert vds_g1.sizes == {"x": 720, "y": 1440}
+    assert vds_g1.coords.keys() == {"x", "y"}
+    vds_g2 = nested_groups_dmrpp.parse_dataset(group="/group1/group2")
+    assert vds_g2.sizes == {"x": 720, "y": 1440}
+    assert vds_g2.data_vars.keys() == {"area"}
+    assert vds_g2.data_vars["area"].dims == ("x", "y")
+
+
+@pytest.mark.parametrize(
+    "dim_path, expected",
+    [
+        ("/a", {"a": 10}),
+        ("/group1/x", {"x": 720}),
+    ],
+)
+def test_parse_dim(nested_groups_dmrpp, dim_path, expected):
+    result = nested_groups_dmrpp._parse_dim(nested_groups_dmrpp.find_node_fqn(dim_path))
+    assert result == expected
+
+
+@pytest.mark.parametrize("dim_path", ["/", "/mask"])
+def test_find_dimension_tags(basic_dmrpp, dim_path):
+    # Check that Dimension tags match Dimension tags from the root
+    # Check that Dim tags reference the same Dimension tags from the root
+    assert basic_dmrpp._find_dimension_tags(
+        basic_dmrpp.find_node_fqn(dim_path)
+    ) == basic_dmrpp.root.findall("dap:Dimension", basic_dmrpp._NS)
+
+
+def test_parse_variable(basic_dmrpp):
+    var = basic_dmrpp._parse_variable(basic_dmrpp.find_node_fqn("/data"))
+    assert var.dtype == "float32"
+    assert var.dims == ("x", "y")
+    assert var.shape == (720, 1440)
+    assert var.data.zarray.chunks == (360, 720)
+    assert var.data.zarray.fill_value == -32768
+    assert var.encoding == {"add_offset": 298.15, "scale_factor": 0.001}
+    assert var.attrs == {
+        "long_name": "analysed sea surface temperature",
+        "items": [1, 2, 3],
+        "coordinates": "x y z",
+        "add_offset": 298.15,
+        "scale_factor": 0.001,
+    }
+
+
+@pytest.mark.parametrize(
+    "attr_path, expected",
+    [
+        ("data/long_name", {"long_name": "analysed sea surface temperature"}),
+        ("data/items", {"items": [1, 2, 3]}),
+        ("data/_FillValue", {"_FillValue": -32768}),
+    ],
+)
+def test_parse_attribute(basic_dmrpp, attr_path, expected):
+    result = basic_dmrpp._parse_attribute(basic_dmrpp.find_node_fqn(attr_path))
+    assert result == expected
+
+
+@pytest.mark.parametrize(
+    "var_path, dtype, expected_filters",
+    [
+        (
+            "/data",
+            np.dtype("float32"),
+            [
+                {"elementsize": np.dtype("float32").itemsize, "id": "shuffle"},
+                {"id": "zlib", "level": 5},
+            ],
+        ),
+        (
+            "/mask",
+            np.dtype("float32"),
+            [{"elementsize": np.dtype("float32").itemsize, "id": "shuffle"}],
+        ),
+    ],
+)
+def test_parse_filters(basic_dmrpp, var_path, dtype, expected_filters):
+    chunks_tag = basic_dmrpp.find_node_fqn(var_path).find(
+        "dmrpp:chunks", basic_dmrpp._NS
+    )
+    result = basic_dmrpp._parse_filters(chunks_tag, dtype)
+    assert result == expected_filters
+
+
+@pytest.mark.parametrize(
+    "var_path, chunk_shape, expected_lengths, expected_offsets, expected_paths",
+    [
+        (
+            "/data",
+            (360, 720),
+            np.full((3, 3), 4083, dtype=np.uint64),
+            (np.arange(9, dtype=np.uint64) * 4083 + 40762).reshape(3, 3),
+            np.full((3, 3), "test.dmrpp", dtype=np.dtypes.StringDType),
+        ),
+        (
+            "/mask",
+            (720, 1440),
+            np.array([4], dtype=np.uint64),
+            np.array([41276], dtype=np.uint64),
+            np.array(["test.dmrpp"], dtype=np.dtypes.StringDType),
+        ),
+    ],
+)
+def test_parse_chunks(
+    basic_dmrpp,
+    var_path,
+    chunk_shape,
+    expected_lengths,
+    expected_offsets,
+    expected_paths,
+):
+    chunks_tag = basic_dmrpp.find_node_fqn(var_path).find(
+        "dmrpp:chunks", basic_dmrpp._NS
+    )
+    result = basic_dmrpp._parse_chunks(chunks_tag, chunk_shape)
+    expected = ChunkManifest.from_arrays(
+        lengths=expected_lengths, offsets=expected_offsets, paths=expected_paths
+    )
+    assert result == expected