Repository: openradar/xradar

Issue:
Test code coverage
With growing code base we should implement code coverage tests.

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 63d5c25..cd65686 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,7 +61,15 @@ jobs:
         python -c "import xradar; print(xradar.version.version)"
     - name: Test with pytest
       run: |
-        pytest -n auto --dist loadfile --verbose --durations=15 --pyargs tests
+        pytest -n auto --dist loadfile --verbose --durations=15 --cov-report xml:coverage.xml --cov=xradar --pyargs tests
+    - name: Upload coverage to Codecov
+      uses: codecov/codecov-action@v3
+      with:
+        file: ./coverage.xml
+        flags: unittests
+        env_vars: RUNNER_OS,PYTHON_VERSION
+        name: codecov-gha
+        fail_ci_if_error: false
 
   test_build_distribution:
     name: test build distribution
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..780722c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,36 @@
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.1.0
+    hooks:
+      - id: trailing-whitespace
+      - id: end-of-file-fixer
+      - id: check-docstring-first
+      - id: check-json
+      - id: check-yaml
+      - id: double-quote-string-fixer
+      - id: debug-statements
+      - id: mixed-line-ending
+
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v2.31.0
+    hooks:
+      - id: pyupgrade
+        args:
+          - '--py38-plus'
+
+  - repo: https://github.com/psf/black
+    rev: 22.3.0
+    hooks:
+      - id: black
+      - id: black-jupyter
+
+  - repo: https://github.com/PyCQA/flake8
+    rev: 4.0.1
+    hooks:
+      - id: flake8
+        args: [--config, setup.cfg]
+
+  - repo: https://github.com/PyCQA/isort
+    rev: 5.10.1
+    hooks:
+      - id: isort
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 2c0a758..8817b50 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -4,4 +4,4 @@ conda:
 build:
   os: 'ubuntu-20.04'
   tools:
-    python: 'mambaforge-4.10'
\ No newline at end of file
+    python: 'mambaforge-4.10'
diff --git a/LICENSE b/LICENSE
index a494018..bbe0135 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
-
diff --git a/docs/history.md b/docs/history.md
index ec47691..3b0e1e0 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -5,6 +5,7 @@
 * add GAMIC HDF5 importer ({pull}`29`) by [@kmuehlbauer](https://github.com/kmuehlbauer)
 * add Furuno SCN/SCNX importer ({pull}`30`) by [@kmuehlbauer](https://github.com/kmuehlbauer)
 * add Rainbow5 importer ({pull}`32`) by [@kmuehlbauer](https://github.com/kmuehlbauer)
+* add Iris/Sigmet importer ({pull}`33`) by [@kmuehlbauer](https://github.com/kmuehlbauer)
 
 ## 0.7.0 (2022-09-21)
 
diff --git a/docs/importers.md b/docs/importers.md
index 1a64f4a..1e2eb10 100644
--- a/docs/importers.md
+++ b/docs/importers.md
@@ -9,6 +9,7 @@ Currently xradar can import:
 - GAMIC HDF5
 - Furuno SCN/SCNX
 - Rainbow
+- Iris/Sigmet
 
 ## CfRadial1
 
@@ -96,3 +97,21 @@ more functions are applied on that {py:class}`xarray:xarray.Dataset`.
 With {class}`xradar.io.backends.rainbow.open_rainbow_datatree` all groups (eg. ``0``)
 are extracted. From that the ``root`` group is processed. Everything is finally added as
 ParentNodes and ChildNodes to a {py:class}`datatree:datatree.Datatree`.
+
+
+## Iris/Sigmet
+
+### IrisBackendEntrypoint
+
+The xarray backend {class}`xradar.io.backends.iris.IrisBackendEntrypoint`
+opens the file with {class}`xradar.io.backends.Iris.IrisStore`. Several
+private helper functions are used to conveniently access data and
+metadata. Finally, the xarray machinery returns a {py:class}`xarray:xarray.Dataset`
+with wanted group (eg. ``0``). Depending on the used backend kwargs several
+more functions are applied on that {py:class}`xarray:xarray.Dataset`.
+
+### open_iris_datatree
+
+With {class}`xradar.io.backends.iris.open_iris_datatree` all groups (eg. ``1``)
+are extracted. From that the ``root`` group is processed. Everything is finally added as
+ParentNodes and ChildNodes to a {py:class}`datatree:datatree.Datatree`.
diff --git a/docs/usage.md b/docs/usage.md
index a75d2e4..b1230ef 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -32,4 +32,7 @@ notebooks/GAMIC_full
 notebooks/Furuno
 notebooks/Rainbow
 notebooks/Rainbow_full
+notebooks/Iris
+notebooks/Iris_full
+notebooks/plot-ppi
 ```
diff --git a/examples/notebooks/Iris.ipynb b/examples/notebooks/Iris.ipynb
new file mode 100644
index 0000000..39e160e
--- /dev/null
+++ b/examples/notebooks/Iris.ipynb
@@ -0,0 +1,206 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "59447ad6-ac47-494e-b696-4335b36b205b",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "# Iris - Simple"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6f96b5d8-2b96-4fd7-b8ba-166c34a8dcd2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "import xarray as xr\n",
+    "import xradar as xd\n",
+    "from urllib.parse import urljoin\n",
+    "from urllib.request import urlretrieve"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "33d50be4-dfe5-4d99-a936-67a9a76bac94",
+   "metadata": {},
+   "source": [
+    "## Download\n",
+    "\n",
+    "Fetching ODIM_H5 radar data file from wradlib-data repository."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d3c6d408-5ab2-43c3-afd1-b3a703ef3b24",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def fetch_iris_file():\n",
+    "    fname = \"iris_data0.raw\"\n",
+    "    if not os.path.exists(fname):\n",
+    "        base_url = \"https://raw.githubusercontent.com/wradlib/wradlib-data/main/sigmet/\"\n",
+    "        filename = \"SUR210819000227.RAWKPJV\"\n",
+    "        url = urljoin(base_url, filename)\n",
+    "        urlretrieve(url, filename=fname)\n",
+    "    return fname\n",
+    "\n",
+    "\n",
+    "filename = fetch_iris_file()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b987dcfd-5105-4483-932e-71b8002e5f09",
+   "metadata": {},
+   "source": [
+    "## xr.open_dataset\n",
+    "\n",
+    "Making use of the xarray `odim` backend. We also need to provide the group."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7675b518-18e4-4ea6-b101-f1bccf603902",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds = xr.open_dataset(filename, group=1, engine=\"iris\", reindex_angle=False)\n",
+    "display(ds)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "01ec8c90-2da8-46ae-a0b5-0e1792a79bbe",
+   "metadata": {},
+   "source": [
+    "### Plot Time vs. Azimuth"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a7fdce90-15e8-44b7-9a90-03b2acc3df7d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds.azimuth.plot()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "222ca704-d6e1-49e6-84a0-de55df9fdf61",
+   "metadata": {},
+   "source": [
+    "### Plot Range vs. Time"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "37534a3f-2ab1-4500-a952-7896add62b91",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds.DBZH.plot()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d904cd09-8590-42e2-8dce-41d3949d313c",
+   "metadata": {},
+   "source": [
+    "### Plot Range vs. Azimuth\n",
+    "\n",
+    "We need to sort by azimuth and specify the y-coordinate."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6479a374-25ab-42be-b53e-82849b6faffc",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds.DBZH.sortby(\"azimuth\").plot(y=\"azimuth\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5deafdf1-9224-459c-8b10-7c501ae13234",
+   "metadata": {},
+   "source": [
+    "## open_odim_datatree"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a65f0b48-7197-4f79-bc26-2045cfc59a4a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree = xd.io.open_iris_datatree(filename, reindex_angle=False)\n",
+    "display(dtree)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "638bc6c0-3293-4661-a5d5-e9aaad14ffe9",
+   "metadata": {},
+   "source": [
+    "### Plot Sweep Range vs. Time"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f9db8500-7072-451b-84a1-f36767110e16",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree[\"sweep_0\"].ds.DBZH.plot()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1e44c0fd-02bc-4d2d-ac91-772bdcebe04b",
+   "metadata": {},
+   "source": [
+    "### Plot Sweep Range vs. Azimuth"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c7aab6b3-aeb0-4ed1-8397-8b505e63464a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree[\"sweep_0\"].ds.DBZH.sortby(\"azimuth\").plot(y=\"azimuth\")"
+   ]
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.13"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/notebooks/Iris_full.ipynb b/examples/notebooks/Iris_full.ipynb
new file mode 100644
index 0000000..9782513
--- /dev/null
+++ b/examples/notebooks/Iris_full.ipynb
@@ -0,0 +1,219 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "59447ad6-ac47-494e-b696-4335b36b205b",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "# Iris - Full"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6f96b5d8-2b96-4fd7-b8ba-166c34a8dcd2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "import xarray as xr\n",
+    "import xradar as xd\n",
+    "from urllib.parse import urljoin\n",
+    "from urllib.request import urlretrieve"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "33d50be4-dfe5-4d99-a936-67a9a76bac94",
+   "metadata": {},
+   "source": [
+    "## Download\n",
+    "\n",
+    "Fetching Iris radar data file from wradlib-data repository."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d3c6d408-5ab2-43c3-afd1-b3a703ef3b24",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def fetch_iris_file():\n",
+    "    fname = \"iris_data1.raw\"\n",
+    "    if not os.path.exists(fname):\n",
+    "        base_url = \"https://raw.githubusercontent.com/wradlib/wradlib-data/main/sigmet/\"\n",
+    "        filename = \"cor-main131125105503.RAW2049\"\n",
+    "        url = urljoin(base_url, filename)\n",
+    "        urlretrieve(url, filename=fname)\n",
+    "    return fname\n",
+    "\n",
+    "\n",
+    "filename = fetch_iris_file()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b987dcfd-5105-4483-932e-71b8002e5f09",
+   "metadata": {},
+   "source": [
+    "## xr.open_dataset\n",
+    "\n",
+    "Making use of the xarray `rainbow` backend. We also need to provide the group."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7675b518-18e4-4ea6-b101-f1bccf603902",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds = xr.open_dataset(filename, group=1, engine=\"iris\", reindex_angle=False)\n",
+    "display(ds)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "27465481-21c0-49c4-8e3f-75577bbf965e",
+   "metadata": {},
+   "source": [
+    "## backend_kwargs\n",
+    "\n",
+    "Beside `first_dim` there are several additional backend_kwargs for the iris backend, which handle different aspects of angle alignment. This comes into play, when azimuth and/or elevation arrays are not evenly spacend and other issues."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "08f12b48-a3b5-43b7-b9a1-87afa9002fb9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "help(xd.io.IrisBackendEntrypoint)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "dd052e96-9666-40ff-8b9b-3723daf68c32",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds = xr.open_dataset(\n",
+    "    filename, group=1, engine=\"iris\", first_dim=\"time\", reindex_angle=False\n",
+    ")\n",
+    "display(ds)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "34aad9c4-dc94-42c4-a29a-3614ebab270f",
+   "metadata": {},
+   "source": [
+    "Please observe the `a1gate`- and `angle_res`-attribute of azimuth-DataArray."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "40b3290d-39ef-4ead-aa53-b2967f769e58",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds.azimuth"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9a09378a-11d9-414f-bc23-0b6f01017d12",
+   "metadata": {},
+   "source": [
+    "## open_iris_datatree\n",
+    "\n",
+    "The same works analoguous with the datatree loader. But additionally we can provide a sweep string, number or list."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e8485006-ca89-4409-82ab-29ae8f93004a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "help(xd.io.open_iris_datatree)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "de430cfa-18cd-4e37-ab36-f4e211dbc7c5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree = xd.io.open_iris_datatree(filename, first_dim=\"auto\", reindex_angle=False)\n",
+    "display(dtree)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9dc09b53-7e2a-4fb6-9b28-a3ae3fd95bd0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree = xd.io.open_iris_datatree(\n",
+    "    filename, first_dim=\"auto\", sweep=\"sweep8\", reindex_angle=False\n",
+    ")\n",
+    "display(dtree)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "41976524-fca8-4569-8b37-867d2214709c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree = xd.io.open_iris_datatree(\n",
+    "    filename, first_dim=\"auto\", sweep=[1, 2, 8], reindex_angle=False\n",
+    ")\n",
+    "display(dtree)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c4239ebf-f16d-42ee-8f03-a38ecbc04cee",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dtree = xd.io.open_iris_datatree(\n",
+    "    filename,\n",
+    "    first_dim=\"auto\",\n",
+    "    sweep=[\"sweep1\", \"sweep2\", \"sweep8\"],\n",
+    "    reindex_angle=False,\n",
+    ")\n",
+    "display(dtree)"
+   ]
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.13"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/notebooks/Rainbow.ipynb b/examples/notebooks/Rainbow.ipynb
index eba513a..d06ccbd 100644
--- a/examples/notebooks/Rainbow.ipynb
+++ b/examples/notebooks/Rainbow.ipynb
@@ -205,4 +205,4 @@
  },
  "nbformat": 4,
  "nbformat_minor": 5
-}
+}
\ No newline at end of file
diff --git a/examples/notebooks/plot-ppi.ipynb b/examples/notebooks/plot-ppi.ipynb
new file mode 100644
index 0000000..03fd1cb
--- /dev/null
+++ b/examples/notebooks/plot-ppi.ipynb
@@ -0,0 +1,151 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "f15f38bb-57f7-4104-8824-9feec9deee47",
+   "metadata": {},
+   "source": [
+    "# Create a Plan Position Indicator (PPI) Plot\n",
+    "A Plan Position Indicator (PPI) plot is a common plot requested by radar scientists. Let's show how to create this plot using `xradar`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "91562ade-05d4-4b1e-ab7b-720b04641c61",
+   "metadata": {},
+   "source": [
+    "## Imports"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ce9365db-30f0-4df1-944f-b3c739c0ef99",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "import xarray as xr\n",
+    "import xradar as xd\n",
+    "from urllib.parse import urljoin\n",
+    "from urllib.request import urlretrieve"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "100584b5-d600-4f93-980b-6c1dde9335a8",
+   "metadata": {},
+   "source": [
+    "## Read in some data\n",
+    "Fetching CfRadial1 radar data file from wradlib-data repository."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "cf955c1f-fa3b-4545-b90a-7afcd9b11b31",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def fetch_cfradial1_file():\n",
+    "    fname = \"cfradial1_data.nc\"\n",
+    "    if not os.path.exists(fname):\n",
+    "        base_url = \"https://raw.githubusercontent.com/wradlib/wradlib-data/main/netcdf/\"\n",
+    "        filename = \"cfrad.20080604_002217_000_SPOL_v36_SUR.nc\"\n",
+    "        url = urljoin(base_url, filename)\n",
+    "        urlretrieve(url, filename=fname)\n",
+    "    return fname\n",
+    "\n",
+    "\n",
+    "filename = fetch_cfradial1_file()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b88a784c-8a18-4a75-973e-5723acdcb4b0",
+   "metadata": {},
+   "source": [
+    "Read the data using the `cfradial1` engine"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e97d5717-e6aa-4155-abe5-0960d846ed1c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds = xr.open_dataset(filename, group=\"sweep_0\", engine=\"cfradial1\")\n",
+    "display(ds)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "7eb28292-d412-4b9f-b506-b983f750ae18",
+   "metadata": {},
+   "source": [
+    "## Add georeferencing\n",
+    "Notice how after applying georeferencing, using the `get_x_y_z` function, we have x, y, and z in our dataset!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "eaa2ee4c-98f2-41d2-b16e-9d05ad8608da",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds = xd.georeference.get_x_y_z(ds)\n",
+    "display(ds)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "72231f7d-2b93-4f15-a8c7-66e4f64f7f8f",
+   "metadata": {},
+   "source": [
+    "## Plot our Data\n",
+    "Now, let's create our PPI plot!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c2f4cfaa-eba9-4b9d-8d84-7b60027ae0fb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ds.DBZ.plot(x='x', y='y', cmap='inferno')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b15b2c05-60a8-4eb0-b2c2-251cabafe7bb",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index 2ac6ff2..4b0262a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,3 +11,7 @@ build-backend = "setuptools.build_meta"
 write_to = "xradar/version.py"
 version_scheme = "guess-next-dev"
 fallback_version = "999"
+
+[tool.black]
+line-length = 88
+skip-string-normalization = true
diff --git a/setup.cfg b/setup.cfg
index a1973f2..5f37d20 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,6 +15,7 @@ ignore =
     W503
 
 [isort]
+profile = black
 default_section = THIRDPARTY
 known_first_party = xradar
 multi_line_output = 3
diff --git a/setup.py b/setup.py
index cb1ee3d..358d4c4 100644
--- a/setup.py
+++ b/setup.py
@@ -52,6 +52,7 @@ setup(
             "odim = xradar.io.backends:OdimBackendEntrypoint",
             "furuno = xradar.io.backends:FurunoBackendEntrypoint",
             "rainbow = xradar.io.backends:RainbowBackendEntrypoint",
+            "iris = xradar.io.backends:IrisBackendEntrypoint",
         ]
     },
     test_suite="tests",
diff --git a/xradar/__init__.py b/xradar/__init__.py
index 9e54d2c..9e83747 100644
--- a/xradar/__init__.py
+++ b/xradar/__init__.py
@@ -6,8 +6,8 @@ Top-level package for xradar.
 
 """
 
-__author__ = """Maxwell Grover"""
-__email__ = "mgroverwx@gmail.com"
+__author__ = """Open Radar Developers"""
+__email__ = 'mgroverwx@gmail.com'
 
 # versioning
 try:
@@ -15,12 +15,13 @@ try:
 except Exception:
     # Local copy or not installed with setuptools.
     # Disable minimum version checks on downstream libraries.
-    __version__ = "999"
+    __version__ = '999'
 
 # import subpackages
 from . import accessors  # noqa
+from . import georeference  # noqa
 from . import io  # noqa
 from . import model  # noqa
 from . import util  # noqa
 
-__all__ = [s for s in dir() if not s.startswith("_")]
+__all__ = [s for s in dir() if not s.startswith('_')]
diff --git a/xradar/georeference/__init__.py b/xradar/georeference/__init__.py
new file mode 100644
index 0000000..2176b89
--- /dev/null
+++ b/xradar/georeference/__init__.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+# Copyright (c) 2022, openradar developers.
+# Distributed under the MIT License. See LICENSE for more info.
+
+"""
+XRadar Georeferencing
+=====================
+
+.. toctree::
+    :maxdepth: 4
+
+.. automodule:: xradar.georeference.transforms
+
+"""
+from .transforms import *  # noqa
+
+__all__ = [s for s in dir() if not s.startswith('_')]
diff --git a/xradar/georeference/transforms.py b/xradar/georeference/transforms.py
new file mode 100644
index 0000000..3d50e82
--- /dev/null
+++ b/xradar/georeference/transforms.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+# Copyright (c) 2022, openradar developers.
+# Distributed under the MIT License. See LICENSE for more info.
+
+import numpy as np
+
+
+def antenna_to_cartesian(
+    ranges, azimuths, elevations, earth_radius=6371000, effective_radius_fraction=None
+):
+    """
+    Return Cartesian coordinates from antenna coordinates.
+    Parameters
+    ----------
+    ranges : array
+        Distances to the center of the radar gates (bins) in kilometers.
+    azimuths : array
+        Azimuth angle of the radar in degrees.
+    elevations : array
+        Elevation angle of the radar in degrees.
+    earth_radius: float
+        Radius of the earth (default is 6371000 m).
+    effective_radius_fraction: float
+        Fraction of earth to use for the effective radius (default is 4/3).
+    Returns
+    -------
+    x, y, z : array
+        Cartesian coordinates in meters from the radar.
+    Notes
+    -----
+    The calculation for Cartesian coordinate is adapted from equations
+    2.28(b) and 2.28(c) of Doviak and Zrnic [1]_ assuming a
+    standard atmosphere (4/3 Earth's radius model).
+    .. math::
+        z = \\sqrt{r^2+R^2+2*r*R*sin(\\theta_e)} - R
+        s = R * arcsin(\\frac{r*cos(\\theta_e)}{R+z})
+        x = s * sin(\\theta_a)
+        y = s * cos(\\theta_a)
+    Where r is the distance from the radar to the center of the gate,
+    :math:`\\theta_a` is the azimuth angle, :math:`\\theta_e` is the
+    elevation angle, s is the arc length, and R is the effective radius
+    of the earth, taken to be 4/3 the mean radius of earth (6371 km).
+    References
+    ----------
+    .. [1] Doviak and Zrnic, Doppler Radar and Weather Observations, Second
+        Edition, 1993, p. 21.
+    """
+    if effective_radius_fraction is None:
+        effective_radius_fraction = 4.0 / 3.0
+    # Convert the elevation angle from degrees to radians
+    theta_e = np.deg2rad(elevations)
+    theta_a = np.deg2rad(azimuths)  # azimuth angle in radians.
+    R = earth_radius * effective_radius_fraction  # effective radius of earth in meters.
+    r = ranges  # distances to gates in meters.
+
+    z = (r**2 + R**2 + 2.0 * r * R * np.sin(theta_e)) ** 0.5 - R
+    s = R * np.arcsin(r * np.cos(theta_e) / (R + z))  # arc length in m.
+    x = s * np.sin(theta_a)
+    y = s * np.cos(theta_a)
+    return x, y, z
+
+
+def get_x_y_z(ds, earth_radius=6371000, effective_radius_fraction=None):
+    """
+    Return Cartesian coordinates from antenna coordinates.
+    Parameters
+    ----------
+    ds: xarray.Dataset
+        Xarray dataset containing range, azimuth, and elevation
+    earth_radius: float
+        Radius of the earth (default is 6371000 m).
+    effective_radius_fraction: float
+        Fraction of earth to use for the effective radius (default is 4/3).
+    Returns
+    -------
+    ds : xarray.Dataset
+        Dataset including x, y, and z as coordinates.
+    Notes
+    -----
+    The calculation for Cartesian coordinate is adapted from equations
+    2.28(b) and 2.28(c) of Doviak and Zrnic [1]_ assuming a
+    standard atmosphere (4/3 Earth's radius model).
+    .. math::
+        z = \\sqrt{r^2+R^2+2*r*R*sin(\\theta_e)} - R
+        s = R * arcsin(\\frac{r*cos(\\theta_e)}{R+z})
+        x = s * sin(\\theta_a)
+        y = s * cos(\\theta_a)
+    Where r is the distance from the radar to the center of the gate,
+    :math:`\\theta_a` is the azimuth angle, :math:`\\theta_e` is the
+    elevation angle, s is the arc length, and R is the effective radius
+    of the earth, taken to be 4/3 the mean radius of earth (6371 km).
+    References
+    ----------
+    .. [1] Doviak and Zrnic, Doppler Radar and Weather Observations, Second
+        Edition, 1993, p. 21.
+    """
+
+    # Calculate x, y, and z from the dataset
+    ds['x'], ds['y'], ds['z'] = antenna_to_cartesian(
+        ds.range,
+        ds.azimuth,
+        ds.elevation,
+        earth_radius=earth_radius,
+        effective_radius_fraction=None,
+    )
+
+    # Set the attributes for the dataset
+    ds.x.attrs = {'standard_name': 'east_west_distance_from_radar', 'units': 'meters'}
+
+    ds.y.attrs = {'standard_name': 'north_south_distance_from_radar', 'units': 'meters'}
+
+    ds.z.attrs = {'standard_name': 'height_above_ground', 'units': 'meters'}
+
+    # Make sure these
+    ds = ds.set_coords(['x', 'y', 'z'])
+
+    return ds
diff --git a/xradar/io/backends/__init__.py b/xradar/io/backends/__init__.py
index 0da569b..4edf3d6 100644
--- a/xradar/io/backends/__init__.py
+++ b/xradar/io/backends/__init__.py
@@ -14,12 +14,14 @@ Xarray Backends
 .. automodule:: xradar.io.backends.odim
 .. automodule:: xradar.io.backends.furuno
 .. automodule:: xradar.io.backends.rainbow
+.. automodule:: xradar.io.backends.iris
 
 """
 
 from .cfradial1 import *  # noqa
 from .furuno import *  # noqa
 from .gamic import *  # noqa
+from .iris import *  # noqa
 from .odim import *  # noqa
 from .rainbow import *  # noqa
 
diff --git a/xradar/io/backends/iris.py b/xradar/io/backends/iris.py
new file mode 100644
index 0000000..9e1fe2c
--- /dev/null
+++ b/xradar/io/backends/iris.py
@@ -0,0 +1,4048 @@
+#!/usr/bin/env python
+# Copyright (c) 2022, openradar developers.
+# Distributed under the MIT License. See LICENSE for more info.
+
+
+"""
+IRIS/Sigmet Data I/O
+^^^^^^^^^^^^^^^^^^^^
+
+Reads data from Vaisala's IRIS data formats
+
+IRIS (Vaisala Sigmet Interactive Radar Information System)
+
+See M211318EN-F Programming Guide ftp://ftp.sigmet.com/outgoing/manuals/
+
+To read from IRIS files :class:`numpy:numpy.memmap` is used to get access to
+the data. The IRIS header (`PRODUCT_HDR`, `INGEST_HEADER`) is read in any case
+into dedicated OrderedDict's. Reading sweep data can be skipped by setting
+`loaddata=False`. By default the data is decoded on the fly.
+Using `rawdata=True` the data will be kept undecoded.
+
+Code ported from wradlib.
+
+.. autosummary::
+   :nosignatures:
+   :toctree: generated/
+
+   {}
+"""
+__all__ = [
+    "IrisBackendEntrypoint",
+    "open_iris_datatree",
+]
+__doc__ = __doc__.format("\n   ".join(__all__))
+
+import contextlib
+import copy
+import datetime as dt
+import io
+import struct
+import warnings
+from collections import OrderedDict
+
+import numpy as np
+import xarray as xr
+from datatree import DataTree
+from xarray.backends.common import AbstractDataStore, BackendArray, BackendEntrypoint
+from xarray.backends.file_manager import CachingFileManager
+from xarray.backends.store import StoreBackendEntrypoint
+from xarray.core import indexing
+from xarray.core.utils import FrozenDict
+from xarray.core.variable import Variable
+
+from ...model import (
+    get_altitude_attrs,
+    get_azimuth_attrs,
+    get_elevation_attrs,
+    get_latitude_attrs,
+    get_longitude_attrs,
+    get_range_attrs,
+    moment_attrs,
+    sweep_vars_mapping,
+)
+from .common import _assign_root, _attach_sweep_groups, _reindex_angle
+
+#: mapping from IRIS names to CfRadial2/ODIM
+iris_mapping = {
+    "DB_DBT": "DBTH",
+    "DB_DBT2": "DBTH",
+    "DB_DBZ": "DBZH",
+    "DB_DBZ2": "DBZH",
+    "DB_VEL": "VRADH",
+    "DB_VEL2": "VRADH",
+    "DB_WIDTH": "WRADH",
+    "DB_WIDTH2": "WRADH",
+    "DB_ZDR": "ZDR",
+    "DB_ZDR2": "ZDR",
+    "DB_KDP": "KDP",
+    "DB_KDP2": "KDP",
+    "DB_PHIDP": "PHIDP",
+    "DB_PHIDP2": "PHIDP",
+    "DB_SQI": "SQIH",
+    "DB_SQI2": "SQIH",
+    "DB_SNR16": "SNRH",
+    "DB_RHOHV": "RHOHV",
+    "DB_RHOHV2": "RHOHV",
+}
+
+
+def get_dtype_size(dtype):
+    """Return size in byte of given ``dtype``.
+
+    Parameters
+    ----------
+    dtype : str
+        dtype string
+
+    Returns
+    -------
+    size : int
+        dtype size in byte
+    """
+    return np.zeros(1, dtype=dtype).dtype.itemsize
+
+
+def to_float(data):
+    """Decode floating point value.
+
+    Parameters
+    ----------
+    data : :class:`numpy:numpy.ndarray`
+        encoded data
+
+    Returns
+    -------
+    decoded : :class:`numpy:numpy.ndarray`
+        decoded floating point data
+
+    Note
+    ----
+    DB_FLIQUID2 decoding see IRIS manuals, 4.4.12 - Page 75
+
+    """
+    exp = data >> 12
+    nz = exp > 0
+    mantissa = (data & 0xFFF).astype(dtype="uint32")
+    mantissa[nz] = (mantissa[nz] | 0x1000) << (exp[nz] - 1)
+    return mantissa
+
+
+def decode_bin_angle(bin_angle, mode=None):
+    """Decode BIN angle.
+
+    See 4.2 p.23
+
+    Parameters
+    ----------
+    bin_angle : array-like
+    mode : int
+        number of bytes
+
+    Returns
+    -------
+    out : array-like
+        decoded angle
+    """
+    return 360.0 * bin_angle / 2 ** (mode * 8)
+
+
+def decode_array(data, scale=1.0, offset=0, offset2=0, tofloat=False, mask=None):
+    """Decode data array.
+
+    .. math::
+
+        decoded = \\frac{data + offset}{scale} + offset2
+
+    Using the default values doesn't change the array.
+
+    Parameters
+    ----------
+    data : array-like
+    scale : int
+    offset: int
+    offset2: int
+    tofloat: bool
+
+    Returns
+    -------
+    data : array-like
+        decoded data
+    """
+    if tofloat:
+        data = to_float(data)
+    if mask is not None:
+        data = np.ma.masked_equal(data, mask)
+    return (data + offset) / scale + offset2
+
+
+def decode_vel(data, **kwargs):
+    """Decode `DB_VEL`.
+
+    See 4.4.46 p.85
+    """
+    nyquist = kwargs.pop("nyquist")
+    # mask = kwargs.pop('mask')
+    # data = np.ma.masked_equal(data, mask)
+    return decode_array(data, **kwargs) * nyquist
+
+
+def decode_width(data, **kwargs):
+    """Decode `DB_WIDTH`.
+
+    See 4.4.50 p.87
+    """
+    nyquist = kwargs.pop("nyquist")
+    return decode_array(data, **kwargs) * nyquist
+
+
+def decode_kdp(data, **kwargs):
+    """Decode `DB_KDP`.
+
+    See 4.4.20 p.77
+    """
+    wavelength = kwargs.pop("wavelength")
+    zero = data[data == -128]
+    data = -0.25 * np.sign(data) * 600 ** ((127 - np.abs(data)) / 126.0)
+    data /= wavelength
+    data[zero] = 0
+    return data
+
+
+def decode_phidp(data, **kwargs):
+    """Decode `DB_PHIDP`.
+
+    See 4.4.28 p.79
+    """
+    return 180.0 * decode_array(data, **kwargs)
+
+
+def decode_phidp2(data, **kwargs):
+    """Decode `DB_PHIDP2`.
+
+    See 4.4.29 p.80
+    """
+    return 360.0 * decode_array(data, **kwargs)
+
+
+def decode_sqi(data, **kwargs):
+    """Decode `DB_SQI`
+
+    See 4.4.41 p.83
+    """
+    return np.sqrt(decode_array(data, **kwargs))
+
+
+def decode_time(data):
+    """Decode `YMDS_TIME` into datetime object."""
+    time = _unpack_dictionary(data, YMDS_TIME)
+    ms = time["milliseconds"]
+    ms &= 0x3FF
+    # assume UTC for now
+    # dst = (time["milliseconds"] & 0x400) == 0x400
+    utc = (time["milliseconds"] & 0x800) == 0x800
+    # local_dst = (time["milliseconds"] & 0x1000) == 0x1000
+
+    try:
+        t = dt.datetime(time["year"], time["month"], time["day"]) + dt.timedelta(
+            seconds=time["seconds"],
+            milliseconds=ms,
+        )
+        if utc:
+            t = t.replace(tzinfo=dt.timezone.utc)
+        return t
+    except ValueError:
+        return None
+
+
+def decode_string(data):
+    """Decode string and strip NULL-bytes from end."""
+    return data.decode("utf-8").rstrip("\0")
+
+
+# IRIS Data Types and corresponding python struct format characters
+# 4.2 Scalar Definitions, Page 23
+# https://docs.python.org/3/library/struct.html#format-characters
+
+SINT1 = {"fmt": "b", "dtype": "int8"}
+SINT2 = {"fmt": "h", "dtype": "int16"}
+SINT4 = {"fmt": "i", "dtype": "int32"}
+UINT1 = {"fmt": "B", "dtype": "unit8"}
+UINT2 = {"fmt": "H", "dtype": "uint16"}
+UINT4 = {"fmt": "I", "dtype": "unint32"}
+FLT4 = {"fmt": "f"}
+FLT8 = {"fmt": "d"}
+BIN1 = {
+    "name": "BIN1",
+    "dtype": "uint8",
+    "size": "B",
+    "func": decode_bin_angle,
+    "fkw": {"mode": 1},
+}
+BIN2 = {
+    "name": "BIN2",
+    "dtype": "uint16",
+    "size": "H",
+    "func": decode_bin_angle,
+    "fkw": {"mode": 2},
+}
+BIN4 = {
+    "name": "BIN4",
+    "dtype": "uint32",
+    "size": "I",
+    "func": decode_bin_angle,
+    "fkw": {"mode": 4},
+}
+MESSAGE = {"fmt": "I"}
+UINT16_T = {"fmt": "H"}
+
+
+def _get_fmt_string(dictionary, retsub=False, byte_order="<"):
+    """Get Format String from given dictionary.
+
+    Parameters
+    ----------
+    dictionary : dict
+        Dictionary containing data structure with fmt-strings.
+    retsub : bool
+        If True, return sub structures.
+
+    Returns
+    -------
+    fmt : str
+        struct format string
+    sub : dict
+        Dictionary containing substructure
+    """
+    fmt = f"{byte_order}"
+    if retsub:
+        sub = OrderedDict()
+    for k, v in dictionary.items():
+        try:
+            fmt += v["fmt"]
+        except KeyError:
+            # remember sub-structures
+            if retsub:
+                sub[k] = v
+            if "size" in v:
+                fmt += v["size"]
+            else:
+                fmt += f"{struct.calcsize(_get_fmt_string(v))}s"
+    if retsub:
+        return fmt, sub
+    else:
+        return fmt
+
+
+def _get_struct_dtype(dictionary):
+    """Get numpy struct dtype from given dictionary.
+
+    Parameters
+    ----------
+    dictionary : dict
+        Dictionary containing data structure with dtype-strings.
+
+    Returns
+    -------
+    dtype : :py:class:`numpy:numpy.dtype`
+        numpy struct dtype
+    """
+    dtypes = []
+    for k, v in dictionary.items():
+        try:
+            dtypes.append((k, v["dtype"]))
+        except KeyError:
+            dtypes.append((k, f"S{v['fmt'][:-1]}"))
+
+    return np.dtype(dtypes)
+
+
+def _unpack_dictionary(buffer, dictionary, rawdata=False):
+    """Unpacks binary data using the given dictionary structure.
+
+    Parameters
+    ----------
+    buffer : array-like
+    dictionary : dict
+        data structure in dictionary, keys are names and values are structure formats
+
+    Returns
+    -------
+    data : dict
+        Ordered Dictionary with unpacked data
+    """
+    # get format and substructures of dictionary
+    fmt, sub = _get_fmt_string(dictionary, retsub=True)
+
+    # unpack into OrderedDict
+    data = OrderedDict(zip(dictionary, struct.unpack(fmt, buffer)))
+
+    # remove spares
+    if not rawdata:
+        keys_to_remove = [k for k in data.keys() if k.startswith("spare")]
+        keys_to_remove.extend([k for k in data.keys() if k.startswith("reserved")])
+        for k in keys_to_remove:
+            data.pop(k, None)
+
+    # iterate over sub dictionary and unpack/read/decode
+    for k, v in sub.items():
+        if not rawdata:
+            # read/decode data
+            for k1 in ["read", "func"]:
+                try:
+                    # print("K/V:", k, v)
+                    data[k] = v[k1](data[k], **v[k1[0] + "kw"])
+                except KeyError:
+                    pass
+                except UnicodeDecodeError:
+                    pass
+        # unpack sub dictionary
+        try:
+            data[k] = _unpack_dictionary(data[k], v, rawdata=rawdata)
+        except TypeError:
+            pass
+
+    return data
+
+
+def _data_types_from_dsp_mask(words):
+    """Return a list of the data types from the words in the data_type mask."""
+    data_types = []
+    for i, word in enumerate(words):
+        data_types += [j + (i * 32) for j in range(32) if word >> j & 1]
+    return data_types
+
+
+def _check_product(product_type):
+    """Return IRIS File Class depending on product type."""
+    if product_type in ["RAW"]:
+        return IrisRawFile
+    else:
+        return False
+
+
+def _check_identifier(identifier):
+    """Return IRIS File Class depending on identifier."""
+    if identifier == "INGEST_HEADER":
+        return IrisIngestHeaderFile
+    elif identifier == "INGEST_DATA_HEADER":
+        return IrisIngestDataFile
+    elif identifier == "PRODUCT_HDR":
+        return IrisRecordFile
+    else:
+        return False
+
+
+@contextlib.contextmanager
+def _get_iris_memmap_handle(filename):
+    if isinstance(filename, str):
+        yield np.memmap(open(filename, "rb"), mode="r")
+    elif isinstance(filename, io.BytesIO):
+        # only read first record
+        yield np.frombuffer(filename.read(RECORD_BYTES), dtype=np.uint8)
+    else:
+        yield filename
+
+
+def _check_iris_file(filename):
+    with _get_iris_memmap_handle(filename) as fh:
+        head = _unpack_dictionary(fh[0:LEN_STRUCTURE_HEADER], STRUCTURE_HEADER, False)
+        structure_identifier = STRUCTURE_HEADER_IDENTIFIERS[
+            head["structure_identifier"]
+        ]["name"]
+        opener = _check_identifier(structure_identifier)
+
+        if structure_identifier == "PRODUCT_HDR":
+            head = _unpack_dictionary(fh[0:LEN_PRODUCT_HDR], PRODUCT_HDR, False)
+            product_type = PRODUCT_DATA_TYPE_CODES[
+                head["product_configuration"]["product_type_code"]
+            ]["name"]
+            opener = _check_product(product_type)
+    return structure_identifier, opener
+
+
+# IRIS Data Structures
+_STRING = {"read": decode_string, "rkw": {}}
+
+
+def string_dict(size):
+    """Return _STRING dictionary"""
+    dic = _STRING.copy()
+    dic["size"] = f"{size}s"
+    return dic
+
+
+_ARRAY = {"read": np.frombuffer, "rkw": {}}
+
+
+def array_dict(size, dtype):
+    """Return _ARRAY dictionary"""
+    dic = _ARRAY.copy()
+    dic["size"] = f"{size * get_dtype_size(dtype)}s"
+    dic["rkw"]["dtype"] = dtype
+    return copy.deepcopy(dic)
+
+
+# structure_header Structure
+# 4.3.48 page 55
+STRUCTURE_HEADER = OrderedDict(
+    [
+        ("structure_identifier", SINT2),
+        ("format_version", SINT2),
+        ("bytes_in_structure", SINT4),
+        ("reserved", SINT2),
+        ("flag", SINT2),
+    ]
+)
+
+# ymds_time Structure
+# 4.3.77, page 70
+YMDS_TIME = OrderedDict(
+    [
+        ("seconds", SINT4),
+        ("milliseconds", UINT2),
+        ("year", SINT2),
+        ("month", SINT2),
+        ("day", SINT2),
+    ]
+)
+
+LEN_YMDS_TIME = struct.calcsize(_get_fmt_string(YMDS_TIME))
+
+_YMDS_TIME = {"size": f"{LEN_YMDS_TIME}s", "func": decode_time, "fkw": {}}
+
+# product_specific_info Structure(s) _PSI_ with connected _RESULTS
+
+# beam_psi_struct
+# 4.3.1, page 24
+BEAM_PSI_STRUCT = OrderedDict(
+    [
+        ("min_range", UINT4),
+        ("max_range", UINT4),
+        ("left_azimuth", BIN4),
+        ("right_azimuth", BIN4),
+        ("lower_elevation", BIN4),
+        ("upper_elevation", BIN4),
+        ("azimuth_smoothing", BIN4),
+        ("elevation_smoothing", BIN4),
+        ("az_of_sun_at_start", BIN4),
+        ("el_of_sun_at_start", BIN4),
+        ("az_of_sun_at_end", BIN4),
+        ("el_of_sun_at_end", BIN4),
+        ("spare_0", {"fmt": "32s"}),
+    ]
+)
+
+# cappi_psi_struct (if CAPPI)
+CAPPI_PSI_STRUCT = OrderedDict(
+    [
+        ("shear_flags", UINT4),
+        ("cappi_height", SINT4),
+        ("flags", UINT2),
+        ("azimuth_shear_smoothing", BIN2),
+        ("vvp_shear_correction_name", string_dict(12)),
+        ("vvp_shear_correction_max_age", UINT4),
+        ("spare_0", {"fmt": "52s"}),
+    ]
+)
+
+# catch_psi_struct (if CATCH)
+CATCH_PSI_STRUCT = OrderedDict(
+    [
+        ("flags", UINT4),
+        ("hours_accumulation", UINT4),
+        ("threshold_offset", SINT4),
+        ("threshold faction", SINT4),
+        ("rain1_name", string_dict(12)),
+        ("catchment_file", string_dict(16)),
+        ("seconds_accumulation", UINT4),
+        ("rain1_min_z", UINT4),
+        ("rain1_span_seconds", UINT4),
+        ("average_gage_correction_factor", UINT4),
+        ("spare_0", {"fmt": "20s"}),
+    ]
+)
+
+# catch_results Struct
+# 4.3.4, page 25
+CATCH_RESULTS = OrderedDict(
+    [
+        ("catchment_area_name", string_dict(16)),
+        ("catchment_area_number", UINT4),
+        ("latitude_of_label", BIN4),
+        ("longitude_of_label", BIN4),
+        ("catchment_area", SINT4),
+        ("num_pixels", SINT4),
+        ("num_pixels_scanned", SINT4),
+        ("flags_0", UINT4),
+        ("rainfall_accumulation", UINT2),
+        ("rainfall_accumulation_warning_threshold", UINT2),
+        ("spare_0", {"fmt": "52s"}),
+        ("input_rainfall_accumulation", array_dict(96, "uint16")),
+    ]
+)
+
+# cross_psi_struct (if XSECT or USERV)
+CROSS_PSI_STRUCT = OrderedDict(
+    [
+        ("azimuth_angle", BIN2),
+        ("spare_0", {"fmt": "10s"}),
+        ("east_center_coordinate", SINT4),
+        ("north_center_coordinate", SINT4),
+        ("user_miscellaneous", SINT4),
+        ("spare_1", {"fmt": "56s"}),
+    ]
+)
+
+# fcast_psi_struct (if FCAST)
+FCAST_PSI_STRUCT = OrderedDict(
+    [
+        ("correlation_threshold", UINT4),
+        ("data_threshold", SINT4),
+        ("mean_speed", SINT4),
+        ("mean_speed_direction", BIN4),
+        ("max_time_between_inputs", UINT4),
+        ("max_allowable_velocity", SINT4),
+        ("flags", UINT4),
+        ("output_resolution", SINT4),
+        ("input_product_type", UINT4),
+        ("input_product_name", string_dict(12)),
+        ("spare_0", {"fmt": "32s"}),
+    ]
+)
+
+# maximum_psi_struct (if MAX)
+MAX_PSI_STRUCT = OrderedDict(
+    [
+        ("spare_0", {"fmt": "4s"}),
+        ("interval_bottom", SINT4),
+        ("interval_top", SINT4),
+        ("side_panels_num_pixels", SINT4),
+        ("side_panels_hor_smoother", SINT4),
+        ("side_panels_ver_smoother", SINT4),
+        ("spare_1", {"fmt": "56s"}),
+    ]
+)
+
+# mlhgt_psi_struct (if MLGHT)
+# 4.3.18, page 34
+MLHGT_PSI_STRUCT = OrderedDict(
+    [
+        ("flags", UINT4),
+        ("averaged_ml_altitude", SINT2),
+        ("interval_ml_altitudes", SINT2),
+        ("vertical_grid_spacing", SINT2),
+        ("num_az_sectors", UINT2),
+        ("relaxation_time_mlhgt_confidence", UINT4),
+        ("modeled_fraction_melt_classifications", UINT2),
+        ("modeled_fraction_nomelt_classifications", UINT2),
+        ("min_confidence", UINT2),
+        ("spare_0", {"fmt": "58s"}),
+    ]
+)
+
+# ndop_input Struct
+# 4.3.19, page 34
+NDOP_INPUT = OrderedDict(
+    [("task_name", string_dict(12)), ("site_code", string_dict(3)), ("flags_0", UINT1)]
+)
+
+# ndop_psi_struct (if NDOP)
+# 4.3.20, page 35
+NDOP_PSI_STRUCT = OrderedDict(
+    [
+        ("ndop_input_0", NDOP_INPUT),
+        ("ndop_input_1", NDOP_INPUT),
+        ("ndop_input_2", NDOP_INPUT),
+        ("time_window", SINT4),
+        ("cappi_height", SINT4),
+        ("output_resolution", SINT4),
+        ("min_permitted_crossing_angle", BIN4),
+        ("flags_0", UINT4),
+        ("output_site_code", string_dict(4)),
+        ("spare_0", {"fmt": "8s"}),
+    ]
+)
+
+# ndop_results Struct
+# 4.3.20, page 35
+NDOP_RESULTS = OrderedDict(
+    [
+        ("velocity_east", UINT2),
+        ("velocity_north", UINT2),
+        ("change_rate", UINT2),
+        ("signal_quality_index", UINT1),
+        ("spare_0", {"fmt": "5s"}),
+    ]
+)
+
+# ppi_psi_struct (if PPI)
+PPI_PSI_STRUCT = OrderedDict([("elevation_angle", BIN2), ("spare_0", {"fmt": "78s"})])
+
+# rain_psi_struct (if RAIN1 or RAINN)
+RAIN_PSI_STRUCT = OrderedDict(
+    [
+        ("min_Z_to_accumulate", UINT4),
+        ("average_gage_correction_factor", UINT2),
+        ("seconds_accumulation", UINT2),
+        ("flags", UINT2),
+        ("hours_to_accumulate", SINT2),
+        ("input_product_name", string_dict(12)),
+        ("span_of input files", UINT4),
+        ("spare_0", {"fmt": "52s"}),
+    ]
+)
+
+# raw_psi_struct (if RAW)
+RAW_PSI_STRUCT = OrderedDict(
+    [
+        ("data_type_mask0", UINT4),
+        ("range_last_bin", SINT4),
+        ("format_conversion_flag", UINT4),
+        ("flags", UINT4),
+        ("sweep_number", SINT4),
+        ("xhdr_type", UINT4),
+        ("data_type_mask1", UINT4),
+        ("data_type_mask2", UINT4),
+        ("data_type_mask3", UINT4),
+        ("data_type_mask4", UINT4),
+        ("playback_version", UINT4),
+        ("spare_0", {"fmt": "36s"}),
+    ]
+)
+
+# rhi_psi_struct (if RHI)
+RHI_PSI_STRUCT = OrderedDict([("azimuth_angle", BIN2), ("spare_0", {"fmt": "78s"})])
+
+# rti_psi-struct
+# 4.3.35, page 46
+RTI_PSI_STRUCT = OrderedDict(
+    [
+        ("nominal_sweep_angle", BIN4),
+        ("starting_time_offset", UINT4),
+        ("ending_time_offset", UINT4),
+        ("azimuth_first_ray", BIN4),
+        ("*elevation_first_ray", BIN4),
+        ("spare_0", {"fmt": "60s"}),
+    ]
+)
+
+# shear_psi_struct (if SHEAR)
+SHEAR_PSI_STRUCT = OrderedDict(
+    [
+        ("azimuthal_smoothing_angle", BIN4),
+        ("elevation_angle", BIN2),
+        ("spare_0", {"fmt": "2s"}),
+        ("flags", UINT4),
+        ("vvp_product_name", string_dict(12)),
+        ("vvp_product_max_age", UINT4),
+        ("spare_1", {"fmt": "52s"}),
+    ]
+)
+
+# sline_psi_struct (if SLINE)
+SLINE_PSI_STRUCT = OrderedDict(
+    [
+        ("area", SINT4),
+        ("shear_threshold", SINT4),
+        ("bit_flags_protected_areas", UINT4),
+        ("max_forecast_time", SINT4),
+        ("max_age_motion_calculation", UINT4),
+        ("max_allowed_velocity", SINT4),
+        ("flags", UINT4),
+        ("azimuthal_smoothing_angle", BIN4),
+        ("elevation_binary_angle1", BIN4),
+        ("elevation_binary_angle2", BIN4),
+        ("name_vvp_task", string_dict(12)),
+        ("vvp_max_age", UINT4),
+        ("curve_fit_std_threshold", SINT4),
+        ("min_length_sline", UINT4),
+        ("spare_0", {"fmt": "16s"}),
+    ]
+)
+
+# tdwr_psi_struct (if TDWR)
+TDWR_PSI_STRUCT = OrderedDict(
+    [
+        ("flags", UINT4),
+        ("max_range", UINT4),
+        ("source_id", string_dict(4)),
+        ("center_field_wind_direction", string_dict(3)),
+        ("spare_0", {"fmt": "1s"}),
+        ("center_field_wind_speed", string_dict(2)),
+        ("center_field_gust_speed", string_dict(2)),
+        ("mask_protected_areas_checked", UINT4),
+        ("warning_count", UINT4),
+        ("sline_count", UINT4),
+        ("spare_1", {"fmt": "48s"}),
+    ]
+)
+
+# top_psi_struct (if TOPS, BASE, HMAX, or THICK)
+TOP_PSI_STRUCT = OrderedDict(
+    [("flags", UINT4), ("z_treshold", SINT2), ("spare_0", {"fmt": "74s"})]
+)
+
+# track_psi_struct (if TRACK)
+TRACK_PSI_STRUCT = OrderedDict(
+    [
+        ("centroid_area_threshold", SINT4),
+        ("centroid_threshold level", SINT4),
+        ("protected_area_mask", UINT4),
+        ("max_forecast_time", SINT4),
+        ("max_age_motion_calculation", UINT4),
+        ("max_motion_allowed", SINT4),
+        ("flags", UINT4),
+        ("max_span_track_points", SINT4),
+        ("input_product_type", UINT4),
+        ("input_product_name", string_dict(12)),
+        ("point_connecting_error_allowance", SINT4),
+        ("spare_0", {"fmt": "28s"}),
+    ]
+)
+
+# track_results_struct
+# 4.3.68, page 65
+TRACK_RESULTS = OrderedDict(
+    [
+        ("latitude", BIN4),
+        ("longitude", BIN4),
+        ("height", SINT4),
+        ("flags_0", UINT4),
+        ("centroid_area", SINT4),
+        ("equal_area_ellipse_major_axis", SINT4),
+        ("equal_area_ellipse_minor_axis", SINT4),
+        ("ellipse_orientation_angle", BIN4),
+        ("protected_area_mask_of_areas_hit", UINT4),
+        ("max_value_within_area", SINT4),
+        ("spare_0", {"fmt": "8s"}),
+        ("average_value_within_area", SINT4),
+        ("spare_1", {"fmt": "8s"}),
+        ("input_data_scale_factor", SINT4),
+        ("track_index_number", SINT4),
+        ("text", string_dict(32)),
+        ("time", _YMDS_TIME),
+        ("eta_protected_areas", array_dict(32, "int32")),
+        ("input_data_type", UINT4),
+        ("spare_2", {"fmt": "8s"}),
+        ("propagation_speed", SINT4),
+        ("propagation_direction", BIN4),
+        ("text_size", UINT4),
+        ("color", UINT4),
+        ("spare_3", {"fmt": "32s"}),
+    ]
+)
+
+# vad_psi_struct (if VAD)
+VAD_PSI_STRUCT = OrderedDict(
+    [
+        ("min_slant_range", SINT4),
+        ("max_slant_range", SINT4),
+        ("flags", UINT4),
+        ("number_elevation_angles", UINT4),
+        ("spare_0", {"fmt": "64s"}),
+    ]
+)
+
+# vad_results Structure
+VAD_RESULTS = OrderedDict(
+    [
+        ("elevation_angle", BIN2),
+        ("azimuth_angle", BIN2),
+        ("num_bins_averaged", UINT2),
+        ("average_velocity", UINT2),
+        ("standard_deviation", UINT2),
+        ("elevation_index", UINT2),
+        ("spare_0", {"fmt": "8s"}),
+    ]
+)
+
+# vil_psi_struct (if VIL)
+# missing in documentation
+
+# vvp_psi_struct (if VVP)
+# 4.3.71, page 67
+VVP_PSI_STRUCT = OrderedDict(
+    [
+        ("min_range", SINT4),
+        ("max_range", SINT4),
+        ("min_height", SINT4),
+        ("max_height", SINT4),
+        ("num_intervals", SINT2),
+        ("min_velocity", UINT2),
+        ("quota_num_bins_interval", SINT4),
+        ("mask_wind_parameters", SINT4),
+        ("spare_0", {"fmt": "52s"}),
+    ]
+)
+
+# vvp_results Struct
+# 4.3.72, page 67
+VVP_RESULTS = OrderedDict(
+    [
+        ("number_data_points", SINT4),
+        ("center_interval_height", SINT4),
+        ("number_reflectivity_data_points", SINT4),
+        ("spare_0", {"fmt": "8s"}),
+        ("wind_speed", SINT2),
+        ("wind_speed_std", SINT2),
+        ("wind_direction", SINT2),
+        ("wind_direction_std", SINT2),
+        ("vertical_wind_speed", SINT2),
+        ("vertical_wind_speed_std", SINT2),
+        ("horizontal_divergence", SINT2),
+        ("horizontal_divergence_std", SINT2),
+        ("radial_velocity_std", SINT2),
+        ("linear_averaged_reflectivity", SINT2),
+        ("log_averaged_reflectivity_std", SINT2),
+        ("deformation", SINT2),
+        ("deformation_std", SINT2),
+        ("axis_of_dilatation", SINT2),
+        ("axis_of_dilatation_std", SINT2),
+        ("log_averaged_reflectivity", SINT2),
+        ("linear_averaged_reflectivity_std", SINT2),
+        ("spare_0", {"fmt": "30s"}),
+    ]
+)
+
+# warn_psi_struct (if WARN)
+# 4.3.73, page 68
+WARN_PSI_STRUCT = OrderedDict(
+    [
+        ("centroid_area_threshold", SINT4),
+        ("threshold_levels", array_dict(3, "int32")),
+        ("data_valid_times", array_dict(3, "int16")),
+        ("spare_0", {"fmt": "2s"}),
+        ("symbol_to_display", string_dict(12)),
+        ("product_file_names", string_dict(36)),
+        ("product_types", array_dict(3, "uint8")),
+        ("spare_1", {"fmt": "1s"}),
+        ("protected_area_flag", UINT4),
+    ]
+)
+
+# warning_results Struct
+# 4.3.74, page 68
+WARNING_RESULTS = OrderedDict(
+    [
+        ("latitude", BIN4),
+        ("longitude", BIN4),
+        ("height", SINT4),
+        ("flags_0", UINT4),
+        ("centroid_area", SINT4),
+        ("equal_area_ellipse_major_axis", SINT4),
+        ("equal_area_ellipse_minor_axis", SINT4),
+        ("ellipse_orientation_angle", BIN4),
+        ("protected_area_mask_of_areas_hit", UINT4),
+        ("max_value_within_area", array_dict(3, "int32")),
+        ("average_value_within_area", array_dict(3, "int32")),
+        ("input_data_scale_factor", SINT4),
+        ("spare_0", {"fmt": "4s"}),
+        ("text", string_dict(16)),
+        ("spare_1", {"fmt": "156s"}),
+        ("input_data_type", array_dict(3, "uint32")),
+        ("propagation_speed", SINT4),
+        ("propagation_direction", BIN4),
+        ("spare_2", {"fmt": "40s"}),
+    ]
+)
+
+# wind_psi_struct (if WIND)
+# 4.3.75, page 69
+WIND_PSI_STRUCT = OrderedDict(
+    [
+        ("min_height", SINT4),
+        ("max_height", SINT4),
+        ("min_range", SINT4),
+        ("max_range", SINT4),
+        ("num_range_points", SINT4),
+        ("num_panel_points", SINT4),
+        ("sector_length", SINT4),
+        ("sector_width_binary_angle", BIN4),
+        ("spare_0", {"fmt": "48s"}),
+    ]
+)
+
+# wind_results Struct
+# 4.3.76, page 70
+WIND_RESULTS = OrderedDict(
+    [
+        ("num_possible_hits", SINT4),
+        ("num_data_points_used", SINT4),
+        ("center_sector_range", SINT4),
+        ("center_sector_azimuth", BIN2),
+        ("east_velocity", SINT2),
+        ("east_velocity_std", SINT2),
+        ("north_velocity", SINT2),
+        ("north_velocity_std", SINT2),
+        ("spare_0", {"fmt": "10s"}),
+    ]
+)
+
+
+# status_antenna_info Structure
+# 4.3.40, page 51
+STATUS_ANTENNA_INFO = OrderedDict(
+    [
+        ("azimuth_position", BIN4),
+        ("elevation_position", BIN4),
+        ("azimuth_velocity", UINT4),
+        ("elevation_velocity", UINT4),
+        ("command_bits", UINT4),
+        ("command_bit_availability_mask", UINT4),
+        ("status_bits", UINT4),
+        ("status_bit_availability_mask", UINT4),
+        ("bite_fault_flag", UINT4),
+        ("lowest_field_num_generating_fault", SINT4),
+        ("status_bits_which_cause_critical_faults", UINT4),
+        ("mask_bite_fields_state", array_dict(3, "uint32")),
+        ("mask_bite_fields_faulted", array_dict(3, "uint32")),
+        ("spare_0", {"fmt": "32s"}),
+    ]
+)
+
+# status_message_info Structure
+# 4.3.42, page 52
+STATUS_MESSAGE_INFO = OrderedDict(
+    [
+        ("message_count", SINT4),
+        ("message_number", MESSAGE),
+        ("message_repeat_number", SINT4),
+        ("process_name", string_dict(16)),
+        ("message_text", string_dict(80)),
+        ("signal_name", string_dict(32)),
+        ("message_time", _YMDS_TIME),
+        ("message_type", UINT4),
+        ("spare_0", {"fmt": "36s"}),
+    ]
+)
+
+# status_misc_info Structure
+# 4.3.43, page 52
+STATUS_MISC_INFO = OrderedDict(
+    [
+        ("radar_status_configuration_name", string_dict(16)),
+        ("task_configuration_name", string_dict(16)),
+        ("product_scheduler_configuration_name", string_dict(16)),
+        ("product_output_configuration_name", string_dict(16)),
+        ("active_task_name", string_dict(16)),
+        ("active_product_name", string_dict(16)),
+        ("site_type", UINT4),
+        ("num_incoming_network_connects", SINT4),
+        ("num_iris_clients_connected", SINT4),
+        ("spare_0", {"fmt": "4s"}),
+        ("num_output_devices", SINT4),
+        ("flags", UINT4),
+        ("node_status_fault_site", string_dict(4)),
+        ("time_of_active_task", _YMDS_TIME),
+        ("spare_1", {"fmt": "64s"}),
+    ]
+)
+
+# status_one_device Structure
+# 4.3.44, page 53
+STATUS_ONE_DEVICE = OrderedDict(
+    [
+        ("device_type", UINT4),
+        ("unit_number", SINT4),
+        ("status", UINT4),
+        ("spare_0", {"fmt": "4s"}),
+        ("process_table_mode", UINT4),
+        ("string", string_dict(16)),
+        ("spare_1", {"fmt": "4s"}),
+    ]
+)
+
+# status_device_info Structure
+# 4.3.41, page 52
+STATUS_DEVICE_INFO = OrderedDict(
+    [
+        ("dsp", STATUS_ONE_DEVICE),
+        ("antenna", STATUS_ONE_DEVICE),
+        ("output_device_0", STATUS_ONE_DEVICE),
+        ("output_device_1", STATUS_ONE_DEVICE),
+        ("output_device_2", STATUS_ONE_DEVICE),
+        ("output_device_3", STATUS_ONE_DEVICE),
+        ("output_device_4", STATUS_ONE_DEVICE),
+        ("output_device_5", STATUS_ONE_DEVICE),
+        ("output_device_6", STATUS_ONE_DEVICE),
+        ("output_device_7", STATUS_ONE_DEVICE),
+        ("output_device_8", STATUS_ONE_DEVICE),
+        ("output_device_9", STATUS_ONE_DEVICE),
+        ("output_device_10", STATUS_ONE_DEVICE),
+        ("output_device_11", STATUS_ONE_DEVICE),
+        ("output_device_12", STATUS_ONE_DEVICE),
+        ("output_device_13", STATUS_ONE_DEVICE),
+        ("output_device_14", STATUS_ONE_DEVICE),
+        ("output_device_15", STATUS_ONE_DEVICE),
+        ("output_device_16", STATUS_ONE_DEVICE),
+        ("output_device_17", STATUS_ONE_DEVICE),
+    ]
+)
+
+# status_one_process Structure
+# 4.3.45, page 54
+STATUS_ONE_PROCESS = OrderedDict(
+    [("command", UINT4), ("mode", UINT4), ("spare_0", {"fmt": "12s"})]
+)
+
+# status_process_info Structure
+# 4.3.46, page 54
+STATUS_PROCESS_INFO = OrderedDict(
+    [
+        ("ingest_process", STATUS_ONE_PROCESS),
+        ("ingfio_process", STATUS_ONE_PROCESS),
+        ("spare_0", {"fmt": "20s"}),
+        ("output_master_process", STATUS_ONE_PROCESS),
+        ("product_process", STATUS_ONE_PROCESS),
+        ("watchdog_process", STATUS_ONE_PROCESS),
+        ("reingest_process", STATUS_ONE_PROCESS),
+        ("network_process", STATUS_ONE_PROCESS),
+        ("nordrad_process", STATUS_ONE_PROCESS),
+        ("server_process", STATUS_ONE_PROCESS),
+        ("ribbuild_process", STATUS_ONE_PROCESS),
+        ("spare_1", {"fmt": "180s"}),
+    ]
+)
+
+# status_results Structure
+# 4.3.47, page 54
+STATUS_RESULTS = OrderedDict(
+    [
+        ("status_misc_info", STATUS_MISC_INFO),
+        ("status_process_info", STATUS_PROCESS_INFO),
+        ("status_device_info", STATUS_DEVICE_INFO),
+        ("status_antenna_info", STATUS_ANTENNA_INFO),
+        ("status_message_info", STATUS_MESSAGE_INFO),
+    ]
+)
+
+# spare (if USER, OTHER, TEXT)
+SPARE_PSI_STRUCT = OrderedDict([("spare_0", {"fmt": "80s"})])
+
+# one_protected_region Structure
+# 4.3.22, page 35
+ONE_PROTECTED_REGION = OrderedDict(
+    [
+        ("east_center", SINT4),
+        ("north_center", SINT4),
+        ("east_west_size", SINT4),
+        ("north_south_size", SINT4),
+        ("angle_of_orientation", BIN2),
+        ("spare_0", {"fmt": "2s"}),
+        ("region_name", string_dict(12)),
+    ]
+)
+
+# protect_setup Structure
+# 4.3.29, page 44
+PROTECT_SETUP = OrderedDict([("one_protected_region", {"fmt", "1024s"})])
+
+# color_scale_def Structure
+# 4.3.5, page 26
+
+COLOR_SCALE_DEF = OrderedDict(
+    [
+        ("iflags", UINT4),
+        ("istart", SINT4),
+        ("istep", SINT4),
+        ("icolcnt", SINT2),
+        ("iset_and_scale", UINT2),
+        ("ilevel_seams", array_dict(16, "uint16")),
+    ]
+)
+
+# product_configuration Structure
+# 4.3.24, page 36
+
+PRODUCT_CONFIGURATION = OrderedDict(
+    [
+        ("structure_header", STRUCTURE_HEADER),
+        ("product_type_code", UINT2),
+        ("scheduling_code", UINT2),
+        ("seconds_between_runs", SINT4),
+        ("generation_time", _YMDS_TIME),
+        ("sweep_ingest_time", _YMDS_TIME),
+        ("file_ingest_time", _YMDS_TIME),
+        ("spare_0", {"fmt": "6s"}),
+        ("product_name", string_dict(12)),
+        ("task_name", string_dict(12)),
+        ("flag", UINT2),
+        ("x_scale", SINT4),
+        ("y_scale", SINT4),
+        ("z_scale", SINT4),
+        ("x_size", SINT4),
+        ("y_size", SINT4),
+        ("z_size", SINT4),
+        ("x_location", SINT4),
+        ("y_location", SINT4),
+        ("z_location", SINT4),
+        ("maximum_range", SINT4),
+        ("spare_1", {"fmt": "2s"}),
+        ("data_type", UINT2),
+        ("projection_name", string_dict(12)),
+        ("input_data_type", UINT2),
+        ("projection_type", UINT1),
+        ("spare_2", {"fmt": "1s"}),
+        ("radial_smoother", SINT2),
+        ("times_run", SINT2),
+        ("zr_constant", SINT4),
+        ("zr_exponent", SINT4),
+        ("x_smoother", SINT2),
+        ("y_smoother", SINT2),
+        ("product_specific_info", {"fmt": "80s"}),
+        ("minor_task_suffixes", string_dict(16)),
+        ("spare_3", {"fmt": "12s"}),
+        ("color_scale_def", COLOR_SCALE_DEF),
+    ]
+)
+
+# product_end Structure
+# 4.3.25, page 39
+
+PRODUCT_END = OrderedDict(
+    [
+        ("site_name", string_dict(16)),
+        ("iris_version_created", string_dict(8)),
+        ("ingest_iris_version", string_dict(8)),
+        ("ingest_time", _YMDS_TIME),
+        ("spare_0", {"fmt": "28s"}),
+        ("GMT_minute_offset_local", SINT2),
+        ("ingest_hardware_name_", string_dict(16)),
+        ("ingest_site_name_", string_dict(16)),
+        ("GMT_minute_offset_standard", SINT2),
+        ("latitude", BIN4),
+        ("longitude", BIN4),
+        ("ground_height", SINT2),
+        ("radar_height", SINT2),
+        ("prf", SINT4),
+        ("pulse_width", SINT4),
+        ("signal_processor_type", UINT2),
+        ("trigger_rate", UINT2),
+        ("samples_used", SINT2),
+        ("clutter_filter", string_dict(12)),
+        ("number_linear_filter", UINT2),
+        ("wavelength", SINT4),
+        ("truncation_height", SINT4),
+        ("first_bin_range", SINT4),
+        ("last_bin_range", SINT4),
+        ("number_bins", SINT4),
+        ("flag", UINT2),
+        ("number_ingest", SINT2),
+        ("polarization", UINT2),
+        ("horizontal_calibration_i0", SINT2),
+        ("horizontal_calibration_noise", SINT2),
+        ("horizontal_radar_constant", SINT2),
+        ("receiver_bandwidth", UINT2),
+        ("horizontal_current_noise", SINT2),
+        ("vertical_current_noise", SINT2),
+        ("ldr_offset", SINT2),
+        ("zdr_offset", SINT2),
+        ("tcf_cal_flags_1", UINT16_T),
+        ("tcf_cal_flags_2", UINT16_T),
+        ("spare_bit1", UINT1),
+        ("spare_bit2", UINT1),
+        ("spare_bit3", UINT1),
+        ("spare_bit4", UINT1),
+        ("spare_bit5", UINT1),
+        ("spare_bit6", UINT1),
+        ("spare_1", {"fmt": "12s"}),
+        ("standard_parallel_1", BIN4),
+        ("standard_parallel_2", BIN4),
+        ("earth_radius", UINT4),
+        ("inverse_flatting", UINT4),
+        ("fault_status", UINT4),
+        ("input_mask", UINT4),
+        ("number_log_filter", UINT2),
+        ("cluttermap", UINT2),
+        ("latitude_projection", BIN4),
+        ("longitude_projection", BIN4),
+        ("product_sequence_number", SINT2),
+        ("spare_2", {"fmt": "32s"}),
+        ("melting_level", SINT2),
+        ("radar_height_above_reference", SINT2),
+        ("number_elements", SINT2),
+        ("mean_wind_speed", UINT1),
+        ("mean_wind_direction", BIN1),
+        ("spare_3", {"fmt": "2s"}),
+        ("tz_name", string_dict(8)),
+        ("extended_product_header_offset", UINT4),
+        ("spare_4", {"fmt": "4s"}),
+    ]
+)
+
+# _product_hdr Structure
+# 4.3.26 page 41
+PRODUCT_HDR = OrderedDict(
+    [
+        ("structure_header", STRUCTURE_HEADER),
+        ("product_configuration", PRODUCT_CONFIGURATION),
+        ("product_end", PRODUCT_END),
+    ]
+)
+
+# ingest_configuration Structure
+# 4.3.14, page 31
+
+INGEST_CONFIGURATION = OrderedDict(
+    [
+        ("filename", string_dict(80)),
+        ("number_files", SINT2),
+        ("number_sweeps_completed", SINT2),
+        ("total_size", SINT4),
+        ("volume_scan_start_time", _YMDS_TIME),
+        ("spare_0", {"fmt": "12s"}),
+        ("ray_header_bytes", SINT2),
+        ("extended_ray_header_bytes", SINT2),
+        ("number_task_config_table", SINT2),
+        ("playback_version", SINT2),
+        ("spare_1", {"fmt": "4s"}),
+        ("iris_version", string_dict(8)),
+        ("hardware_site", string_dict(16)),
+        ("gmt_offset_minutes_local", SINT2),
+        ("site_name", string_dict(16)),
+        ("gmt_offset_minutes_standard", SINT2),
+        ("latitude_radar", BIN4),
+        ("longitude_radar", BIN4),
+        ("height_site", SINT2),
+        ("height_radar", SINT2),
+        ("resolution_rays", UINT2),
+        ("first_ray_index", UINT2),
+        ("number_rays_sweep", UINT2),
+        ("gparam_bytes", SINT2),
+        ("altitude_radar", SINT4),
+        ("velocity_east", SINT4),
+        ("velocity_north", SINT4),
+        ("velocity_up", SINT4),
+        ("antenna_offset_starboard", SINT4),
+        ("antenna_offset_bow", SINT4),
+        ("antenna_offset_up", SINT4),
+        ("fault_status", UINT4),
+        ("melting_layer", SINT2),
+        ("spare_2", {"fmt": "2s"}),
+        ("local_timezone", string_dict(8)),
+        ("flags", UINT4),
+        ("configuration_name", string_dict(16)),
+        ("spare_3", {"fmt": "228s"}),
+    ]
+)
+
+# task_sched Structure
+# 4.3.62, page 63
+
+TASK_SCHED_INFO = OrderedDict(
+    [
+        ("start_time", SINT4),
+        ("stop_time", SINT4),
+        ("skip_time", SINT4),
+        ("time_last_run", SINT4),
+        ("time_used_last_run", SINT4),
+        ("day_last_run", SINT4),
+        ("flag", UINT2),
+        ("spare_0", {"fmt": "94s"}),
+    ]
+)
+
+# dsp_data_mask Structure
+# 4.3.7, page 28
+
+DSP_DATA_MASK = OrderedDict(
+    [
+        ("mask_word_0", UINT4),
+        ("extended_header_type", UINT4),
+        ("mask_word_1", UINT4),
+        ("mask_word_2", UINT4),
+        ("mask_word_3", UINT4),
+        ("mask_word_4", UINT4),
+    ]
+)
+
+# task_dsp_mode_batch Structure
+# 4.3.53, page 59
+
+TASK_DSP_MODE_BATCH = OrderedDict(
+    [
+        ("low_prf", UINT2),
+        ("low_prf_fraction_part", UINT2),
+        ("low_prf_sample_size", SINT2),
+        ("low_prf_range_averaging_bins", SINT2),
+        ("reflectivity_unfolding_threshold", SINT2),
+        ("velocity_unfolding_threshold", SINT2),
+        ("width_unfolding_threshold", SINT2),
+        ("spare_0", {"fmt": "18s"}),
+    ]
+)
+
+# task_dsp_info Structure
+# 4.3.52, page 57f
+
+TASK_DSP_INFO = OrderedDict(
+    [
+        ("major_mode", UINT2),
+        ("dsp_type", UINT2),
+        ("dsp_data_mask0", DSP_DATA_MASK),
+        ("dsp_data_mask1", DSP_DATA_MASK),
+        ("task_dsp_mode", TASK_DSP_MODE_BATCH),
+        ("spare_0", {"fmt": "52s"}),
+        ("prf", SINT4),
+        ("pulse_width", SINT4),
+        ("multi_prf_mode_flag", UINT2),
+        ("dual_prf_delay", SINT2),
+        ("agc_feedback_code", UINT2),
+        ("sample_size", SINT2),
+        ("gain_control_flag", UINT2),
+        ("clutter_filter_name", string_dict(12)),
+        ("linear_filter_num_first_bin", UINT1),
+        ("log_filter_num_first_bin", UINT1),
+        ("attenuation_fixed_gain", SINT2),
+        ("gas_attenuation", UINT2),
+        ("cluttermap_flag", UINT2),
+        ("xmt_phase_sequence", UINT2),
+        ("ray_header_config_mask", UINT4),
+        ("playback_flags", UINT2),
+        ("spare_1", {"fmt": "2s"}),
+        ("custom_ray_header_name", string_dict(16)),
+        ("spare_2", {"fmt": "120s"}),
+    ]
+)
+
+# task_calib_info Structure
+# 4.3.50, page 56f
+
+TASK_CALIB_INFO = OrderedDict(
+    [
+        ("reflectivity_slope", SINT2),
+        ("reflectivity_noise_threshold", SINT2),
+        ("clutter_correction_threshold", SINT2),
+        ("sqi_threshold", SINT2),
+        ("power_threshold", SINT2),
+        ("spare_0", {"fmt": "8s"}),
+        ("calibration_reflectivity", SINT2),
+        ("uncorrected_reflectivity_threshold_flags", UINT2),
+        ("corrected_reflectivity_threshold_flags", UINT2),
+        ("velocity_threshold_flags", UINT2),
+        ("width_threshold_flags", UINT2),
+        ("zdr_threshold_flags", UINT2),
+        ("spare_1", {"fmt": "6s"}),
+        ("flags_1", UINT2),
+        ("spare_2", {"fmt": "2s"}),
+        ("ldr_bias", SINT2),
+        ("zdr_bias", SINT2),
+        ("nexrad_point_clutter_threshold", SINT2),
+        ("nexrad_point_clutter_bin_skip", UINT2),
+        ("horizontal_io_cal_value", SINT2),
+        ("vertical_io_cal_value", SINT2),
+        ("horizontal_noise_calibration", SINT2),
+        ("vertical_noise_calibration", SINT2),
+        ("horizontal_radar_constant", SINT2),
+        ("vertical_radar_constant", SINT2),
+        ("receiver_bandwidth", SINT2),
+        ("flags_2", UINT16_T),
+        ("spare_3", {"fmt": "256s"}),
+    ]
+)
+
+# task_range_info Structure
+# 4.3.59, page 61
+
+TASK_RANGE_INFO = OrderedDict(
+    [
+        ("range_first_bin", SINT4),
+        ("range_last_bin", SINT4),
+        ("number_input_bins", SINT2),
+        ("number_output_bins", SINT2),
+        ("step_input_bins", SINT4),
+        ("step_output_bins", SINT4),
+        ("variable_range_bin_spacing_flag", UINT2),
+        ("range_bin_averaging_flag", SINT2),
+        ("spare_0", {"fmt": "136s"}),
+    ]
+)
+
+# task_rhi_scan_info Structure
+# 4.3.60, page 61
+
+_ANGLE_LIST = {
+    "size": "80s",
+    "read": np.frombuffer,
+    "rkw": {"dtype": "uint16"},
+    "func": decode_bin_angle,
+    "fkw": {"mode": 2},
+}
+
+TASK_RHI_SCAN_INFO = OrderedDict(
+    [
+        ("lower_elevation_limit", BIN2),
+        ("upper_elevation_limit", BIN2),
+        ("list_of_azimuths", _ANGLE_LIST),
+        ("spare_0", {"fmt": "115s"}),
+        ("start_first_sector_sweep", UINT1),
+    ]
+)
+# task_ppi_scan_info Structure
+# 4.3.58, page 61
+
+TASK_PPI_SCAN_INFO = OrderedDict(
+    [
+        ("left_azimuth_limit", BIN2),
+        ("right_azimuth_limit", BIN2),
+        ("list_of_elevations", _ANGLE_LIST),
+        ("spare_0", {"fmt": "115s"}),
+        ("start_first_sector_sweep", UINT1),
+    ]
+)
+
+# task_file_scan_info Structure
+# 4.3.55, page 60
+
+TASK_FILE_SCAN_INFO = OrderedDict(
+    [
+        ("first_azimuth_angle", UINT2),
+        ("first_elevation_angle", UINT2),
+        ("filename_antenna_control", string_dict(12)),
+        ("spare_0", {"fmt": "184s"}),
+    ]
+)
+
+# task_manual_scan_info Structure
+# 4.3.56, page 60
+
+TASK_MANUAL_SCAN_INFO = OrderedDict([("flags", UINT2), ("spare_0", {"fmt": "198s"})])
+
+# task_scan_info Structure
+# 4.3.61, page 62
+
+TASK_SCAN_INFO = OrderedDict(
+    [
+        ("antenna_scan_mode", UINT2),
+        ("desired_angular_resolution", SINT2),
+        ("spare_0", {"fmt": "2s"}),
+        ("sweep_number", SINT2),
+        ("task_type_scan_info", {"fmt": "200s"}),
+        ("spare_1", {"fmt": "112s"}),
+    ]
+)
+
+# task_misc_info Structure
+# 4.3.57, page 60
+
+TASK_MISC_INFO = OrderedDict(
+    [
+        ("wavelength", SINT4),
+        ("tr_serial_number", string_dict(16)),
+        ("transmit_power", SINT4),
+        ("flags", UINT2),
+        ("polarization_type", UINT2),
+        ("truncation_height", SINT4),
+        ("spare_0", {"fmt": "18s"}),
+        ("spare_1", {"fmt": "12s"}),
+        ("number_comment_bytes", SINT2),
+        ("horizontal_beam_width", BIN4),
+        ("vertical_beam_width", BIN4),
+        ("customer_storage", {"fmt": "40s"}),
+        ("spare_2", {"fmt": "208s"}),
+    ]
+)
+
+# task_end_info Structure
+# 4.3.54, page 59
+
+TASK_END_INFO = OrderedDict(
+    [
+        ("task_major_number", SINT2),
+        ("task_minor_number", SINT2),
+        ("task_configuration_file_name", string_dict(12)),
+        ("task_description", string_dict(80)),
+        ("number_tasks", SINT4),
+        ("task_state", UINT2),
+        ("spare_0", {"fmt": "2s"}),
+        ("task_data_time", _YMDS_TIME),
+        ("echo_class_identifiers", {"fmt": "6s"}),
+        ("spare_1", {"fmt": "198s"}),
+    ]
+)
+
+# task_configuration Structure
+# 4.3.51, page 57
+
+TASK_CONFIGURATION = OrderedDict(
+    [
+        ("structure_header", STRUCTURE_HEADER),
+        ("task_sched_info", TASK_SCHED_INFO),
+        ("task_dsp_info", TASK_DSP_INFO),
+        ("task_calib_info", TASK_CALIB_INFO),
+        ("task_range_info", TASK_RANGE_INFO),
+        ("task_scan_info", TASK_SCAN_INFO),
+        ("task_misc_info", TASK_MISC_INFO),
+        ("task_end_info", TASK_END_INFO),
+        ("comments", string_dict(720)),
+    ]
+)
+
+# _ingest_header Structure
+# 4.3.16, page 33
+
+INGEST_HEADER = OrderedDict(
+    [
+        ("structure_header", STRUCTURE_HEADER),
+        ("ingest_configuration", INGEST_CONFIGURATION),
+        ("task_configuration", TASK_CONFIGURATION),
+        ("spare_0", {"fmt": "732s"}),
+        ("gparm", {"fmt": "128s"}),
+        ("reserved", {"fmt": "920s"}),
+    ]
+)
+
+# raw_prod_bhdr Structure
+# 4.3.31, page 45
+
+RAW_PROD_BHDR = OrderedDict(
+    [
+        ("_record_number", SINT2),
+        ("sweep_number", SINT2),
+        ("first_ray_byte_offset", SINT2),
+        ("sweep_ray_number", SINT2),
+        ("flags", UINT2),
+        ("spare", {"fmt": "2s"}),
+    ]
+)
+
+# ingest_data_header Structure
+# 4.3.15, page 32
+
+INGEST_DATA_HEADER = OrderedDict(
+    [
+        ("structure_header", STRUCTURE_HEADER),
+        ("sweep_start_time", _YMDS_TIME),
+        ("sweep_number", SINT2),
+        ("number_rays_per_sweep", SINT2),
+        ("first_ray_index", SINT2),
+        ("number_rays_file_expected", SINT2),
+        ("number_rays_file_written", SINT2),
+        ("fixed_angle", BIN2),
+        ("bits_per_bin", SINT2),
+        ("data_type", UINT2),
+        ("spare_0", {"fmt": "36s"}),
+    ]
+)
+
+# ray_header Structure
+# 4.3.33, page 46
+
+RAY_HEADER = OrderedDict(
+    [
+        ("azi_start", BIN2),
+        ("ele_start", BIN2),
+        ("azi_stop", BIN2),
+        ("ele_stop", BIN2),
+        ("rbins", SINT2),
+        ("dtime", UINT2),
+    ]
+)
+
+# extended_header_v0 Structure
+# 4.3.8, page 28
+
+EXTENDED_HEADER_V0 = OrderedDict(
+    [
+        ("dtime_ms", SINT4),
+        ("calib_signal_level", SINT2),
+        ("spare_0", {"fmt": "14s"}),
+    ]
+)
+
+# extended_header_v1 Structure
+# 4.3.9, page 28f
+
+EXTENDED_HEADER_V1 = OrderedDict(
+    [
+        ("dtime_ms", SINT4),
+        ("calib_signal_level", SINT2),
+        ("azimuth", BIN2),
+        ("elevation", BIN2),
+        ("train_order", BIN2),
+        ("elevation_order", BIN2),
+        ("pitch", BIN2),
+        ("roll", BIN2),
+        ("heading", BIN2),
+        ("azimuth_rate", BIN2),
+        ("elevation_rate", BIN2),
+        ("pitch_rate", BIN2),
+        ("roll_rate", BIN2),
+        ("latitude", BIN4),
+        ("longitude", BIN4),
+        ("heading_rate", BIN2),
+        ("altitude", SINT2),
+        ("velocity_east", SINT2),
+        ("velocity_north", SINT2),
+        ("time_since_last_update", SINT4),
+        ("velocity_up", SINT2),
+        ("navigation_system_ok_flag", UINT2),
+        ("radial_velocity_correction", SINT2),
+    ]
+)
+
+# extended_header_v2 Structure
+# 4.3.10, page 29
+
+EXTENDED_HEADER_V2 = OrderedDict(
+    [
+        ("dtime_ms", SINT4),
+        ("calib_signal_level", SINT2),
+        ("spare_0", {"fmt": "2s"}),
+        ("num_bytes_header", SINT4),
+        # todo: implement customer remainder
+    ]
+)
+
+
+# some length's of data structures
+LEN_STRUCTURE_HEADER = struct.calcsize(_get_fmt_string(STRUCTURE_HEADER))
+LEN_PRODUCT_HDR = struct.calcsize(_get_fmt_string(PRODUCT_HDR))
+LEN_INGEST_HEADER = struct.calcsize(_get_fmt_string(INGEST_HEADER))
+LEN_RAW_PROD_BHDR = struct.calcsize(_get_fmt_string(RAW_PROD_BHDR))
+LEN_INGEST_DATA_HEADER = struct.calcsize(_get_fmt_string(INGEST_DATA_HEADER))
+LEN_RAY_HEADER = struct.calcsize(_get_fmt_string(RAY_HEADER))
+
+# Sigmet structure header identifiers
+# extracted from headers.h
+STRUCTURE_HEADER_IDENTIFIERS = OrderedDict(
+    [
+        (20, {"name": "VERSION_STEP"}),
+        (22, {"name": "TASK_CONFIGURATION"}),
+        (23, {"name": "INGEST_HEADER"}),
+        (24, {"name": "INGEST_DATA_HEADER"}),
+        (25, {"name": "TAPE_INVENTORY"}),
+        (26, {"name": "PRODUCT_CONFIGURATION"}),
+        (27, {"name": "PRODUCT_HDR"}),
+        (28, {"name": "TAPE_HEADER_RECORD"}),
+    ]
+)
+
+STRUCTURE_HEADER_FORMAT_VERSION = OrderedDict(
+    [
+        (3, {"name": "INGEST_DATA_HEADER"}),
+        (4, {"name": "INGEST_HEADER"}),
+        (5, {"name": "TASK_CONFIGURATION"}),
+        (6, {"name": "PRODUCT_CONFIGURATION"}),
+        (8, {"name": "PRODUCT_HDR"}),
+    ]
+)
+
+# Sigmet data types
+# 4.9 Constants, Table 17
+
+SIGMET_DATA_TYPES = OrderedDict(
+    [
+        # Extended Headers
+        (0, {"name": "DB_XHDR", "dtype": "uint8", "func": None}),
+        # Total H power (1 byte)
+        (
+            1,
+            {
+                "name": "DB_DBT",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -64.0},
+            },
+        ),
+        # Clutter Corrected H reflectivity (1 byte)
+        (
+            2,
+            {
+                "name": "DB_DBZ",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -64.0},
+            },
+        ),
+        # Velocity (1 byte)
+        (
+            3,
+            {
+                "name": "DB_VEL",
+                "dtype": "uint8",
+                "func": decode_vel,
+                "fkw": {"scale": 127.0, "offset": -128.0, "mask": 0.0},
+            },
+        ),
+        # Width (1 byte)
+        (
+            4,
+            {
+                "name": "DB_WIDTH",
+                "dtype": "uint8",
+                "func": decode_width,
+                "fkw": {"scale": 256.0},
+            },
+        ),
+        # Differential reflectivity (1 byte)
+        (
+            5,
+            {
+                "name": "DB_ZDR",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 16.0, "offset": -128.0},
+            },
+        ),
+        # Old Rainfall rate (stored as dBZ), not used
+        (6, {"name": "DB_ORAIN", "dtype": "uint8", "func": None}),
+        # Fully corrected reflectivity (1 byte)
+        (
+            7,
+            {
+                "name": "DB_DBZC",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -64.0},
+            },
+        ),
+        # Uncorrected reflectivity (2 byte)
+        (
+            8,
+            {
+                "name": "DB_DBT2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Corrected reflectivity (2 byte)
+        (
+            9,
+            {
+                "name": "DB_DBZ2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Velocity (2 byte)
+        (
+            10,
+            {
+                "name": "DB_VEL2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Width (2 byte)
+        (
+            11,
+            {
+                "name": "DB_WIDTH2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0},
+            },
+        ),
+        # Differential reflectivity (2 byte)
+        (
+            12,
+            {
+                "name": "DB_ZDR2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Rainfall rate (2 byte)
+        (
+            13,
+            {
+                "name": "DB_RAINRATE2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 10000.0, "offset": -1.0, "tofloat": True},
+            },
+        ),
+        # Kdp (specific differential phase)(1 byte)
+        (14, {"name": "DB_KDP", "dtype": "int8", "func": decode_kdp, "fkw": {}}),
+        # Kdp (specific differential phase)(2 byte)
+        (
+            15,
+            {
+                "name": "DB_KDP2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # PHIdp (differential phase)(1 byte)
+        (
+            16,
+            {
+                "name": "DB_PHIDP",
+                "dtype": "uint8",
+                "func": decode_phidp,
+                "fkw": {"scale": 254.0, "offset": -1},
+            },
+        ),
+        # Corrected Velocity (1 byte)
+        (17, {"name": "DB_VELC", "dtype": "uint8", "func": None}),
+        # SQI (1 byte)
+        (
+            18,
+            {
+                "name": "DB_SQI",
+                "dtype": "uint8",
+                "func": decode_sqi,
+                "fkw": {"scale": 253.0, "offset": -1},
+            },
+        ),
+        # RhoHV(0) (1 byte)
+        (
+            19,
+            {
+                "name": "DB_RHOHV",
+                "dtype": "uint8",
+                "func": decode_sqi,
+                "fkw": {"scale": 253.0, "offset": -1},
+            },
+        ),
+        # RhoHV(0) (2 byte)
+        (
+            20,
+            {
+                "name": "DB_RHOHV2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 65536.0, "offset": -1.0},
+            },
+        ),
+        # Fully corrected reflectivity (2 byte)
+        (
+            21,
+            {
+                "name": "DB_DBZC2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Corrected Velocity (2 byte)
+        (
+            22,
+            {
+                "name": "DB_VELC2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # SQI (2 byte)
+        (
+            23,
+            {
+                "name": "DB_SQI2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 65536.0, "offset": -1.0},
+            },
+        ),
+        # PHIdp (differential phase)(2 byte)
+        (
+            24,
+            {
+                "name": "DB_PHIDP2",
+                "dtype": "uint16",
+                "func": decode_phidp2,
+                "fkw": {"scale": 65534.0, "offset": -1.0},
+            },
+        ),
+        # LDR H to V (1 byte)
+        (
+            25,
+            {
+                "name": "DB_LDRH",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 5.0, "offset": -1.0, "offset2": -45.0},
+            },
+        ),
+        # LDR H to V (2 byte)
+        (
+            26,
+            {
+                "name": "DB_LDRH2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # LDR V to H (1 byte)
+        (
+            27,
+            {
+                "name": "DB_LDRV",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 5.0, "offset": -1.0, "offset2": -45.0},
+            },
+        ),
+        # LDR V to H (2 byte)
+        (
+            28,
+            {
+                "name": "DB_LDRV2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Individual flag bits for each bin
+        (29, {"name": "DB_FLAGS", "func": None}),
+        # (See bit definitions below)
+        (30, {"name": "DB_FLAGS2", "func": None}),
+        # Test of floating format
+        (31, {"name": "DB_FLOAT32", "func": None}),
+        # Height (1/10 km) (1 byte)
+        (
+            32,
+            {
+                "name": "DB_HEIGHT",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 10.0, "offset": -1.0},
+            },
+        ),
+        # Linear liquid (.001mm) (2 byte)
+        (
+            33,
+            {
+                "name": "DB_VIL2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 1000.0, "offset": -1.0},
+            },
+        ),
+        # Data type is not applicable
+        (34, {"name": "DB_RAW", "func": None}),
+        # Wind Shear (1 byte)
+        (
+            35,
+            {
+                "name": "DB_SHEAR",
+                "dtype": "int8",
+                "func": decode_array,
+                "fkw": {"scale": 5.0, "offset": -128.0},
+            },
+        ),
+        # Divergence (.001 10**-4) (2-byte)
+        (
+            36,
+            {
+                "name": "DB_DIVERGE2",
+                "dtype": "int16",
+                "func": decode_array,
+                "fkw": {"scale": 10e-7},
+            },
+        ),
+        # Floated liquid (2 byte)
+        (
+            37,
+            {
+                "name": "DB_FLIQUID2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 1000.0, "tofloat": True},
+            },
+        ),
+        # User type, unspecified data (1 byte)
+        (38, {"name": "DB_USER", "func": None}),
+        # Unspecified data, no color legend
+        (39, {"name": "DB_OTHER", "func": None}),
+        # Deformation (.001 10**-4) (2-byte)
+        (
+            40,
+            {
+                "name": "DB_DEFORM2",
+                "dtype": "int16",
+                "func": decode_array,
+                "fkw": {"scale": 10e-7},
+            },
+        ),
+        # Vertical velocity (.01 m/s) (2-byte)
+        (
+            41,
+            {
+                "name": "DB_VVEL2",
+                "dtype": "int16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0},
+            },
+        ),
+        # Horizontal velocity (.01 m/s) (2-byte)
+        (42, {"name": "DB_HVEL2", "func": decode_array, "fkw": {"scale": 100.0}}),
+        # Horizontal wind direction (.1 degree) (2-byte)
+        (
+            43,
+            {
+                "name": "DB_HDIR2",
+                "dtype": "int16",
+                "func": decode_array,
+                "fkw": {"scale": 10.0},
+            },
+        ),
+        # Axis of Dilation (.1 degree) (2-byte)
+        (
+            44,
+            {
+                "name": "DB_AXDIL2",
+                "dtype": "int16",
+                "func": decode_array,
+                "fkw": {"scale": 10.0},
+            },
+        ),
+        # Time of data (seconds) (2-byte)
+        (
+            45,
+            {
+                "name": "DB_TIME2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 60.0, "offset": -32768},
+            },
+        ),
+        # Rho H to V (1 byte)
+        (
+            46,
+            {
+                "name": "DB_RHOH",
+                "dtype": "uint8",
+                "func": decode_sqi,
+                "fkw": {"scale": 253.0, "offset": -1},
+            },
+        ),
+        # Rho H to V (2 byte)
+        (
+            47,
+            {
+                "name": "DB_RHOH2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 65536.0, "offset": -1.0},
+            },
+        ),
+        # Rho V to H (1 byte)
+        (
+            48,
+            {
+                "name": "DB_RHOV",
+                "dtype": "uint8",
+                "func": decode_sqi,
+                "fkw": {"scale": 253.0, "offset": -1},
+            },
+        ),
+        # Rho V to H (2 byte)
+        (
+            49,
+            {
+                "name": "DB_RHOV2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 65536.0, "offset": -1.0},
+            },
+        ),
+        # Phi H to V (1 byte)
+        (50, {"name": "DB_PHIH", "dtype": "uint8", "func": decode_phidp}),
+        # Phi H to V (2 byte)
+        (
+            51,
+            {
+                "name": "DB_PHIH2",
+                "dtype": "uint16",
+                "func": decode_phidp2,
+                "fkw": {"scale": 65534.0, "offset": -1.0},
+            },
+        ),
+        # Phi V to H (1 byte)
+        (52, {"name": "DB_PHIV", "dtype": "uint8", "func": decode_phidp}),
+        # Phi V to H (2 byte)
+        (
+            53,
+            {
+                "name": "DB_PHIV2",
+                "dtype": "uint16",
+                "func": decode_phidp2,
+                "fkw": {"scale": 65534.0, "offset": -1.0},
+            },
+        ),
+        # User type, unspecified data (2 byte)
+        (54, {"name": "DB_USER2", "dtype": "uint16", "func": None}),
+        # Hydrometeor class (1 byte)
+        (55, {"name": "DB_HCLASS", "dtype": "uint8", "func": None}),
+        # Hydrometeor class (2 byte)
+        (56, {"name": "DB_HCLASS2", "dtype": "uint16", "func": None}),
+        # Corrected Differential reflectivity (1 byte)
+        (
+            57,
+            {
+                "name": "DB_ZDRC",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 16.0, "offset": -128.0},
+            },
+        ),
+        # Corrected Differential reflectivity (2 byte)
+        (
+            58,
+            {
+                "name": "DB_ZDRC2",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Temperature (2 byte)
+        (59, {"name": "DB_TEMPERATURE16", "dtype": "uint16", "func": None}),
+        # Vertically Integrated Reflectivity (2 byte)
+        (60, {"name": "DB_VIR16", "dtype": "uint16", "func": None}),
+        # Total V Power (1 byte)
+        (
+            61,
+            {
+                "name": "DB_DBTV8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -64.0},
+            },
+        ),
+        # Total V Power (2 byte)
+        (
+            62,
+            {
+                "name": "DB_DBTV16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Clutter Corrected V Reflectivity (1 byte)
+        (
+            63,
+            {
+                "name": "DB_DBZV8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -64.0},
+            },
+        ),
+        # Clutter Corrected V Reflectivity (2 byte)
+        (
+            64,
+            {
+                "name": "DB_DBZV16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 100.0, "offset": -32768.0},
+            },
+        ),
+        # Signal to Noise ratio (1 byte)
+        (
+            65,
+            {
+                "name": "DB_SNR8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Signal to Noise ratio (2 byte)
+        (
+            66,
+            {
+                "name": "DB_SNR16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Albedo (1 byte)
+        (67, {"name": "DB_ALBEDO8", "dtype": "uint8", "func": None}),
+        # Albedo (2 byte)
+        (68, {"name": "DB_ALBEDO16", "dtype": "uint16", "func": None}),
+        # VIL Density (2 byte)
+        (69, {"name": "DB_VILD16", "dtype": "uint16", "func": None}),
+        # Turbulence (2 byte)
+        (70, {"name": "DB_TURB16", "dtype": "uint16", "func": None}),
+        # Total Power Enhanced (via H+V or HV) (1 byte)
+        (71, {"name": "DB_DBTE8", "dtype": "uint8", "func": None}),
+        # Total Power Enhanced (via H+V or HV) (2 byte)
+        (72, {"name": "DB_DBTE16", "dtype": "uint16", "func": None}),
+        # Clutter Corrected Reflectivity Enhanced (1 byte)
+        (73, {"name": "DB_DBZE8", "dtype": "uint8", "func": None}),
+        # Clutter Corrected Reflectivity Enhanced (2 byte)
+        (74, {"name": "DB_DBZE16", "dtype": "uint16", "func": None}),
+        # Polarimetric meteo index (1 byte)
+        (
+            75,
+            {
+                "name": "DB_PMI8",
+                "dtype": "uint8",
+                "func": decode_sqi,
+                "fkw": {"scale": 253.0, "offset": -1},
+            },
+        ),
+        # Polarimetric meteo index (2 byte)
+        (
+            76,
+            {
+                "name": "DB_PMI16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 65536.0, "offset": -1.0},
+            },
+        ),
+        # The log receiver signal-to-noise ratio (1 byte)
+        (
+            77,
+            {
+                "name": "DB_LOG8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # The log receiver signal-to-noise ratio (2 byte)
+        (
+            78,
+            {
+                "name": "DB_LOG16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Doppler channel clutter signal power (-CSR) (1 byte)
+        (
+            79,
+            {
+                "name": "DB_CSP8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Doppler channel clutter signal power (-CSR) (2 byte)
+        (
+            80,
+            {
+                "name": "DB_CSP16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Cross correlation, uncorrected rhohv (1 byte)
+        (
+            81,
+            {
+                "name": "DB_CCOR8",
+                "dtype": "uint8",
+                "func": decode_sqi,
+                "fkw": {"scale": 253.0, "offset": -1},
+            },
+        ),
+        # Cross correlation, uncorrected rhohv (2 byte)
+        (
+            82,
+            {
+                "name": "DB_CCOR16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 65536.0, "offset": -1.0},
+            },
+        ),
+        # Attenuation of Zh (1 byte)
+        (
+            83,
+            {
+                "name": "DB_AH8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Attenuation of Zh (2 byte)
+        (
+            84,
+            {
+                "name": "DB_AH16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Attenuation of Zv (1 byte)
+        (
+            85,
+            {
+                "name": "DB_AV8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Attenuation of Zv (2 byte)
+        (
+            86,
+            {
+                "name": "DB_AV16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Attenuation of Zzdr (1 byte)
+        (
+            87,
+            {
+                "name": "DB_AZDR8",
+                "dtype": "uint8",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+        # Attenuation of Zzdr (2 byte)
+        (
+            88,
+            {
+                "name": "DB_AZDR16",
+                "dtype": "uint16",
+                "func": decode_array,
+                "fkw": {"scale": 2.0, "offset": -63.0},
+            },
+        ),
+    ]
+)
+
+
+PRODUCT_DATA_TYPE_CODES = OrderedDict(
+    [
+        (0, {"name": "NULL", "struct": SPARE_PSI_STRUCT}),
+        (1, {"name": "PPI", "struct": PPI_PSI_STRUCT}),
+        (2, {"name": "RHI", "struct": RHI_PSI_STRUCT}),
+        (3, {"name": "CAPPI", "struct": CAPPI_PSI_STRUCT}),
+        (4, {"name": "CROSS", "struct": CROSS_PSI_STRUCT}),
+        (5, {"name": "TOPS", "struct": TOP_PSI_STRUCT}),
+        (
+            6,
+            {
+                "name": "TRACK",
+                "struct": TRACK_PSI_STRUCT,
+                "result": TRACK_RESULTS,
+                "protect_setup": True,
+            },
+        ),
+        (7, {"name": "RAIN1", "struct": RAIN_PSI_STRUCT}),
+        (8, {"name": "RAINN", "struct": RAIN_PSI_STRUCT}),
+        (9, {"name": "VVP", "struct": VVP_PSI_STRUCT, "result": VVP_RESULTS}),
+        (10, {"name": "VIL", "struct": SPARE_PSI_STRUCT}),
+        (11, {"name": "SHEAR", "struct": SHEAR_PSI_STRUCT}),
+        (
+            12,
+            {
+                "name": "WARN",
+                "struct": WARN_PSI_STRUCT,
+                "result": WARNING_RESULTS,
+                "protect_setup": True,
+            },
+        ),
+        (13, {"name": "CATCH", "struct": CATCH_PSI_STRUCT, "result": CATCH_RESULTS}),
+        (14, {"name": "RTI", "struct": RTI_PSI_STRUCT}),
+        (15, {"name": "RAW", "struct": RAW_PSI_STRUCT}),
+        (16, {"name": "MAX", "struct": MAX_PSI_STRUCT}),
+        (17, {"name": "USER", "struct": SPARE_PSI_STRUCT}),
+        (18, {"name": "USERV", "struct": CROSS_PSI_STRUCT}),
+        (19, {"name": "OTHER", "struct": SPARE_PSI_STRUCT}),
+        (20, {"name": "STATUS", "struct": SPARE_PSI_STRUCT, "result": STATUS_RESULTS}),
+        (21, {"name": "SLINE", "struct": SLINE_PSI_STRUCT, "protect_setup": True}),
+        (22, {"name": "WIND", "struct": WIND_PSI_STRUCT, "result": WIND_RESULTS}),
+        (23, {"name": "BEAM", "struct": BEAM_PSI_STRUCT}),
+        (24, {"name": "TEXT", "struct": SPARE_PSI_STRUCT}),
+        (25, {"name": "FCAST", "struct": FCAST_PSI_STRUCT, "result": NDOP_RESULTS}),
+        (26, {"name": "NDOP", "struct": NDOP_PSI_STRUCT, "result": NDOP_RESULTS}),
+        (27, {"name": "IMAGE", "struct": SPARE_PSI_STRUCT}),
+        (28, {"name": "COMP", "struct": SPARE_PSI_STRUCT}),
+        (29, {"name": "TDWR", "struct": TDWR_PSI_STRUCT, "protect_setup": True}),
+        (30, {"name": "GAGE", "struct": SPARE_PSI_STRUCT}),
+        (31, {"name": "DWELL", "struct": SPARE_PSI_STRUCT}),
+        (32, {"name": "SRI", "struct": SPARE_PSI_STRUCT}),
+        (33, {"name": "BASE", "struct": TOP_PSI_STRUCT}),
+        (34, {"name": "HMAX", "struct": TOP_PSI_STRUCT}),
+        (35, {"name": "VAD", "struct": VAD_PSI_STRUCT, "result": VAD_RESULTS}),
+        (36, {"name": "THICK", "struct": SPARE_PSI_STRUCT}),
+        (37, {"name": "SATELLITE", "struct": SPARE_PSI_STRUCT}),
+        (38, {"name": "LAYER", "struct": SPARE_PSI_STRUCT}),
+        (39, {"name": "SWS", "struct": SPARE_PSI_STRUCT}),
+        (40, {"name": "MLHGT", "struct": MLHGT_PSI_STRUCT}),
+    ]
+)
+
+
+RECORD_BYTES = 6144
+
+
+class IrisRecord:
+    """Class holding a single record from a Sigmet IRIS file."""
+
+    def __init__(self, record, recnum):
+        """
+
+        Parameters
+        ----------
+        record : array-like
+            Slice into memory mapped file.
+        recnum : int
+        """
+        self.record = record
+        self._pos = 0
+        self.recnum = recnum
+
+    @property
+    def pos(self):
+        """Returns current byte offset."""
+        return self._pos
+
+    @pos.setter
+    def pos(self, value):
+        """Sets current byte offset."""
+        self._pos = value
+
+    @property
+    def recpos(self):
+        """Returns current word offset."""
+        return int(self._pos / 2)
+
+    @recpos.setter
+    def recpos(self, value):
+        """Sets current word offset."""
+        self._pos = value * 2
+
+    def read(self, words, width=2):
+        """Reads from Record.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to be read.
+        width : int
+            Width (bytes) of data words to be read. Defaults to 2.
+
+        Returns
+        -------
+        ret : array-like
+        """
+        ret = self.record[self._pos : self._pos + words * width]
+        self.pos += len(ret)
+        return ret
+
+
+class IrisHeaderBase:
+    """Base Class for Iris Headers."""
+
+    def __init__(self, **kwargs):
+        super().__init__()
+
+    def init_header(self):
+        pass
+
+
+class IrisStructureHeader(IrisHeaderBase):
+    """Iris Structure Header class."""
+
+    len = LEN_STRUCTURE_HEADER
+    structure = STRUCTURE_HEADER
+    name = "_structure_header"
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._structure_header = None
+
+    @property
+    def structure_identifier(self):
+        return STRUCTURE_HEADER_IDENTIFIERS[
+            self._structure_header["structure_identifier"]
+        ]["name"]
+
+    @property
+    def structure_format(self):
+        return STRUCTURE_HEADER_FORMAT_VERSION[
+            self._structure_header["format_version"]
+        ]["name"]
+
+    @property
+    def structure_size(self):
+        return self._structure_header["bytes_in_structure"]
+
+
+class IrisIngestHeader(IrisHeaderBase):
+    """Iris Ingest Header class."""
+
+    len = LEN_INGEST_HEADER
+    structure = INGEST_HEADER
+    name = "_ingest_header"
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._ingest_header = None
+        self._data_types_numbers = None
+
+    @property
+    def ingest_header(self):
+        """Returns ingest_header dictionary."""
+        return self._ingest_header
+
+    @property
+    def nsweeps(self):
+        """Returns number of sweeps."""
+        head = self._ingest_header["task_configuration"]["task_scan_info"]
+        return head["sweep_number"]
+
+    @property
+    def nrays(self):
+        """Returns number of rays."""
+        return self._ingest_header["ingest_configuration"]["number_rays_sweep"]
+
+    @property
+    def data_types_dict(self):
+        """Returns list of data type dictionaries."""
+        return [
+            SIGMET_DATA_TYPES.get(i, {"name": f"DB_UNKNOWN_{i}", "func": None})
+            for i in self._data_types_numbers
+        ]
+
+    @property
+    def data_types_count(self):
+        """Returns number of data types."""
+        return len(self._data_types_numbers)
+
+    @property
+    def data_types(self):
+        """Returns list of data type names."""
+        return [d["name"] for d in self.data_types_dict]
+
+    def get_data_types_numbers(self):
+        """Returns the available data types."""
+        # determine the available fields
+        task_config = self.ingest_header["task_configuration"]
+        task_dsp_info = task_config["task_dsp_info"]
+        word0 = task_dsp_info["dsp_data_mask0"]["mask_word_0"]
+        word1 = task_dsp_info["dsp_data_mask0"]["mask_word_1"]
+        word2 = task_dsp_info["dsp_data_mask0"]["mask_word_2"]
+        word3 = task_dsp_info["dsp_data_mask0"]["mask_word_3"]
+
+        return _data_types_from_dsp_mask([word0, word1, word2, word3])
+
+    def get_task_type_scan_info(self, rawdata):
+        """Retrieves task type info"""
+        task_info = self.ingest_header["task_configuration"]["task_scan_info"]
+        mode = task_info["antenna_scan_mode"]
+        key = "task_type_scan_info"
+        if mode in [1, 4]:
+            task_info[key] = _unpack_dictionary(
+                task_info[key], TASK_PPI_SCAN_INFO, rawdata
+            )
+        elif mode == 2:
+            task_info[key] = _unpack_dictionary(
+                task_info[key], TASK_RHI_SCAN_INFO, rawdata
+            )
+        elif mode == 3:
+            task_info[key] = _unpack_dictionary(
+                task_info[key], TASK_MANUAL_SCAN_INFO, rawdata
+            )
+        elif mode == 5:
+            task_info[key] = _unpack_dictionary(
+                task_info[key], TASK_FILE_SCAN_INFO, rawdata
+            )
+        else:
+            pass
+
+    def init_header(self, rawdata=False):
+        self._data_types_numbers = self.get_data_types_numbers()
+        self.get_task_type_scan_info(rawdata)
+
+
+class IrisProductHeader(IrisHeaderBase):
+    """Iris Product Header class."""
+
+    len = LEN_PRODUCT_HDR
+    structure = PRODUCT_HDR
+    name = "_product_hdr"
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._product_hdr = None
+        self._product_type_code = None
+
+    @property
+    def product_hdr(self):
+        """Returns ingest_header dictionary."""
+        return self._product_hdr
+
+    @property
+    def nbins(self):
+        """Returns number of bins."""
+        return self._product_hdr["product_end"]["number_bins"]
+
+    @property
+    def product_type_code(self):
+        """Returns product type code."""
+        return self._product_type_code
+
+    @property
+    def product_type(self):
+        """Returns product type."""
+        return PRODUCT_DATA_TYPE_CODES[self.product_type_code]["name"]
+
+    @property
+    def product_type_dict(self):
+        """Returns product type dictionary."""
+        return PRODUCT_DATA_TYPE_CODES[self.product_type_code]
+
+    @property
+    def data_type(self):
+        """Returns product configuration data type."""
+        data_type = self.product_hdr["product_configuration"]["data_type"]
+        return SIGMET_DATA_TYPES[data_type]
+
+    def init_header(self, rawdata=False):
+        self._product_type_code = self.get_product_type_code()
+        self.get_product_specific_info(rawdata)
+
+    def get_product_type_code(self):
+        """Returns product type code."""
+        prod_conf = self.product_hdr["product_configuration"]
+        return prod_conf["product_type_code"]
+
+    def get_product_specific_info(self, rawdata):
+        """Retrieves product specific info"""
+        config = self.product_hdr["product_configuration"]
+        pt = self.product_type_dict
+        key = "product_specific_info"
+        try:
+            config[key] = _unpack_dictionary(config[key], pt["struct"], rawdata)
+        except KeyError:
+            warnings.warn(
+                f"product type {pt['name']} not implemented, \n"
+                "only header information available",
+                RuntimeWarning,
+                stacklevel=3,
+            )
+
+
+class IrisIngestDataHeader(IrisHeaderBase):
+    """Iris Ingest Data Header class."""
+
+    len = LEN_INGEST_DATA_HEADER
+    structure = INGEST_DATA_HEADER
+    name = "_ingest_data_header"
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._ingest_data_header = None
+        self._nrays_expected = None
+        self._data_types_numbers = None
+        self._rawdata = None
+
+    @property
+    def ingest_data_header(self):
+        """Returns ingest_header dictionary."""
+        return self._ingest_data_header
+
+    @property
+    def nrays(self):
+        return self._nrays_expected
+
+    @property
+    def data_types_dict(self):
+        """Returns list of data type dictionaries."""
+        i = self._data_types_numbers
+
+        return SIGMET_DATA_TYPES.get(i, {"name": f"DB_UNKNOWN_{i}", "func": None})
+
+    @property
+    def data_types(self):
+        """Returns list of data type names."""
+        return self.data_types_dict["name"]
+
+    def init_header(self):
+        self._nrays_expected = self._ingest_data_header["number_rays_file_expected"]
+        self._data_types_numbers = self.get_data_types_numbers()
+
+    def get_data_types_numbers(self):
+        """Returns the available data types."""
+        return self.ingest_data_header["data_type"]
+
+
+class IrisFileBase:
+    """Base class for Iris Files."""
+
+    def __init__(self, **kwargs):
+        super().__init__()
+
+
+class IrisFile(IrisFileBase, IrisStructureHeader):
+    """IrisFile class"""
+
+    identifier = ["PRODUCT_HDR", "INGEST_HEADER", "INGEST_DATA_HEADER"]
+
+    def __init__(self, filename, **kwargs):
+        self._debug = kwargs.get("debug", False)
+        self._rawdata = kwargs.get("rawdata", False)
+        self._loaddata = kwargs.get("loaddata", True)
+        self._fp = None
+        self._filename = filename
+        if isinstance(filename, str):
+            self._fp = open(filename, "rb")
+            self._fh = np.memmap(self._fp, mode="r")
+        else:
+            if isinstance(filename, io.BytesIO):
+                filename.seek(0)
+                filename = filename.read()
+            self._fh = np.frombuffer(filename, dtype=np.uint8)
+        self._filepos = 0
+        self._data = None
+        super().__init__(**kwargs)
+        # read first structure header
+        self.get_header(IrisStructureHeader)
+        self._filepos = 0
+
+    def close(self):
+        if self._fp is not None:
+            self._fp.close()
+
+    __del__ = close
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.close()
+
+    def check_identifier(self):
+        if self.structure_identifier in self.identifier:
+            return self.structure_identifier
+        else:
+            raise IOError(
+                f"Cannot read {self.structure_identifier} with "
+                f"{self.__class__.__name__} class"
+            )
+
+    @property
+    def data(self):
+        return self._data
+
+    @property
+    def loaddata(self):
+        """Returns `loaddata` switch."""
+        return self._loaddata
+
+    @property
+    def rawdata(self):
+        """Returns `rawdata` switch."""
+        return self._rawdata
+
+    @property
+    def debug(self):
+        return self._debug
+
+    @property
+    def filename(self):
+        return self._filename
+
+    @property
+    def fh(self):
+        return self._fh
+
+    @property
+    def filepos(self):
+        return self._filepos
+
+    @filepos.setter
+    def filepos(self, pos):
+        self._filepos = pos
+
+    def read_from_file(self, size):
+        """Read from file.
+
+        Parameters
+        ----------
+        size : int
+            Number of data words to read.
+
+        Returns
+        -------
+        data : array-like
+            numpy array of data
+        """
+        start = self._filepos
+        self._filepos += size
+        return self._fh[start : self._filepos]
+
+    def get_header(self, header):
+        head = _unpack_dictionary(
+            self.read_from_file(header.len), header.structure, self._rawdata
+        )
+        setattr(self, header.name, head)
+        header.init_header(self)
+
+
+class IrisIngestHeaderFile(IrisFile, IrisIngestHeader):
+    """Iris Ingest Header File class."""
+
+    identifier = "INGEST_HEADER"
+
+    def __init__(self, filename, **kwargs):
+        super().__init__(filename=filename, **kwargs)
+        self.check_identifier()
+        self.get_header(IrisIngestHeader)
+
+
+class IrisIngestDataFile(IrisFile, IrisIngestDataHeader):
+    """Iris Ingest Data File class."""
+
+    identifier = "INGEST_DATA_HEADER"
+
+    def __init__(self, filename, **kwargs):
+        super().__init__(filename=filename, **kwargs)
+        self.check_identifier()
+        self.get_header(IrisIngestDataHeader)
+
+        self.pointers = self.array_from_file(self.nrays, "int32")
+
+        ingest_header = kwargs.get("ingest_header", None)
+        if ingest_header:
+            self.ingest_header = ingest_header
+        else:
+            raise TypeError("`ingest_header` missing in keyword parameters")
+
+        if self.loaddata:
+            self._data = self.get_sweep()
+
+    def array_from_file(self, words, dtype):
+        """Retrieve array from current record.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to read.
+        width : int
+            Size of the data word to read in bytes.
+        dtype : str
+            dtype string specifying data format.
+
+        Returns
+        -------
+        data : array-like
+            numpy array of data
+        """
+        width = get_dtype_size(dtype)
+        data = self.read_from_file(words * width)
+        return data.view(dtype=dtype)
+
+    def get_sweep(self):
+        sweep_data = OrderedDict()
+        sweep_prod = OrderedDict()
+        sweep_prod["data"] = []
+        sweep_prod["azi_start"] = []
+        sweep_prod["ele_start"] = []
+        sweep_prod["azi_stop"] = []
+        sweep_prod["ele_stop"] = []
+        sweep_prod["rbins"] = []
+        sweep_prod["dtime"] = []
+
+        start = self.filepos
+        for i, p in enumerate(self.pointers):
+            if not p:
+                continue
+            self.filepos = start - 1 + p
+            ray_header = _unpack_dictionary(
+                self.read_from_file(LEN_RAY_HEADER), RAY_HEADER, self.rawdata
+            )
+            data = self.decode_data(
+                self.read_from_file(ray_header["rbins"]), self.data_types_dict
+            )
+            sweep_prod["data"].append(data)
+            sweep_prod["azi_start"].append(ray_header["azi_start"])
+            sweep_prod["ele_start"].append(ray_header["ele_start"])
+            sweep_prod["azi_stop"].append(ray_header["azi_stop"])
+            sweep_prod["ele_stop"].append(ray_header["ele_stop"])
+            sweep_prod["rbins"].append(ray_header["rbins"])
+            sweep_prod["dtime"].append(ray_header["dtime"])
+
+        [sweep_prod.update({k: np.array(v)}) for k, v in sweep_prod.items()]
+
+        sweep_data[self.data_types] = sweep_prod
+
+        return sweep_data
+
+    def decode_data(self, data, prod):
+        """Decode data according given prod-dict.
+
+        Parameters
+        ----------
+        data : :py:class:`numpy:numpy.ndarray`
+            data to decode
+        prod : dict
+            dictionary holding decoding information
+
+        Returns
+        -------
+        data : :py:class:`numpy:numpy.ndarray`
+            decoded data
+
+        """
+        if self._rawdata:
+            return data
+        kw = {}
+        if prod["func"]:
+            try:
+                kw.update(prod["fkw"])
+            except KeyError:
+                pass
+            # this doesn't work for ingest data files
+            # if get_dtype_size(prod['dtype']) == 1:
+            #    dtype = '(2,) {0}'.format(prod['dtype'])
+            # else:
+            #    dtype = '{0}'.format(prod['dtype'])
+            dtype = f"{prod['dtype']}"
+            try:
+                rays, bins = data.shape
+                data = data.view(dtype).reshape(rays, -1)[:, :bins]
+            except ValueError:
+                data = data.view(dtype)
+            if prod["func"] in [decode_vel, decode_width, decode_kdp]:
+                # wavelength is normally used from product_hdr
+                # wavelength = self.product_hdr['product_end']['wavelength']
+                # but we can retrieve it from TASK_MISC_INFO, too
+                wavelength = self.ingest_header["task_configuration"]["task_misc_info"][
+                    "wavelength"
+                ]
+                if prod["func"] == decode_kdp:
+                    # get wavelength in cm
+                    kw.update({"wavelength": wavelength / 100})
+                    return prod["func"](data, **kw)
+                # PRF is normally used from product_hdr
+                # prf = self.product_hdr['product_end']['prf']
+                # but we can retrieve it from TASK_DSP_INFO, too
+                prf = self.ingest_header["task_configuration"]["task_dsp_info"]["prf"]
+                # division by 10000 to get from 1/100 cm to m
+                nyquist = wavelength * prf / (10000.0 * 4.0)
+                if prod["func"] == decode_vel:
+                    nyquist *= (
+                        self.ingest_header["task_configuration"]["task_dsp_info"][
+                            "multi_prf_mode_flag"
+                        ]
+                        + 1
+                    )
+                kw.update({"nyquist": nyquist})
+
+            return prod["func"](data, **kw)
+        else:
+            return data
+
+
+class IrisRecordFile(IrisFile, IrisProductHeader):
+    """Iris Record File class"""
+
+    identifier = ["PRODUCT_HDR"]
+    product_identifier = [
+        "RAW",
+    ]
+
+    def __init__(self, filename, **kwargs):
+        super().__init__(filename=filename, **kwargs)
+        self._rh = None
+        self._record_number = None
+        self.get_header(IrisProductHeader)
+        self.check_product_identifier()
+
+    def check_product_identifier(self):
+        if self.product_type in self.product_identifier:
+            return self.product_type
+        else:
+            raise IOError(
+                f"Cannot read {self.product_type} with {self.__class__.__name__} class"
+            )
+
+    @property
+    def rh(self):
+        """Returns current record object."""
+        return self._rh
+
+    @rh.setter
+    def rh(self, value):
+        """Sets current record object."""
+        self._rh = value
+
+    @property
+    def record_number(self):
+        """Returns current record number."""
+        return self._record_number
+
+    @record_number.setter
+    def record_number(self, value):
+        """Sets current record number."""
+        self._record_number = value
+
+    def _check_record(self):
+        """Checks record for correct size.
+
+        Need to be implemented in the derived classes
+        """
+        return True
+
+    def init_record(self, recnum):
+        """Initialize record using given number."""
+        start = recnum * RECORD_BYTES
+        stop = start + RECORD_BYTES
+        self.record_number = recnum
+        self.rh = IrisRecord(self.fh[start:stop], recnum)
+        self.filepos = self.record_number * RECORD_BYTES
+        return self._check_record()
+
+    def init_next_record(self):
+        """Get next record from file.
+
+        This increases record_number count and initialises a new IrisRecord
+        with the calculated start and stop file offsets.
+
+        Returns
+        -------
+        chk : bool
+            True, if record is truncated.
+        """
+        return self.init_record(self.record_number + 1)
+
+    def array_from_record(self, words, width, dtype):
+        """Retrieve array from current record.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to read.
+        width : int
+            Size of the data word to read in bytes.
+        dtype : str
+            dtype string specifying data format.
+
+        Returns
+        -------
+        data : array-like
+            numpy array of data
+        """
+        return self.rh.read(words, width=width).view(dtype=dtype)
+
+    def bytes_from_record(self, words, width):
+        """Retrieve bytes from current record.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to read.
+        width : int
+            Size of the data word to read in bytes.
+
+        Returns
+        -------
+        data : array-like
+            numpy array of data
+        """
+        return self.rh.read(words, width=width)
+
+    def read_from_record(self, words, dtype):
+        """Read from file.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to read.
+        dtype : str
+            dtype string specifying data format.
+
+        Returns
+        -------
+        data : array-like
+            numpy array of data
+        """
+        width = get_dtype_size(dtype)
+        data = self.array_from_record(words, width, dtype)
+        words -= len(data)
+        while words > 0:
+            self.init_next_record()
+            next_data = self.array_from_record(words, width, dtype)
+            data = np.append(data, next_data)
+            words -= len(next_data)
+        return data
+
+
+class IrisRawFile(IrisRecordFile, IrisIngestHeader):
+    """Iris Raw File class."""
+
+    product_identifier = ["RAW"]
+
+    def __init__(self, filename, **kwargs):
+        super().__init__(filename, **kwargs)
+
+        self.check_product_identifier()
+
+        self.init_record(1)
+        self.get_header(IrisIngestHeader)
+
+        # get RAW file specifics
+        self._raw_product_bhdrs = []
+        self._data = OrderedDict()
+        # get data headers in any case
+        self.get_data_headers()
+        if self.loaddata:
+            self.get_data()
+
+    @property
+    def raw_product_bhdrs(self):
+        """Returns `raw_product_bhdrs` dictionary."""
+        return self._raw_product_bhdrs
+
+    def _check_record(self):
+        """Checks record for correct size.
+
+        Returns
+        -------
+        chk : bool
+            False, if record is truncated.
+        """
+        # we do not know filesize (structure_size) before reading first record,
+        # so we try and pass
+        try:
+            if self.record_number >= self.structure_size / RECORD_BYTES:
+                return False
+        except AttributeError:
+            pass
+        chk = self._rh.record.shape[0] == RECORD_BYTES
+        if not chk:
+            raise EOFError(f"Unexpected file end detected at record {self.rh.recnum}")
+        return chk
+
+    def read_from_record(self, words, dtype):
+        """Read from record/file.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to read.
+        dtype : str
+            dtype string specifying data format.
+        Returns
+        -------
+        data : array-like
+            numpy array of data
+        """
+        width = get_dtype_size(dtype)
+        data = self.array_from_record(words, width, dtype)
+        words -= len(data)
+        while words > 0:
+            self.init_next_record()
+            self.raw_product_bhdrs.append(self.get_raw_prod_bhdr())
+            next_data = self.array_from_record(words, width, dtype)
+            data = np.append(data, next_data)
+            words -= len(next_data)
+        return data
+
+    def skip_from_record(self, words, dtype):
+        """Only move filepointer.
+
+        Parameters
+        ----------
+        words : int
+            Number of data words to skip.
+        dtype : str
+            dtype string specifying data format.
+        """
+        width = get_dtype_size(dtype)
+        size = words * width
+        remain = RECORD_BYTES - self.rh.pos
+        remain -= size
+        if remain >= 0:
+            self.rh.pos += size
+        size = -remain
+        while size > 0:
+            self.init_next_record()
+            self.get_raw_prod_bhdr()
+            remain = RECORD_BYTES - self.rh.pos
+            remain -= size
+            if remain >= 0:
+                self.rh.pos += size
+            size = -remain
+
+    def get_compression_code(self):
+        """Read and return data compression code.
+
+        Returns
+        -------
+        cmp_msb : bool
+            True, if MSB is set.
+        cmp_val : int
+            Value of data compression code.
+        """
+        cmp_val = self.read_from_record(1, "int16")[0]
+        cmp_msb = np.sign(cmp_val) == -1
+        if cmp_msb:
+            cmp_val = cmp_val + 32768
+        if self._debug:
+            print(
+                "--- Sub CMP Code:",
+                cmp_msb,
+                cmp_val,
+                self._rh.recpos - 1,
+                self._rh.recpos,
+            )
+        return cmp_msb, cmp_val
+
+    def get_raw_prod_bhdr(self):
+        """Read and unpack raw product bhdr."""
+        return _unpack_dictionary(
+            self._rh.read(LEN_RAW_PROD_BHDR, width=1), RAW_PROD_BHDR, self._rawdata
+        )
+
+    def get_ingest_data_headers(self):
+        """Read and return ingest data headers."""
+        ingest_data_hdrs = OrderedDict()
+        for i, dn in enumerate(self.data_types):
+            ingest_data_hdrs[dn] = _unpack_dictionary(
+                self._rh.read(LEN_INGEST_DATA_HEADER, width=1),
+                INGEST_DATA_HEADER,
+                self._rawdata,
+            )
+
+        return ingest_data_hdrs
+
+    def get_ray(self, data, skip=False):
+        """Retrieve single ray.
+
+        Returns
+        -------
+        data : array-like
+            Numpy array containing data of one ray.
+        """
+        ray_pos = 0
+        recnum = self.rh.recnum
+        recoff = self.rh.pos
+
+        cmp_msb, cmp_val = self.get_compression_code()
+
+        # ray is missing
+        if (cmp_val == 1) & (cmp_msb == 0):
+            if self._debug:
+                print("ray missing")
+            return recnum, recoff
+
+        while not ((cmp_val == 1) & (not cmp_msb)):
+            if cmp_msb:
+                if self._debug:
+                    print(
+                        f"--- Add {cmp_val} WORDS at range {ray_pos}, "
+                        f"record {self._rh.recpos}:{self._rh.recpos + cmp_val}:"
+                    )
+                if skip:
+                    self.skip_from_record(cmp_val, "int16")
+                else:
+                    data[ray_pos : ray_pos + cmp_val] = self.read_from_record(
+                        cmp_val, "int16"
+                    )
+            # # compressed zeros follow
+            # # can be skipped, if data array is created all zeros
+            # else:
+            #     if not skip:
+            #         if self._debug:
+            #             print(
+            #                 "--- Add {0} Zeros at range {1}, record {2}:{3}:"
+            #                 "".format(
+            #                     cmp_val, ray_pos, self._rh.recpos, self._rh.recpos + 1
+            #                 )
+            #             )
+            #         if cmp_val + ray_pos > self.nbins + 6:
+            #             return recnum, recoff
+            #         data[ray_pos : ray_pos + cmp_val] = 0
+
+            ray_pos += cmp_val
+
+            # read next compression code
+            cmp_msb, cmp_val = self.get_compression_code()
+
+        return recnum, recoff
+
+    def _get_ray_record_offsets_and_data(self, sweep_number, raw_data, idx=None):
+        """Get record numbers and ray offsets for specific sweep."""
+        sweep = self.data[sweep_number]
+
+        # get rays per available data type
+        rays_per_data_type = [
+            d["number_rays_file_expected"] for d in sweep["ingest_data_hdrs"].values()
+        ]
+        # data type count
+        data_type_count = len(rays_per_data_type)
+        ray_count = sum(rays_per_data_type)
+        rnums = np.zeros((ray_count,), dtype=int)
+        roffs = np.zeros((ray_count,), dtype=int)
+        phdr = self._product_hdr
+        bins = phdr["product_end"]["number_bins"]
+        single_data = np.zeros((bins + 6), dtype="int16")
+
+        # initialize first record
+        self.init_record(sweep["record_number"])
+        self.rh.pos = sweep["first_ray_byte_offset"]
+
+        #  get all ray record numbers and offsets
+        # also get data for
+        j = -1
+        for i in range(ray_count):
+            if idx is not None:
+                if (i % data_type_count) == idx:
+                    recnum, recoff = self.get_ray(raw_data[j])
+                    j += 1
+                else:
+                    recnum, recoff = self.get_ray(single_data, skip=True)
+            else:
+                recnum, recoff = self.get_ray(single_data, skip=True)
+            rnums[i] = recnum
+            roffs[i] = recoff
+
+        ray_offsets = np.stack([rnums, roffs], axis=-1)
+        ray_offsets = ray_offsets.reshape(
+            rays_per_data_type[0], len(rays_per_data_type), 2
+        )
+        ray_offsets = ray_offsets.swapaxes(0, 1)
+        offsets = OrderedDict()
+        for i, data_type in enumerate(sweep["ingest_data_hdrs"].keys()):
+            offsets[data_type] = ray_offsets[i]
+        sweep["ray_offsets"] = offsets
+
+    def get_moment(self, sweep_number, moment):
+        """Load a single moment of a given sweep.
+
+        Parameters
+        ----------
+        sweep_number : int
+            Sweep Number.
+        moment : str
+            Data Type to retrieve.
+
+        """
+        sweep = self.data[sweep_number]
+
+        # create new sweep_data OrderedDict
+        if not sweep.get("sweep_data", False):
+            sweep["sweep_data"] = OrderedDict()
+
+        # check if moment exist and return early
+        data = sweep["sweep_data"].get(moment, False)
+        if data is not False:
+            return
+
+        all_types = list(sweep["ingest_data_hdrs"].keys())
+
+        # if coordinate is wanted, get from first moment
+        if moment in ["azimuth", "elevation", "dtime", "dtime_ms", "range"]:
+            moment = all_types[0]
+
+        idx = self.data_types.index(moment)
+        prod = self.data_types_dict[idx]
+        moment_num = all_types.index(moment)
+        moment_type = self.data_types_dict[moment_num]
+
+        phdr = self._product_hdr
+
+        rays = sweep["ingest_data_hdrs"][moment]["number_rays_file_expected"]
+        bins = phdr["product_end"]["number_bins"]
+
+        raw_data = np.zeros((rays, bins + 6), dtype="int16")
+
+        # get raw_data
+        if not sweep.get("ray_offsets", False):
+            self._get_ray_record_offsets_and_data(sweep_number, raw_data, idx=idx)
+        else:
+            for i, (rnum, roff) in enumerate(sweep["ray_offsets"][moment_type["name"]]):
+                if self.rh.recnum != rnum:
+                    self.init_record(rnum)
+                self.rh.pos = roff
+                self.get_ray(raw_data[i])
+
+        xhdr_type = phdr["product_configuration"]["product_specific_info"]["xhdr_type"]
+
+        data = raw_data[:, 6:]
+
+        # extended header contains exact ray times with ms resolution
+        if moment == "DB_XHDR":
+            if xhdr_type == 0:
+                ext_hdr = EXTENDED_HEADER_V0
+            elif xhdr_type == 1:
+                ext_hdr = EXTENDED_HEADER_V1
+            elif xhdr_type == 2:
+                ext_hdr = EXTENDED_HEADER_V2
+                warnings.warn(
+                    "xradar: Sigmet/Iris Extended Header V2 not implemented "
+                    "completely. The customer specified reminder will not be "
+                    "decoded. Please use `rawdata=True` to load undecoded data."
+                )
+            else:
+                raise ValueError(f"wradlib: unknown extended header type V{xhdr_type}")
+            len_ext_hdr = struct.calcsize(_get_fmt_string(ext_hdr))
+            dtype = _get_struct_dtype(ext_hdr)
+            if not self.rawdata:
+                data = data[:, : len_ext_hdr // 2].copy().view(dtype)
+
+        # remove missing rays marked as 0 in the following arrays
+        idx = np.where(raw_data[:, 4] != 0)[0]
+        if idx.size:
+            data = data[idx]
+            raw_data = raw_data[idx]
+
+        sweep_data = sweep["sweep_data"]
+
+        sweep_data[moment] = self.decode_data(data, prod)
+
+        # get millisecond times if available
+        if moment == "DB_XHDR":
+            sweep_data["dtime_ms"] = np.squeeze(data.T["dtime_ms"])
+
+        if sweep_data.get("azimuth", False) is False:
+            dec_data = self.decode_data(raw_data[:, :4], BIN2)
+            azi_start = dec_data[:, 0]
+            azi_stop = dec_data[:, 2]
+            # correct elevation for negative angles
+            # see https://github.com/wradlib/wradlib/issues/560
+            ele_start = dec_data[:, 1]
+            ele_start[ele_start > 180] -= 360
+            ele_stop = dec_data[:, 3]
+            ele_stop[ele_stop > 180] -= 360
+
+            # calculate beam center values
+            # find locations with reasonable difference
+            zero_index = np.where(azi_start - azi_stop > 5)
+            azi_stop2 = azi_stop.copy()
+            azi_stop2[zero_index[0]] += 360
+            az = (azi_start + azi_stop2) / 2.0
+            az[az >= 360] -= 360
+            el = (ele_start + ele_stop) / 2.0
+
+            sweep_data["azi_start"] = azi_start
+            sweep_data["azi_stop"] = azi_stop
+            sweep_data["azimuth"] = az
+            sweep_data["ele_start"] = ele_start
+            sweep_data["ele_stop"] = ele_stop
+            sweep_data["elevation"] = el
+            sweep_data["rbins"] = raw_data[:, 4]
+            sweep_data["dtime"] = raw_data[:, 5]
+
+    def get_sweep(self, sweep_number, moments):
+        """Load a single sweep.
+
+        Parameters
+        ----------
+        sweep_number : int
+            Sweep number.
+        moments : list of strings
+            Data Types to retrieve.
+
+        """
+        sweep = self.data[sweep_number]
+
+        # get selected data type names
+        selected = [k for k in sweep["ingest_data_hdrs"].keys() if k in moments]
+
+        for moment in selected:
+            self.get_moment(sweep_number, moment)
+
+    def decode_data(self, data, prod):
+        """Decode data according given prod-dict.
+
+        Parameters
+        ----------
+        data : :py:class:`numpy:numpy.ndarray`
+            data to decode
+        prod : dict
+            dictionary holding decoding information
+
+        Returns
+        -------
+        data : :py:class:`numpy:numpy.ndarray`
+            decoded data
+
+        """
+        if self._rawdata:
+            return data
+        kw = {}
+        if prod["func"]:
+            try:
+                kw.update(prod["fkw"])
+            except KeyError:
+                pass
+            if get_dtype_size(prod["dtype"]) == 1:
+                dtype = f"(2,) {prod['dtype']}"
+            else:
+                dtype = f"{prod['dtype']}"
+            try:
+                rays, bins = data.shape
+                data = data.view(dtype).reshape(rays, -1)[:, :bins]
+            except ValueError:
+                data = data.view(dtype)
+            if prod["func"] in [decode_vel, decode_width, decode_kdp]:
+                wavelength = self.product_hdr["product_end"]["wavelength"]
+                if prod["func"] == decode_kdp:
+                    kw.update({"wavelength": wavelength / 100})
+                    return prod["func"](data, **kw)
+
+                prf = self.product_hdr["product_end"]["prf"]
+                nyquist = wavelength * prf / (10000.0 * 4.0)
+                if prod["func"] == decode_vel:
+                    nyquist *= (
+                        self.ingest_header["task_configuration"]["task_dsp_info"][
+                            "multi_prf_mode_flag"
+                        ]
+                        + 1
+                    )
+                kw.update({"nyquist": nyquist})
+
+            return prod["func"](data, **kw)
+        else:
+            return data
+
+    def get_data(self):
+        """Load all sweeps from file."""
+        dt_names = self.data_types  # [d['name'] for d in self.data_types]
+        rsweeps = range(1, self.nsweeps + 1)
+
+        loaddata = self.loaddata
+        try:
+            sweep = loaddata.copy().pop("sweep", rsweeps)
+            moment = loaddata.copy().pop("moment", dt_names)
+        except AttributeError:
+            sweep = rsweeps
+            moment = dt_names
+
+        self.init_record(1)
+        for num, sw in self.data.items():
+            if num not in sweep:
+                continue
+            self.init_record(sw["record_number"])
+            self.rh.pos = sw["first_ray_byte_offset"]
+            self.get_sweep(num, moment)
+
+    def get_data_headers(self):
+        """Load all sweep's `ingest_data_header` from file.
+
+        Also saves all `raw_prod_bhdr`. `record_number` and `first_ray_byte_offset`
+        per sweep for easy access.
+        """
+        try:
+            sweeps = self.loaddata.get("sweep", None)
+        except AttributeError:
+            sweeps = None
+
+        self.init_record(1)
+        current_sweep = 0
+        # try to read claim all sweep headers
+        while self.init_next_record():
+            # get raw_prod_bhdr
+            raw_prod_bhdr = self.get_raw_prod_bhdr()
+            # append to bhdrs list
+            self.raw_product_bhdrs.append(raw_prod_bhdr)
+            # if new sweep: set current sweep number, read ingest_data_headers
+            # and add to _data
+            if raw_prod_bhdr["sweep_number"] != current_sweep:
+                current_sweep = raw_prod_bhdr["sweep_number"]
+                if sweeps is not None and current_sweep not in sweeps:
+                    continue
+                sweep = OrderedDict()
+                sweep["ingest_data_hdrs"] = self.get_ingest_data_headers()
+                sweep["record_number"] = self.rh.recnum
+                sweep["first_ray_byte_offset"] = self.rh.pos
+                self._data[current_sweep] = sweep
+
+    @property
+    def site_coords(self):
+        """Return Radar Location Tuple"""
+        ing_conf = self.ingest_header["ingest_configuration"]
+        return (
+            ing_conf["longitude_radar"],
+            ing_conf["latitude_radar"],
+            ing_conf["altitude_radar"] / 100.0,
+        )
+
+    @property
+    def scan_mode(self):
+        """Return Antenna Scan Mode"""
+        task_conf = self.ingest_header["task_configuration"]
+        scan_info = task_conf["task_scan_info"]
+        return scan_info["antenna_scan_mode"]
+
+    @property
+    def first_dimension(self):
+        """Returns first dimension"""
+        if self.scan_mode == 2:
+            return "elevation"
+        else:
+            return "azimuth"
+
+
+class IrisArrayWrapper(BackendArray):
+    """Wraps array of Iris RAW data."""
+
+    def __init__(self, datastore, name, var):
+        self.datastore = datastore
+        self.group = var["sweep_number"]
+        self.name = name
+        # get rays and bins
+        nrays = var["number_rays_file_written"]
+        nbins = datastore.root.product_hdr["product_end"]["number_bins"]
+        # todo: retrieve datatype from io.iris.SIGMET_DATA_TYPES
+        # hint: source data for RAW files is int16
+        # for now: assume floating point for all moments
+        self.dtype = np.dtype("float32")
+        # and for undecoded moments use int16
+        prod = [v for v in datastore.root.data_types_dict if v["name"] == name]
+        if prod and prod[0]["func"] is None:
+            self.dtype = np.dtype("int16")
+        if name == "DB_XHDR":
+            self.dtype = np.dtype("O")
+        if name in ["azimuth", "elevation"]:
+            self.shape = (nrays,)
+        elif name == "dtime":
+            self.shape = (nrays,)
+            self.dtype = np.dtype("uint16")
+        elif name == "dtime_ms":
+            self.shape = (nrays,)
+            self.dtype = np.dtype("int32")
+        else:
+            self.shape = (nrays, nbins)
+
+    def _getitem(self, key):
+        # read the data and put it into dict
+        self.datastore.root.get_moment(self.group, self.name)
+        return self.datastore.ds["sweep_data"][self.name][key]
+
+    def __getitem__(self, key):
+        return indexing.explicit_indexing_adapter(
+            key,
+            self.shape,
+            indexing.IndexingSupport.BASIC,
+            self._getitem,
+        )
+
+
+class IrisStore(AbstractDataStore):
+    """Store for reading IRIS sweeps via wradlib."""
+
+    def __init__(self, manager, group=None):
+
+        self._manager = manager
+        self._group = group
+        self._filename = self.filename
+        self._need_time_recalc = False
+
+    @classmethod
+    def open(cls, filename, mode="r", group=None, **kwargs):
+        manager = CachingFileManager(IrisRawFile, filename, mode=mode, kwargs=kwargs)
+        return cls(manager, group=group)
+
+    @property
+    def filename(self):
+        with self._manager.acquire_context(False) as root:
+            return root.filename
+
+    @property
+    def root(self):
+        with self._manager.acquire_context(False) as root:
+            return root
+
+    def _acquire(self, needs_lock=True):
+        with self._manager.acquire_context(needs_lock) as root:
+            ds = root.data[self._group]
+        return ds
+
+    @property
+    def ds(self):
+        return self._acquire()
+
+    def open_store_variable(self, name, var):
+        dim = self.root.first_dimension
+
+        data = indexing.LazilyOuterIndexedArray(IrisArrayWrapper(self, name, var))
+        encoding = {"group": self._group, "source": self._filename}
+
+        mname = iris_mapping.get(name, name)
+        mapping = sweep_vars_mapping.get(mname, {})
+        attrs = {key: mapping[key] for key in moment_attrs if key in mapping}
+        attrs[
+            "coordinates"
+        ] = "elevation azimuth range latitude longitude altitude time rtime sweep_mode"
+        return mname, Variable((dim, "range"), data, attrs, encoding)
+
+    def open_store_coordinates(self, var):
+        azimuth = indexing.LazilyOuterIndexedArray(
+            IrisArrayWrapper(self, "azimuth", var)
+        )
+        elevation = indexing.LazilyOuterIndexedArray(
+            IrisArrayWrapper(self, "elevation", var)
+        )
+
+        # handle DB_XHDR time
+        dtime = "dtime"
+        time_prefix = ""
+        if "DB_XHDR" in self.ds["ingest_data_hdrs"]:
+            dtime = "dtime_ms"
+            time_prefix = "milli"
+
+        rtime = indexing.LazilyOuterIndexedArray(IrisArrayWrapper(self, dtime, var))
+        encoding = {"group": self._group}
+        rtime_attrs = {
+            "units": f"{time_prefix}seconds since {var['sweep_start_time'].replace(tzinfo=None).isoformat()}Z",
+            "standard_name": "time",
+        }
+        dim = self.root.first_dimension
+
+        # get coordinates from IrisFile
+        sweep_mode = "azimuth_surveillance" if dim == "azimuth" else "rhi"
+        sweep_number = self._group
+        prt_mode = "not_set"
+        follow_mode = "not_set"
+
+        lon, lat, alt = self.root.site_coords
+
+        task = self.root.ingest_header["task_configuration"]["task_range_info"]
+        range_first_bin = task["range_first_bin"]
+        range_last_bin = task["range_last_bin"]
+        if range_first_bin == 0:
+            range_first_bin = task["step_output_bins"] / 2
+            range_last_bin += task["step_output_bins"]
+        rng = (
+            np.arange(
+                range_first_bin,
+                range_last_bin,
+                task["step_output_bins"],
+                dtype="float32",
+            )[: task["number_output_bins"]]
+            / 1e2
+        )
+        range_attrs = get_range_attrs(rng)
+
+        rtime = Variable((dim,), rtime, rtime_attrs, encoding)
+        azimuth = Variable((dim,), azimuth, {}, encoding)
+
+        ing_head = self.ds["ingest_data_hdrs"]
+        data = ing_head[list(ing_head.keys())[0]]
+        fixed_angle = np.round(data["fixed_angle"], 1)
+
+        coords = {
+            "azimuth": Variable((dim,), azimuth, get_azimuth_attrs(azimuth), encoding),
+            "elevation": Variable((dim,), elevation, get_elevation_attrs(), encoding),
+            "time": rtime,
+            "range": Variable(("range",), rng, range_attrs),
+            "longitude": Variable((), lon, get_longitude_attrs()),
+            "latitude": Variable((), lat, get_latitude_attrs()),
+            "altitude": Variable((), alt, get_altitude_attrs()),
+            "sweep_mode": Variable((), sweep_mode),
+            "sweep_number": Variable((), sweep_number),
+            "prt_mode": Variable((), prt_mode),
+            "follow_mode": Variable((), follow_mode),
+            "fixed_angle": Variable((), fixed_angle),
+        }
+
+        # a1gate
+        a1gate = np.where(rtime.values == rtime.values.min())[0][0]
+        coords[dim].attrs["a1gate"] = a1gate
+        # angle_res
+        task_scan_info = self.root.ingest_header["task_configuration"]["task_scan_info"]
+        coords[dim].attrs["angle_res"] = (
+            task_scan_info["desired_angular_resolution"] / 1000.0
+        )
+
+        return coords
+
+    def get_variables(self):
+        return FrozenDict(
+            (k1, v1)
+            for k1, v1 in {
+                **dict(
+                    self.open_store_variable(k, v)
+                    for k, v in self.ds["ingest_data_hdrs"].items()
+                ),
+                **self.open_store_coordinates(
+                    list(self.ds["ingest_data_hdrs"].values())[0]
+                ),
+            }.items()
+        )
+
+    def get_attrs(self):
+        attributes = {}
+        # RHI limits
+        if self.root.scan_mode == 2:
+            tsi = self.root.ingest_header["task_configuration"]["task_scan_info"][
+                "task_type_scan_info"
+            ]
+            ll = tsi["lower_elevation_limit"]
+            ul = tsi["upper_elevation_limit"]
+            attributes.update(
+                {"elevation_lower_limit": ll, "elevation_upper_limit": ul}
+            )
+        return FrozenDict(attributes)
+
+
+def _get_iris_group_names(filename):
+    sid, opener = _check_iris_file(filename)
+    with opener(filename, loaddata=False) as ds:
+        keys = list(ds.data.keys())
+    return keys
+
+
+class IrisBackendEntrypoint(BackendEntrypoint):
+    """Xarray BackendEntrypoint for IRIS/Sigmet data."""
+
+    def open_dataset(
+        self,
+        filename_or_obj,
+        *,
+        mask_and_scale=True,
+        decode_times=True,
+        concat_characters=True,
+        decode_coords=True,
+        drop_variables=None,
+        use_cftime=None,
+        decode_timedelta=None,
+        group=None,
+        keep_elevation=False,
+        keep_azimuth=False,
+        reindex_angle=None,
+        first_dim="time",
+    ):
+        store = IrisStore.open(
+            filename_or_obj,
+            group=group,
+            loaddata=False,
+        )
+
+        store_entrypoint = StoreBackendEntrypoint()
+
+        ds = store_entrypoint.open_dataset(
+            store,
+            mask_and_scale=mask_and_scale,
+            decode_times=decode_times,
+            concat_characters=concat_characters,
+            decode_coords=decode_coords,
+            drop_variables=drop_variables,
+            use_cftime=use_cftime,
+            decode_timedelta=decode_timedelta,
+        )
+
+        # drop DB_XHDR for now
+        ds = ds.drop_vars("DB_XHDR", errors="ignore")
+
+        # todo: new approach of sorting, fixes wrong sorting when using sortby if we
+        #  have multiple rays with the same timestamp
+        # first ray comes first
+        # ds = ds.roll(azimuth=-ds.time.argmin().values, roll_coords=True)
+        ds = ds.sortby(store.root.first_dimension)
+
+        if decode_coords and reindex_angle is not False:
+            ds = ds.pipe(_reindex_angle, store=store, tol=reindex_angle)
+
+        ds.attrs.pop("elevation_lower_limit", None)
+        ds.attrs.pop("elevation_upper_limit", None)
+
+        # handling first dimension
+        dim0 = "elevation" if ds.sweep_mode.load() == "rhi" else "azimuth"
+
+        if first_dim == "auto":
+            if "time" in ds.dims:
+                ds = ds.swap_dims({"time": dim0})
+            ds = ds.sortby(dim0)
+        else:
+            if "time" not in ds.dims:
+                ds = ds.swap_dims({dim0: "time"})
+            ds = ds.sortby("time")
+
+        # reassign azimuth/elevation/time coordinates
+        ds = ds.assign_coords({"azimuth": ds.azimuth})
+        ds = ds.assign_coords({"elevation": ds.elevation})
+        ds = ds.assign_coords({"time": ds.time})
+
+        # assign geo-coords
+        ds = ds.assign_coords(
+            {
+                "latitude": ds.latitude,
+                "longitude": ds.longitude,
+                "altitude": ds.altitude,
+            }
+        )
+
+        return ds
+
+
+def open_iris_datatree(filename_or_obj, **kwargs):
+    """Open Iris/Sigmet dataset as xradar Datatree.
+
+    Parameters
+    ----------
+    filename_or_obj : str, Path, file-like or DataStore
+        Strings and Path objects are interpreted as a path to a local or remote
+        radar file
+
+    Keyword Arguments
+    -----------------
+    first_dim : str
+        Default to 'time' as first dimension. If set to 'auto', first dimension will
+        be either 'azimuth' or 'elevation' depending on type of sweep.
+    sweep : int, list of int, optional
+        Sweep number(s) to extract, default to first sweep. If None, all sweeps are
+        extracted into a list.
+    keep_elevation : bool
+        For PPI only. Keep original elevation data if True. If False,
+        fixes erroneous elevation data. Defaults to True.
+    keep_azimuth : bool
+        For RHI only. Keep original azimuth data if True. If False,
+        fixes erroneous azimuth data. Defaults to True.
+    reindex_angle : bool or float
+        Defaults to False, no reindexing. If True reindex angle with tol=0.4deg. If
+        given a floating point number, it is used as tolerance.
+        Only invoked if `decode_coord=True`.
+    kwargs :  kwargs
+        Additional kwargs are fed to `xr.open_dataset`.
+
+    Returns
+    -------
+    dtree: DataTree
+        DataTree
+    """
+    # handle kwargs, extract first_dim
+    backend_kwargs = kwargs.pop("backend_kwargs", {})
+    # first_dim = backend_kwargs.pop("first_dim", None)
+    sweep = kwargs.pop("sweep", None)
+    sweeps = []
+    kwargs["backend_kwargs"] = backend_kwargs
+
+    if isinstance(sweep, str):
+        sweeps = [int(sweep[5:])]
+    elif isinstance(sweep, int):
+        sweeps = sweep
+    elif isinstance(sweep, list):
+        if isinstance(sweep[0], str):
+            sweeps = [int(sw[5:]) for sw in sweep]
+        else:
+            sweeps.extend(sweep)
+    else:
+        sweeps = _get_iris_group_names(filename_or_obj)
+
+    ds = [
+        xr.open_dataset(filename_or_obj, group=swp, engine="iris", **kwargs)
+        for swp in sweeps
+    ]
+
+    ds.insert(0, xr.Dataset())
+
+    # create datatree root node with required data
+    dtree = DataTree(data=_assign_root(ds), name="root")
+    # return datatree with attached sweep child nodes
+    return _attach_sweep_groups(dtree, ds[1:])
diff --git a/xradar/io/backends/rainbow.py b/xradar/io/backends/rainbow.py
index fe94a73..000cf53 100644
--- a/xradar/io/backends/rainbow.py
+++ b/xradar/io/backends/rainbow.py
@@ -640,7 +640,7 @@ class RainbowStore(AbstractDataStore):
         attrs["_FillValue"] = 0
         attrs[
             "coordinates"
-        ] = "elevation azimuth range latitude longitude altitude time sweep_mode"
+        ] = "elevation azimuth range latitude longitude altitude time"
         return {mname: Variable((dim, "range"), data, attrs, encoding)}
 
     def open_store_coordinates(self, var):


Test patch:
diff --git a/ci/unittests.yml b/ci/unittests.yml
index c367959..2b4db8d 100644
--- a/ci/unittests.yml
+++ b/ci/unittests.yml
@@ -3,6 +3,8 @@ channels:
   - conda-forge
 dependencies:
   - python>=3.7
+  - codecov
+  - coverage
   - dask
   - h5netcdf
   - h5py
@@ -11,6 +13,7 @@ dependencies:
   - numpy
   - pip
   - pytest
+  - pytest-cov
   - pytest-doctestplus
   - pytest-sugar
   - pytest-xdist
diff --git a/tests/conftest.py b/tests/conftest.py
index d61b723..f6252ab 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -61,3 +61,36 @@ def gamic_file(tmp_path_factory):
     fname = os.path.join(fn, "gamic_data.h5")
     urlretrieve(url, filename=fname)
     return fname
+
+
+@pytest.fixture(scope="session")
+def rainbow_file(tmp_path_factory):
+    base_url = "https://raw.githubusercontent.com/wradlib/wradlib-data/main/rainbow/"
+    filename = "2013051000000600dBZ.vol"
+    url = urljoin(base_url, filename)
+    fn = tmp_path_factory.mktemp("rainbow_data")
+    fname = os.path.join(fn, "rainbow_data.vol")
+    urlretrieve(url, filename=fname)
+    return fname
+
+
+@pytest.fixture(scope="session")
+def iris0_file(tmp_path_factory):
+    base_url = "https://raw.githubusercontent.com/wradlib/wradlib-data/main/sigmet/"
+    filename = "cor-main131125105503.RAW2049"
+    url = urljoin(base_url, filename)
+    fn = tmp_path_factory.mktemp("iris_data")
+    fname = os.path.join(fn, "iris0_data.raw")
+    urlretrieve(url, filename=fname)
+    return fname
+
+
+@pytest.fixture(scope="session")
+def iris1_file(tmp_path_factory):
+    base_url = "https://raw.githubusercontent.com/wradlib/wradlib-data/main/sigmet/"
+    filename = "SUR210819000227.RAWKPJV"
+    url = urljoin(base_url, filename)
+    fn = tmp_path_factory.mktemp("iris_data")
+    fname = os.path.join(fn, "iris1_data.raw")
+    urlretrieve(url, filename=fname)
+    return fname
diff --git a/tests/georeference/test_transforms.py b/tests/georeference/test_transforms.py
new file mode 100644
index 0000000..683e937
--- /dev/null
+++ b/tests/georeference/test_transforms.py
@@ -0,0 +1,56 @@
+import numpy as np
+import pandas as pd
+from numpy.testing import assert_almost_equal
+
+import xradar
+from xradar.georeference import antenna_to_cartesian, get_x_y_z
+
+
+def test_antenna_to_cartesian():
+    ranges = np.arange(0, 1000, 100)
+    azimuths = np.arange(0, 300, 30)
+    elevations = np.arange(0, 50, 5)
+
+    # Apply georeferencing to this sample data
+    x, y, z = antenna_to_cartesian(ranges, azimuths, elevations)
+
+    # Check to see if the origin contains all 0s
+    assert_almost_equal(x[0], 0)
+    assert_almost_equal(y[0], 0)
+    assert_almost_equal(z[0], 0)
+
+    # Make sure that at 180 degrees, x is (close to) 0
+    assert_almost_equal(x[np.where(azimuths == 180.0)], 0)
+
+    # Make sure that at 270 degrees, y is (close to) 0
+    assert_almost_equal(y[np.where(azimuths == 270.0)], 0)
+
+
+def test_get_x_y_z():
+
+    # Create an empty xradar dataset
+    ds = xradar.model.Dataset()
+
+    # Add similar data to before
+    ds['time'] = pd.date_range(start='2018-11-10', end='2018-11-10T00:09:00', freq='T')
+    ds['range'] = np.arange(0, 1000, 100)
+    ds['azimuth'] = ('time', np.arange(0, 300, 30))
+    ds['elevation'] = ('time', np.arange(0, 50, 5))
+
+    ds = get_x_y_z(ds)
+
+    # Check to see if the origin contains all 0s
+    origin = ds.isel(range=0, time=0)
+    assert_almost_equal(origin.x, 0)
+    assert_almost_equal(origin.y, 0)
+    assert_almost_equal(origin.z, 0)
+
+    # Make sure that at 180 degrees, x is (close to) 0
+    np.testing.assert_almost_equal(
+        ds.isel(range=0, time=np.where(ds.azimuth == 180)[0]).x, 0
+    )
+
+    # Make sure that at 270 degrees, y is (close to) 0
+    np.testing.assert_almost_equal(
+        ds.isel(range=0, time=np.where(ds.azimuth == 270)[0]).y, 0
+    )
diff --git a/tests/test_io.py b/tests/test_io.py
index f6fe643..b9f2080 100644
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -7,7 +7,13 @@
 import numpy as np
 import xarray as xr
 
-from xradar.io import open_cfradial1_datatree, open_gamic_datatree, open_odim_datatree
+from xradar.io import (
+    open_cfradial1_datatree,
+    open_gamic_datatree,
+    open_iris_datatree,
+    open_odim_datatree,
+    open_rainbow_datatree,
+)
 from xradar.model import (
     non_standard_sweep_dataset_vars,
     required_sweep_metadata_vars,
@@ -347,3 +353,221 @@ def test_open_furuno_scnx_dataset(furuno_scnx_file):
         backend_kwargs=dict(first_dim="auto"),
     )
     assert dict(ds.dims) == {"azimuth": 720, "range": 936}
+
+
+def test_open_rainbow_datatree(rainbow_file):
+    dtree = open_rainbow_datatree(rainbow_file)
+
+    # root_attrs
+    attrs = dtree.attrs
+    assert attrs["Conventions"] == "None"
+
+    # root vars
+    rvars = dtree.data_vars
+    assert rvars["volume_number"] == 0
+    assert rvars["platform_type"] == "fixed"
+    assert rvars["instrument_type"] == "radar"
+    assert rvars["time_coverage_start"] == "2013-05-10T00:00:06Z"
+    assert rvars["time_coverage_end"] == "2013-05-10T00:03:14Z"
+    np.testing.assert_almost_equal(rvars["latitude"].values, np.array(50.856633))
+    np.testing.assert_almost_equal(rvars["longitude"].values, np.array(6.379967))
+    np.testing.assert_almost_equal(rvars["altitude"].values, np.array(116.7))
+
+    # iterate over subgroups and check some values
+    moments = [
+        "DBZH",
+    ]
+    elevations = [
+        0.6,
+        1.4,
+        2.4,
+        3.5,
+        4.8,
+        6.3,
+        8.0,
+        9.9,
+        12.2,
+        14.8,
+        17.9,
+        21.3,
+        25.4,
+        30.0,
+    ]
+    azimuths = [360] * 14
+    ranges = [400] * 14
+    for i, grp in enumerate(dtree.groups[1:]):
+        ds = dtree[grp].ds
+        assert dict(ds.dims) == {"time": azimuths[i], "range": ranges[i]}
+        assert set(ds.data_vars) & (
+            sweep_dataset_vars | non_standard_sweep_dataset_vars
+        ) == set(moments)
+        assert set(ds.data_vars) & (required_sweep_metadata_vars) == set(
+            required_sweep_metadata_vars ^ {"azimuth", "elevation"}
+        )
+        assert set(ds.coords) == {
+            "azimuth",
+            "elevation",
+            "time",
+            "latitude",
+            "longitude",
+            "altitude",
+            "range",
+        }
+        assert np.round(ds.elevation.mean().values.item(), 1) == elevations[i]
+
+
+def test_open_rainbow_dataset(rainbow_file):
+    # open first sweep group
+    ds = xr.open_dataset(rainbow_file, group=0, engine="rainbow")
+    assert dict(ds.dims) == {"time": 360, "range": 400}
+    assert set(ds.data_vars) & (
+        sweep_dataset_vars | non_standard_sweep_dataset_vars
+    ) == {
+        "DBZH",
+    }
+
+    # open last sweep group
+    ds = xr.open_dataset(rainbow_file, group=13, engine="rainbow")
+    assert dict(ds.dims) == {"time": 360, "range": 400}
+    assert set(ds.data_vars) & (
+        sweep_dataset_vars | non_standard_sweep_dataset_vars
+    ) == {
+        "DBZH",
+    }
+
+    # open last sweep group, auto
+    ds = xr.open_dataset(
+        rainbow_file,
+        group=13,
+        engine="rainbow",
+        backend_kwargs=dict(first_dim="auto"),
+    )
+    assert dict(ds.dims) == {"azimuth": 360, "range": 400}
+
+
+def test_open_iris_datatree(iris0_file):
+    dtree = open_iris_datatree(iris0_file, reindex_angle=False)
+
+    # root_attrs
+    attrs = dtree.attrs
+    assert attrs["Conventions"] == "None"
+
+    # root vars
+    rvars = dtree.data_vars
+    assert rvars["volume_number"] == 0
+    assert rvars["platform_type"] == "fixed"
+    assert rvars["instrument_type"] == "radar"
+    assert rvars["time_coverage_start"] == "2013-11-25T10:55:04Z"
+    assert rvars["time_coverage_end"] == "2013-11-25T10:59:24Z"
+    np.testing.assert_almost_equal(rvars["latitude"].values, np.array(9.331))
+    np.testing.assert_almost_equal(rvars["longitude"].values, np.array(284.7170001))
+    np.testing.assert_almost_equal(rvars["altitude"].values, np.array(143.0))
+
+    # iterate over subgroups and check some values
+    moments = [
+        "ZDR",
+        "RHOHV",
+        "DBZH",
+        "PHIDP",
+        "KDP",
+        "VRADH",
+    ]
+    elevations = [
+        0.5,
+        1.0,
+        2.0,
+        3.0,
+        5.0,
+        7.0,
+        10.0,
+        15.0,
+        20.0,
+        30.0,
+    ]
+    azimuths = [360] * 10
+    ranges = [664] * 10
+    for i, grp in enumerate(dtree.groups[1:]):
+        ds = dtree[grp].ds
+        assert dict(ds.dims) == {"time": azimuths[i], "range": ranges[i]}
+        assert set(ds.data_vars) & (
+            sweep_dataset_vars | non_standard_sweep_dataset_vars
+        ) == set(moments)
+        assert set(ds.data_vars) & (required_sweep_metadata_vars) == set(
+            required_sweep_metadata_vars ^ {"azimuth", "elevation"}
+        )
+        assert set(ds.coords) == {
+            "azimuth",
+            "elevation",
+            "time",
+            "latitude",
+            "longitude",
+            "altitude",
+            "range",
+        }
+        assert np.round(ds.elevation.mean().values.item(), 1) == elevations[i]
+
+
+def test_open_iris0_dataset(iris0_file):
+    # open first sweep group
+    ds = xr.open_dataset(iris0_file, group=1, engine="iris")
+    assert dict(ds.dims) == {"time": 360, "range": 664}
+    assert set(ds.data_vars) & (
+        sweep_dataset_vars | non_standard_sweep_dataset_vars
+    ) == {
+        "DBZH",
+        "VRADH",
+        "KDP",
+        "RHOHV",
+        "PHIDP",
+        "ZDR",
+    }
+
+    # open last sweep group
+    ds = xr.open_dataset(iris0_file, group=10, engine="iris")
+    assert dict(ds.dims) == {"time": 360, "range": 664}
+    assert set(ds.data_vars) & (
+        sweep_dataset_vars | non_standard_sweep_dataset_vars
+    ) == {
+        "DBZH",
+        "VRADH",
+        "KDP",
+        "RHOHV",
+        "PHIDP",
+        "ZDR",
+    }
+
+    # open last sweep group, auto
+    ds = xr.open_dataset(
+        iris0_file,
+        group=10,
+        engine="iris",
+        backend_kwargs=dict(first_dim="auto"),
+    )
+    assert dict(ds.dims) == {"azimuth": 360, "range": 664}
+
+
+def test_open_iris1_dataset(iris1_file):
+    # open first and only sweep group
+    ds = xr.open_dataset(iris1_file, group=1, engine="iris")
+    assert dict(ds.dims) == {"time": 360, "range": 833}
+    assert set(ds.data_vars) & (
+        sweep_dataset_vars | non_standard_sweep_dataset_vars
+    ) == {
+        "DBZH",
+        "KDP",
+        "DBTH",
+        "PHIDP",
+        "ZDR",
+        "RHOHV",
+        "VRADH",
+        "WRADH",
+    }
+
+    # open first and only sweep group
+    ds = xr.open_dataset(
+        iris1_file,
+        group=1,
+        engine="iris",
+        backend_kwargs=dict(first_dim="auto"),
+    )
+    assert dict(ds.dims) == {"azimuth": 360, "range": 833}