Repository: British-Oceanographic-Data-Centre/COAsT (language: python)

Issue:
Profile validation scripts



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build_package.yml b/.github/workflows/build_package.yml
index 02848c04..a8c393ee 100644
--- a/.github/workflows/build_package.yml
+++ b/.github/workflows/build_package.yml
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [ 3.7, 3.8, 3.9 ]
+        python-version: [ 3.8, 3.9 ]
 
     steps:
       - uses: actions/checkout@v2
diff --git a/.github/workflows/publish_package.yml b/.github/workflows/publish_package.yml
index 375ebe03..f4f81b40 100644
--- a/.github/workflows/publish_package.yml
+++ b/.github/workflows/publish_package.yml
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [ 3.7 ]
+        python-version: [ 3.8 ]
 
     steps:
       - uses: actions/checkout@v2
diff --git a/.github/workflows/verify_package.yml b/.github/workflows/verify_package.yml
index b29925eb..6c4cd5db 100644
--- a/.github/workflows/verify_package.yml
+++ b/.github/workflows/verify_package.yml
@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [ 3.7, 3.8, 3.9 ]
+        python-version: [ 3.8, 3.9 ]
 
     steps:
       - uses: actions/checkout@v2
diff --git a/.gitignore b/.gitignore
index 97f1eca9..b3d96b34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,5 @@ unit_testing/*log
 sandbox.py
 
 config/nemo_tst_gridded.json
-
+.coverage
+unit_testing/coverage.txt
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 00000000..7b6e70af
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,48 @@
+cff-version: 1.1.0
+message: "If you use this software, please cite it as below."
+authors:
+  - family-names: Polton
+    given-names: Jeff A.
+    orcid: https://orcid.org/0000-0003-0131-5250
+
+  - family-names: Byrne
+    given-names: David
+    orcid: https://orcid.org/0000-0002-9994-3468
+
+  - family-names: Wise
+    given-names: Anthony
+    orcid: https://orcid.org/0000-0003-2985-9516
+
+  - family-names: Holt
+    given-names: Jason
+    orcid: https://orcid.org/0000-0002-3298-8477
+
+  - family-names: Katavouta
+    given-names: Anna
+    orcid: https://orcid.org/0000-0002-1587-4996
+
+  - family-names: Rulent
+    given-names: Julia
+    orcid: https://orcid.org/0000-0002-2357-4838
+ 
+  - family-names: Gardner
+    given-names: Thomas
+
+  - family-names: Cazaly
+    given-names: Matthew
+
+  - family-names: Hearn
+    given-names: Malcolm
+
+  - family-names: Jennings
+    given-names: Rob
+
+  - family-names: Lượng
+    given-names: Quyen
+
+  - family-names: Loch
+    given-names: Stephen
+
+title: British-Oceanographic-Data-Centre/COAsT: v1.2.7
+version: v1.2.7
+date-released: 2021-11-02
diff --git a/coast/__init__.py b/coast/__init__.py
index 2b9d8db8..f66d94a4 100644
--- a/coast/__init__.py
+++ b/coast/__init__.py
@@ -13,6 +13,7 @@
 from . import crps_util
 from .index import Indexed
 from .profile import Profile
+from .profile_analysis import ProfileAnalysis
 from .track import Track
 from .lagrangian import Lagrangian
 from .oceanparcels import Oceanparcels
@@ -21,8 +22,10 @@
 from .altimetry import Altimetry
 from .timeseries import Timeseries
 from .tidegauge import Tidegauge
-from .tidegauge_multiple import TidegaugeMultiple
+from .tidegauge_analysis import TidegaugeAnalysis
 from .config_parser import ConfigParser
+from .xesmf_convert import xesmf_convert
+from .process_data import Process_data
 
 # Set default for logging level when coast is imported
 import logging
diff --git a/coast/coast.py b/coast/coast.py
index 7c57fe14..d3e3bdd8 100644
--- a/coast/coast.py
+++ b/coast/coast.py
@@ -157,7 +157,7 @@ def isel(self, indexers: dict = None, drop: bool = False, **kwargs):
         """
         Indexes COAsT object along specified dimensions using xarray isel.
         Input is of same form as xarray.isel. Basic use, hand in either:
-            1. Dictionary with keys = dimensions, values = indices
+            1. dictionary with keys = dimensions, values = indices
             2. **kwargs of form dimension = indices
         """
         obj_copy = self.copy()
@@ -271,18 +271,18 @@ def calculate_haversine_distance(lon1, lat1, lon2, lat2):  # TODO This could be
         debug(f"Calculating haversine distance between {lon1},{lat1} and {lon2},{lat2}")
 
         # Convert to radians for calculations
-        lon1 = xr.ufuncs.deg2rad(lon1)
-        lat1 = xr.ufuncs.deg2rad(lat1)
-        lon2 = xr.ufuncs.deg2rad(lon2)
-        lat2 = xr.ufuncs.deg2rad(lat2)
+        lon1 = np.deg2rad(lon1)
+        lat1 = np.deg2rad(lat1)
+        lon2 = np.deg2rad(lon2)
+        lat2 = np.deg2rad(lat2)
 
         # Latitude and longitude differences
         dlat = (lat2 - lat1) / 2
         dlon = (lon2 - lon1) / 2
 
         # Haversine function.
-        distance = xr.ufuncs.sin(dlat) ** 2 + xr.ufuncs.cos(lat1) * xr.ufuncs.cos(lat2) * xr.ufuncs.sin(dlon) ** 2
-        distance = 2 * 6371.007176 * xr.ufuncs.arcsin(xr.ufuncs.sqrt(distance))
+        distance = np.sin(dlat) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon) ** 2
+        distance = 2 * 6371.007176 * np.arcsin(np.sqrt(distance))
 
         return distance
 
diff --git a/coast/config_structure.py b/coast/config_structure.py
index 75e987b9..d30f97be 100644
--- a/coast/config_structure.py
+++ b/coast/config_structure.py
@@ -85,7 +85,7 @@ class Config:
     Args:
         dataset (Dataset): Dataset object representing 'dataset' config.
         processing_flags (list): List of processing flags.
-        chunks (dict): Dict for dask chunking config. (i.e. {"dim1":100, "dim2":100, "dim3":100}).
+        chunks (dict): dict for dask chunking config. (i.e. {"dim1":100, "dim2":100, "dim3":100}).
         type (ConfigTypes): Type of config. Must be a valid ConfigType.
     """
 
diff --git a/coast/contour.py b/coast/contour.py
index a6c8f469..48006743 100755
--- a/coast/contour.py
+++ b/coast/contour.py
@@ -223,8 +223,8 @@ def process_contour(self, dataset: xr.Dataset, y_ind, x_ind):
             x_ind = np.asarray(x_ind)
             # When replacing diagonal segments in the contour, pick the path that is
             # closest to the contour isobath depth
-            option1 = uf.fabs(dataset.bathymetry[xr.DataArray(y_ind + 1), xr.DataArray(x_ind)] - self.depth)
-            option0 = uf.fabs(dataset.bathymetry[xr.DataArray(y_ind), xr.DataArray(x_ind + 1)] - self.depth)
+            option1 = np.fabs(dataset.bathymetry[xr.DataArray(y_ind + 1), xr.DataArray(x_ind)] - self.depth)
+            option0 = np.fabs(dataset.bathymetry[xr.DataArray(y_ind), xr.DataArray(x_ind + 1)] - self.depth)
             add_new_y_point = xr.where(option1 <= option0, 1, 0)
 
             spacing = np.abs(np.diff(y_ind)) + np.abs(np.diff(x_ind))
@@ -362,32 +362,46 @@ def calc_cross_contour_flow(self, gridded_u: Coast, gridded_v: Coast):
 
         # Note that subsetting the dataset first instead of subsetting each array seperately,
         # as we do here, is neater but significantly slower.
-        self.data_cross_flow["normal_velocities"] = xr.full_like(u_ds.u_velocity, np.nan)
-        self.data_cross_flow["normal_velocities"][:, :, dr_n] = u_ds.u_velocity.data[:, :, dr_n + 1]
-        self.data_cross_flow["normal_velocities"][:, :, dr_s] = -u_ds.u_velocity.data[:, :, dr_s]
-        self.data_cross_flow["normal_velocities"][:, :, dr_e] = -v_ds.v_velocity.data[:, :, dr_e + 1]
-        self.data_cross_flow["normal_velocities"][:, :, dr_w] = v_ds.v_velocity.data[:, :, dr_w]
+        tmp_velocities = xr.full_like(u_ds.u_velocity, np.nan)
+        tmp_velocities[:, :, dr_n] = u_ds.u_velocity.data[:, :, dr_n + 1]
+        tmp_velocities[:, :, dr_s] = -u_ds.u_velocity.data[:, :, dr_s]
+        tmp_velocities[:, :, dr_e] = -v_ds.v_velocity.data[:, :, dr_e + 1]
+        tmp_velocities[:, :, dr_w] = v_ds.v_velocity.data[:, :, dr_w]
+        self.data_cross_flow["normal_velocities"] = tmp_velocities[:, :, :-1]
         self.data_cross_flow["normal_velocities"].attrs = {"units": "m/s", "standard_name": "contour-normal velocities"}
 
+        # Store the length of the contour segement (calling it e4) on the cross-contour velocity grid
+        tmp_e4 = xr.full_like(u_ds.e1, np.nan)
+        tmp_e4[dr_n] = u_ds.e2.data[dr_n + 1]
+        tmp_e4[dr_s] = u_ds.e2.data[dr_s]
+        tmp_e4[dr_e] = v_ds.e1.data[dr_e + 1]
+        tmp_e4[dr_w] = v_ds.e1.data[dr_w]
+        self.data_cross_flow["e4"] = tmp_e4[:-1]
+        self.data_cross_flow["e4"].attrs = {
+            "units": "m",
+            "standard_name": "length of contour segment at the cross-contour velocity grid points",
+        }
+
         if compute_transports:
-            self.data_cross_flow["normal_transport"] = xr.full_like(u_ds.u_velocity, np.nan)
-            self.data_cross_flow["normal_transport"][:, :, dr_n] = (
+            # calculate the transport across the contour
+            tmp_transport = xr.full_like(u_ds.u_velocity, np.nan)
+            tmp_transport[:, :, dr_n] = (
                 u_ds.u_velocity.data[:, :, dr_n + 1] * u_ds.e2.data[dr_n + 1] * u_ds.e3.data[:, :, dr_n + 1]
             )
-            self.data_cross_flow["normal_transport"][:, :, dr_s] = (
+            tmp_transport[:, :, dr_s] = (
                 -u_ds.u_velocity.data[:, :, dr_s] * u_ds.e2.data[dr_s] * u_ds.e3.data[:, :, dr_s]
             )
-            self.data_cross_flow["normal_transport"][:, :, dr_e] = (
+            tmp_transport[:, :, dr_e] = (
                 -v_ds.v_velocity.data[:, :, dr_e + 1] * v_ds.e1.data[dr_e + 1] * v_ds.e3.data[:, :, dr_e + 1]
             )
-            self.data_cross_flow["normal_transport"][:, :, dr_w] = (
-                v_ds.v_velocity.data[:, :, dr_w] * v_ds.e1.data[dr_w] * v_ds.e3.data[:, :, dr_w]
-            )
+            tmp_transport[:, :, dr_w] = v_ds.v_velocity.data[:, :, dr_w] * v_ds.e1.data[dr_w] * v_ds.e3.data[:, :, dr_w]
+            self.data_cross_flow["normal_transport"] = tmp_transport[:, :, :-1]
             self.data_cross_flow["normal_transport"].attrs = {
                 "units": "m^3/s",
                 "standard_name": "contour-normal volume transport",
             }
 
+            # calculate the depth integrated transport across the contour
             self.data_cross_flow["depth_integrated_normal_transport"] = (
                 self.data_cross_flow.normal_transport.sum(dim="z_dim") / 1000000.0
             )
@@ -397,48 +411,53 @@ def calc_cross_contour_flow(self, gridded_u: Coast, gridded_v: Coast):
             }
 
         self._update_cross_flow_vars("depth_0", u_ds.depth_0, v_ds.depth_0, dr_n, dr_s, dr_e, dr_w, 1)
-        self._update_cross_flow_vars("longitude", u_ds.longitude, v_ds.longitude, dr_n, dr_s, dr_e, dr_w, 0)
-        self._update_cross_flow_vars("latitude", u_ds.latitude, v_ds.latitude, dr_n, dr_s, dr_e, dr_w, 0)
-        self.data_cross_flow["e1"] = xr.full_like(self.data_contour.e1, np.nan)
+        self._update_cross_flow_latlon(u_ds, v_ds, dr_n, dr_s, dr_e, dr_w)
+        self._update_cross_flow_vars("bathymetry", u_ds.bathymetry, v_ds.bathymetry, dr_n, dr_s, dr_e, dr_w, 0)
         self._update_cross_flow_vars("e1", u_ds.e1, v_ds.e1, dr_n, dr_s, dr_e, dr_w, 0)
-        self.data_cross_flow["e2"] = xr.full_like(self.data_contour.e2, np.nan)
         self._update_cross_flow_vars("e2", u_ds.e2, v_ds.e2, dr_n, dr_s, dr_e, dr_w, 0)
-        self.data_cross_flow["e3"] = xr.full_like(self.data_cross_flow.normal_velocities, np.nan)
         if compute_transports:
             self._update_cross_flow_vars("e3", u_ds.e3, v_ds.e3, dr_n, dr_s, dr_e, dr_w, 2)
 
         self.data_cross_flow["depth_0"].attrs = {
             "standard_name": "Depth at time zero on the contour-normal velocity grid points"
         }
-        self.data_cross_flow["latitude"].attrs = {
-            "standard_name": "Latitude at the contour-normal velocity grid points"
-        }
-        self.data_cross_flow["longitude"].attrs = {
-            "standard_name": "Longitude at the contour-normal velocity grid points"
-        }
+
         self.data_cross_flow = self.data_cross_flow.squeeze()
 
     def _update_cross_flow_vars(self, var, u_var, v_var, dr_n, dr_s, dr_e, dr_w, pos):
         """This method will pull variable data at specific points along the contour
-        from the u and v grid datasets and put them into the data_cross_flow dataset"""
+        from the u and v grid datasets and put them into the self.data_cross_flow dataset"""
+        tmp_var = xr.full_like(u_var, np.nan)
         if pos == 0:
-            self.data_cross_flow[var][dr_n] = u_var.data[dr_n + 1]
-            self.data_cross_flow[var][dr_s] = u_var.data[dr_s]
-            self.data_cross_flow[var][dr_e] = v_var.data[dr_e + 1]
-            self.data_cross_flow[var][dr_w] = v_var.data[dr_w]
-            self.data_cross_flow[var][-1] = np.nan
+            tmp_var[dr_n] = u_var.data[dr_n + 1]
+            tmp_var[dr_s] = u_var.data[dr_s]
+            tmp_var[dr_e] = v_var.data[dr_e + 1]
+            tmp_var[dr_w] = v_var.data[dr_w]
+            self.data_cross_flow[var] = tmp_var[:-1]
         elif pos == 1:
-            self.data_cross_flow[var][:, dr_n] = u_var.data[:, dr_n + 1]
-            self.data_cross_flow[var][:, dr_s] = u_var.data[:, dr_s]
-            self.data_cross_flow[var][:, dr_e] = v_var.data[:, dr_e + 1]
-            self.data_cross_flow[var][:, dr_w] = v_var.data[:, dr_w]
-            self.data_cross_flow[var][:, -1] = np.nan
+            tmp_var[:, dr_n] = u_var.data[:, dr_n + 1]
+            tmp_var[:, dr_s] = u_var.data[:, dr_s]
+            tmp_var[:, dr_e] = v_var.data[:, dr_e + 1]
+            tmp_var[:, dr_w] = v_var.data[:, dr_w]
+            self.data_cross_flow[var] = tmp_var[:, :-1]
         elif pos == 2:
-            self.data_cross_flow[var][:, :, dr_n] = u_var.data[:, :, dr_n + 1]
-            self.data_cross_flow[var][:, :, dr_s] = u_var.data[:, :, dr_s]
-            self.data_cross_flow[var][:, :, dr_e] = v_var.data[:, :, dr_e + 1]
-            self.data_cross_flow[var][:, :, dr_w] = v_var.data[:, :, dr_w]
-            self.data_cross_flow[var][:, :, -1] = np.nan
+            tmp_var[:, :, dr_n] = u_var.data[:, :, dr_n + 1]
+            tmp_var[:, :, dr_s] = u_var.data[:, :, dr_s]
+            tmp_var[:, :, dr_e] = v_var.data[:, :, dr_e + 1]
+            tmp_var[:, :, dr_w] = v_var.data[:, :, dr_w]
+            self.data_cross_flow[var] = tmp_var[:, :, :-1]
+
+    def _update_cross_flow_latlon(self, ds_u, ds_v, dr_n, dr_s, dr_e, dr_w):
+        """This method will pull the latitude and longitude data at specific points along the
+        contour from the u and v grid datasets and put them into the self.data_cross_flow dataset"""
+        for var in ["longitude", "latitude"]:
+            tmp_var = xr.full_like(ds_u[var], np.nan)
+            tmp_var[dr_n] = ds_u[var].data[dr_n + 1]
+            tmp_var[dr_s] = ds_u[var].data[dr_s]
+            tmp_var[dr_e] = ds_v[var].data[dr_e + 1]
+            tmp_var[dr_w] = ds_v[var].data[dr_w]
+            tmp_var.attrs = {"standard_name": var.capitalize() + " at the contour-normal velocity grid points"}
+            self.data_cross_flow.assign_coords({var: tmp_var[:-1]})
 
     @staticmethod
     def _pressure_gradient_fpoint2(ds_t, ds_t_j1, ds_t_i1, ds_t_j1i1, r_ind, velocity_component):
@@ -707,26 +726,10 @@ def calc_geostrophic_flow(
         da_x_ind = xr.DataArray(self.x_ind, dims=["r_dim"])
         u_ds = Gridded(fn_domain=self.filename_domain, config=config_u).dataset.isel(y_dim=da_y_ind, x_dim=da_x_ind)
         v_ds = Gridded(fn_domain=self.filename_domain, config=config_v).dataset.isel(y_dim=da_y_ind, x_dim=da_x_ind)
-        self.data_cross_flow["e1"] = xr.full_like(self.data_contour.e1, np.nan)
-        self.data_cross_flow["e2"] = xr.full_like(self.data_contour.e2, np.nan)
-        self.data_cross_flow["latitude"] = xr.full_like(self.data_contour.latitude, np.nan)
-        self.data_cross_flow["longitude"] = xr.full_like(self.data_contour.longitude, np.nan)
-        self._update_cross_flow_vars(
-            "longitude", u_ds.longitude, v_ds.longitude, dr_list[0], dr_list[1], dr_list[2], dr_list[3], 0
-        )
-        self._update_cross_flow_vars(
-            "latitude", u_ds.latitude, v_ds.latitude, dr_list[0], dr_list[1], dr_list[2], dr_list[3], 0
-        )
+
+        self._update_cross_flow_latlon(u_ds, v_ds, dr_list[0], dr_list[1], dr_list[2], dr_list[3])
         self._update_cross_flow_vars("e1", u_ds.e1, v_ds.e1, dr_list[0], dr_list[1], dr_list[2], dr_list[3], 0)
         self._update_cross_flow_vars("e2", u_ds.e2, v_ds.e2, dr_list[0], dr_list[1], dr_list[2], dr_list[3], 0)
-        self.data_cross_flow["latitude"].attrs = {
-            "standard_name": "Latitude at \
-                the contour-normal velocity grid points"
-        }
-        self.data_cross_flow["longitude"].attrs = {
-            "standard_name": "Longitude at \
-                the contour-normal velocity grid points"
-        }
 
         # DataArray attributes
         coords_hpg = {
@@ -760,20 +763,22 @@ def calc_geostrophic_flow(
 
         # Add DataArrays  to dataset
         self.data_cross_flow["normal_velocity_hpg"] = xr.DataArray(
-            np.squeeze(normal_velocity_hpg), coords=coords_hpg, dims=dims_hpg, attrs=attributes_hpg
+            np.squeeze(normal_velocity_hpg[:, :, :-1]), coords=coords_hpg, dims=dims_hpg, attrs=attributes_hpg
         )
         self.data_cross_flow["normal_velocity_spg"] = xr.DataArray(
-            np.squeeze(normal_velocity_spg), coords=coords_spg, dims=dims_spg, attrs=attributes_spg
+            np.squeeze(normal_velocity_spg[:, :-1]), coords=coords_spg, dims=dims_spg, attrs=attributes_spg
         )
         self.data_cross_flow["transport_across_AB_hpg"] = (
-            (self.data_cross_flow.normal_velocity_hpg.fillna(0).integrate(coord="depth_z_levels")) * e_horiz / 1000000
+            (self.data_cross_flow.normal_velocity_hpg.fillna(0).integrate(coord="depth_z_levels"))
+            * e_horiz[:, :-1]
+            / 1000000
         )
         self.data_cross_flow.transport_across_AB_hpg.attrs = {
             "units": "Sv",
             "standard_name": "volume transport across transect due to the hydrostatic pressure gradient",
         }
         self.data_cross_flow["transport_across_AB_spg"] = (
-            self.data_cross_flow.normal_velocity_spg * h * e_horiz / 1000000
+            self.data_cross_flow.normal_velocity_spg * h[:-1] * e_horiz[:, :-1] / 1000000
         )
         self.data_cross_flow.transport_across_AB_spg.attrs = {
             "units": "Sv",
@@ -803,6 +808,7 @@ class ContourT(Contour):
 
     def __init__(self, gridded_t: Coast, y_ind, x_ind, depth):
         super().__init__(gridded_t, y_ind, x_ind, depth)
+        self.data_along_flow = xr.Dataset()
 
     def construct_pressure(self, ref_density=None, z_levels=None, extrapolate=False):
         """
@@ -897,10 +903,14 @@ def construct_pressure(self, ref_density=None, z_levels=None, extrapolate=False)
             z_levels = z_levels[:active_z_levels]
 
         # Absolute Pressure (depth must be negative)
-        pressure_absolute = np.ma.masked_invalid(gsw.p_from_z(-z_levels[:, np.newaxis], self.data_contour.latitude))
+        pressure_absolute = np.ma.masked_invalid(
+            gsw.p_from_z(-z_levels[:, np.newaxis], self.data_contour.latitude.values)
+        )
         # Absolute Salinity
         salinity_absolute = np.ma.masked_invalid(
-            gsw.SA_from_SP(salinity_z, pressure_absolute, self.data_contour.longitude, self.data_contour.latitude)
+            gsw.SA_from_SP(
+                salinity_z, pressure_absolute, self.data_contour.longitude.values, self.data_contour.latitude.values
+            )
         )
         salinity_absolute = np.ma.masked_less(salinity_absolute, 0)
         # Conservative Temperature
@@ -942,3 +952,231 @@ def construct_pressure(self, ref_density=None, z_levels=None, extrapolate=False)
             "units": "kg m^{-1} s^{-2}",
             "standard_name": "Surface perturbation pressure",
         }
+
+    def calc_along_contour_flow(self, gridded_u: Coast, gridded_v: Coast):
+        """
+        Function that will calculate the flow along the contour and store this data
+        within Contour_t.data_along_flow, which is an xarray.Dataset. Specifically
+        Contour_t.data_along_flow.velocities are the velocities along the contour with dimensions
+        (t_dim, z_dim, r_dim), where r_dim is the dimension along the contour.
+        Contour_t.data_along_flow.transport are the velocities along the contour multiplied by the
+        thickness of the cell (velocity * e3) with dimensions
+        (t_dim, z_dim, r_dim).
+
+        If the time dependent cell thicknesses (e3) on the u and v grids are
+        present in the gridded_u and gridded_v datasets they will be used, if they
+        are not then the initial cell thicknesses (e3_0) will be used.
+
+        Parameters
+        ----------
+        gridded_u : Coast
+            The nemo object containing the model data on the u-grid.
+        gridded_v : Coast
+            The nemo object containing the model data on the v-grid.
+
+        Returns
+        -------
+        None.
+
+        """
+        # compute transports flag; set to false if suitable e3 not found
+        compute_transports = True
+
+        # subset the u and v datasets
+        da_y_ind = xr.DataArray(self.y_ind, dims=["r_dim"])
+        da_x_ind = xr.DataArray(self.x_ind, dims=["r_dim"])
+        u_ds = gridded_u.dataset.isel(y_dim=da_y_ind, x_dim=da_x_ind)
+        v_ds = gridded_v.dataset.isel(y_dim=da_y_ind, x_dim=da_x_ind)
+
+        # use time varying if e3 is present, if not default to e3_0
+        if "e3" not in u_ds.data_vars:
+            if "e3_0" not in u_ds.data_vars:
+                warn("e3 not found, transports will not be calculated")
+                compute_transports = False
+            else:
+                u_ds["e3"] = u_ds.e3_0.broadcast_like(u_ds.u_velocity)
+        if "e3" not in v_ds.data_vars:
+            if "e3_0" not in v_ds.data_vars:
+                warn("e3 not found, transports will not be calculated")
+                compute_transports = False
+            else:
+                v_ds["e3"] = v_ds.e3_0.broadcast_like(v_ds.v_velocity)
+
+        # If time dimension is missing it can throw off the indexing so expand dims
+        if "t_dim" not in u_ds.dims:
+            u_ds["u_velocity"] = u_ds.u_velocity.expand_dims("t_dim", axis=0)
+            if compute_transports:
+                u_ds["e3"] = u_ds.e3.expand_dims("t_dim", axis=0)
+        if "t_dim" not in v_ds.dims:
+            v_ds["v_velocity"] = v_ds.v_velocity.expand_dims("t_dim", axis=0)
+            if compute_transports:
+                v_ds["e3"] = v_ds.e3.expand_dims("t_dim", axis=0)
+
+        dr_n = np.where(np.diff(self.y_ind) > 0, np.arange(0, u_ds.r_dim.size - 1), np.nan)
+        dr_n = dr_n[~np.isnan(dr_n)].astype(int)
+        dr_s = np.where(np.diff(self.y_ind) < 0, np.arange(0, u_ds.r_dim.size - 1), np.nan)
+        dr_s = dr_s[~np.isnan(dr_s)].astype(int)
+        dr_e = np.where(np.diff(self.x_ind) > 0, np.arange(0, v_ds.r_dim.size - 1), np.nan)
+        dr_e = dr_e[~np.isnan(dr_e)].astype(int)
+        dr_w = np.where(np.diff(self.x_ind) < 0, np.arange(0, v_ds.r_dim.size - 1), np.nan)
+        dr_w = dr_w[~np.isnan(dr_w)].astype(int)
+
+        tmp_velocities = xr.full_like(u_ds.u_velocity, np.nan)
+        tmp_velocities[:, :, dr_n] = v_ds.v_velocity.data[:, :, dr_n]
+        tmp_velocities[:, :, dr_s] = -v_ds.v_velocity.data[:, :, dr_s + 1]
+        tmp_velocities[:, :, dr_e] = u_ds.u_velocity.data[:, :, dr_e]
+        tmp_velocities[:, :, dr_w] = -u_ds.u_velocity.data[:, :, dr_w + 1]
+        self.data_along_flow["velocities"] = tmp_velocities[:, :, :-1]
+        self.data_along_flow["velocities"].attrs = {"units": "m/s", "standard_name": "contour-tangent velocities"}
+
+        # Store the length of contour segement between t-points
+        tmp_e4 = xr.full_like(u_ds.e1, np.nan)
+        tmp_e4[dr_n] = v_ds.e2.data[dr_n]
+        tmp_e4[dr_s] = v_ds.e2.data[dr_s + 1]
+        tmp_e4[dr_e] = u_ds.e1.data[dr_e]
+        tmp_e4[dr_w] = u_ds.e1.data[dr_w + 1]
+        self.data_along_flow["e4"] = tmp_e4[:-1]
+        self.data_along_flow["e4"].attrs = {
+            "units": "m",
+            "standard_name": "length of contour segment at the along-contour velocity grid points",
+        }
+
+        if compute_transports:
+            tmp_transport = xr.full_like(u_ds.u_velocity, np.nan)
+            tmp_transport[:, :, dr_n] = v_ds.v_velocity.data[:, :, dr_n] * v_ds.e3.data[:, :, dr_n]
+            tmp_transport[:, :, dr_s] = -v_ds.v_velocity.data[:, :, dr_s + 1] * v_ds.e3.data[:, :, dr_s + 1]
+            tmp_transport[:, :, dr_e] = u_ds.u_velocity.data[:, :, dr_e] * u_ds.e3.data[:, :, dr_e]
+            tmp_transport[:, :, dr_w] = -u_ds.u_velocity.data[:, :, dr_w + 1] * u_ds.e3.data[:, :, dr_w + 1]
+            self.data_along_flow["transport"] = tmp_transport[:, :, :-1]
+            self.data_along_flow["transport"].attrs = {
+                "units": "m^2/s",
+                "standard_name": "along-contour transport (v * e3)",
+            }
+
+        self._update_flow_vars("depth_0", u_ds.depth_0, v_ds.depth_0, dr_n, dr_s, dr_e, dr_w, 1)
+        self._update_along_flow_latlon(u_ds, v_ds, dr_n, dr_s, dr_e, dr_w)
+        self._update_flow_vars("bathymetry", u_ds.bathymetry, v_ds.bathymetry, dr_n, dr_s, dr_e, dr_w, 0)
+        self._update_flow_vars("e1", u_ds.e1, v_ds.e1, dr_n, dr_s, dr_e, dr_w, 0)
+        self._update_flow_vars("e2", u_ds.e2, v_ds.e2, dr_n, dr_s, dr_e, dr_w, 0)
+        if compute_transports:
+            self._update_flow_vars("e3", u_ds.e3, v_ds.e3, dr_n, dr_s, dr_e, dr_w, 2)
+
+        self.data_along_flow["depth_0"].attrs = {
+            "standard_name": "Depth at time zero on the along contour velocity grid points"
+        }
+
+        self.data_along_flow = self.data_along_flow.squeeze()
+
+    def calc_along_contour_flow_2d(self, gridded_u: Coast, gridded_v: Coast):
+        """
+        Function that will calculate the 2d flow (no vertical dimension
+        along the contour and store this data within Contour_t.data_along_flow,
+        which is an xarray.Dataset. Contour_t.data_along_flow.velocities are
+        the velocities along the contour with dimensions (t_dim, r_dim),
+        where r_dim is the dimension along the contour. e3 and
+        e3_0 are interpreted to be the water column thicknesses and
+        are included in the dataset as Contour_t.data_along_flow.e3 and
+        Contour_t.data_along_flow.e3_0
+
+        Parameters
+        ----------
+        gridded_u : Coast
+            The nemo object containing the model data on the u-grid.
+        gridded_v : Coast
+            The nemo object containing the model data on the v-grid.
+
+        Returns
+        -------
+        None.
+
+        """
+
+        # subset the u and v datasets
+        da_y_ind = xr.DataArray(self.y_ind, dims=["r_dim"])
+        da_x_ind = xr.DataArray(self.x_ind, dims=["r_dim"])
+        u_ds = gridded_u.dataset.isel(y_dim=da_y_ind, x_dim=da_x_ind)
+        v_ds = gridded_v.dataset.isel(y_dim=da_y_ind, x_dim=da_x_ind)
+
+        # If time dimension is missing it can throw off the indexing so expand dims
+        if "t_dim" not in u_ds.dims:
+            u_ds["u_velocity"] = u_ds.u_velocity.expand_dims("t_dim", axis=0)
+        if "t_dim" not in v_ds.dims:
+            v_ds["v_velocity"] = v_ds.v_velocity.expand_dims("t_dim", axis=0)
+
+        dr_n = np.where(np.diff(self.y_ind) > 0, np.arange(0, u_ds.r_dim.size - 1), np.nan)
+        dr_n = dr_n[~np.isnan(dr_n)].astype(int)
+        dr_s = np.where(np.diff(self.y_ind) < 0, np.arange(0, u_ds.r_dim.size - 1), np.nan)
+        dr_s = dr_s[~np.isnan(dr_s)].astype(int)
+        dr_e = np.where(np.diff(self.x_ind) > 0, np.arange(0, v_ds.r_dim.size - 1), np.nan)
+        dr_e = dr_e[~np.isnan(dr_e)].astype(int)
+        dr_w = np.where(np.diff(self.x_ind) < 0, np.arange(0, v_ds.r_dim.size - 1), np.nan)
+        dr_w = dr_w[~np.isnan(dr_w)].astype(int)
+
+        # Note that subsetting the dataset first instead of subsetting each array seperately,
+        # as we do here, is neater but significantly slower.
+        tmp_velocities = xr.full_like(u_ds.u_velocity, np.nan)
+        tmp_velocities[:, dr_n] = v_ds.v_velocity.data[:, dr_n]
+        tmp_velocities[:, dr_s] = -v_ds.v_velocity.data[:, dr_s + 1]
+        tmp_velocities[:, dr_e] = u_ds.u_velocity.data[:, dr_e]
+        tmp_velocities[:, dr_w] = -u_ds.u_velocity.data[:, dr_w + 1]
+        self.data_along_flow["velocities"] = tmp_velocities[:, :-1]
+        self.data_along_flow["velocities"].attrs = {"units": "m/s", "standard_name": "along-contour velocities"}
+
+        # Store the length of contour segement between t-points
+        tmp_e4 = xr.full_like(u_ds.e1, np.nan)
+        tmp_e4[dr_n] = v_ds.e2.data[dr_n]
+        tmp_e4[dr_s] = v_ds.e2.data[dr_s + 1]
+        tmp_e4[dr_e] = u_ds.e1.data[dr_e]
+        tmp_e4[dr_w] = u_ds.e1.data[dr_w + 1]
+        self.data_along_flow["e4"] = tmp_e4[:-1]
+        self.data_along_flow["e4"].attrs = {
+            "units": "m",
+            "standard_name": "length of contour segment at the along-contour velocity grid points",
+        }
+
+        self._update_along_flow_latlon(u_ds, v_ds, dr_n, dr_s, dr_e, dr_w)
+        self._update_flow_vars("bathymetry", u_ds.bathymetry, v_ds.bathymetry, dr_n, dr_s, dr_e, dr_w, 0)
+        self._update_flow_vars("e1", u_ds.e1, v_ds.e1, dr_n, dr_s, dr_e, dr_w, 0)
+        self._update_flow_vars("e2", u_ds.e2, v_ds.e2, dr_n, dr_s, dr_e, dr_w, 0)
+
+        if ("e3" in u_ds.data_vars) and ("e3" in v_ds.data_vars):
+            self._update_flow_vars("e3", u_ds.e3, v_ds.e3, dr_n, dr_s, dr_e, dr_w, 1)
+        if ("e3_0" in u_ds.data_vars) and ("e3_0" in v_ds.data_vars):
+            self._update_flow_vars("e3_0", u_ds.e3_0, v_ds.e3_0, dr_n, dr_s, dr_e, dr_w, 0)
+
+        self.data_along_flow = self.data_along_flow.squeeze()
+
+    def _update_flow_vars(self, var, u_var, v_var, dr_n, dr_s, dr_e, dr_w, pos):
+        """This method will pull variable data at specific points along the contour
+        from the u and v grid datasets and put them into the self.data_along_flow dataset"""
+        tmp_var = xr.full_like(u_var, np.nan)
+        if pos == 0:
+            tmp_var[dr_n] = v_var.data[dr_n]
+            tmp_var[dr_s] = v_var.data[dr_s + 1]
+            tmp_var[dr_e] = u_var.data[dr_e]
+            tmp_var[dr_w] = u_var.data[dr_w + 1]
+            self.data_along_flow[var] = tmp_var[:-1]
+        elif pos == 1:
+            tmp_var[:, dr_n] = v_var.data[:, dr_n]
+            tmp_var[:, dr_s] = v_var.data[:, dr_s + 1]
+            tmp_var[:, dr_e] = u_var.data[:, dr_e]
+            tmp_var[:, dr_w] = u_var.data[:, dr_w + 1]
+            self.data_along_flow[var] = tmp_var[:, :-1]
+        elif pos == 2:
+            tmp_var[:, :, dr_n] = v_var.data[:, :, dr_n]
+            tmp_var[:, :, dr_s] = v_var.data[:, :, dr_s + 1]
+            tmp_var[:, :, dr_e] = u_var.data[:, :, dr_e]
+            tmp_var[:, :, dr_w] = u_var.data[:, :, dr_w + 1]
+            self.data_along_flow[var] = tmp_var[:, :, :-1]
+
+    def _update_along_flow_latlon(self, ds_u, ds_v, dr_n, dr_s, dr_e, dr_w):
+        """This method will pull latitude and longitude data at specific points along the
+        contour from the u and v grid datasets and put them into the self.data_along_flow dataset"""
+        for var in ["longitude", "latitude"]:
+            tmp_var = xr.full_like(ds_u[var], np.nan)
+            tmp_var[dr_n] = ds_u[var].data[dr_n]
+            tmp_var[dr_s] = ds_u[var].data[dr_s + 1]
+            tmp_var[dr_e] = ds_v[var].data[dr_e]
+            tmp_var[dr_w] = ds_v[var].data[dr_w + 1]
+            tmp_var.attrs = {"standard_name": var.capitalize() + " at the along-contour velocity grid points"}
+            self.data_along_flow.assign_coords({var: tmp_var[:-1]})
diff --git a/coast/crps_util.py b/coast/crps_util.py
index 3f690922..5439ee05 100755
--- a/coast/crps_util.py
+++ b/coast/crps_util.py
@@ -30,7 +30,7 @@ def crps_empirical(sample, obs):
     """
 
     def calc(alpha, beta, p):  # TODO It would be better to define this outside of the function
-        return alpha * p ** 2 + beta * (1 - p) ** 2  # TODO Could this be a lambda?
+        return alpha * p**2 + beta * (1 - p) ** 2  # TODO Could this be a lambda?
 
     xa = float(obs)
     crps_integral = 0
@@ -61,7 +61,7 @@ def calc(alpha, beta, p):  # TODO It would be better to define this outside of t
     beta[tmp_logic] = tmp[tmp_logic]
 
     p = np.arange(1, sample_size) / sample_size
-    c = alpha * p ** 2 + beta * (1 - p) ** 2
+    c = alpha * p**2 + beta * (1 - p) ** 2
     crps_integral = np.sum(c)
 
     # Intervals 0 and N, where p = 0 and 1 respectively
@@ -90,7 +90,7 @@ def crps_empirical_loop(sample, obs):
     """
 
     def calc(alpha, beta, p):  # TODO It would be better to define this outside of the function
-        return alpha * p ** 2 + beta * (1 - p) ** 2  # TODO Could this be a lambda?
+        return alpha * p**2 + beta * (1 - p) ** 2  # TODO Could this be a lambda?
 
     crps_integral = 0
     sample = np.array(sample)
diff --git a/coast/docsy_tools.py b/coast/docsy_tools.py
new file mode 100644
index 00000000..788432c5
--- /dev/null
+++ b/coast/docsy_tools.py
@@ -0,0 +1,71 @@
+class DocsyTools:  # TODO All abstract methods should be implemented
+    """ """
+
+    def __init__(self):
+        return  # TODO Super __init__ should be called at some point
+
+    @classmethod
+    def write_class_to_markdown(
+        cls, class_to_write, fn_out, method_to_omit=[], omit_private_methods=True, omit_parent_methods=True
+    ):
+
+        methods_to_write = cls._get_list_of_methods(class_to_write)
+
+        for method in methods_to_write:
+            method_block = cls._method_to_str(getattr(class_to_write, method))
+
+    @classmethod
+    def _method_to_str(cls, method_name):
+        pass
+
+    @classmethod
+    def _get_list_of_methods(
+        cls, class_to_search, methods_to_omit=[], omit_private_methods=True, omit_parent_methods=True
+    ):
+        """
+        Returns a list of methods inside a provided COAsT class, with some
+        other options
+
+        Parameters
+        ----------
+        class_to_search : imported class
+            Class imported from COAsT (e.g. from coast import Profile)
+        methods_to_omit : list
+            List of method strings to omit from the output. The default is [].
+        omit_private_methods : bool, optional
+            If true, omit methods beginning with "_". The default is True.
+        omit_parent_methods : bool, optional
+           If true, omit methods in any parent/ancestor class. The default is True.
+
+        Returns
+        -------
+        methods_to_write : list
+            List of strings denoting method names
+
+        """
+
+        # Get list of methods
+        methods_to_write = dir(class_to_search)
+
+        if omit_parent_methods:
+            # Get parent class and methods (if user wants)
+            parents = class_to_search.__mro__[1:-1]
+            parent_methods = []
+
+            # Get (non-unique) parent methods
+            for pp in parents:
+                methods_pp = dir(pp)
+                [parent_methods.append(mm) for mm in methods_pp]
+
+            # Remove classes from methods list if they are the same name
+            for method in parent_methods:
+                if method in methods_to_write:
+                    methods_to_write.remove(method)
+
+        # Look for methods beginning with "_" and remove them (if true)
+        if omit_private_methods:
+            for method in methods_to_write:
+                if method[0] == "_":
+                    methods_to_write.remove(method)
+
+        return methods_to_write
diff --git a/coast/eof.py b/coast/eof.py
index 5f6b58d1..d545ebcb 100755
--- a/coast/eof.py
+++ b/coast/eof.py
@@ -201,13 +201,13 @@ def _compute(signal, full_matrices, active_ind, number_points):
 
     # Calculate variance explained
     if full_matrices:
-        variance_explained = 100.0 * (d ** 2 / np.sum(d ** 2))
+        variance_explained = 100.0 * (d**2 / np.sum(d**2))
     else:
         Inv = p.dot(np.dot(np.diag(d), q))  # PDQ
         var1 = np.sum(np.var(Inv, axis=1, ddof=1))
         var2 = np.sum(np.var(signal, axis=1, ddof=1))
         mult = var1 / var2
-        variance_explained = 100.0 * mult * (d ** 2 / np.sum(d ** 2))
+        variance_explained = 100.0 * mult * (d**2 / np.sum(d**2))
 
     # Extract EOF projections
     projections = np.transpose(q) * d
diff --git a/coast/general_utils.py b/coast/general_utils.py
index 9a20f5ca..7b40e974 100755
--- a/coast/general_utils.py
+++ b/coast/general_utils.py
@@ -1,6 +1,31 @@
 import xarray as xr
 import numpy as np
 import sklearn.neighbors as nb
+import pandas as pd
+
+
+def determine_season(t):
+    """
+    Determine season (or array of seasons) from a time (Datetime or xarray)
+    object. Put in an array of times, get out an array of seasons.
+    """
+    season_dict = {
+        12: "DJF",
+        1: "DJF",
+        2: "DJF",
+        3: "MAM",
+        4: "MAM",
+        5: "MAM",
+        6: "JJA",
+        7: "JJA",
+        8: "JJA",
+        9: "SON",
+        10: "SON",
+        11: "SON",
+    }
+    pd_month = pd.to_datetime(t).month
+    pd_season = [season_dict[pp] for pp in pd_month]
+    return np.array(pd_season)
 
 
 def subset_indices_by_distance_balltree(longitude, latitude, centre_lon, centre_lat, radius: float, mask=None):
@@ -126,7 +151,7 @@ def cartesian_to_polar(x, y, degrees=True):
     # Conversion of cartesian to polar coordinate system
     # Output theta is in radians
     """
-    r = np.sqrt(x ** 2 + y ** 2)
+    r = np.sqrt(x**2 + y**2)
     theta = np.arctan2(y, x)
     if degrees:
         theta = np.rad2deg(theta)
@@ -165,18 +190,18 @@ def calculate_haversine_distance(lon1, lat1, lon2, lat2):
     """
 
     # Convert to radians for calculations
-    lon1 = xr.ufuncs.deg2rad(lon1)
-    lat1 = xr.ufuncs.deg2rad(lat1)
-    lon2 = xr.ufuncs.deg2rad(lon2)
-    lat2 = xr.ufuncs.deg2rad(lat2)
+    lon1 = np.deg2rad(lon1)
+    lat1 = np.deg2rad(lat1)
+    lon2 = np.deg2rad(lon2)
+    lat2 = np.deg2rad(lat2)
 
     # Latitude and longitude differences
     dlat = (lat2 - lat1) / 2
     dlon = (lon2 - lon1) / 2
 
     # Haversine function.
-    distance = xr.ufuncs.sin(dlat) ** 2 + xr.ufuncs.cos(lat1) * xr.ufuncs.cos(lat2) * xr.ufuncs.sin(dlon) ** 2
-    distance = 2 * 6371.007176 * xr.ufuncs.arcsin(xr.ufuncs.sqrt(distance))
+    distance = np.sin(dlat) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon) ** 2
+    distance = 2 * 6371.007176 * np.arcsin(np.sqrt(distance))
 
     return distance
 
diff --git a/coast/gridded.py b/coast/gridded.py
index e95bae37..6b9f86bb 100644
--- a/coast/gridded.py
+++ b/coast/gridded.py
@@ -11,6 +11,8 @@
 from .coast import Coast
 from .config_parser import ConfigParser
 from .logging_util import get_slug, debug, info, warn, error, warning
+from . import logging_util
+import logging
 import pandas as pd
 
 
@@ -269,7 +271,7 @@ def find_j_i(self, lat: float, lon: float):
         :return: the y and x coordinates for the NEMO object's grid_ref, i.e. t,u,v,f,w.
         """
         debug(f"Finding j,i for {lat},{lon} from {get_slug(self)}")
-        dist2 = xr.ufuncs.square(self.dataset.latitude - lat) + xr.ufuncs.square(self.dataset.longitude - lon)
+        dist2 = np.square(self.dataset.latitude - lat) + np.square(self.dataset.longitude - lon)
         [y, x] = np.unravel_index(dist2.argmin(), dist2.shape)
         return [y, x]
 
@@ -287,7 +289,7 @@ def find_j_i_domain(self, lat: float, lon: float, dataset_domain: xr.DataArray):
         debug(f"Finding j,i domain for {lat},{lon} from {get_slug(self)} using {get_slug(dataset_domain)}")
         internal_lat = dataset_domain[self.grid_vars[1]]  # [f"gphi{self.grid_ref.replace('-grid','')}"]
         internal_lon = dataset_domain[self.grid_vars[0]]  # [f"glam{self.grid_ref.replace('-grid','')}"]
-        dist2 = xr.ufuncs.square(internal_lat - lat) + xr.ufuncs.square(internal_lon - lon)
+        dist2 = np.square(internal_lat - lat) + np.square(internal_lon - lon)
         [_, y, x] = np.unravel_index(dist2.argmin(), dist2.shape)
         return [y, x]
 
@@ -889,3 +891,14 @@ def harmonics_convert(
             print("Unknown direction setting. Choose cart2polar or polar2cart")
 
         return
+
+    def time_slice(self, date0, date1):
+        """Return new Gridded object, indexed between dates date0 and date1"""
+        dataset = self.dataset
+        t_ind = pd.to_datetime(dataset.time.values) >= date0
+        dataset = dataset.isel(t_dim=t_ind)
+        t_ind = pd.to_datetime(dataset.time.values) < date1
+        dataset = dataset.isel(t_dim=t_ind)
+        gridded_out = Gridded()
+        gridded_out.dataset = dataset
+        return gridded_out
diff --git a/coast/index.py b/coast/index.py
index a95b6a20..e30b3d86 100644
--- a/coast/index.py
+++ b/coast/index.py
@@ -30,6 +30,7 @@ def __init__(self, config: Union[Path, str] = None):
         self.keep_all_vars = False
 
         if config:
+            print(config)
             self.json_config = ConfigParser(config)
             self.chunks = self.json_config.config.chunks
             self.dim_mapping = self.json_config.config.dataset.dimension_map
@@ -62,3 +63,9 @@ def apply_config_mappings(self) -> None:
 
         if self.coord_vars is not None:
             self.dataset = self.dataset.set_coords(self.coord_vars)
+
+    def insert_dataset(self, dataset, apply_config_mappings=False):
+        """Insert a dataset straight into this object instance"""
+        self.dataset = dataset
+        if apply_config_mappings:
+            self.apply_config_mappings()
diff --git a/coast/internal_tide.py b/coast/internal_tide.py
old mode 100644
new mode 100755
index 185354a5..e7f00211
--- a/coast/internal_tide.py
+++ b/coast/internal_tide.py
@@ -111,6 +111,7 @@ def construct_pycnocline_vars(self, gridded_t: xr.Dataset, gridded_w: xr.Dataset
         IT.quickplot()
 
         """
+
         debug(f"Constructing pycnocline variables for {get_slug(self)}")
         # Construct in-situ density if not already done
         if not hasattr(gridded_t.dataset, "density"):
@@ -137,7 +138,7 @@ def construct_pycnocline_vars(self, gridded_t: xr.Dataset, gridded_w: xr.Dataset
         # Ensure bed value is 0
         strat[:, -1, :, :] = 0
         # mask out the Nan values
-        strat = strat.where(~xr.ufuncs.isnan(gridded_w.dataset.rho_dz), drop=False)
+        strat = strat.where(~np.isnan(gridded_w.dataset.rho_dz), drop=False)
         # create mask with a stratification threshold
         strat_m = gridded_w.dataset.latitude * 0 + 1  # create a stratification mask: [1/0] = strat/un-strat
         strat_m = strat_m.where(strat.min(dim="z_dim").squeeze() < strat_thres, 0, drop=False)
@@ -166,10 +167,10 @@ def construct_pycnocline_vars(self, gridded_t: xr.Dataset, gridded_w: xr.Dataset
         pycnocline_depth = intzN2 / intN2  # pycnocline depth
 
         # compute pycnocline thickness
-        intz2N2 = (xr.ufuncs.square(depth_0_4d - pycnocline_depth) * e3_0_4d * strat).sum(
+        intz2N2 = (np.square(depth_0_4d - pycnocline_depth) * e3_0_4d * strat).sum(
             dim="z_dim", skipna=True
         )  # TODO Can someone sciencey give me the proper name for this?
-        zt = xr.ufuncs.sqrt(intz2N2 / intN2)  # pycnocline thickness
+        zt = np.sqrt(intz2N2 / intN2)  # pycnocline thickness
 
         # Define xarray attributes
         coords = {
diff --git a/coast/mask_maker.py b/coast/mask_maker.py
index ad58da8e..dc3a4b51 100644
--- a/coast/mask_maker.py
+++ b/coast/mask_maker.py
@@ -98,7 +98,7 @@ def region_def_nws_north_sea(cls, longitude, latitude, bath):
         vertices_lat = [56.93, 54.09, 54.09, 56, 56, 57.859, 57.859, 58.121, 58.121, 58.59, 58.59, 60.5, 60.5]
 
         mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
-        mask = mask * (bath < 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
+        mask = mask * (bath < 200) * (bath > 0) * (~np.isnan(bath))
         return mask
 
     @classmethod
@@ -111,7 +111,7 @@ def region_def_nws_outer_shelf(cls, longitude, latitude, bath):
         vertices_lon = [-4, -9.5, -1, 3.171, 3.171, -3.76, -3.76, -12, -12, -12, -4]
         vertices_lat = [50.5, 52.71, 60.5, 60.45, 63.3, 63.3, 60.45, 60.45, 55.28, 48, 48]
         mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
-        mask = mask * (bath < 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
+        mask = mask * (bath < 200) * (bath > 0) * (~np.isnan(bath))
         return mask
 
     @classmethod
@@ -124,7 +124,7 @@ def region_def_nws_norwegian_trench(cls, longitude, latitude, bath):
         vertices_lon = [10.65, 1.12, 1.12, 10.65]
         vertices_lat = [61.83, 61.83, 48, 48]
         mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
-        mask = mask * (bath > 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
+        mask = mask * (bath > 200) * (bath > 0) * (~np.isnan(bath))
         return mask
 
     @classmethod
@@ -137,5 +137,37 @@ def region_def_nws_english_channel(cls, longitude, latitude, bath):
         vertices_lon = [7.57, 7.57, -0.67, -2, -3.99, -3.99, -3.5, 12, 14]
         vertices_lat = [56, 54.08, 54.08, 50.7, 50.7, 48.8, 48, 48, 56]
         mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
+        mask = mask * (bath < 200) * (bath > 0) * (~np.isnan(bath))
+        return mask
+
+    @classmethod
+    def region_def_south_north_sea(cls, longitude, latitude, bath):
+        vertices_lon = [-0.67, -0.67, 9, 9, 7.57, 7.57]
+        vertices_lat = [54.08, 51, 51, 56, 56, 54.08]
+        mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
+        mask = mask * (bath < 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
+        return mask
+
+    @classmethod
+    def region_def_off_shelf(cls, longitude, latitude, bath):
+        vertices_lon = [10, 10, -5, -10, 0, 0, -20, -20]
+        vertices_lat = [65, 60, 59, 52.5, 47.5, 45, 40, 63]
+        mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
+        mask = mask * (bath > 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
+        return mask
+
+    @classmethod
+    def region_def_irish_sea(cls, longitude, latitude, bath):
+        vertices_lon = [-5, -7.6, -7.5, -4.1, 0, -2.6]
+        vertices_lat = [56.4, 55, 52, 50.7, 51.5, 55.3]
+        mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
+        mask = mask * (bath < 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
+        return mask
+
+    @classmethod
+    def region_def_kattegat(cls, longitude, latitude, bath):
+        vertices_lon = [9, 9, 13, 13]
+        vertices_lat = [60, 52.5, 52.5, 60]
+        mask = cls.fill_polygon_by_lonlat(np.zeros(longitude.shape), longitude, latitude, vertices_lon, vertices_lat)
         mask = mask * (bath < 200) * (bath > 0) * (~xr.ufuncs.isnan(bath))
         return mask
diff --git a/coast/plot_util.py b/coast/plot_util.py
index b192a3da..53f616bf 100755
--- a/coast/plot_util.py
+++ b/coast/plot_util.py
@@ -100,6 +100,80 @@ def scatter_with_fit(x, y, s=10, c="k", yex=True, dofit=True):
     return fig, ax
 
 
+def create_geo_subplots(lonbounds, latbounds, n_r=1, n_c=1, figsize=(7, 7)):
+    """
+    A routine for creating an axis for any geographical plot. Within the
+    specified longitude and latitude bounds, a map will be drawn up using
+    cartopy. Any type of matplotlib plot can then be added to this figure.
+    For example:
+    Example Useage
+    #############
+        f,a = create_geo_axes(lonbounds, latbounds)
+        sca = a.scatter(stats.longitude, stats.latitude, c=stats.corr,
+                        vmin=.75, vmax=1,
+                        edgecolors='k', linewidths=.5, zorder=100)
+        f.colorbar(sca)
+        a.set_title('SSH correlations \n Monthly PSMSL tide gauge vs CO9_AMM15p0',
+                    fontsize=9)
+    * Note: For scatter plots, it is useful to set zorder = 100 (or similar
+            positive number)
+    """
+
+    import cartopy.crs as ccrs  # mapping plots
+    from cartopy.feature import NaturalEarthFeature
+
+    # If no figure or ax is provided, create a new one
+    # fig = plt.figure()
+    # fig.clf()
+    fig, ax = plt.subplots(
+        n_r, n_c, subplot_kw={"projection": ccrs.PlateCarree()}, sharey=True, sharex=True, figsize=figsize
+    )
+    land_color = [0.9, 0.9, 0.9]
+    coast_color = [0, 0, 0]
+    coast_width = 0.25
+
+    if n_r * n_c > 1:
+        ax = ax.flatten()
+        for rr in range(n_r * n_c):
+            coast = NaturalEarthFeature(category="physical", facecolor=land_color, name="coastline", scale="50m")
+            ax[rr].add_feature(coast, edgecolor=coast_color, linewidth=coast_width)
+            # ax.coastlines(facecolor=[0.8,0.8,0.8])
+            gl = ax[rr].gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=0.5, color="gray", linestyle="-")
+            gl.top_labels = False
+            gl.right_labels = False
+            if rr % n_c == 0:
+                gl.left_labels = True
+            else:
+                gl.left_labels = False
+
+            if np.abs(n_r * n_c - rr) <= n_c:
+                gl.bottom_labels = True
+            else:
+                gl.bottom_labels = False
+            ax[rr].set_xlim(lonbounds[0], lonbounds[1])
+            ax[rr].set_ylim(latbounds[0], latbounds[1])
+            ax[rr].set_aspect("auto")
+
+        ax = ax.reshape((n_r, n_c))
+    else:
+        coast = NaturalEarthFeature(category="physical", facecolor=land_color, name="coastline", scale="50m")
+        ax.add_feature(coast, edgecolor=coast_color, linewidth=coast_width)
+        # ax.coastlines(facecolor=[0.8,0.8,0.8])
+        gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=0.5, color="gray", linestyle="-")
+        gl.top_labels = False
+        gl.right_labels = False
+
+        gl.left_labels = True
+        gl.bottom_labels = True
+
+        ax.set_xlim(lonbounds[0], lonbounds[1])
+        ax.set_ylim(latbounds[0], latbounds[1])
+        ax.set_aspect("auto")
+
+    plt.show()
+    return fig, ax
+
+
 def create_geo_axes(lonbounds, latbounds):
     """
     A routine for creating an axis for any geographical plot. Within the
diff --git a/coast/process_data.py b/coast/process_data.py
new file mode 100644
index 00000000..7f755025
--- /dev/null
+++ b/coast/process_data.py
@@ -0,0 +1,120 @@
+from .gridded import Gridded
+from .logging_util import info, warn, error
+import dask.array as da
+from dask import delayed
+import xarray as xr
+import numpy as np
+
+
+class Process_data:  # TODO All abstract methods should be implemented
+    """
+    A Python class containing methods for processing time series.
+    """
+
+    def __init__(self):
+        return  # TODO Super __init__ should be called at some point
+
+    def seasonal_decomp(self, ts_chunk, **kwargs):
+        """
+        Note this is intended as an inner functions to be called by a wrapper function
+        Process_data.seasonal_decomposition
+
+        This function is itself a wrapper function for statsmodel.seasonal_decompose
+        that accepts multiple timeseries as multiple columns (s_dim)
+        as an xr.DataArray: ts_chunk = ts_chunk(t_dim,s_dim).
+        Returns the trend, seasonal and residual componenets of the time series.
+        Invalid points i.e. land points, should be specified as np.nans.
+
+        If called directly, evaluation is eager, i.e. numpy arrays are returned
+        for the trend, seasonal and residual components of the time series
+
+        ts_chunk is the input array or chunk
+        **kwargs are the kwargs for statsmodels.seasonal_decompose
+
+        Calling with dask.delayed:
+        dask.delayed(self.seasonal_decomp, nout=3)(ts_chunk, **kwargs)
+
+        Need statsmodels package installed
+        """
+        from statsmodels.tsa.seasonal import seasonal_decompose
+
+        # Get the indices of land points (points that are nan for all time steps)
+        active_ind = np.where(np.nan_to_num(ts_chunk.data).any(axis=0))[0]
+        # Remove them (statsmodel.seasonal_decompose will not accept them)
+        ts_chunk_valid = ts_chunk[:, active_ind]
+        # Call statsmodels.seasonal_decompose()
+        decomp_chunk = seasonal_decompose(ts_chunk_valid, **kwargs)
+        # Store time series components in seperate arrays and return
+        trend_chunk, seasonal_chunk, residual_chunk = (
+            np.full(ts_chunk.shape, np.nan),
+            np.full(ts_chunk.shape, np.nan),
+            np.full(ts_chunk.shape, np.nan),
+        )
+        trend_chunk[:, active_ind] = decomp_chunk.trend
+        seasonal_chunk[:, active_ind] = decomp_chunk.seasonal
+        residual_chunk[:, active_ind] = decomp_chunk.resid
+        return trend_chunk, seasonal_chunk, residual_chunk
+
+    def seasonal_decomposition(self, time_series: xr.DataArray, num_chunks=1, **kwargs):
+        """
+        Dask delayed wrapper for statsmodel.seasonal_decompose that accepts multiple timeseries
+        distributed across multiple dimensitons as an xr.DataArray:
+        time_series = time_series(t_dim,z_dim,x_dim,y_dim,...)
+
+        Accepts land points (z,x,y) as time_series=np.nan for all time.
+
+        num_chunks allows caller to specify the number of chunks in the delayed call, which enables
+        parallelism. i.e. num_chunks = 4 splits the timeserieses into 4 groups that will be decomposed
+        in parallel by the dask scheduler.
+
+        **kwargs are the kwargs for statsmodels.seasonal_decompose function
+
+        Returns coast.Gridded object containing an xarray.Dataset with the trend, seasonal, residual
+        timeseries components as xarray.DataArrays.
+
+        Need statsmodels package installed
+        """
+        # first dim needs to be time
+        time_series = time_series.transpose("t_dim", ...)
+        # stack all the other dims
+        ts_stacked = time_series.stack(space=time_series.dims[1:])
+        # calculate chunk size for stacked dimension and rechunk
+        chunk_size = ts_stacked["space"].size // num_chunks + 1
+        ts_stacked = ts_stacked.chunk({"t_dim": ts_stacked["t_dim"].size, "space": chunk_size})
+        # convert to list of dask delayed objects (e.g. 4 chunks => 4 delayed objects in list)
+        ts_delayed = ts_stacked.data.to_delayed().ravel()
+
+        # delayed call to statsmodel.seasonal_decomposition (with some other processing) that returns
+        # 3 lists:
+        # 1) contains the dask delayed chunks for trend (e.g. 4 delayed objects in list if 4 chunks),
+        # 2) contains the dask delayed chunks for seasonal,
+        # 3) contains the dask delayed chunks for residual,
+        trend_de, seasonal_de, resid_de = map(
+            list, zip(*[delayed(self.seasonal_decomp, nout=3)(ts_chunk, **kwargs) for ts_chunk in ts_delayed])
+        )
+
+        # convert to lists of dask arrays to allow array operations
+        trend_da, seasonal_da, resid_da = [], [], []
+        for chunk_idx, (tr_chunk, se_chunk, re_chunk) in enumerate(zip(trend_de, seasonal_de, resid_de)):
+            # When converting from delayed to array, you must know the shape of the
+            # array. Here we know this from the chunk sizes of the (stacked) DataArray
+            chunk_shape = (ts_stacked.chunks[0][0], ts_stacked.chunks[1][chunk_idx])
+            trend_da.append(da.from_delayed(tr_chunk, shape=chunk_shape, dtype=float))
+            seasonal_da.append(da.from_delayed(se_chunk, shape=chunk_shape, dtype=float))
+            resid_da.append(da.from_delayed(re_chunk, shape=chunk_shape, dtype=float))
+
+        # concatenate the array chunks together and reshape back to original shape
+        trend = da.reshape(da.concatenate(trend_da, axis=1), time_series.shape)
+        seasonal = da.reshape(da.concatenate(seasonal_da, axis=1), time_series.shape)
+        residual = da.reshape(da.concatenate(resid_da, axis=1), time_series.shape)
+        # create  gridded object for return. New DataArrays created from the original
+        # timeseries DataArray so it will have the smae coordinates and shape. Rechunk it
+        # to have same structure as the newly created dask arrays and then assign to gridded
+        gd = Gridded()
+        gd.dataset["trend"] = xr.full_like(time_series.chunk(trend.chunks), np.nan)
+        gd.dataset["trend"][:] = trend
+        gd.dataset["seasonal"] = xr.full_like(time_series.chunk(seasonal.chunks), np.nan)
+        gd.dataset["seasonal"][:] = seasonal
+        gd.dataset["residual"] = xr.full_like(time_series.chunk(residual.chunks), np.nan)
+        gd.dataset["residual"][:] = residual
+        return gd
diff --git a/coast/profile.py b/coast/profile.py
index 8edf63a5..fe2e08f4 100755
--- a/coast/profile.py
+++ b/coast/profile.py
@@ -9,56 +9,98 @@
 from .logging_util import get_slug, debug, info, warn, warning
 from typing import Union
 from pathlib import Path
-import xarray.ufuncs as uf
 import pandas as pd
 
 
 class Profile(Indexed):
     """
-    OBSERVATION type class for storing data from a CTD Profile (or similar
-    down and up observations). The structure of the class is based on data from
-    the EN4 database. The class dataset should contain two dimension:
-
-        > profile :: The profiles dimension. Called N_PROF in EN4 data.
-                     Each element of this dimension contains data for a
-                     individual location.
-        > z_dim   :: The dimension for depth levels. Called N_LEVELS in EN4
-                     files.
+    INDEXED type class for storing data from a CTD Profile (or similar
+    down and up observations). The structure of the class is based around having
+    discrete profile locations with independent depth dimensions and coords.
+    The class dataset should contain two dimensions:
+
+        > id_dim      :: The profiles dimension. Each element of this dimension
+                     contains data (e.g. cast) for an individual location.
+        > z_dim   :: The dimension for depth levels. A profile object does not
+                     need to have shared depths, so NaNs might be used to
+                     pad any depth array.
+
+    Alongside these dimensions, the following minimal coordinates should also
+    be available:
+
+        > longitude (id_dim)   :: 1D array of longitudes, one for each id_dim
+        > latitude  (id_dim)   :: 1D array of latitudes, one for each id_dim
+        > time      (id_dim)   :: 1D array of times, one for each id_dim
+        > depth     (id_dim, z_dim)  :: 2D array of depths, with different depth
+                                    levels being provided for each profile.
+                                    Note that these depth levels need to be
+                                    stored in a 2D array, so NaNs can be used
+                                    to pad out profiles with shallower depths.
+        > id_name   (id_dim)   :: [Optional] Name of id_dim/case or id_dim number.
+
+    You may create an empty profile object by using profile = coast.Profile().
+    You may then add your own dataset to the object profile or use one of the
+    functions within Profile() for reading common profile datasets:
+
+        > read_en4()
+        > read_wod()
+
+    Optionally, you may pass a dataset to the Profile object on creation:
+
+        profile = coast.Profile(dataset = profile_dataset)
+
+    A config file can also be provided, in which case any netcdf read functions
+    will rename dimensions and variables as dictated.
     """
 
-    def __init__(self, file_path: str = None, multiple=False, config: Union[Path, str] = None):
-        """Initialization and file reading.
+    def __init__(self, dataset=None, config: Union[Path, str] = None):
+        """Initialization and file reading. You may initialize
 
         Args:
-            file_path (str): path to data file
-            multiple (boolean): True if reading multiple files otherwise False
             config (Union[Path, str]): path to json config file.
         """
         debug(f"Creating a new {get_slug(self)}")
-        super().__init__(config)
+        self.config = config
+        super().__init__(self.config)
 
-        if file_path is None:
-            warn("Object created but no file or directory specified: \n" "{0}".format(str(self)), UserWarning)
-        else:
-            self.read_en4(file_path, self.chunks, multiple)
+        # If dataset is provided, put inside this object
+        if dataset is not None:
+            self.dataset = dataset
             self.apply_config_mappings()
 
         debug(f"{get_slug(self)} initialised")
 
     def read_en4(self, fn_en4, chunks: dict = {}, multiple=False) -> None:
-        """Reads a single or multiple EN4 netCDF files into the COAsT profile data structure.
-
-        Args:
-            fn_en4 (str): path to data file
-            chunks (dict): chunks
-            multiple (boolean): True if reading multiple files otherwise False
         """
+        Reads a single or multiple EN4 netCDF files into the COAsT profile
+        data structure.
+
+        Parameters
+        ----------
+        fn_en4 : TYPE
+            path to data file.
+        chunks : dict, optional
+            Chunking specification
+        multiple : TYPE, optional
+            True if reading multiple files otherwise False
+
+        Returns
+        -------
+        None. Populates dataset within Profile object.
+        """
+
+        # If not multiple then just read the netcdf file
         if not multiple:
             self.dataset = xr.open_dataset(fn_en4, chunks=chunks)
+
+        # If multiple, then we have to get all file names and read them in a
+        # loop, followed by concatenation
         else:
+            # Check a list is provided
             if type(fn_en4) is not list:
                 fn_en4 = [fn_en4]
 
+            # Use glob to get a list of file paths from input
             file_to_read = []
             for file in fn_en4:
                 if "*" in file:
@@ -74,6 +116,7 @@ def read_en4(self, fn_en4, chunks: dict = {}, multiple=False) -> None:
             sort_ind = np.argsort(dates)
             file_to_read = file_to_read[sort_ind]
 
+            # Loop over all files, open them and concatenation them into one
             for ff in range(0, len(file_to_read)):
                 file = file_to_read[ff]
                 data_tmp = xr.open_dataset(file, chunks=chunks)
@@ -82,22 +125,33 @@ def read_en4(self, fn_en4, chunks: dict = {}, multiple=False) -> None:
                 else:
                     self.dataset = xr.concat((self.dataset, data_tmp), dim="N_PROF")
 
+        # Apply config settings
+        self.apply_config_mappings()
+
+    def read_wod(self, fn_wod, chunks: dict = {}) -> None:
+        """Reads a single World Ocean Database netCDF files into the COAsT profile data structure.
+
+        Args:
+            fn_wod (str): path to data file
+            chunks (dict): chunks
+        """
+        self.dataset = xr.open_dataset(fn_wod, chunks=chunks)
+        self.apply_config_mappings()
+
     """======================= Manipulate ======================="""
 
     def subset_indices_lonlat_box(self, lonbounds, latbounds):
-        """Generates array indices for data which lies in a given lon/lat box.
-        Keyword arguments:
-        lon       -- Longitudes, 1D or 2D.
-        lat       -- Latitudes, 1D or 2D
+        """Get a subset of this Profile() object in a spatial box.
+
         lonbounds -- Array of form [min_longitude=-180, max_longitude=180]
         latbounds -- Array of form [min_latitude, max_latitude]
 
-        return: Indices corresponding to datapoints inside specified box
+        return: A new profile object containing subsetted data
         """
         ind = general_utils.subset_indices_lonlat_box(
             self.dataset.longitude, self.dataset.latitude, lonbounds[0], lonbounds[1], latbounds[0], latbounds[1]
         )
-        return ind
+        return Profile(dataset=self.dataset.isel(id_dim=ind[0]))
 
     """======================= Plotting ======================="""
 
@@ -106,12 +160,12 @@ def plot_profile(self, var: str, profile_indices=None):
         fig = plt.figure(figsize=(7, 10))
 
         if profile_indices is None:
-            profile_indices = np.arange(0, self.dataset.dims["profile"])
+            profile_indices = np.arange(0, self.dataset.dims["id_dim"])
             pass
 
         for ii in profile_indices:
-            prof_var = self.dataset[var].isel(profile=ii)
-            prof_depth = self.dataset.depth.isel(profile=ii)
+            prof_var = self.dataset[var].isel(id_dim=ii)
+            prof_depth = self.dataset.depth.isel(id_dim=ii)
             ax = plt.plot(prof_var, prof_depth)
 
         plt.gca().invert_yaxis()
@@ -133,7 +187,7 @@ def plot_map(self, var_str=None):
 
     def plot_ts_diagram(self, profile_index, var_t="potential_temperature", var_s="practical_salinity"):
 
-        profile = self.dataset.isel(profile=profile_index)
+        profile = self.dataset.isel(id_dim=profile_index)
         temperature = profile[var_t].values
         salinity = profile[var_s].values
         depth = profile.depth.values
@@ -143,300 +197,167 @@ def plot_ts_diagram(self, profile_index, var_t="potential_temperature", var_s="p
 
     """======================= Model Comparison ======================="""
 
-    def depth_means(self, depth_bounds):
-        """
-        (04/10/2021)
-        Author: David Byrne
-
-        INPUTS:
-         masks (Dataset) :
-
-        OUTPUTS:
-
+    def process_en4(self, sort_time=True):
         """
+        VERSION 1.4 (05/07/2021)
 
-        debug(f"Averaging all variables between {0}m <= x < {1}m".format(depth_bounds[0], depth_bounds[1]))
-        ds = self.dataset
-
-        # We need to remove any time variables or the later .where() won't work
-        var_list = list(ds.keys())
-        time_var_list = ["time"]
-        for vv in var_list:
-            if ds[vv].dtype in ["M8[ns]", "timedelta64[ns]"]:
-                time_var_list.append(vv)
-
-        time_vars = ds[time_var_list]
-        ds = ds.drop(time_var_list)
-
-        layer_ind0 = ds.depth >= depth_bounds[0]
-        layer_ind1 = ds.depth < depth_bounds[1]
-        layer_ind = layer_ind0 * layer_ind1
-        masked = ds.where(layer_ind, np.nan)
-        meaned = masked.mean(dim="z_dim", skipna=True).load()
-
-        # Remerge time variables
-        ds = xr.merge((meaned, time_vars))
+        PREPROCESSES EN4 data ready for comparison with model data.
+        This routine will cut out a desired geographical box of EN4 data and
+        then apply quality control according to the available flags in the
+        netCDF files. Quality control happens in two steps:
+            1. Where a whole data profile is flagged, it is completely removed
+               from the dataset
+            2. Where a single datapoint is rejected in either temperature or
+               salinity, it is set to NaN.
+        This routine attempts to use xarray/dask chunking magic to keep
+        memory useage low however some memory is still needed for loading
+        flags etc. May be slow if using large EN4 datasets.
 
-        return_prof = Profile()
-        return_prof.dataset = ds
+        Routine will return a processed profile object dataset and can write
+        the new dataset to file if fn_out is defined. If saving to the
+        PROFILE object, be aware that DASK computations will not have happened
+        and will need to be done using .load(), .compute() or similar before
+        accessing the values. IF using multiple EN4 files or large dataset,
+        make sure you have chunked the data over N_PROF dimension.
 
-        return return_prof
+        INPUTS
+         fn_out (str)      : Full path to a desired output file. If unspecified
+                             then nothing is written.
 
-    def bottom_means(self, layer_thickness, depth_thresholds=[np.inf]):
+        EXAMPLE USEAGE:
+         profile = coast.PROFILE()
+         profile.read_EN4(fn_en4, chunks={'N_PROF':10000})
+         fn_out = '~/output_file.nc'
+         new_profile = profile.preprocess_en4(fn_out = fn_out,
+                                              lonbounds = [-10, 10],
+                                              latbounds = [45, 65])
         """
-        (04/10/2021)
-        Author: David Byrne
-
-        Averages profile data in some layer above the bathymetric depth. This
-        routine requires there to be a 'bathymetry' variable in the Profile dataset.
-        It can apply a constant averaging layer thickness across all profiles
-        or a bespoke thickness dependent on the bathymetric depth. For example,
-        you may want to define the 'bottom' as the average of 100m above the
-        bathymetry in very deep ocean but only 10m in the shallower ocean.
-        If there is no data available in the layer specified (e.g. CTD cast not
-        deep enough or model bathymetry wrong) then it will be NaN
-
-        To apply constant thickness, you only need to provide a value (in metre)
-        for layer_thickness. For different thicknesses, you also need to give
-        depth_thresholds. The last threshold must always be np.inf, i.e. all
-        data below a specific bathymetry depth.
-
-        For example, to apply 10m to everywhere <100m, 50m to 100m -> 500m and
-        100m elsewhere, use:
 
-            layer_thickness = [10, 50, 100]
-            depth_thresholds = [100, 500, np.inf]
-
-        The bottom bound is always assumed to be 0.
-
-        *NOTE: If time related issues arise, then remove any time variables
-        from the profile dataset before running this routine.
-
-        INPUTS:
-         layer_thickness (array) : A scalar layer thickness or list of values
-         depth_thresholds (array) : Optional. List of bathymetry thresholds.
+        ds = self.dataset
 
-        OUTPUTS:
-         New profile object containing bottom averaged data.
+        # Load in the quality control flags
+        debug(f" Applying QUALITY CONTROL to EN4 data...")
+        ds.qc_flags_profiles.load()
 
-        """
+        # This line reads converts the QC integer to a binary string.
+        # Each bit of this string is a different QC flag. Which flag is which can
+        # be found on the EN4 website:
+        # https://www.metoffice.gov.uk/hadobs/en4/en4-0-2-profile-file-format.html
+        qc_str = [np.binary_repr(ds.qc_flags_profiles.values[pp]).zfill(30)[::-1] for pp in range(ds.sizes["id_dim"])]
 
-        # Extract bathymetry points
-        ds = self.dataset
-        bathy_pts = ds.bathymetry.values
+        # Determine indices of the profiles that we want to keep
+        reject_tem_prof = np.array([int(qq[0]) for qq in qc_str], dtype=bool)
+        reject_sal_prof = np.array([int(qq[1]) for qq in qc_str], dtype=bool)
+        reject_both_prof = np.logical_and(reject_tem_prof, reject_sal_prof)
+        ds["reject_tem_prof"] = (["id_dim"], reject_tem_prof)
+        ds["reject_sal_prof"] = (["id_dim"], reject_sal_prof)
+        debug("     >>> QC: Completely rejecting {0} / {1} id_dims".format(np.sum(reject_both_prof), ds.dims["id_dim"]))
 
-        # We need to remove any time variables or the later .where() won't work
-        var_list = list(ds.keys())
-        time_var_list = ["time"]
-        for vv in var_list:
-            if ds[vv].dtype in ["M8[ns]", "timedelta64[ns]"]:
-                time_var_list.append(vv)
+        # Subset profile dataset to remove profiles that are COMPLETELY empty
+        ds = ds.isel(id_dim=~reject_both_prof)
+        reject_tem_prof = reject_tem_prof[~reject_both_prof]
+        reject_sal_prof = reject_sal_prof[~reject_both_prof]
 
-        time_vars = ds[time_var_list]
-        ds = ds.drop(time_var_list)
+        # Get new QC flags array
+        qc_lev = ds.qc_flags_levels.values
 
-        if depth_thresholds[-1] != np.inf:
-            raise ValueError("Please ensure the final element of depth_thresholds is np.inf")
+        debug(f" QC: Additional profiles converted to NaNs: ")
+        debug(f"     >>> {0} temperature profiles ".format(np.sum(reject_tem_prof)))
+        debug(f"     >>> {0} salinity profiles ".format(np.sum(reject_sal_prof)))
 
-        # Convert to numpy arrays where necessary
-        if np.isscalar(layer_thickness):
-            layer_thickness = [layer_thickness]
+        #
+        reject_tem_lev = np.zeros((ds.dims["id_dim"], ds.dims["z_dim"]), dtype=bool)
+        reject_sal_lev = np.zeros((ds.dims["id_dim"], ds.dims["z_dim"]), dtype=bool)
 
-        depth_thresholds = np.array(depth_thresholds)
-        layer_thickness = np.array(layer_thickness)
+        int_tem, int_sal, int_both = self.calculate_all_en4_qc_flags()
+        for ii in range(len(int_tem)):
+            reject_tem_lev[qc_lev == int_tem[ii]] = 1
+        for ii in range(len(int_sal)):
+            reject_sal_lev[qc_lev == int_sal[ii]] = 1
+        for ii in range(len(int_both)):
+            reject_tem_lev[qc_lev == int_both[ii]] = 1
+            reject_sal_lev[qc_lev == int_both[ii]] = 1
 
-        # Get depths, thresholds and thicknesses at each profile
-        prof_threshold = np.array([np.sum(ii > depth_thresholds) for ii in bathy_pts])
-        prof_thickness = layer_thickness[prof_threshold]
+        ds["reject_tem_datapoint"] = (["id_dim", "z_dim"], reject_tem_lev)
+        ds["reject_sal_datapoint"] = (["id_dim", "z_dim"], reject_sal_lev)
 
-        # Insert NaNs where not in the bottom
-        try:
-            bottom_ind = ds.depth >= (bathy_pts - prof_thickness)
-        except:
-            bottom_ind = ds.depth.transpose() >= (bathy_pts - prof_thickness)
-        ds = ds.where(bottom_ind, np.nan)
+        debug(f"MASKING rejected datapoints, replacing with NaNs...")
+        ds["temperature"] = xr.where(~reject_tem_lev, ds["temperature"], np.nan)
+        ds["potential_temperature"] = xr.where(~reject_tem_lev, ds["temperature"], np.nan)
+        ds["practical_salinity"] = xr.where(~reject_tem_lev, ds["practical_salinity"], np.nan)
 
-        # Average the remaining data
-        ds = ds.mean(dim="z_dim", skipna=True).load()
+        if sort_time:
+            debug(f"Sorting Time Dimension...")
+            ds = ds.sortby("time")
 
-        # Remerge time variables
-        ds = xr.merge((ds, time_vars))
+        debug(f"Finished processing data. Returning new Profile object.")
 
         return_prof = Profile()
         return_prof.dataset = ds
-
         return return_prof
 
-    def determine_mask_indices(self, masks):
-        """
-        (04/10/2021)
-        Author: David Byrne
-
-        INPUTS:
-         masks (Dataset) :
-
-        OUTPUTS:
-
-        """
-
-        ds = self.dataset
-
-        # If landmask not present, set to None for nearest_indices (no masking)
-        if "landmask" not in list(masks.keys()):
-            landmask = None
-        else:
-            landmask = masks.landmask
-        # SPATIAL indices - nearest neighbour
-        ind_x, ind_y = general_utils.nearest_indices_2d(
-            masks["longitude"], masks["latitude"], ds["longitude"], ds["latitude"], mask=landmask
-        )
-
-        # Figure out which points lie in which region
-        debug(f"Figuring out which regions each profile is in..")
-        region_indices = masks.isel(x_dim=ind_x, y_dim=ind_y)
-
-        return region_indices.rename({"dim_0": "profile"})
-
-    def mask_means(self, mask_indices):
-
-        ds = self.dataset
-
-        n_masks = mask_indices.dims["dim_mask"]
-
-        # Loop over maskal arrays. Assign mean to mask and seasonal means
-        debug(f"Calculating maskal averages..")
-        for mm in range(0, n_masks):
-            mask = mask_indices.isel(dim_mask=mm).mask.values
-            mask_ind = np.where(mask.astype(bool))[0]
-            if len(mask_ind) < 1:
-                if mm == 0:
-                    ds_average = xr.Dataset()
-                continue
-            mask_data = ds.isel(profile=mask_ind)
-            ds_average_prof = mask_data.mean(dim="profile", skipna=True).compute()
-            ds_average_all = mask_data.mean(skipna=True).compute()
-
-            var_list = list(ds_average_prof.keys())
-            for vv in var_list:
-                ds_average_prof = ds_average_prof.rename({vv: "profile_average_" + vv})
-                ds_average_all = ds_average_all.rename({vv: "average_" + vv})
-
-            ds_average_tmp = xr.merge((ds_average_prof, ds_average_all))
-
-            if mm == 0:
-                ds_average = ds_average_tmp
-            else:
-                ds_average = xr.concat((ds_average, ds_average_tmp), dim="dim_mask")
-
-        return ds_average
-
-    def difference(self, other, absolute_diff=True, square_diff=True):
-
-        differenced = self.dataset - other.dataset
-        diff_vars = list(differenced.keys())
-        save_coords = list(self.dataset.coords.keys())
-
-        for vv in diff_vars:
-            differenced = differenced.rename({vv: "diff_" + vv})
-
-        if absolute_diff:
-            abs_tmp = uf.fabs(differenced)
-            diff_vars = list(abs_tmp.keys())
-            for vv in diff_vars:
-                abs_tmp = abs_tmp.rename({vv: "abs_" + vv})
-        else:
-            abs_tmp = xr.Dataset()
-
-        if square_diff:
-            sq_tmp = uf.square(differenced)
-            diff_vars = list(sq_tmp.keys())
-            for vv in diff_vars:
-                sq_tmp = sq_tmp.rename({vv: "square_" + vv})
-        else:
-            sq_tmp = xr.Dataset()
-
-        differenced = xr.merge((differenced, abs_tmp, sq_tmp, self.dataset[save_coords]))
-
-        return_differenced = Profile()
-        return_differenced.dataset = differenced
-
-        return return_differenced
-
-    def interpolate_vertical(self, new_depth, interp_method="linear"):
+    @classmethod
+    def calculate_all_en4_qc_flags(cls):
         """
-        (04/10/2021)
-        Author: David Byrne
-
-        For vertical interpolation of all profiles within this object. User
-        should pass an array describing the new depths or another profile object
-        containing the same number of profiles as this object.
-
-        If a 1D numpy array is passed then all profiles will be interpolated
-        onto this single set of depths. If a xarray.DataArray is passed, it
-        should have dimensions (profile, z_dim) and contain a variable called
-        depth. This DataArray should contain the same number of profiles as
-        this object and will map profiles in order for interpolation. If
-        another profile object is passed, profiles will be mapped and
-        interpolated onto the other objects depth array.
+        Brute force method for identifying all rejected points according to
+        EN4 binary integers. It can be slow to convert large numbers of integers
+        to a sequence of bits and is actually quicker to just generate every
+        combination of possible QC integers. That's what this routine does.
+        Used in PROFILE.preprocess_en4().
 
-        INPUTS:
-         new_depth (array or dataArray) : new depths onto which to interpolate
-                                          see description above for more info.
-         interp_method (str)            : Any scipy interpolation string.
+        INPUTS
+         NO INPUTS
 
-        OUTPUTS:
-         Returns a new PROFILE object containing the interpolated dataset.
+        OUTPUTS
+         qc_integers_tem  : Array of integers signifying the rejection of ONLY
+                            temperature datapoints
+         qc_integers_sal  : Array of integers signifying the rejection of ONLY
+                            salinity datapoints
+         qc_integers_both : Array of integers signifying the rejection of BOTH
+                            temperature and salinity datapoints.
         """
 
-        # If input is Profile, extract depth dataarray
-        if type(new_depth) is Profile:
-            new_depth = new_depth.dataset.depth
-
-        # If input is 1D, then interpolation will be done onto this for all.
-        if len(new_depth.shape) == 1:
-            repeated_depth = True
-            debug(f"Interpolating onto reference depths")
-        else:
-            debug(f"Interpolating onto depths of existing Profile object")
-            repeated_depth = False
-
-        ds = self.dataset
-        n_prof = ds.dims["profile"]
-
-        # Now loop over profiles and interpolate model onto obs.
-        count_ii = 0
-        for pp in range(0, n_prof):
-
-            # Select the current profile
-            profile = ds.isel(profile=pp).rename({"depth": "z_dim"})
-            profile = profile.dropna("z_dim")
-            profile = profile.set_coords("z_dim")
+        reject_tem_ind = 0
+        reject_sal_ind = 1
+        reject_tem_reasons = [2, 3, 8, 9, 10, 11, 12, 13, 14, 15, 16]
+        reject_sal_reasons = [2, 3, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
 
-            # Extract new depths for this profile
-            if repeated_depth:
-                new_depth_prof = new_depth
-            else:
-                new_depth_prof = new_depth.isel(profile=pp).values
+        qc_integers_tem = []
+        qc_integers_sal = []
+        qc_integers_both = []
+        n_tem_reasons = len(reject_tem_reasons)
+        n_sal_reasons = len(reject_sal_reasons)
+        bin_len = 30
 
-            # Do the interpolation and rename dimensions/vars back to normal
-            interpolated_tmp = profile.interp(z_dim=new_depth_prof, method=interp_method)
+        # IF reject_tem = 1, reject_sal = 0
+        for ii in range(n_tem_reasons):
+            bin_tmp = np.zeros(bin_len, dtype=int)
+            bin_tmp[reject_tem_ind] = 1
+            bin_tmp[reject_tem_reasons[ii]] = 1
+            qc_integers_tem.append(int("".join(str(jj) for jj in bin_tmp)[::-1], 2))
 
-            interpolated_tmp = interpolated_tmp.rename_vars({"z_dim": "depth"})
-            interpolated_tmp = interpolated_tmp.reset_coords(["depth"])
+        # IF reject_tem = 0, reject_sal = 1
+        for ii in range(n_sal_reasons):
+            bin_tmp = np.zeros(bin_len, dtype=int)
+            bin_tmp[reject_sal_ind] = 1
+            bin_tmp[reject_sal_reasons[ii]] = 1
+            qc_integers_sal.append(int("".join(str(jj) for jj in bin_tmp)[::-1], 2))
 
-            # If not first iteration, concat this interpolated profile
-            if count_ii == 0:
-                interpolated = interpolated_tmp
-            else:
-                interpolated = xr.concat((interpolated, interpolated_tmp), dim="profile", coords="all")
-            count_ii = count_ii + 1
+        # IF reject_tem = 1, reject_sal = 1
+        for tt in range(n_tem_reasons):
+            for ss in range(n_sal_reasons):
+                bin_tmp = np.zeros(bin_len, dtype=int)
+                bin_tmp[reject_tem_ind] = 1
+                bin_tmp[reject_sal_ind] = 1
+                bin_tmp[reject_tem_reasons[tt]] = 1
+                bin_tmp[reject_sal_reasons[ss]] = 1
+                qc_integers_both.append(int("".join(str(jj) for jj in bin_tmp)[::-1], 2))
 
-        # Create and format output dataset
-        interpolated = interpolated.set_coords(["depth"])
-        return_interpolated = Profile()
-        return_interpolated.dataset = interpolated
+        qc_integers_tem = list(set(qc_integers_tem))
+        qc_integers_sal = list(set(qc_integers_sal))
+        qc_integers_both = list(set(qc_integers_both))
 
-        return return_interpolated
+        return qc_integers_tem, qc_integers_sal, qc_integers_both
 
     def obs_operator(self, gridded, mask_bottom_level=True):
         """
@@ -494,7 +415,7 @@ def obs_operator(self, gridded, mask_bottom_level=True):
 
         # TIME indices - model nearest to obs time
         en4_time = en4.time.values
-        ind_t = [np.argmin(np.abs(mod_time - en4_time[tt])) for tt in range(en4.dims["profile"])]
+        ind_t = [np.argmin(np.abs(mod_time - en4_time[tt])) for tt in range(en4.dims["id_dim"])]
         ind_t = xr.DataArray(ind_t)
         debug(f"Time Indices Calculated")
 
@@ -524,9 +445,10 @@ def obs_operator(self, gridded, mask_bottom_level=True):
             # Check There are some indices at all
             if np.sum(ind_in_chunk) == 0:
                 start_ii = end_ii
-                count_ii = count_ii + 1
                 if count_ii == 0:
                     mod_profiles = xr.Dataset()
+
+                count_ii = count_ii + 1
                 continue
 
             # Pull out x,y and t indices
@@ -539,7 +461,7 @@ def obs_operator(self, gridded, mask_bottom_level=True):
 
             # Index loaded chunk and rename dim_0 to profile
             ds_tmp_indexed = ds_tmp.isel(x_dim=ind_x_in_chunk, y_dim=ind_y_in_chunk, t_dim=ind_t_in_chunk)
-            ds_tmp_indexed = ds_tmp_indexed.rename({"dim_0": "profile"})
+            ds_tmp_indexed = ds_tmp_indexed.rename({"dim_0": "id_dim"})
 
             # Mask out all levels deeper than bottom_level
             # Here I have used set_coords() and reset_coords() to omit variables
@@ -549,7 +471,7 @@ def obs_operator(self, gridded, mask_bottom_level=True):
                 n_z_tmp = ds_tmp_indexed.dims["z_dim"]
                 bl_array = ds_tmp_indexed.bottom_level.values
                 z_index, bl_index = np.meshgrid(np.arange(0, n_z_tmp), bl_array)
-                mask2 = xr.DataArray(z_index < bl_index, dims=["profile", "z_dim"])
+                mask2 = xr.DataArray(z_index < bl_index, dims=["id_dim", "z_dim"])
                 ds_tmp_indexed = ds_tmp_indexed.set_coords(bl_var_list)
                 ds_tmp_indexed = ds_tmp_indexed.where(mask2)
                 ds_tmp_indexed = ds_tmp_indexed.reset_coords(bl_var_list)
@@ -559,34 +481,33 @@ def obs_operator(self, gridded, mask_bottom_level=True):
             if count_ii == 0:
                 mod_profiles = ds_tmp_indexed
             else:
-                mod_profiles = xr.concat((mod_profiles, ds_tmp_indexed), dim="profile")
+                if len(mod_profiles.data_vars) == 0:
+                    mod_profiles = xr.merge((mod_profiles, ds_tmp_indexed))
+                else:
+                    mod_profiles = xr.concat((mod_profiles, ds_tmp_indexed), dim="id_dim")
 
             # Update counters
             start_ii = end_ii
             count_ii = count_ii + 1
 
         # Put obs time into the output array
-        mod_profiles["obs_time"] = (["profile"], en4_time)
+        mod_profiles["obs_time"] = (["id_dim"], en4_time)
 
         # Calculate interpolation distances
         interp_dist = general_utils.calculate_haversine_distance(
             en4.longitude, en4.latitude, mod_profiles.longitude, mod_profiles.latitude
         )
-        mod_profiles["interp_dist"] = (["profile"], interp_dist.values)
+        mod_profiles["interp_dist"] = (["id_dim"], interp_dist.values)
 
         # Calculate interpolation time lags
         interp_lag = (mod_profiles.time.values - en4_time).astype("timedelta64[h]")
-        mod_profiles["interp_lag"] = (["profile"], interp_lag)
+        mod_profiles["interp_lag"] = (["id_dim"], interp_lag)
 
         # Put x and y indices into dataset
-        mod_profiles["nearest_index_x"] = (["profile"], ind_x.values)
-        mod_profiles["nearest_index_y"] = (["profile"], ind_y.values)
-        mod_profiles["nearest_index_t"] = (["profile"], ind_t.values)
-
-        # Create return object and put dataset into it.
-        return_prof = Profile()
-        return_prof.dataset = mod_profiles
-        return return_prof
+        mod_profiles["nearest_index_x"] = (["id_dim"], ind_x.values)
+        mod_profiles["nearest_index_y"] = (["id_dim"], ind_y.values)
+        mod_profiles["nearest_index_t"] = (["id_dim"], ind_t.values)
+        return Profile(dataset=mod_profiles)
 
     def process_en4(self, sort_time=True):
         """
@@ -634,19 +555,19 @@ def process_en4(self, sort_time=True):
         # Each bit of this string is a different QC flag. Which flag is which can
         # be found on the EN4 website:
         # https://www.metoffice.gov.uk/hadobs/en4/en4-0-2-profile-file-format.html
-        qc_str = [np.binary_repr(ds.qc_flags_profiles.values[pp]).zfill(30)[::-1] for pp in range(ds.dims["profile"])]
+        qc_str = [np.binary_repr(ds.qc_flags_profiles.values[pp]).zfill(30)[::-1] for pp in range(ds.dims["id_dim"])]
 
         # Determine indices of kept profiles
         reject_tem_prof = np.array([int(qq[0]) for qq in qc_str], dtype=bool)
         reject_sal_prof = np.array([int(qq[1]) for qq in qc_str], dtype=bool)
         reject_both_prof = np.logical_and(reject_tem_prof, reject_sal_prof)
-        ds["reject_tem_prof"] = (["profile"], reject_tem_prof)
-        ds["reject_sal_prof"] = (["profile"], reject_sal_prof)
+        ds["reject_tem_prof"] = (["id_dim"], reject_tem_prof)
+        ds["reject_sal_prof"] = (["id_dim"], reject_sal_prof)
         debug(
-            "     >>> QC: Completely rejecting {0} / {1} profiles".format(np.sum(reject_both_prof), ds.dims["profile"])
+            "     >>> QC: Completely rejecting {0} / {1} profiles".format(np.sum(reject_both_prof), ds.dims["id_dim"])
         )
 
-        ds = ds.isel(profile=~reject_both_prof)
+        ds = ds.isel(id_dim=~reject_both_prof)
         reject_tem_prof = reject_tem_prof[~reject_both_prof]
         reject_sal_prof = reject_sal_prof[~reject_both_prof]
         qc_lev = ds.qc_flags_levels.values
@@ -655,8 +576,8 @@ def process_en4(self, sort_time=True):
         debug(f"     >>> {0} temperature profiles ".format(np.sum(reject_tem_prof)))
         debug(f"     >>> {0} salinity profiles ".format(np.sum(reject_sal_prof)))
 
-        reject_tem_lev = np.zeros((ds.dims["profile"], ds.dims["z_dim"]), dtype=bool)
-        reject_sal_lev = np.zeros((ds.dims["profile"], ds.dims["z_dim"]), dtype=bool)
+        reject_tem_lev = np.zeros((ds.dims["id_dim"], ds.dims["z_dim"]), dtype=bool)
+        reject_sal_lev = np.zeros((ds.dims["id_dim"], ds.dims["z_dim"]), dtype=bool)
 
         int_tem, int_sal, int_both = self.calculate_all_en4_qc_flags()
         for ii in range(len(int_tem)):
@@ -667,8 +588,8 @@ def process_en4(self, sort_time=True):
             reject_tem_lev[qc_lev == int_both[ii]] = 1
             reject_sal_lev[qc_lev == int_both[ii]] = 1
 
-        ds["reject_tem_datapoint"] = (["profile", "z_dim"], reject_tem_lev)
-        ds["reject_sal_datapoint"] = (["profile", "z_dim"], reject_sal_lev)
+        ds["reject_tem_datapoint"] = (["id_dim", "z_dim"], reject_tem_lev)
+        ds["reject_sal_datapoint"] = (["id_dim", "z_dim"], reject_sal_lev)
 
         debug(f"MASKING rejected datapoints, replacing with NaNs...")
         ds["temperature"] = xr.where(~reject_tem_lev, ds["temperature"], np.nan)
@@ -746,3 +667,154 @@ def calculate_all_en4_qc_flags(self):
         qc_integers_both = list(set(qc_integers_both))
 
         return qc_integers_tem, qc_integers_sal, qc_integers_both
+
+    """================Reshape to 2D================"""
+
+    def reshape_2d(self, var_user_want):
+        """
+        OBSERVATION type class for reshaping World Ocean Data (WOD) or similar that
+        contains 1D profiles (profile * depth levels)  into a 2D array.
+        Note that its variable has its own dimention and in some profiles
+        only some variables are present. WOD can be observed depth or a
+        standard depth as regrided by NOAA.
+           Args:
+            > X     --      The variable (e.g,Temperatute, Salinity, Oxygen, DIC ..)
+            > X_N    --     Dimensions of observed variable as 1D
+                            (essentially number of obs variable = casts * osberved depths)
+            > casts --      Dimension for locations of observations (ie. profiles)
+            > z_N   --      Dimension for depth levels of all observations as 1D
+                            (essentially number of depths = casts * osberved depths)
+            > X_row_size -- Gives the vertical index (number of depths)
+                            for each variable
+        """
+
+        """reshape the 1D variable into 2D variable (id_dimß, z_dim)    
+        Args:
+            profile       ::   The profile dimension. Called cast in WOD, 
+                               common in all variables, but nans if 
+                                a variable is not observed at a location
+            z_dim         ::   The dimension for depth levels.
+            var_user_want ::   List of observations the user wants to reshape       
+        """
+
+        # find maximum z levels in any of the profiles
+        d_max = int(np.max(self.dataset.z_row_size.values))
+        # number of profiles
+        prof_size = self.dataset.z_row_size.shape[0]
+
+        # set a 2D array (relevant to maximum depth)
+        depth_2d = np.empty(
+            (
+                prof_size,
+                d_max,
+            )
+        )
+        depth_2d[:] = np.nan
+        # reshape depth information from 1D to 2D
+        if np.isnan(self.dataset.z_row_size.values[0]) == False:
+            I_SIZE = int(self.dataset.z_row_size.values[0])
+            depth_2d[0, :I_SIZE] = self.dataset.depth[0:I_SIZE].values
+        for iJ in range(1, prof_size):
+            if np.isnan(self.dataset.z_row_size.values[iJ]) == False:
+                I_START = int(np.nansum(self.dataset.z_row_size.values[:iJ]))
+                I_END = int(np.nansum(self.dataset.z_row_size.values[: iJ + 1]))
+                I_SIZE = int(self.dataset.z_row_size.values[iJ])
+                depth_2d[iJ, 0:I_SIZE] = self.dataset.depth[I_START:I_END].values
+
+        # check reshape
+        T_OBS1 = np.delete(np.ravel(depth_2d), np.isnan(np.ravel(depth_2d)))
+        T_OBS2 = np.delete(self.dataset.depth.values, np.isnan(self.dataset.depth.values))
+        if T_OBS1.size == T_OBS2.size and (int(np.min(T_OBS1 - T_OBS2)) == 0 or int(np.max(T_OBS1 - T_OBS2)) == 0):
+            print("Depth OK reshape successful")
+        else:
+            print("Depth WRONG!! reshape")
+
+        # reshape obs for each variable from 1D to 2D
+        var_all = np.empty(
+            (
+                len(var_user_want),
+                prof_size,
+                d_max,
+            )
+        )
+        var_list = var_user_want[:]
+        counter_i = 0
+        for iN in range(0, len(var_user_want)):
+            print(var_user_want[iN])
+            # check that variable exist in the WOD observations file
+            if var_user_want[iN] in self.dataset:
+                print("observed variable exist")
+                # reshape it into 2D
+                var_2d = np.empty(
+                    (
+                        prof_size,
+                        d_max,
+                    )
+                )
+                var_2d[:] = np.nan
+                # populate array but make sure that the indexing for number of levels
+                # is not nan, as in the data there are nan indexings for number of levels
+                # indicating no observations there
+                if np.isnan(self.dataset[var_user_want[iN] + "_row_size"][0].values) == False:
+                    I_SIZE = int(self.dataset[var_user_want[iN] + "_row_size"][0].values)
+                    var_2d[0, :I_SIZE] = self.dataset[var_user_want[iN]][0:I_SIZE].values
+                for iJ in range(1, prof_size):
+                    if np.isnan(self.dataset[var_user_want[iN] + "_row_size"].values[iJ]) == False:
+                        I_START = int(np.nansum(self.dataset[var_user_want[iN] + "_row_size"].values[:iJ]))
+                        I_END = int(np.nansum(self.dataset[var_user_want[iN] + "_row_size"].values[: iJ + 1]))
+                        I_SIZE = int(self.dataset[var_user_want[iN] + "_row_size"].values[iJ])
+                        var_2d[iJ, 0:I_SIZE] = self.dataset[var_user_want[iN]].values[I_START:I_END]
+
+                # all variables in one array
+                var_all[counter_i, :, :] = var_2d
+                counter_i = counter_i + 1
+                # check that you did everything correctly and the obs in yoru reshaped
+                # array match the observations in original array
+                T_OBS1 = np.delete(np.ravel(var_2d), np.isnan(np.ravel(var_2d)))
+                del I_START, I_END, I_SIZE, var_2d
+                T_OBS2 = np.delete(
+                    self.dataset[var_user_want[iN]].values, np.isnan(self.dataset[var_user_want[iN]].values)
+                )
+
+                if T_OBS1.size == T_OBS2.size and (
+                    int(np.min(T_OBS1 - T_OBS2)) == 0 or int(np.max(T_OBS1 - T_OBS2)) == 0
+                ):
+                    print("OK reshape successful")
+                else:
+                    print("WRONG!! reshape")
+
+            else:
+                print("variable not in observations")
+                var_list[iN] = "NO"
+
+        # REMOVE DUBLICATES
+        var_list = list(dict.fromkeys(var_list))
+        # REMOVE the non-observed variables from the list of variables
+        var_list.remove("NO")
+
+        # create the new 2D dataset array
+        wod_profiles_2d = xr.Dataset(
+            {
+                "depth": (["id_dim", "z_dim"], depth_2d),
+            },
+            coords={
+                "time": (["id_dim"], self.dataset.time.values),
+                "latitude": (["id_dim"], self.dataset.latitude.values),
+                "longitude": (["id_dim"], self.dataset.longitude.values),
+            },
+        )
+        for iN in range(0, len(var_list)):
+            wod_profiles_2d[var_list[iN]] = (["id_dim", "z_dim"], var_all[iN, :, :])
+
+        return_prof = Profile()
+        return_prof.dataset = wod_profiles_2d
+        return return_prof
+
+    def time_slice(self, date0, date1):
+        """Return new Gridded object, indexed between dates date0 and date1"""
+        dataset = self.dataset
+        t_ind = pd.to_datetime(dataset.time.values) >= date0
+        dataset = dataset.isel(id_dim=t_ind)
+        t_ind = pd.to_datetime(dataset.time.values) < date1
+        dataset = dataset.isel(id_dim=t_ind)
+        return Profile(dataset=dataset)
diff --git a/coast/profile_analysis.py b/coast/profile_analysis.py
new file mode 100644
index 00000000..8e607af5
--- /dev/null
+++ b/coast/profile_analysis.py
@@ -0,0 +1,507 @@
+"""Profile Class"""
+from .index import Indexed
+import numpy as np
+import xarray as xr
+from . import general_utils, plot_util, Gridded, Profile
+from .logging_util import get_slug, debug, info, warn, warning
+from scipy import interpolate
+
+
+class ProfileAnalysis(Indexed):
+    """
+    A set of analysis routines suitable for datasets in a Profile object.
+    See individual docstrings in each method for more info.
+    """
+
+    def __init__(self):
+        """ """
+
+    """======================= Model Comparison ======================="""
+
+    @classmethod
+    def depth_means(cls, profile, depth_bounds):
+        """
+        Calculates a mean of all variable data that lie between two depths.
+        Returns a new Profile() object containing the meaned data
+
+        INPUTS:
+         dataset (Dataset)    : A dataset from a Profile object.
+         depth_bounds (Tuple) : A tuple of length 2 describing depth bounds
+                                Should be of form: (lower, upper) and in metres
+        """
+
+        debug(f"Averaging all variables between {0}m <= x < {1}m".format(depth_bounds[0], depth_bounds[1]))
+        dataset = profile.dataset
+
+        # We need to remove any time variables or the later .where() won't work
+        var_list = list(dataset.keys())
+        time_var_list = ["time"]
+        for vv in var_list:
+            if dataset[vv].dtype in ["M8[ns]", "timedelta64[ns]"]:
+                time_var_list.append(vv)
+
+        # Extract/remove time vars from input dataset (but save them)
+        time_vars = dataset[time_var_list]
+        dataset = dataset.drop(time_var_list)
+
+        # Get 2D indices of depth bounds, then indices of all points in the layer
+        layer_ind0 = dataset.depth >= depth_bounds[0]
+        layer_ind1 = dataset.depth < depth_bounds[1]
+        layer_ind = layer_ind0 * layer_ind1
+
+        # Mask out all other points
+        masked = dataset.where(layer_ind, np.nan)
+
+        # Calculate the average. By skipping nans, we only mean the points
+        # within the desired layer
+        meaned = masked.mean(dim="z_dim", skipna=True)
+
+        # Remerge time variables and return new Profile object.
+        ds = xr.merge((meaned, time_vars))
+        return Profile(dataset=ds)
+
+    @classmethod
+    def bottom_means(cls, profile, layer_thickness, depth_thresholds=[np.inf]):
+        """
+        Averages profile data in some layer above the bathymetric depth. This
+        routine requires there to be a 'bathymetry' variable in the Profile dataset.
+        It can apply a constant averaging layer thickness across all profiles
+        or a bespoke thickness dependent on the bathymetric depth. For example,
+        you may want to define the 'bottom' as the average of 100m above the
+        bathymetry in very deep ocean but only 10m in the shallower ocean.
+        If there is no data available in the layer specified (e.g. CTD cast not
+        deep enough or model bathymetry wrong) then it will be NaN
+
+        To apply constant thickness, you only need to provide a value (in metre)
+        for layer_thickness. For different thicknesses, you also need to give
+        depth_thresholds. The last threshold must always be np.inf, i.e. all
+        data below a specific bathymetry depth.
+
+        For example, to apply 10m to everywhere <100m, 50m to 100m -> 500m and
+        100m elsewhere, use:
+
+            layer_thickness = [10, 50, 100]
+            depth_thresholds = [100, 500, np.inf]
+
+        The bottom bound is always assumed to be 0.
+
+        *NOTE: If time related issues arise, then remove any time variables
+        from the profile dataset before running this routine.
+
+        INPUTS:
+         layer_thickness (array) : A scalar layer thickness or list of values
+         depth_thresholds (array) : Optional. List of bathymetry thresholds.
+
+        OUTPUTS:
+         New profile object containing bottom averaged data.
+
+        """
+        dataset = profile.dataset
+
+        # Extract bathymetry points and load them
+        bathy_pts = dataset.bathymetry.values
+
+        # We need to remove any time variables or the later .where() won't work
+        var_list = list(dataset.keys())
+        time_var_list = ["time"]
+        for vv in var_list:
+            if dataset[vv].dtype in ["M8[ns]", "timedelta64[ns]"]:
+                time_var_list.append(vv)
+
+        # Remove the time variables and save them for merge back later
+        time_vars = dataset[time_var_list]
+        dataset = dataset.drop(time_var_list)
+
+        # A quick check to make sure the last threshold is infinite
+        if depth_thresholds[-1] != np.inf:
+            raise ValueError("Please ensure the final element of depth_thresholds is np.inf")
+
+        # Convert to numpy arrays where necessary
+        if np.isscalar(layer_thickness):
+            layer_thickness = [layer_thickness]
+        depth_thresholds = np.array(depth_thresholds)
+        layer_thickness = np.array(layer_thickness)
+
+        # Get depths, thresholds and thicknesses at each profile
+        prof_threshold = np.array([np.sum(ii > depth_thresholds) for ii in bathy_pts])
+        prof_thickness = layer_thickness[prof_threshold]
+
+        # Insert NaNs where not in the bottom
+        try:
+            bottom_ind = dataset.depth >= (bathy_pts - prof_thickness)
+        except:
+            bottom_ind = dataset.depth.transpose() >= (bathy_pts - prof_thickness)
+        dataset = dataset.where(bottom_ind, np.nan)
+
+        # Average the remaining data
+        dataset = dataset.mean(dim="z_dim", skipna=True)
+
+        # Remerge time variables
+        dataset = xr.merge((dataset, time_vars))
+        return Profile(dataset=dataset)
+
+    @classmethod
+    def determine_mask_indices(cls, profile, mask_dataset):
+        """
+        Determines whether each profile is within a mask (region) or not.
+        These masks should be in Dataset form, as returned by
+        Mask_maker().make_mask_dataset(). I.E, each mask
+        should be a 2D array with corresponding 2D longitude and latitude
+        arrays. Multiple masks should be stored along a dim_mask dimension.
+
+        Parameters
+        ----------
+        dataset : xarray.Dataset
+            A dataset from a profile object
+        mask_dataset : xarray.Dataset
+            Dataset with dimensions (dim_mask, x_dim, y_dim).
+            Should contain longitude, latitude and mask. Mask has dimensions
+            (dim_mask, y_dim, x_dim). Spatial dimensions should align with
+            longitude and latitude
+
+        Returns
+        -------
+        Dataset describing which profiles are in which mask/region.
+        Ready for input to Profile.mask_means()
+        """
+        dataset = profile.dataset
+        # If landmask not present, set to None for nearest_indices (no masking)
+        if "landmask" not in list(mask_dataset.keys()):
+            landmask = None
+        else:
+            landmask = mask_dataset.landmask
+
+        # SPATIAL indices - nearest neighbour
+        ind_x, ind_y = general_utils.nearest_indices_2d(
+            mask_dataset["longitude"],
+            mask_dataset["latitude"],
+            dataset["longitude"],
+            dataset["latitude"],
+            mask=landmask,
+        )
+
+        # Figure out which points lie in which region
+        debug(f"Figuring out which regions each profile is in..")
+        region_indices = mask_dataset.isel(x_dim=ind_x, y_dim=ind_y)
+
+        return region_indices.rename({"dim_0": "id_dim"})
+
+    @classmethod
+    def mask_means(cls, profile, mask_indices):
+        """
+        Averages all data inside a given profile dataset across a regional mask
+        or for multiples regional masks.
+
+        Parameters
+        ----------
+        dataset : xarray.Dataset
+            The profile dataset to average.
+        mask_indices : xarray.Dataset
+            Describes which profiles are in which region. Returned from
+            profile_analysis.determine_mask_indices().
+
+        Returns
+        -------
+        xarray.Dataset containing meaned data.
+
+        """
+        dataset = profile.dataset
+        # Get the number of masks provided
+        n_masks = mask_indices.dims["dim_mask"]
+
+        # Loop over maskal arrays. Assign mean to mask and seasonal means
+        debug(f"Calculating maskal averages..")
+
+        # Loop over masks and index the data
+        for mm in range(0, n_masks):
+            mask = mask_indices.isel(dim_mask=mm).mask.values
+            mask_ind = np.where(mask.astype(bool))[0]
+            if len(mask_ind) < 1:
+                if mm == 0:
+                    ds_average = xr.Dataset()
+                continue
+
+            # Get actual profile data for this mask
+            mask_data = dataset.isel(id_dim=mask_ind)
+            # Get two averages. One preserving depths and the other averaging
+            # across all data in a region
+            ds_average_prof = mask_data.mean(dim="id_dim", skipna=True).compute()
+            ds_average_all = mask_data.mean(skipna=True).compute()
+
+            # Loop over variables and save to output dataset.
+            var_list = list(ds_average_prof.keys())
+            for vv in var_list:
+                ds_average_prof = ds_average_prof.rename({vv: "profile_mean_" + vv})
+                ds_average_all = ds_average_all.rename({vv: "all_mean_" + vv})
+
+            # Merge profile means and all means into one dataset for output
+            ds_average_tmp = xr.merge((ds_average_prof, ds_average_all))
+
+            # If only one mask then just return the merged dataset
+            # else concatenate onto existing dataset.
+            if mm == 0:
+                ds_average = ds_average_tmp
+            else:
+                ds_average = xr.concat((ds_average, ds_average_tmp), dim="dim_mask")
+
+        return ds_average
+
+    @classmethod
+    def difference(cls, profile1, profile2, absolute_diff=True, square_diff=True):
+        """
+        Calculates differences between all matched variables in two Profile
+        datasets. Difference direction is dataset1 - dataset2.
+
+        Parameters
+        ----------
+        dataset1 : xarray.Dataset
+            First profile dataset
+        dataset2 : xarray.Dataset
+            Second profile dataset
+        absolute_diff : bool, optional
+            Whether to calculate absolute differences. The default is True.
+        square_diff : bool, optional
+            Whether to calculate square differences. The default is True.
+
+        Returns
+        -------
+        New Profile object containing differenced dataset.
+        Differences have suffix diff_
+        Absolute differences have suffix abs_
+        Square differences have suffic square_
+
+        """
+        dataset1 = profile1.dataset
+        dataset2 = profile2.dataset
+
+        # Difference the two input dataset
+        differenced = dataset1 - dataset2
+
+        # Get list of vars and coord names for later
+        diff_vars = list(differenced.keys())
+        save_coords = list(dataset1.coords.keys())
+
+        # Loop over variables and rename differenced vars
+        for vv in diff_vars:
+            differenced = differenced.rename({vv: "diff_" + vv})
+
+        # Loop over and rename absolutely differenced vars (if wanted)
+        if absolute_diff:
+            abs_tmp = np.fabs(differenced)
+            diff_vars = list(abs_tmp.keys())
+            for vv in diff_vars:
+                abs_tmp = abs_tmp.rename({vv: "abs_" + vv})
+        else:
+            abs_tmp = xr.Dataset()
+
+        # Loop over and rename square differenced vars (if wanted)
+        if square_diff:
+            sq_tmp = np.square(differenced)
+            diff_vars = list(sq_tmp.keys())
+            for vv in diff_vars:
+                sq_tmp = sq_tmp.rename({vv: "square_" + vv})
+        else:
+            sq_tmp = xr.Dataset()
+
+        # Merge the differences, absolute differences and square differences
+        differenced = xr.merge((differenced, abs_tmp, sq_tmp, dataset1[save_coords]))
+        return Profile(dataset=differenced)
+
+    @classmethod
+    def interpolate_vertical(cls, profile, new_depth, interp_method="linear", print_progress=False):
+        """
+        (04/10/2021)
+        Author: David Byrne
+
+        For vertical interpolation of all profiles within this object. User
+        should pass an array describing the new depths or another profile object
+        containing the same number of profiles as this object.
+
+        If a 1D numpy array is passed then all profiles will be interpolated
+        onto this single set of depths. If a xarray.DataArray is passed, it
+        should have dimensions (id_dim, z_dim) and contain a variable called
+        depth. This DataArray should contain the same number of profiles as
+        this object and will map profiles in order for interpolation. If
+        another profile object is passed, profiles will be mapped and
+        interpolated onto the other objects depth array.
+
+        INPUTS:
+         new_depth (array or dataArray) : new depths onto which to interpolate
+                                          see description above for more info.
+         interp_method (str)            : Any scipy interpolation string.
+
+        OUTPUTS:
+         Returns a new PROFILE object containing the interpolated dataset.
+        """
+
+        # If input is Profile, extract depth dataarray
+        if type(new_depth) is Profile:
+            new_depth = new_depth.dataset.depth
+
+        # If input is 1D, then interpolation will be done onto this for all.
+        if len(new_depth.shape) == 1:
+            repeated_depth = True
+            debug(f"Interpolating onto reference depths")
+        else:
+            debug(f"Interpolating onto depths of existing Profile object")
+            repeated_depth = False
+
+        ds = profile.dataset
+        n_prof = ds.sizes["id_dim"]
+        n_z = ds.sizes["z_dim"]
+
+        # Get variable names on z_dim dimension
+        zvars = []
+        notzvars = []
+        for items in ds.keys():
+            if "z_dim" in ds[items].dims:
+                zvars.append(items)
+            else:
+                notzvars.append(items)
+
+        # Now loop over profiles and interpolate model onto obs.
+        count_ii = 0
+        for pp in range(0, n_prof):
+            if print_progress:
+                print("{0} / {1}".format(pp, n_prof - 1))
+
+            # Select the current profile
+            profile = ds.isel(id_dim=pp)  # .rename({"depth": "z_dim"})
+
+            # Extract new depths for this profile
+            if repeated_depth:
+                new_depth_prof = new_depth
+            else:
+                new_depth_prof = new_depth.isel(id_dim=pp).values
+
+            # Do the interpolation and rename dimensions/vars back to normal
+            interpolated_tmp = profile[notzvars]
+
+            for vv in zvars:
+                if vv == "depth":
+                    continue
+                # Get arrays to interpolate
+                interpx = profile.depth.values
+                interpy = profile[vv].values
+
+                # Remove NaNs
+                xnans = np.isnan(interpx)
+                ynans = np.isnan(interpy)
+                xynans = np.logical_or(xnans, ynans)
+                interpx = interpx[~xynans]
+                interpy = interpy[~xynans]
+
+                # If there are <2 datapoints, dont interpolate. Return a nan
+                # array instead for this profile variable
+                if len(interpx) >= 2:
+                    # Use scipy to interpolate this profile
+                    interp_func = interpolate.interp1d(
+                        interpx, interpy, bounds_error=False, kind=interp_method, fill_value=np.nan
+                    )
+                    vv_interp = interp_func(new_depth_prof)
+                    interpolated_tmp[vv] = ("z_dim", vv_interp)
+                else:
+                    interpolated_tmp[vv] = ("z_dim", np.zeros(len(new_depth_prof)) * np.nan)
+
+            # Put the new depth into the interpolated profile
+            interpolated_tmp["depth"] = ("z_dim", new_depth_prof)
+
+            # If not first iteration, concat this interpolated profile
+            if count_ii == 0:
+                interpolated = interpolated_tmp
+            else:
+                interpolated = xr.concat((interpolated, interpolated_tmp), dim="id_dim", coords="all")
+            count_ii = count_ii + 1
+
+        # Set depth to be a coordinate and return a new Profile object.
+        interpolated = interpolated.set_coords(["depth"])
+        return Profile(dataset=interpolated)
+
+    @classmethod
+    def average_into_grid_boxes(cls, profile, grid_lon, grid_lat, min_datapoints=1, season=None, var_modifier=""):
+        """
+        Takes the contents of this Profile() object and averages each variables
+        into geographical grid boxes. At the moment, this expects there to be
+        no vertical dimension (z_dim), so make sure to slice the data out you
+        want first using isel, Profile.depth_means() or Profile.bottom_means().
+
+        INPUTS
+         grid_lon (array)     : 1d array of longitudes
+         grid_lat (array)     : 1d array of latitude
+         min_datapoints (int) : Minimum N of datapoints at which to average
+                                into box. Will return Nan in boxes with smaller N.
+                                NOTE this routine will also return the variable
+                                grid_N, which tells you how many points were
+                                averaged into each box.
+        season (str)          : 'DJF','MAM','JJA' or 'SON'. Will only average
+                                data from specified season.
+        var_modifier (str)    : Suffix to add to all averaged variables in the
+                                output dataset. For example you may want to add
+                                _DJF to all vars if restricting only to winter.
+
+        OUTPUTS
+         COAsT Gridded object containing averaged data.
+        """
+
+        # Get the dataset in this object
+        ds = profile.dataset
+
+        # Get a list of variables in this dataset
+        vars_in = [items for items in ds.keys()]
+        vars_out = [vv + "{0}".format(var_modifier) for vv in vars_in]
+
+        # Get output dimensions and create 2D longitude and latitude arrays
+        n_r = len(grid_lat) - 1
+        n_c = len(grid_lon) - 1
+        lon_mids = (grid_lon[1:] + grid_lon[:-1]) / 2
+        lat_mids = (grid_lat[1:] + grid_lat[:-1]) / 2
+        lon2, lat2 = np.meshgrid(lon_mids, lat_mids)
+
+        # Create empty output dataset
+        ds_out = xr.Dataset(coords=dict(longitude=(["y_dim", "x_dim"], lon2), latitude=(["y_dim", "x_dim"], lat2)))
+
+        # Loop over variables and create empty placeholders
+        for vv in vars_out:
+            ds_out[vv] = (["y_dim", "x_dim"], np.zeros((n_r, n_c)) * np.nan)
+        # Grid_N is the count ineach box
+        ds_out["grid_N{0}".format(var_modifier)] = (["y_dim", "x_dim"], np.zeros((n_r, n_c)) * np.nan)
+
+        # Extract season if needed
+        if season is not None:
+            season_array = general_utils.determine_season(ds.time)
+            s_ind = season_array == season
+            ds = ds.isel(id_dim=s_ind)
+
+        # Loop over every box (slow??)
+        for rr in range(n_r - 1):
+            for cc in range(n_c - 1):
+                # Get box bounds for easier understanding
+                lon_min = grid_lon[cc]
+                lon_max = grid_lon[cc + 1]
+                lat_min = grid_lat[rr]
+                lat_max = grid_lat[rr + 1]
+
+                # Get profiles inside this box
+                condition1 = np.logical_and(ds.longitude >= lon_min, ds.longitude < lon_max)
+                condition2 = np.logical_and(ds.latitude >= lat_min, ds.latitude < lat_max)
+                prof_ind = np.logical_and(condition1, condition2)
+
+                # Only average if N > min_datapoints
+                if np.sum(prof_ind) > min_datapoints:
+                    for vv in range(len(vars_in)):
+                        vv_in = vars_in[vv]
+                        vv_out = vars_out[vv]
+
+                        # Check datatype isn't string
+                        if ds[vv_in].dtype in ["S1", "S2"]:
+                            continue
+                        ds_out[vv_out][rr, cc] = ds[vv_in].isel(id_dim=prof_ind).mean()
+
+                # Store N in own variable
+                ds_out["grid_N{0}".format(var_modifier)][rr, cc] = np.sum(prof_ind)
+
+        # Create and populate output dataset
+        gridded_out = Gridded()
+        gridded_out.dataset = ds_out
+
+        return gridded_out
diff --git a/coast/stats_util.py b/coast/stats_util.py
index 5fc3343e..164438f0 100755
--- a/coast/stats_util.py
+++ b/coast/stats_util.py
@@ -63,7 +63,7 @@ def find_maxima(x, y, method="comp", **kwargs):
             method = "comp"
 
     if method == "comp":
-        peaks, props = scipy.signal.find_peaks(y, **kwargs)
+        peaks, props = scipy.signal.find_peaks(np.copy(y), **kwargs)
         return x[peaks], y[peaks]
 
     if method == "cubic":
diff --git a/coast/tidegauge.py b/coast/tidegauge.py
index 99cb9068..eff5f937 100755
--- a/coast/tidegauge.py
+++ b/coast/tidegauge.py
@@ -7,7 +7,6 @@
 import glob
 import re
 import pytz
-import sklearn.metrics as metrics
 from . import general_utils, plot_util, crps_util, stats_util
 from .logging_util import get_slug, debug, error, info
 from typing import Union
@@ -16,112 +15,85 @@
 
 class Tidegauge(Timeseries):
     """
-    An object for reading, storing and manipulating tide gauge data.
-    Functionality available for reading and organisation of GESLA files.
-    (Source: https://www.gesla.org/).  However, any fixed time series data can
-    be used if in the correct format.
-
-    The data format used for this object is as follows:
-
-    *Data Format Overview*
-
-        1. Data for a single tide gauge is stored in an xarray Dataset object.
-           This can be accessed using TIDEGAUGE.dataset.
-        2. The dataset has a single dimension: time.
-        3. Latitude/Longitude and other single values parameters are stored as
-           attributes or single float variables.
-        4. Time is a coordinate variable and time dimension.
-        5. Data variables are stored along the time dimension.
-        6. The attributes: site_name, latitude, longitude are expected. If they
-            are missing functionality may be reduced.
-
-
-    *Methods Overview*
-
-        *Initialisation and File Reading*
-        -> __init__: Can be initialised with a GESLA file or empty.
-        -> obs_operator: Interpolates model data to time series locations
-           and times (not yet implemented).
-        -> read_gesla_to_xarray_v3: Reads a format version 3.0
-           GESLA file to an xarray Dataset.
-        -> read_gesla_header_v3: Reads the header of a version 3
-           GESLA file.
-        -> read_gesla_data_v3: Reads data from a version 3 GESLA
-           file.
-        -> create_multiple_tidegauge: Creates multiple tide gauge objects
-           objects from a list of filenames or directory and returns them
-           in a list.
-
-        *Plotting*
-        -> plot_on_map: Plots location of TIDEGAUGE object on map.
-        -> plot_timeseries: Plots a specified time series.
-
-        *Model Comparison*
-        -> obs_operator(): For interpolating model data to this object.
-        -> cprs(): Calculates the CRPS between a model and obs variable.
-        -> difference(): Differences two specified variables
-        -> absolute_error(): Absolute difference, two variables
-        -> mean_absolute_error(): MAE between two variables
-        -> root_mean_square_error(): RMSE between two variables
-        -> time_mean(): Mean of a variable in time
-        -> time_std(): St. Dev of a variable in time
-        -> time_correlation(): Correlation between two variables
-        -> time_covariance(): Covariance between two variables
-        -> basic_stats(): Calculates multiple of the above metrics.
-
-        *Analysis*
-        -> resample_mean(): For resampling data in time using averaging
-        -> apply_doodson_xo_filter(): Remove tidal signal using Doodson XO
-        -> find_high_and_low_water(): Find maxima and minima of time series
+    This is an object for storage and manipulation of tide gauge data
+    in a single dataset. This may require some processing of the observations
+    such as interpolation to a common time step.
+
+    This object's dataset should take the form (as with Timeseries):
+
+        Dimensions:
+            id_dim   : The locations dimension. Each time series has an index
+            time : The time dimension. Each datapoint at each port has an index
+
+        Coordinates:
+            longitude (id_dim) : Longitude values for each port index
+            latitude  (id_dim) : Latitude values for each port index
+            time      (time) : Time values for each time index (datetime)
+            id_name   (id_dim)   : Name of index, e.g. port name or mooring id.
+
+    An example data variable could be ssh, or ntr (non-tidal residual). This
+    object can also be used for other instrument types, not just tide gauges.
+    For example moorings.
+
+    Every id index for this object should use the same time coordinates.
+    Therefore, timeseries need to be aligned before being placed into the
+    object. If there is any padding needed, then NaNs should be used. NaNs
+    should also be used for quality control/data rejection.
     """
 
-    ##############################################################################
-    ###                ~ Initialisation and File Reading ~                     ###
-    ##############################################################################
-
-    def __init__(self, file_path: str = None, date_start=None, date_end=None, config: Union[Path, str] = None):
+    def __init__(self, dataset=None, config: Union[Path, str] = None, new_time_coords=None):
         """
-        Initialise TIDEGAUGE object either as empty (no arguments) or by
-        reading GESLA data from a directory between two datetime objects.
+        Initialise TIDEGAUGE object as empty or by providing an existing
+        dataset or tidegauge object. There are read functions within Tidegauge()
+        which can subsequently read into this object's dataset.'
 
         Example usage:
         --------------
-        # Read tide gauge data for data in January 1990
-        date0 = datetime.datetime(1990,1,1)
-        date1 = datetime.datetime(1990,2,1)
-        tg = coast.TIDEGAUGE(<'path_to_file'>, date0, date1)
+        # Create empty tidegauge object
+        tidegauge = coast.Tidegauge()
 
-        # Access the data
-        tg.dataset
+        # Create new tidegauge object containing an existing xarray dataset
+        tidegauge = coast.Tidegauge(dataset = dataset_name)
 
-        Parameters
+        # Create new tidegauge object containing existing xarray dataset and
+        # create new time coordinate (will remove all variables)
+        tidegauge = coast.Tidegage(tidegauge0.dataset, new_coords = time_array)
+
+        INPUTS
         ----------
-        file_path (list of str) : Filename to read from directory.
-        date_start (datetime) : Start date for data read. Optional
-        date_end (datetime) : end date for data read. Optional
-        config (Path or str) : configuration file
+        dataset (xr.Dataset)    :: Xarray dataset to insert into new object
+        config  (Path or Str)   :: Configuration file to use when calling
+                                   read functions (saved in object) [Optional]
+        new_time_coords (array) :: New time coords to create [Optional]
 
         Returns
         -------
-        Self
+        New Tidegauge object.
         """
         debug(f"Creating a new ..... {get_slug(self)}")
         super().__init__(config)
 
         # If file list is supplied, read files from directory
-        if file_path is None:
-            self.dataset = None
-        else:
-            self.dataset = self.read_gesla_to_xarray_v3(file_path, date_start, date_end)
+        if dataset is not None:
+            self.dataset = dataset.copy()
             self.apply_config_mappings()
 
+            # If new_time_coords, replace existing time dimension with new
+            if new_time_coords is not None:
+                ds_coords = xr.Dataset(self.dataset.coords).drop("time")
+                ds_coords["time"] = ("t_dim", new_time_coords)
+                ds_coords = ds_coords.set_coords("time")
+                self.dataset = ds_coords.copy()
+
+        else:
+            self.dataset = None
+
         print(f"{get_slug(self)} initialised")
 
-    ############ tide gauge methods ##############################################
-    @classmethod
-    def read_gesla_to_xarray_v3(cls, fn_gesla, date_start=None, date_end=None):
+    ############ tide gauge methods ###########################################
+    def read_gesla_v3(self, fn_gesla, date_start=None, date_end=None):
         """
-        For reading from a single GESLA2 (Format version 3.0) file into an
+        For reading from a GESLA2 (Format version 3.0) file(s) into an
         xarray dataset. Formatting according to Woodworth et al. (2017).
         Website: https://www.gesla.org/
         If no data lies between the specified dates, a dataset is still created
@@ -129,32 +101,60 @@ def read_gesla_to_xarray_v3(cls, fn_gesla, date_start=None, date_end=None):
         be empty.
         Parameters
         ----------
-        fn_gesla (str) : path to gesla tide gauge file
+        fn_gesla (str) : path to gesla tide gauge file, list of files or a glob
         date_start (datetime) : start date for returning data
         date_end (datetime) : end date for returning data
 
         Returns
         -------
-        xarray.Dataset object.
+        Creates xarray.dataset within tidegauge object containing loaded data.
+        If multiple files are provided then instead returns a list of NEW
+        tidegauge objects.
         """
-        debug(f'Reading "{fn_gesla}" as a GESLA file with {get_slug(cls)}')  # TODO Maybe include start/end dates
-        try:
-            header_dict = cls.read_gesla_header_v3(fn_gesla)
-            dataset = cls.read_gesla_data_v3(fn_gesla, date_start, date_end)
-        except:
-            raise Exception("Problem reading GESLA file: " + fn_gesla)
-        # Attributes
-        dataset["longitude"] = header_dict["longitude"]
-        dataset["latitude"] = header_dict["latitude"]
-        del header_dict["longitude"]
-        del header_dict["latitude"]
+        debug(f'Reading "{fn_gesla}" as a GESLA file with {get_slug(self)}')
+        # TODO Maybe include start/end dates
+        dataset = None
 
-        dataset.attrs = header_dict
+        # See if its a file list input, or a glob
+        if type(fn_gesla) is not list:
+            file_list = glob.glob(fn_gesla)
+        else:
+            file_list = fn_gesla
 
-        return dataset
+        multiple = False
+        if len(file_list) > 1:
+            multiple = True
 
-    @staticmethod
-    def read_gesla_header_v3(fn_gesla):
+        ds_list = []
+        # Loop over files and put resulting dataset into an output list
+        for fn in file_list:
+            try:
+                header_dict = self._read_gesla_header_v3(fn)
+                dataset = self._read_gesla_data_v3(fn, date_start, date_end)
+            except:
+                raise Exception("Problem reading GESLA file: " + fn)
+            # Attributes
+            dataset["longitude"] = ("id_dim", [header_dict["longitude"]])
+            dataset["latitude"] = ("id_dim", [header_dict["latitude"]])
+            dataset["id_name"] = ("id_dim", [header_dict["site_name"]])
+            dataset = dataset.set_coords(["longitude", "latitude", "id_name"])
+
+            # Create tidegauge object, save dataset and append to list
+            if multiple:
+                tg_tmp = Tidegauge()
+                tg_tmp.dataset = dataset
+                tg_tmp.apply_config_mappings()
+                ds_list.append(tg_tmp)
+
+        # If there is only one file, then just return the dataset, not a list
+        if multiple:
+            return ds_list
+        else:
+            self.dataset = dataset
+            self.apply_config_mappings()
+
+    @classmethod
+    def _read_gesla_header_v3(cls, fn_gesla):
         """
         Reads header from a GESLA file (format version 3.0).
 
@@ -214,8 +214,8 @@ def read_gesla_header_v3(fn_gesla):
         }
         return header_dict
 
-    @staticmethod
-    def read_gesla_data_v3(fn_gesla, date_start=None, date_end=None, header_length: int = 32):
+    @classmethod
+    def _read_gesla_data_v3(cls, fn_gesla, date_start=None, date_end=None, header_length: int = 32):
         """
         Reads observation data from a GESLA file (format version 3.0).
 
@@ -234,7 +234,7 @@ def read_gesla_data_v3(fn_gesla, date_start=None, date_end=None, header_length:
         debug(f'Reading GESLA data from "{fn_gesla}"')
         dataset = xr.Dataset()
         time = []
-        sea_level = []
+        ssh = []
         qc_flags = []
         # Open file and loop until EOF
         with open(fn_gesla) as file:
@@ -245,7 +245,7 @@ def read_gesla_data_v3(fn_gesla, date_start=None, date_end=None, header_length:
                     working_line = line.split()
                     if working_line[0] != "#":
                         time.append(working_line[0] + " " + working_line[1])
-                        sea_level.append(float(working_line[2]))
+                        ssh.append(float(working_line[2]))
                         qc_flags.append(int(working_line[3]))
 
                 line_count = line_count + 1
@@ -264,67 +264,24 @@ def read_gesla_data_v3(fn_gesla, date_start=None, date_end=None, header_length:
             date_end = np.datetime64(date_end)
             end_index = np.argmax(time > date_end)
         time = time[start_index:end_index]
-        sea_level = sea_level[start_index:end_index]
+        ssh = ssh[start_index:end_index]
         qc_flags = qc_flags[start_index:end_index]
 
         # Set null values to nan
-        sea_level = np.array(sea_level)
+        ssh = np.array(ssh)
         qc_flags = np.array(qc_flags)
-        sea_level[qc_flags == 5] = np.nan
+        ssh[qc_flags == 5] = np.nan
 
         # Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
-        dataset["qc_flags"] = xr.DataArray(qc_flags, dims=["time"])
-        dataset = dataset.assign_coords(time=("time", time))
+        dataset["ssh"] = xr.DataArray(ssh, dims=["t_dim"]).expand_dims("id_dim")
+        dataset["qc_flags"] = xr.DataArray(qc_flags, dims=["t_dim"]).expand_dims("id_dim")
+        dataset = dataset.assign_coords(time=("t_dim", time))
 
         # Assign local dataset to object-scope dataset
         return dataset
 
-    @classmethod
-    def create_multiple_tidegauge(cls, file_list, date_start=None, date_end=None, config: Union[Path, str] = None):
-        """
-        Reads multiple GESLA tide gauge files from file_list (can include
-        wildcards) and return them in a list. date_start and date_end should
-        be datetime like objects. For a lot of files/data, this may take a
-        while.
-
-        Example usage:
-        --------------
-            # Read all data in directory in January 1990
-            date0 = datetime.datetime(1990,1,1)
-            date1 = datetime.datetime(1990,2,1)
-            tg = coast.TIDEGAUGE('gesla_directory/*', date0, date1)
-        Returns
-        -------
-        List of TIDEGAUGE objects.
-        """
-        # If single string is given then put into a single element list
-        if type(file_list) is str:
-            file_list = [file_list]
-
-        # Check file_list for wildcards and make list of files to read
-        file_to_read = []
-        for file in file_list:
-            if "*" in file:
-                wildcard_list = glob.glob(file)
-                file_to_read = file_to_read + wildcard_list
-            else:
-                file_to_read.append(file)
-
-        # Loop over files to read and read them into datasets
-        tidegauge_list = []
-        for file in file_to_read:
-            try:
-                new_object = Tidegauge(file, date_start, date_end, config)
-                tidegauge_list.append(new_object)
-            except FileNotFoundError as fne:
-                Warning(str(fne))
-
-        return tidegauge_list
-
-    ############ tide table methods (HLW) #########################################
-    @classmethod
-    def read_hlw_to_xarray(cls, fn_hlw, date_start=None, date_end=None):
+    ### tide table methods (HLW)
+    def read_hlw(self, fn_hlw, date_start=None, date_end=None):
         """
         For reading from a file of tidetable High and Low Waters (HLW) data into an
         xarray dataset. File contains high water and low water heights and times
@@ -350,10 +307,11 @@ def read_hlw_to_xarray(cls, fn_hlw, date_start=None, date_end=None):
         -------
         xarray.Dataset object.
         """
-        debug(f'Reading "{fn_hlw}" as a HLW file with {get_slug(cls)}')  # TODO Maybe include start/end dates
+        debug(f'Reading "{fn_hlw}" as a HLW file with {get_slug(self)}')
+        # TODO Maybe include start/end dates
         try:
-            header_dict = cls.read_hlw_header(fn_hlw)
-            dataset = cls.read_hlw_data(fn_hlw, header_dict, date_start, date_end)
+            header_dict = self._read_hlw_header(fn_hlw)
+            dataset = self._read_hlw_data(fn_hlw, header_dict, date_start, date_end)
             if header_dict["field"] == "TZ:UT(GMT)/BST":
                 debug("Read in as BST, stored as UTC")
             elif header_dict["field"] == "TZ:GMTonly":
@@ -365,16 +323,16 @@ def read_hlw_to_xarray(cls, fn_hlw, date_start=None, date_end=None):
             raise Exception("Problem reading HLW file: " + fn_hlw)
 
         dataset.attrs = header_dict
+        self.dataset = dataset
+        self.apply_config_mappings()
 
-        return dataset
-
-    @staticmethod
-    def read_hlw_header(filnam):
+    @classmethod
+    def _read_hlw_header(cls, filnam):
         """
         Reads header from a HWL file.
 
         The data takes the form:
-        LIVERPOOL (GLADSTONE DOCK)    TZ: UT(GMT)/BST     Units: METRES    Datum: Chart Datum
+        LIVERPOOL (GLADSTONE DOCK) TZ: UT(GMT)/BST Units: METRES Datum: Chart Datum
         01/10/2020  06:29    1.65
         01/10/2020  11:54    9.01
         01/10/2020  18:36    1.87
@@ -411,8 +369,8 @@ def read_hlw_header(filnam):
         header_dict = {"site_name": site_name, "field": field, "units": units, "datum": datum}
         return header_dict
 
-    @staticmethod
-    def read_hlw_data(filnam, header_dict, date_start=None, date_end=None, header_length: int = 1):
+    @classmethod
+    def _read_hlw_data(cls, filnam, header_dict, date_start=None, date_end=None, header_length: int = 1):
         """
         Reads HLW data from a tidetable file.
 
@@ -433,7 +391,7 @@ def read_hlw_data(filnam, header_dict, date_start=None, date_end=None, header_le
         debug(f'Reading HLW data from "{filnam}"')
         dataset = xr.Dataset()
         time = []
-        sea_level = []
+        ssh = []
 
         if header_dict["field"] == "TZ:UT(GMT)/BST":
             localtime_flag = True
@@ -452,10 +410,11 @@ def read_hlw_data(filnam, header_dict, date_start=None, date_end=None, header_le
                         # Read time as datetime.datetime because it can handle local timezone easily AND the unusual date format
                         datetime_obj = datetime.datetime.strptime(time_str, "%d/%m/%Y %H:%M")
                         if localtime_flag == True:
-                            time.append(np.datetime64(datetime_obj.astimezone()))
+                            bst_obj = pytz.timezone("Europe/London")
+                            time.append(np.datetime64(bst_obj.localize(datetime_obj).astimezone(pytz.utc)))
                         else:
                             time.append(np.datetime64(datetime_obj))
-                        sea_level.append(float(working_line[2]))
+                        ssh.append(float(working_line[2]))
                 line_count = line_count + 1
             debug(f'Read done, close file "{filnam}"')
 
@@ -473,10 +432,10 @@ def read_hlw_data(filnam, header_dict, date_start=None, date_end=None, header_le
             end_index = np.argmax(time > date_end)
             debug(f"date_end: {date_end}. end_index: {end_index}")
         time = time[start_index:end_index]
-        sea_level = sea_level[start_index:end_index]
-        debug(f"sea_level: {sea_level}")
+        ssh = ssh[start_index:end_index]
+        debug(f"ssh: {ssh}")
         # Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
+        dataset["ssh"] = xr.DataArray(ssh, dims=["time"]).expand_dims("id_dim")
         dataset = dataset.assign_coords(time=("time", time))
         # Assign local dataset to object-scope dataset
         return dataset
@@ -488,25 +447,25 @@ def show(self, timezone: str = None):
         """
         # debug(" Saltney pred", np.datetime_as_string(Saltney_time_pred[i], unit='m', timezone=pytz.timezone('Europe/London')),". Height: {:.2f} m".format( HT.values[i] ))
         if timezone == None:
-            for i in range(len(self.dataset.sea_level)):
+            for i in range(len(self.dataset.ssh)):
                 #               debug('time:', self.dataset.time[i].values,
                 debug(
                     "time (UTC):",
                     general_utils.dayoweek(self.dataset.time[i].values),
                     np.datetime_as_string(self.dataset.time[i], unit="m"),
                     "height:",
-                    self.dataset.sea_level[i].values,
+                    self.dataset.ssh[i].values,
                     "m",
                 )
         else:  # display timezone aware times
-            for i in range(len(self.dataset.sea_level)):
+            for i in range(len(self.dataset.ssh)):
                 #               debug('time:', self.dataset.time[i].values,
                 debug(
                     "time (" + timezone + "):",
                     general_utils.day_of_week(self.dataset.time[i].values),
                     np.datetime_as_string(self.dataset.time[i], unit="m", timezone=pytz.timezone(timezone)),
                     "height:",
-                    self.dataset.sea_level[i].values,
+                    self.dataset.ssh[i].values,
                     "m",
                 )
 
@@ -514,7 +473,7 @@ def get_tide_table_times(
         self,
         time_guess: np.datetime64 = None,
         time_var: str = "time",
-        measure_var: str = "sea_level",
+        measure_var: str = "ssh",
         method: str = "window",
         winsize=None,
     ):
@@ -524,7 +483,7 @@ def get_tide_table_times(
         time_guess : np.datetime64 or datetime
                 assumes utc
         time_var : name of time variable [default: 'time']
-        measure_var : name of sea_level variable [default: 'sea_level']
+        measure_var : name of ssh variable [default: 'ssh']
 
         method =
             window:  +/- hours window size, winsize, (int) return values in that window
@@ -534,7 +493,7 @@ def get_tide_table_times(
             nearest_HW: return nearest High Water event (computed as the max of `nearest_2`), if in winsize [default:None]
 
         returns: xr.DataArray( measure_var, coords=time_var)
-            E.g. sea_level (m), time (utc)
+            E.g. ssh (m), time (utc)
             If value is not found, it returns a NaN with time value as the
             guess value.
 
@@ -561,9 +520,9 @@ def get_tide_table_times(
             date_end = time_guess + np.timedelta64(winsize, "h")
             end_index = np.argmax(self.dataset[time_var].values > date_end)
 
-            sea_level = self.dataset[measure_var][start_index:end_index]
+            ssh = self.dataset[measure_var].isel(time=slice(start_index, end_index))
 
-            return sea_level
+            return ssh[0]
 
         elif method == "nearest_1":
             dt = np.abs(self.dataset[time_var] - time_guess)
@@ -572,7 +531,7 @@ def get_tide_table_times(
                 if np.timedelta64(dt[index[0]].values, "m").astype("int") <= 60 * winsize:  # compare in minutes
                     debug(f"dt:{np.timedelta64(dt[index[0]].values, 'm').astype('int')}")
                     debug(f"winsize:{winsize}")
-                    return self.dataset[measure_var][index[0]]
+                    return self.dataset[measure_var].isel(time=index[0])
                 else:
                     # return a NaN in an xr.Dataset
                     # The rather odd trailing zero is to remove the array layer
@@ -580,18 +539,18 @@ def get_tide_table_times(
                     # alternative for a return object
                     return xr.DataArray([np.NaN], dims=(time_var), coords={time_var: [time_guess]})[0]
             else:  # give the closest without window search truncation
-                return self.dataset[measure_var][index[0]]
+                return self.dataset[measure_var].isel(time=index[0])
 
         elif method == "nearest_2":
             index = np.argsort(np.abs(self.dataset[time_var] - time_guess)).values
-            nearest_2 = self.dataset[measure_var][index[0 : 1 + 1]]  # , self.dataset.time[index[0:1+1]]
-            return nearest_2
+            nearest_2 = self.dataset[measure_var].isel(time=index[0 : 1 + 1])  # , self.dataset.time[index[0:1+1]]
+            return nearest_2[0]
 
         elif method == "nearest_HW":
             index = np.argsort(np.abs(self.dataset[time_var] - time_guess)).values
-            # return self.dataset.sea_level[ index[np.argmax( self.dataset.sea_level[index[0:1+1]]] )] #, self.dataset.time[index[0:1+1]]
-            nearest_2 = self.dataset[measure_var][index[0 : 1 + 1]]  # , self.dataset.time[index[0:1+1]]
-            return nearest_2[nearest_2.argmax()]
+            # return self.dataset.ssh[ index[np.argmax( self.dataset.ssh[index[0:1+1]]] )] #, self.dataset.time[index[0:1+1]]
+            nearest_2 = self.dataset[measure_var].isel(time=index[0 : 1 + 1])  # , self.dataset.time[index[0:1+1]]
+            return nearest_2.isel(time=nearest_2.argmax())
 
         else:
             debug("Not expecting that option / method")
@@ -620,7 +579,7 @@ def read_ea_api_to_xarray(
             station_id : int. Station id. Also referred to as stationReference in
              EA API. Default value is for Liverpool.
         OUTPUT:
-            sea_level, time : xr.Dataset
+            ssh, time : xr.Dataset
         """
         import requests, json
 
@@ -629,7 +588,7 @@ def read_ea_api_to_xarray(
         cls.date_end = date_end
         cls.station_id = station_id  # EA id: stationReference
 
-        # %% Obtain and process header information
+        # Obtain and process header information
         info("load station info")
         url = "https://environment.data.gov.uk/flood-monitoring/id/stations/" + cls.station_id + ".json"
         try:
@@ -651,7 +610,7 @@ def read_ea_api_to_xarray(
         except:
             debug(f"problem defining the parameter to read")
 
-        # %% Construct API request for data recovery
+        # Construct API request for data recovery
         info("load station data")
         if (cls.date_start == None) & (cls.date_end == None):
             info(f"GETting n_days= {cls.n_days} of data")
@@ -673,7 +632,7 @@ def read_ea_api_to_xarray(
             else:
                 debug("Expecting date_start and date_end as datetime objects")
 
-        # %% Get the data
+        # Get the data
         try:
             request_raw = requests.get(url)
             request = json.loads(request_raw.content)
@@ -682,27 +641,26 @@ def read_ea_api_to_xarray(
             debug(f"Failed request: {request_raw}")
             return
 
-        # %% Process timeseries data
+        # Process timeseries data
         dataset = xr.Dataset()
         time = []
-        sea_level = []
+        ssh = []
         nvals = len(request["items"])
         time = np.array([np.datetime64(request["items"][i]["dateTime"]) for i in range(nvals)])
-        sea_level = np.array([request["items"][i]["value"] for i in range(nvals)])
+        ssh = np.array([request["items"][i]["value"] for i in range(nvals)])
 
-        # %% Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
+        # Assign arrays to Dataset
+        dataset["ssh"] = xr.DataArray(ssh, dims=["time"])
         dataset = dataset.assign_coords(time=("time", time))
         dataset.attrs = header_dict
         debug(f"EA API request headers: {header_dict}")
-        # debug(f"EA API request 1st time: {time[0]} and value: {sea_level[0]}")
+        # debug(f"EA API request 1st time: {time[0]} and value: {ssh[0]}")
 
         # Assign local dataset to object-scope dataset
         return dataset
 
-    ############ BODC tide gauge methods ##############################################
-    @classmethod
-    def read_bodc_to_xarray(cls, fn_bodc, date_start=None, date_end=None):
+    ############ BODC tide gauge methods ######################################
+    def read_bodc(self, fn_bodc, date_start=None, date_end=None):
         """
         For reading from a single BODC (processed) file into an
         xarray dataset.
@@ -723,7 +681,8 @@ def read_bodc_to_xarray(cls, fn_bodc, date_start=None, date_end=None):
             End Date:          31AUG2020-23.45.00
             Contributor:       National Oceanography Centre, Liverpool
             Datum information: The data refer to Admiralty Chart Datum (ACD)
-            Parameter code:    ASLVBG02 = Surface elevation (unspecified datum) of the water body by bubbler tide gauge (second sensor)
+            Parameter code:    ASLVBG02 = Surface elevation (unspecified datum)
+            of the water body by bubbler tide gauge (second sensor)
               Cycle    Date      Time    ASLVBG02   Residual
              Number yyyy mm dd hh mi ssf         f          f
                  1) 2020/08/01 00:00:00     5.354M     0.265M
@@ -743,10 +702,11 @@ def read_bodc_to_xarray(cls, fn_bodc, date_start=None, date_end=None):
         -------
         xarray.Dataset object.
         """
-        debug(f'Reading "{fn_bodc}" as a BODC file with {get_slug(cls)}')  # TODO Maybe include start/end dates
+        debug(f'Reading "{fn_bodc}" as a BODC file with {get_slug(self)}')
+        # TODO Maybe include start/end dates
         try:
-            header_dict = cls.read_bodc_header(fn_bodc)
-            dataset = cls.read_bodc_data(fn_bodc, date_start, date_end)
+            header_dict = self._read_bodc_header(fn_bodc)
+            dataset = self._read_bodc_data(fn_bodc, date_start, date_end)
         except:
             raise Exception("Problem reading BODC file: " + fn_bodc)
         # Attributes
@@ -756,11 +716,10 @@ def read_bodc_to_xarray(cls, fn_bodc, date_start=None, date_end=None):
         del header_dict["latitude"]
 
         dataset.attrs = header_dict
-
-        return dataset
+        self.dataset = dataset
 
     @staticmethod
-    def read_bodc_header(fn_bodc):
+    def _read_bodc_header(fn_bodc):
         """
         Reads header from a BODC file (format version 3.0).
 
@@ -799,7 +758,7 @@ def read_bodc_header(fn_bodc):
         return header_dict
 
     @staticmethod
-    def read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int = 11):
+    def _read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int = 11):
         """
         Reads observation data from a BODC file.
 
@@ -818,7 +777,7 @@ def read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int =
         debug(f'Reading BODC data from "{fn_bodc}"')
         dataset = xr.Dataset()
         time = []
-        sea_level = []
+        ssh = []
         qc_flags = []
         residual = []
         # Open file and loop until EOF
@@ -830,24 +789,24 @@ def read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int =
                     try:
                         working_line = line.split()
                         time_str = working_line[1] + " " + working_line[2]  # Empty lines cause trouble
-                        sea_level_str = working_line[3]
+                        ssh_str = working_line[3]
                         residual_str = working_line[4]
-                        if sea_level_str[-1].isalpha():
-                            qc_flag_str = sea_level_str[-1]
-                            sea_level_str = sea_level_str.replace(qc_flag_str, "")
+                        if ssh_str[-1].isalpha():
+                            qc_flag_str = ssh_str[-1]
+                            ssh_str = ssh_str.replace(qc_flag_str, "")
                             residual_str = residual_str.replace(qc_flag_str, "")
                         elif residual_str[-1].isalpha():  # sometimes residual has a
                             # flag when elevation does not
                             qc_flag_str = residual_str[-1]
-                            sea_level_str = sea_level_str.replace(qc_flag_str, "")
+                            ssh_str = ssh_str.replace(qc_flag_str, "")
                             residual_str = residual_str.replace(qc_flag_str, "")
                         else:
                             qc_flag_str = ""
                         # debug(line_count-header_length, residual_str, float(residual_str))
-                        # debug(working_line, sea_level_str, qc_flag_str)
+                        # debug(working_line, ssh_str, qc_flag_str)
                         time.append(time_str)
                         qc_flags.append(qc_flag_str)
-                        sea_level.append(float(sea_level_str))
+                        ssh.append(float(ssh_str))
                         residual.append(float(residual_str))
                     except:
                         debug(f"{file} probably empty line at end. Breaks split()")
@@ -867,17 +826,17 @@ def read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int =
             date_end = np.datetime64(date_end)
             end_index = np.argmax(time > date_end)
         time = time[start_index:end_index]
-        sea_level = sea_level[start_index:end_index]
+        ssh = ssh[start_index:end_index]
         qc_flags = qc_flags[start_index:end_index]
 
         # Set null values to nan
-        sea_level = np.array(sea_level)
+        ssh = np.array(ssh)
         qc_flags = np.array(qc_flags)
-        # sea_level[qc_flags==5] = np.nan
+        # ssh[qc_flags==5] = np.nan
 
         # Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
-        dataset["qc_flags"] = xr.DataArray(qc_flags, dims=["time"])
+        dataset["ssh"] = xr.DataArray(ssh, dims=["time"]).expand_dims("id_dim")
+        dataset["qc_flags"] = xr.DataArray(qc_flags, dims=["time"]).expand_dims("id_dim")
         dataset = dataset.assign_coords(time=("time", time))
 
         # Assign local dataset to object-scope dataset
@@ -887,68 +846,14 @@ def read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int =
     ###                ~            Plotting             ~                     ###
     ##############################################################################
 
-    def plot_on_map(self):
-        """
-        Show the location of a tidegauge on a map.
-
-        Example usage:
-        --------------
-        # For a TIDEGAUGE object tg
-        tg.plot_map()
-
-        """
-
-        debug(f"Plotting tide gauge locations for {get_slug(self)}")
-
-        title = "Location: " + self.dataset.attrs["site_name"]
-        X = self.dataset.longitude
-        Y = self.dataset.latitude
-        fig, ax = plot_util.geo_scatter(X, Y, title=title)
-        ax.set_xlim((X - 10, X + 10))
-        ax.set_ylim((Y - 10, Y + 10))
-        return fig, ax
-
-    @classmethod
-    def plot_on_map_multiple(cls, tidegauge_list, color_var_str=None):
-        """
-        Show the location of a tidegauge on a map.
-
-        Example usage:
-        --------------
-        # For a TIDEGAUGE object tg
-        tg.plot_map()
-
-        """
-
-        debug(f"Plotting tide gauge locations for {get_slug(cls)}")
-
-        X = []
-        Y = []
-        C = []
-        for tg in tidegauge_list:
-            X.append(tg.dataset.longitude)
-            Y.append(tg.dataset.latitude)
-            if color_var_str is not None:
-                C.append(tg.dataset[color_var_str].values)
-
-        title = ""
-        if color_var_str is None:
-            fig, ax = plot_util.geo_scatter(X, Y, title=title)
-        else:
-            fig, ax = plot_util.geo_scatter(X, Y, title=title, c=C)
-
-        ax.set_xlim((min(X) - 10, max(X) + 10))
-        ax.set_ylim((min(Y) - 10, max(Y) + 10))
-        return fig, ax
-
-    def plot_timeseries(self, var_list=["sea_level"], date_start=None, date_end=None, plot_line=False):
+    def plot_timeseries(self, id, var_list=["ssh"], date_start=None, date_end=None, plot_line=False):
         """
         Quick plot of time series stored within object's dataset
         Parameters
         ----------
         date_start (datetime) : Start date for plotting
         date_end (datetime) : End date for plotting
-        var_list  (str)  : List of variables to plot. Default: just sea_level
+        var_list  (str)  : List of variables to plot. Default: just ssh
         plot_line (bool) : If true, draw line between markers
 
         Returns
@@ -993,302 +898,132 @@ def plot_timeseries(self, var_list=["sea_level"], date_start=None, date_end=None
 
         return fig, ax
 
-    ##############################################################################
-    ###                ~        Model Comparison         ~                     ###
-    ##############################################################################
-
-    def obs_operator(self, model, mod_var_name: str, time_interp="nearest", model_mask=None):
+    def plot_on_map(self):
         """
-        Interpolates a model array (specified using a model object and variable
-        string) to TIDEGAUGE location and times. Takes the nearest model grid
-        cell to the tide gauge.
-
-        Parameters
-        ----------
-        model : MODEL object (e.g. NEMO)
-        model_var_name (str) : Name of variable (inside MODEL) to interpolate.
-        time_interp (str) : type of scipy time interpolation (e.g. linear)
-        model_mask : Mask to apply to model data in geographical interpolation
-                     of model. For example, use to ignore land points.
-                     If None, no mask is applied. If 'bathy', model variable
-                     (bathymetry==0) is used. Custom 2D mask arrays can be
-                     supplied.
-
-        Returns
-        -------
-        Saves interpolated array to TIDEGAUGE.dataset
+        Show the location of a tidegauge on a map.
+        Example usage:
+        --------------
+        # For a TIDEGAUGE object tg
+        tg.plot_map()
         """
-        # Determine mask
-        if model_mask == "bathy":
-            model_mask = model.dataset.bathymetry.values == 0
 
-        # Get data arrays
-        mod_var_array = model.dataset[mod_var_name]
+        debug(f"Plotting tide gauge locations for {get_slug(self)}")
 
-        # Depth interpolation -> for now just take 0 index
-        if "z_dim" in mod_var_array.dims:
-            mod_var_array = mod_var_array.isel(z_dim=0).squeeze()
+        X = self.dataset.longitude
+        Y = self.dataset.latitude
+        fig, ax = plot_util.geo_scatter(X, Y)
+        ax.set_xlim((X - 10, X + 10))
+        ax.set_ylim((Y - 10, Y + 10))
+        return fig, ax
 
-        # Cast lat/lon to numpy arrays
-        obs_lon = np.array([self.dataset.longitude])
-        obs_lat = np.array([self.dataset.latitude])
+    @classmethod
+    def plot_on_map_multiple(cls, tidegauge_list, color_var_str=None):
+        """
+        Show the location of a tidegauge on a map.
+        Example usage:
+        --------------
+        # For a TIDEGAUGE object tg
+        tg.plot_map()
+        """
 
-        interpolated = model.interpolate_in_space(mod_var_array, obs_lon, obs_lat, mask=model_mask)
+        debug(f"Plotting tide gauge locations for {get_slug(cls)}")
 
-        interpolated = model.interpolate_in_time(interpolated, self.dataset.time)
+        X = []
+        Y = []
+        C = []
+        for tg in tidegauge_list:
+            X.append(tg.dataset.longitude)
+            Y.append(tg.dataset.latitude)
+            if color_var_str is not None:
+                C.append(tg.dataset[color_var_str].values)
 
-        # Store interpolated array in dataset
-        new_var_name = "interp_" + mod_var_name
-        self.dataset[new_var_name] = interpolated.drop(["longitude", "latitude"])
-        return
+        title = ""
+        if color_var_str is None:
+            fig, ax = plot_util.geo_scatter(X, Y, title=title)
+        else:
+            fig, ax = plot_util.geo_scatter(X, Y, title=title, c=C)
 
-    def crps(
-        self,
-        model_object,
-        model_var_name,
-        obs_var_name: str = "sea_level",
-        nh_radius: float = 20,
-        time_interp: str = "linear",
-        create_new_obj=True,
-    ):
-        """
-        Comparison of observed variable to modelled using the Continuous
-        Ranked Probability Score. This is done using this TIDEGAUGE object.
-        This method specifically performs a single-observation neighbourhood-
-        forecast method.
+        ax.set_xlim((min(X) - 10, max(X) + 10))
+        ax.set_ylim((min(Y) - 10, max(Y) + 10))
+        return fig, ax
 
-        Parameters
-        ----------
-        model_object (model) : Model object (NEMO) containing model data
-        model_var_name (str) : Name of model variable to compare.
-        obs_var_name (str)   : Name of observed variable to compare.
-        nh_radius (float)    : Neighbourhood rad
-        cdf_type (str)       : Type of cumulative distribution to use for the
-                               model data ('empirical' or 'theoretical').
-                               Observations always use empirical.
-        time_interp (str)    : Type of time interpolation to use (s)
-        create_new_obj (bool): If True, save output to new TIDEGAUGE obj.
-                               Otherwise, save to this obj.
+    ##############################################################################
+    ###                ~        Model Comparison         ~                     ###
+    ##############################################################################
+    def obs_operator(self, gridded, time_interp="nearest"):
+        """
+        Regrids a Gridded object onto a tidegauge_multiple object. A nearest
+        neighbour interpolation is done for spatial interpolation and time
+        interpolation can be specified using the time_interp argument. This
+        takes any scipy interpolation string. If Gridded object contains a
+        landmask variables, then the nearest WET point is taken for each tide
+        gauge.
+
+        Output is a new tidegauge_multiple object containing interpolated data.
+        """
 
-        Returns
-        -------
-        xarray.Dataset containing times, sealevel and quality control flags
+        gridded = gridded.dataset
+        ds = self.dataset
 
-        Example Useage
-        -------
-        # Compare modelled 'sossheig' with 'sea_level' using CRPS
-        crps = altimetry.crps(nemo, 'sossheig', 'sea_level')
-        """
+        # Determine if landmask is present
+        if "landmask" not in gridded:
+            gridded.landmask = None
 
-        mod_var = model_object.dataset[model_var_name]
-        obs_var = self.dataset[obs_var_name]
-
-        crps_list, n_model_pts, contains_land = crps_util.crps_sonf_fixed(
-            mod_var,
-            self.dataset.longitude.values,
-            self.dataset.latitude.values,
-            obs_var.values,
-            obs_var.time.values,
-            nh_radius,
-            time_interp,
+        # Determine spatial indices
+        print("Calculating spatial indices.", flush=True)
+        ind_x, ind_y = general_utils.nearest_indices_2d(
+            gridded.longitude, gridded.latitude, ds.longitude, ds.latitude, mask=gridded.landmask
         )
-        if create_new_obj:
-            new_object = Tidegauge()
-            new_dataset = self.dataset[["longitude", "latitude", "time"]]
-            new_dataset["crps"] = (("time"), crps_list)
-            new_dataset["crps_n_model_pts"] = (("time"), n_model_pts)
-            new_object.dataset = new_dataset
-            return new_object
-        else:
-            self.dataset["crps"] = (("time"), crps_list)
-            self.dataset["crps_n_model_pts"] = (("time"), n_model_pts)
-
-    def difference(self, var_str0: str, var_str1: str, date0=None, date1=None):
-        """Difference two variables defined by var_str0 and var_str1 between
-        two dates date0 and date1. Returns xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        diff = var0 - var1
-        return xr.DataArray(diff, dims="time", name="error", coords={"time": self.dataset.time})
-
-    def absolute_error(self, var_str0, var_str1, date0=None, date1=None):
-        """Absolute difference two variables defined by var_str0 and var_str1
-        between two dates date0 and date1. Return xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        adiff = np.abs(var0 - var1)
-        return xr.DataArray(adiff, dims="time", name="absolute_error", coords={"time": self.dataset.time})
-
-    def mean_absolute_error(self, var_str0, var_str1, date0=None, date1=None):
-        """Mean absolute difference two variables defined by var_str0 and
-        var_str1 between two dates date0 and date1. Return xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        mae = metrics.mean_absolute_error(var0, var1)
-        return mae
-
-    def root_mean_square_error(self, var_str0, var_str1, date0=None, date1=None):
-        """Root mean square difference two variables defined by var_str0 and
-        var_str1 between two dates date0 and date1. Return xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        rmse = metrics.mean_squared_error(var0, var1)
-        return np.sqrt(rmse)
-
-    def time_mean(self, var_str, date0=None, date1=None):
-        """Time mean of variable var_str between dates date0, date1"""
-        var = self.dataset[var_str]
-        var = general_utils.data_array_time_slice(var, date0, date1)
-        return np.nanmean(var)
-
-    def time_std(self, var_str, date0=None, date1=None):
-        """Time st. dev of variable var_str between dates date0 and date1"""
-        var = self.dataset[var_str]
-        var = general_utils.data_array_time_slice(var, date0, date1)
-        return np.nanstd(var)
-
-    def time_correlation(self, var_str0, var_str1, date0=None, date1=None, method="pearson"):
-        """Time correlation between two variables defined by var_str0,
-        var_str1 between dates date0 and date1. Uses Pandas corr()."""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = var0.rename("var1")
-        var1 = var1.rename("var2")
-        var0 = general_utils.data_array_time_slice(var0, date0, date1)
-        var1 = general_utils.data_array_time_slice(var1, date0, date1)
-        pdvar = xr.merge((var0, var1))
-        pdvar = pdvar.to_dataframe()
-        corr = pdvar.corr(method=method)
-        return corr.iloc[0, 1]
-
-    def time_covariance(self, var_str0, var_str1, date0=None, date1=None):
-        """Time covariance between two variables defined by var_str0,
-        var_str1 between dates date0 and date1. Uses Pandas corr()."""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = var0.rename("var1")
-        var1 = var1.rename("var2")
-        var0 = general_utils.data_array_time_slice(var0, date0, date1)
-        var1 = general_utils.data_array_time_slice(var1, date0, date1)
-        pdvar = xr.merge((var0, var1))
-        pdvar = pdvar.to_dataframe()
-        cov = pdvar.cov()
-        return cov.iloc[0, 1]
-
-    def basic_stats(self, var_str0, var_str1, date0=None, date1=None, create_new_object=True):
-        """Calculates a selection of statistics for two variables defined by
-        var_str0 and var_str1, between dates date0 and date1. This will return
-        their difference, absolute difference, mean absolute error, root mean
-        square error, correlation and covariance. If create_new_object is True
-        then this method returns a new TIDEGAUGE object containing statistics,
-        otherwise variables are saved to the dateset inside this object."""
-
-        diff = self.difference(var_str0, var_str1, date0, date1)
-        ae = self.absolute_error(var_str0, var_str1, date0, date1)
-        mae = self.mean_absolute_error(var_str0, var_str1, date0, date1)
-        rmse = self.root_mean_square_error(var_str0, var_str1, date0, date1)
-        corr = self.time_correlation(var_str0, var_str1, date0, date1)
-        cov = self.time_covariance(var_str0, var_str1, date0, date1)
-
-        if create_new_object:
-            new_object = Tidegauge()
-            new_dataset = self.dataset[["longitude", "latitude", "time"]]
-            new_dataset["absolute_error"] = ae
-            new_dataset["error"] = diff
-            new_dataset["mae"] = mae
-            new_dataset["rmse"] = rmse
-            new_dataset["corr"] = corr
-            new_dataset["cov"] = cov
-            new_object.dataset = new_dataset
-            return new_object
+
+        # Extract spatial time series
+        print("Calculating time indices.", flush=True)
+        extracted = gridded.isel(x_dim=ind_x, y_dim=ind_y)
+        if "dim_0" in extracted.dims:
+            extracted = extracted.swap_dims({"dim_0": "id_dim"})
         else:
-            self.dataset["absolute_error"] = ae
-            self.dataset["error"] = diff
-            self.dataset["mae"] = mae
-            self.dataset["rmse"] = rmse
-            self.dataset["corr"] = corr
-            self.dataset["cov"] = cov
+            extracted = extracted.expand_dims("id_dim")
 
-    ##############################################################################
-    ###                ~            Analysis             ~                     ###
-    ##############################################################################
+        # Compute data (takes a while..)
+        print(" Indexing model data at tide gauge locations.. ", flush=True)
+        extracted.load()
 
-    def resample_mean(self, var_str: str, time_freq: str, **kwargs):
-        """Resample a TIDEGAUGE variable in time by calculating the mean
-            of all data points at a given frequency.
+        # Check interpolation distances
+        print("Calculating interpolation distances.", flush=True)
+        interp_dist = general_utils.calculate_haversine_distance(
+            extracted.longitude.values, extracted.latitude.values, ds.longitude.values, ds.latitude.values
+        )
 
-        Parameters
-        ----------
-        var_str (str)    : Variable name to resample
-        time_freq (str)  : Time frequency. e.g. '1H' for hourly, '1D' for daily
-                           Can also be a timedelta object. See Pandas resample
-                           method for more info.
-        **kwargs (other) : Other arguments to pass to xarray.Dataset.resample
-        (http://xarray.pydata.org/en/stable/generated/xarray.Dataset.resample.html)
+        # Interpolate model onto obs times
+        print("Interpolating in time...", flush=True)
+        extracted = extracted.rename({"time": "t_dim"})
+        extracted = extracted.interp(t_dim=ds.time.values, method=time_interp)
 
-        Returns
-        -------
-        New variable (var_str_freq) and dimension (time_freq) in tg.dataset
-        """
-        # Define new variable and dimension names
-        var = self.dataset[var_str]
-        new_var_str = var_str + "_" + time_freq
-        new_dim_str = "time_" + time_freq
-
-        # Resample using xarray.resample
-        resampled = var.resample(time=time_freq, **kwargs).mean()
-
-        # Rename dimensions and variables. Put into original dataset.
-        resampled = resampled.rename({"time": new_dim_str})
-        resampled = resampled.rename(new_var_str)
-        self.dataset[new_var_str] = resampled
-
-    def apply_doodson_x0_filter(self, var_str):
-        """Applies doodson X0 filter to a specified TIDEGAUGE variable
-        Input ius expected to be hourly. Use resample_mean to average data
-        to hourly frequency."""
-        filtered = stats_util.doodson_x0_filter(self.dataset[var_str], ax=0)
-        self.dataset[var_str + "_dx0"] = (("time_1H"), filtered)
-
-    def find_high_and_low_water(self, var_str, method="comp", **kwargs):
-        """
-        Finds high and low water for a given variable.
-        Returns in a new TIDEGAUGE object with similar data format to
-        a TIDETABLE.
-
-        Methods:
-        'comp' :: Find maxima by comparison with neighbouring values.
-                  Uses scipy.signal.find_peaks. **kwargs passed to this routine
-                  will be passed to scipy.signal.find_peaks.
-        'cubic':: Find the maxima using the roots of cubic spline.
-                  Uses scipy.interpolate.InterpolatedUnivariateSpline
-                  and scipy.signal.argrelmax. **kwargs are not activated.
-        NOTE: Currently only the 'comp' and 'cubic' methods implemented. Future
-                  methods include linear interpolation or refinements.
-        """
+        # Put interp_dist into dataset
+        extracted["interp_dist"] = interp_dist
+        extracted = extracted.rename_vars({"t_dim": "time"})
 
-        x = self.dataset.time
-        y = self.dataset[var_str]
+        tg_out = Tidegauge()
+        tg_out.dataset = extracted
+        return tg_out
 
-        time_max, values_max = stats_util.find_maxima(x, y, method=method, **kwargs)
-        time_min, values_min = stats_util.find_maxima(x, -y, method=method, **kwargs)
+    def time_slice(self, date0, date1):
+        """Return new Gridded object, indexed between dates date0 and date1"""
+        dataset = self.dataset
+        t_ind = pd.to_datetime(dataset.time.values) >= date0
+        dataset = dataset.isel(t_dim=t_ind)
+        t_ind = pd.to_datetime(dataset.time.values) < date1
+        dataset = dataset.isel(t_dim=t_ind)
+        return Tidegauge(dataset=dataset)
 
-        new_dataset = xr.Dataset()
-        new_dataset.attrs = self.dataset.attrs
-        new_dataset[var_str + "_highs"] = ("time_highs", values_max.data)
-        new_dataset[var_str + "_lows"] = ("time_lows", -values_min.data)
-        new_dataset["time_highs"] = ("time_highs", time_max.data)
-        new_dataset["time_lows"] = ("time_lows", time_min.data)
+    def subset_indices_lonlat_box(self, lonbounds, latbounds):
+        """Get a subset of this Profile() object in a spatial box.
 
-        new_object = Tidegauge()
-        new_object.dataset = new_dataset
+        lonbounds -- Array of form [min_longitude=-180, max_longitude=180]
+        latbounds -- Array of form [min_latitude, max_latitude]
 
-        return new_object
+        return: A new profile object containing subsetted data
+        """
+        ind = general_utils.subset_indices_lonlat_box(
+            self.dataset.longitude, self.dataset.latitude, lonbounds[0], lonbounds[1], latbounds[0], latbounds[1]
+        )
+        return Tidegauge(dataset=self.dataset.isel(id_dim=ind[0]))
diff --git a/coast/tidegauge_analysis.py b/coast/tidegauge_analysis.py
new file mode 100644
index 00000000..96408683
--- /dev/null
+++ b/coast/tidegauge_analysis.py
@@ -0,0 +1,504 @@
+import numpy as np
+import xarray as xr
+from . import Tidegauge, general_utils
+import matplotlib.dates as mdates
+import utide as ut
+import scipy.signal as signal
+from coast import stats_util, crps_util
+
+
+class TidegaugeAnalysis:
+    """
+    This contains analysis methods suitable for use with the dataset structure
+    of Tidegauge()
+    """
+
+    def __init__(self):
+        return
+
+    @classmethod
+    def match_missing_values(cls, data_array1, data_array2, fill_value=np.nan):
+        """
+        Will match any missing values between two tidegauge_multiple datasets.
+        Where missing values (defined by fill_value) are found in either dataset
+        they are also placed in the corresponding location in the other dataset.
+        Returns two new tidegaugeMultiple objects containing the new
+        ssh data. Datasets must contain ssh variables and only ssh will be
+        masked.
+        """
+
+        if data_array2.dims[0] == "t_dim":
+            data_array2 = data_array2.transpose()
+        if data_array1.dims[0] == "t_dim":
+            data_array1 = data_array1.transpose()
+
+        if np.isnan(fill_value):
+            ind1 = np.isnan(data_array1.values)
+            ind2 = np.isnan(data_array2.values)
+        else:
+            ind1 = data_array1.values == fill_value
+            ind2 = data_array2.values == fill_value
+
+        ds1 = data_array1.where(~ind2)
+        ds2 = data_array2.where(~ind1)
+
+        return Tidegauge(dataset=ds1.to_dataset()), Tidegauge(dataset=ds2.to_dataset())
+
+    @classmethod
+    def harmonic_analysis_utide(
+        cls,
+        data_array,
+        min_datapoints=1000,
+        nodal=False,
+        trend=False,
+        method="ols",
+        conf_int="linear",
+        Rayleigh_min=0.95,
+    ):
+        """
+        Does a harmonic analysis for each timeseries inside this object using
+        the utide library. All arguments except min_datapoints are arguments
+        that are passed to ut.solve(). Please see the utide  website for more
+        information:
+
+            https://pypi.org/project/UTide/
+
+        Utide will by default do it's harmonic analysis using a set of harmonics
+        determined using the Rayleigh criterion. This changes the number of
+        harmonics depending on the length and frequency of the time series.
+
+        Output from this routine is not a new dataset, but a list of utide
+        analysis object. These are structures containing, amongst other things,
+        amplitudes, phases, constituent names and confidence intervals. This
+        list can be passed to reconstruct_tide_utide() in this object to create
+        a new TidegaugeMultiple object containing reconstructed tide data.
+
+        INPUTS
+         data_array     : Xarray data_array from a coast.Tidegauge() object
+                          e.g. tidegauge.dataset.ssh
+         min_datapoints : If a time series has less than this value number of
+                          datapoints, then omit from the analysis.
+         <all_others>   : Inputs to utide.solve(). See website above.
+
+        OUTPUTS
+         A list of utide structures from the solve() routine. If a location
+         is omitted, it will contain [] for it's entry.
+        """
+        # Make name shorter for computations and get dimension lengths
+        ds = data_array
+        n_port = ds.sizes["id_dim"]
+        n_time = ds.sizes["t_dim"]
+
+        # Harmonic analysis datenums -- for utide to work correctly
+        time = mdates.date2num(ds.time.values)
+
+        # Create empty list of analyses
+        analyses = []
+
+        # Loop over ports
+        for pp in range(0, n_port):
+
+            # Temporary in-loop datasets
+            ds_port = ds.isel(id_dim=pp).load()
+            number_of_nan = np.sum(np.isnan(ds_port.values))
+
+            # If not enough datapoints for analysis then append an empty list
+            if (n_time - number_of_nan) < min_datapoints:
+                analyses.append([])
+                continue
+
+            # Do harmonic analysis using UTide
+            uts_obs = ut.solve(
+                time,
+                ds_port.values,
+                lat=ds_port.latitude.values,
+                nodal=nodal,
+                trend=trend,
+                method=method,
+                conf_int=conf_int,
+                Rayleigh_min=Rayleigh_min,
+            )
+
+            analyses.append(uts_obs)
+
+        return analyses
+
+    @classmethod
+    def reconstruct_tide_utide(cls, data_array, utide_solution_list, constit=None, output_name="reconstructed"):
+        """
+        Use the tarray of times to reconstruct a time series series using a
+        list of utide analysis objects. This list can be obtained
+        using harmonic_analysis_utide(). Specify constituents to use in the
+        reconstruction by passing a list of strings such as 'M2' to the constit
+        argument. This won't work if a specified constituent is not present in
+        the analysis.
+        """
+
+        # Get dimension lengths
+        n_port = len(utide_solution_list)
+        n_time = len(data_array.time)
+
+        # Harmonic analysis datenums -- needed for utide
+        time = mdates.date2num(data_array.time)
+
+        # Get  coordinates from data_array and convert to Dataset for output
+        reconstructed = np.zeros((n_port, n_time)) * np.nan
+
+        # Loop over ports
+        for pp in np.arange(n_port):
+
+            # Reconstruct full tidal signal using utide
+            pp_solution = utide_solution_list[pp]
+            if len(pp_solution) == 0:
+                continue
+
+            # Call utide.reconstruct
+            tide = np.array(ut.reconstruct(time, pp_solution, constit=constit).h)
+            reconstructed[pp] = tide
+
+        # Create output dataset and return it in new Tidegauge object.
+        ds_out = xr.Dataset(data_array.coords)
+        ds_out[output_name] = (["id_dim", "t_dim"], reconstructed)
+
+        return Tidegauge(dataset=ds_out)
+
+    @classmethod
+    def calculate_non_tidal_residuals(
+        cls, data_array_ssh, data_array_tide, apply_filter=True, window_length=25, polyorder=3
+    ):
+        """
+        Calculate non tidal residuals by subtracting values in data_array_tide
+        from data_array_ssh. You may optionally apply a filter to the non
+        tidal residual data by setting apply_filter = True. This uses the
+        scipy.signal.savgol_filter function, which you ay pass window_length
+        and poly_order.
+        """
+
+        # NTR: Calculate non tidal residuals
+        ntr = data_array_ssh - data_array_tide
+        n_port = data_array_ssh.sizes["id_dim"]
+
+        # NTR: Apply filter if wanted
+        if apply_filter:
+            for pp in range(n_port):
+                ntr[pp, :] = signal.savgol_filter(ntr[pp, :], 25, 3)
+
+        # Create output Tidegauge object and return
+        ds_coords = data_array_ssh.coords.to_dataset()
+        ds_coords["ntr"] = ntr
+        return Tidegauge(dataset=ds_coords)
+
+    @classmethod
+    def threshold_statistics(cls, dataset, thresholds=np.arange(-0.4, 2, 0.1), peak_separation=12):
+        """
+        Do some threshold statistics for all variables with a time dimension
+        inside this tidegauge_multiple object. Specifically, this routine will
+        calculate:
+
+                peak_count          : The number of indepedent peaks over
+                                      each specified threshold. Independent peaks
+                                      are defined using the peak_separation
+                                      argument. This is the number of datapoints
+                                      either side of a peak within which data
+                                      is ommited for further peak search.
+                time_over_threshold : The total time spent over each threshold
+                                      This is NOT an integral, but simple a count
+                                      of all points over threshold.
+                dailymax_count      : A count of the number of daily maxima over
+                                      each threshold
+                monthlymax_count    : A count of the number of monthly maxima
+                                      over each threshold.
+
+        Output is a xarray dataset containing analysed variables. The name of
+        each analysis variable is constructed using the original variable name
+        and one of the above analysis categories.
+        """
+
+        # Set up working datasets and lists
+        ds = dataset
+        ds_thresh = xr.Dataset(ds.coords)
+        ds_thresh["threshold"] = ("threshold", thresholds)
+        var_list = list(ds.keys())
+        n_thresholds = len(thresholds)
+        n_port = ds.sizes["id_dim"]
+
+        # Loop over vars in the input dataset
+        for vv in var_list:
+
+            empty_thresh = np.zeros((n_port, n_thresholds)) * np.nan
+            ds_thresh["peak_count_" + vv] = (["id_dim", "threshold"], np.array(empty_thresh))
+            ds_thresh["time_over_threshold_" + vv] = (["id_dim", "threshold"], np.array(empty_thresh))
+            ds_thresh["dailymax_count_" + vv] = (["id_dim", "threshold"], np.array(empty_thresh))
+            ds_thresh["monthlymax_count_" + vv] = (["id_dim", "threshold"], np.array(empty_thresh))
+
+            for pp in range(n_port):
+
+                # Identify NTR peaks for threshold analysis
+                data_pp = ds[vv].isel(id_dim=pp)
+                if np.sum(np.isnan(data_pp.values)) == ds.sizes["t_dim"]:
+                    continue
+
+                pk_ind, _ = signal.find_peaks(data_pp.values.copy(), distance=peak_separation)
+                pk_values = data_pp[pk_ind]
+
+                # Threshold Analysis
+                for nn in range(0, n_thresholds):
+
+                    # Calculate daily and monthly maxima for threshold analysis
+                    ds_daily = data_pp.groupby("time.day")
+                    ds_daily_max = ds_daily.max(skipna=True)
+                    ds_monthly = data_pp.groupby("time.month")
+                    ds_monthly_max = ds_monthly.max(skipna=True)
+
+                    threshn = thresholds[nn]
+                    # NTR: Threshold Frequency (Peaks)
+                    ds_thresh["peak_count_" + vv][pp, nn] = np.sum(pk_values >= threshn)
+
+                    # NTR: Threshold integral (Time over threshold)
+                    ds_thresh["time_over_threshold_" + vv][pp, nn] = np.sum(data_pp >= threshn)
+
+                    # NTR: Number of daily maxima over threshold
+                    ds_thresh["dailymax_count_" + vv][pp, nn] = np.sum(ds_daily_max.values >= threshn)
+
+                    # NTR: Number of monthly maxima over threshold
+                    ds_thresh["monthlymax_count_" + vv][pp, nn] = np.sum(ds_monthly_max.values >= threshn)
+
+        return ds_thresh
+
+    @staticmethod
+    def demean_timeseries(dataset):
+        """
+        Subtract time means from all variables within this tidegauge_multiple
+        object. This is done independently for each id_dim location.
+        """
+        demeaned = dataset - dataset.mean(dim="t_dim")
+        return Tidegauge(dataset=demeaned)
+
+    @classmethod
+    def difference(cls, dataset1, dataset2, absolute_diff=True, square_diff=True):
+        """
+        Calculates differences between two tide gauge objects datasets. Will calculate
+        differences, absolute differences and square differences between all
+        common variables within each object. Each object should have the same
+        sized dimensions. When calling this routine, the differencing is done
+        as follows:
+
+            dataset1.difference(dataset2)
+
+        This will do dataset1 - dataset2.
+
+        Output is a new tidegauge object containing differenced variables.
+        """
+
+        # Get all differences and save coordintes for later
+        differenced = dataset1 - dataset2
+        diff_vars = list(differenced.keys())
+        save_coords = list(dataset1.coords.keys())
+
+        # Loop oer all variables
+        for vv in diff_vars:
+            differenced = differenced.rename({vv: "diff_" + vv})
+
+        # Calculate absolute differences maybe
+        if absolute_diff:
+            abs_tmp = np.fabs(differenced)
+            diff_vars = list(abs_tmp.keys())
+            for vv in diff_vars:
+                abs_tmp = abs_tmp.rename({vv: "abs_" + vv})
+        else:
+            abs_tmp = xr.Dataset()
+
+        # Calculate squared differences maybe
+        if square_diff:
+            sq_tmp = np.square(differenced)
+            diff_vars = list(sq_tmp.keys())
+            for vv in diff_vars:
+                sq_tmp = sq_tmp.rename({vv: "square_" + vv})
+        else:
+            sq_tmp = xr.Dataset()
+
+        # Merge all differences into one
+        differenced = xr.merge((differenced, abs_tmp, sq_tmp, dataset1[save_coords]))
+
+        return Tidegauge(dataset=differenced)
+
+    @staticmethod
+    def find_high_and_low_water(data_array, method="comp", **kwargs):
+        """
+        Finds high and low water for a given variable.
+        Returns in a new TIDEGAUGE object with similar data format to
+        a TIDETABLE. If this Tidegauge object contains more than one location
+        (id_dim > 1) then a list of Tidegauges will be returned.
+
+        Methods:
+        'comp' :: Find maxima by comparison with neighbouring values.
+                  Uses scipy.signal.find_peaks. **kwargs passed to this routine
+                  will be passed to scipy.signal.find_peaks.
+        'cubic':: Find the maxima using the roots of cubic spline.
+                  Uses scipy.interpolate.InterpolatedUnivariateSpline
+                  and scipy.signal.argrelmax. **kwargs are not activated.
+        NOTE: Currently only the 'comp' and 'cubic' methods implemented. Future
+                  methods include linear interpolation or refinements.
+        """
+
+        if "id_dim" in data_array.dims:
+            n_id = data_array.sizes["id_dim"]
+        else:
+            n_id = 1
+            data_array = data_array.expand_dims("id_dim")
+
+        tg_list = []
+        # Loop over id_dim dimension
+        for ii in range(n_id):
+            # Get x and y for input to find_maxima
+
+            x = data_array.isel(id_dim=ii).time.values
+            y = data_array.isel(id_dim=ii).values
+
+            # Get time and values of maxima and minima
+            time_max, values_max = stats_util.find_maxima(x, y, method=method, **kwargs)
+            time_min, values_min = stats_util.find_maxima(x, -y, method=method, **kwargs)
+            # Place the above values into a brand new dataset for this id_dim index
+            new_dataset = xr.Dataset()
+            new_dataset.attrs = data_array.attrs
+            new_dataset[data_array.name + "_highs"] = ("time_highs", values_max)
+            new_dataset[data_array.name + "_lows"] = ("time_lows", -values_min)
+            new_dataset["time_highs"] = ("time_highs", time_max)
+            new_dataset["time_lows"] = ("time_lows", time_min)
+
+            # Place dataset into a new Tidegauge object and append to output
+            new_object = Tidegauge(dataset=new_dataset)
+            tg_list.append(new_object)
+
+        # If only 1 index, return just a Tidegauge object, else return list.
+        if n_id == 1:
+            return new_object
+        else:
+            return tg_list
+
+    @staticmethod
+    def doodson_x0_filter(dataset, var_str):
+        """Applies doodson X0 filter to a specified TIDEGAUGE variable
+        Input ius expected to be hourly. Use resample_mean to average data
+        to hourly frequency."""
+        n_id = dataset.sizes["id_dim"]
+        n_time = dataset.sizes["t_dim"]
+        filtered = np.zeros((n_id, n_time))
+        for ii in range(n_id):
+            ds_ii = dataset.isel(id_dim=ii)
+            filtered[ii] = stats_util.doodson_x0_filter(ds_ii[var_str], ax=0)
+        new_dataset = xr.Dataset(dataset.coords)
+        new_dataset[var_str] = (("id_dim", "t_dim"), filtered)
+        return Tidegauge(dataset=new_dataset)
+
+    @classmethod
+    def crps(
+        cls,
+        tidegauge_data,
+        gridded_data,
+        nh_radius: float = 20,
+        time_interp: str = "linear",
+    ):
+        """
+        Comparison of observed variable to modelled using the Continuous
+        Ranked Probability Score. This is done using this TIDEGAUGE object.
+        This method specifically performs a single-observation neighbourhood-
+        forecast method.
+
+        Parameters
+        ----------
+        model_object (model) : Model object (NEMO) containing model data
+        model_var_name (str) : Name of model variable to compare.
+        obs_var_name (str)   : Name of observed variable to compare.
+        nh_radius (float)    : Neighbourhood rad
+        cdf_type (str)       : Type of cumulative distribution to use for the
+                               model data ('empirical' or 'theoretical').
+                               Observations always use empirical.
+        time_interp (str)    : Type of time interpolation to use (s)
+        create_new_obj (bool): If True, save output to new TIDEGAUGE obj.
+                               Otherwise, save to this obj.
+
+        Returns
+        -------
+        xarray.Dataset containing times, sealevel and quality control flags
+
+        Example Useage
+        -------
+        # Compare modelled 'sossheig' with 'ssh' using CRPS
+        crps = altimetry.crps(nemo, 'sossheig', 'ssh')
+        """
+
+        # Extract variables and get shape
+        mod_var = gridded_data
+        obs_var = tidegauge_data
+        n_id, n_time = obs_var.shape
+
+        # Make output CRPS array
+        crps_out = np.zeros((n_id, n_time))
+        N_out = np.zeros((n_id, n_time))
+
+        # Loop over location indices
+        for ii in range(n_id):
+
+            obs_ii = obs_var.isel(id_dim=ii)
+
+            # Calculate CRPS
+            crps_list, n_model_pts, contains_land = crps_util.crps_sonf_fixed(
+                mod_var,
+                tidegauge_data.longitude.values,
+                tidegauge_data.latitude.values,
+                obs_ii.values,
+                tidegauge_data.time.values,
+                nh_radius,
+                time_interp,
+            )
+
+            crps_out[ii] = crps_list
+            N_out[ii] = n_model_pts
+
+        # Put into new object
+        new_dataset = xr.Dataset(tidegauge_data.coords)
+        new_dataset["crps"] = (("id_dim", "t_dim"), crps_out)
+        new_dataset["crps_N"] = (("id_dim", "t_dim"), N_out)
+        return Tidegauge(dataset=new_dataset)
+
+    @classmethod
+    def time_mean(cls, dataset, date0=None, date1=None):
+        """Time mean of all variables between dates date0, date1"""
+        var = general_utils.data_array_time_slice(dataset, date0, date1)
+        return Tidegauge(dataset=var.mean(dim="t_dim", skipna=True))
+
+    @classmethod
+    def time_std(cls, dataset, date0=None, date1=None):
+        """Time st. dev of variable var_str between dates date0 and date1"""
+        var = general_utils.data_array_time_slice(dataset, date0, date1)
+        return Tidegauge(dataset=var.std(dim="t_dim", skipna=True))
+
+    @classmethod
+    def time_slice(cls, dataset, date0=None, date1=None):
+        sliced = general_utils.data_array_time_slice(dataset, date0, date1)
+        return Tidegauge(dataset=sliced)
+
+    @classmethod
+    def resample_mean(cls, dataset, time_freq: str, **kwargs):
+        """Resample a TIDEGAUGE variable in time by calculating the mean
+            of all data points at a given frequency.
+
+        Parameters
+        ----------
+        time_freq (str)  : Time frequency. e.g. '1H' for hourly, '1D' for daily
+                           Can also be a timedelta object. See Pandas resample
+                           method for more info.
+        **kwargs (other) : Other arguments to pass to xarray.Dataset.resample
+        (http://xarray.pydata.org/en/stable/generated/xarray.Dataset.resample.html)
+
+        Returns
+        -------
+        New Tidegauge() object containing resampled data
+        """
+
+        # Resample using xarray.resample
+        dataset = dataset.swap_dims({"t_dim": "time"})
+        resampled = dataset.resample(time=time_freq, **kwargs).mean()
+        resampled = resampled.swap_dims({"time": "t_dim"})
+        return Tidegauge(dataset=resampled)
diff --git a/coast/tidegauge_multiple.py b/coast/tidegauge_multiple.py
deleted file mode 100644
index 23a8033e..00000000
--- a/coast/tidegauge_multiple.py
+++ /dev/null
@@ -1,402 +0,0 @@
-import numpy as np
-import xarray as xr
-import matplotlib.pyplot as plt
-import pandas as pd
-import glob
-import re
-import pytz
-import sklearn.metrics as metrics
-from . import general_utils, plot_util, crps_util, stats_util
-from .logging_util import get_slug, debug, error, info
-import xarray.ufuncs as uf
-import matplotlib.dates as mdates
-import utide as ut
-import scipy.signal as signal
-
-
-class TidegaugeMultiple:
-    """
-    This is an object for storage and manipulation of multiple tide gauges
-    in a single dataset. This may require some processing of the observations
-    such as interpolation to a common time step.
-
-    This object's dataset should take the form:
-
-        Dimensions:
-            port : The locations dimension. Each time series has an index
-            time : The time dimension. Each datapoint at each port has an index
-
-        Coordinates:
-            longitude (port) : Longitude values for each port index
-            latitude  (port) : Latitude values for each port index
-            time      (port) : Time values for each time index (datetime)
-
-    An example data variable could be ssh, or ntr (non-tidal residual). This
-    object can also be used for other instrument types, not just tide gauges.
-    For example moorings.
-
-    Every port index for this object should use the same time coordinates.
-    Therefore, timeseries need to be aligned before being placed into the
-    object. If there is any padding needed, then NaNs should be used. NaNs
-    should also be used for quality control/data rejection.
-    """
-
-    def init():
-        return
-
-    ##############################################################################
-    ###                ~            Plotting             ~                     ###
-    ##############################################################################
-
-    ##############################################################################
-    ###                ~        Model Comparison         ~                     ###
-    ##############################################################################
-
-    ##############################################################################
-    ###                ~            Analysis             ~                     ###
-    ##############################################################################
-
-    def match_missing_values(self, other, fill_value=np.nan):
-        """
-        Will match any missing values between two tidegauge_multiple datasets.
-        Where missing values (defined by fill_value) are found in either dataset
-        they are also placed in the corresponding location in the other dataset.
-        Returns two new tidegaugeMultiple objects containing the new
-        ssh data. Datasets must contain ssh variables and only ssh will be
-        masked.
-        """
-
-        ds_self = self.dataset
-        ds_other = other.dataset
-
-        ssh_self = ds_self.ssh
-        ssh_other = ds_other.ssh
-
-        if ssh_other.dims[0] == "t_dim":
-            ssh_other = ssh_other.transpose()
-        if ssh_self.dims[0] == "t_dim":
-            ssh_self = ssh_self.transpose()
-
-        if np.isnan(fill_value):
-            ind_self = uf.isnan(ssh_self)
-            ind_other = uf.isnan(ssh_other)
-        else:
-            ind_self = ssh_self == fill_value
-            ind_other = ssh_other == fill_value
-
-        ds_self["ssh"] = ssh_self.where(~ind_other)
-        ds_other["ssh"] = ssh_other.where(~ind_self)
-
-        tg_self = TidegaugeMultiple()
-        tg_other = TidegaugeMultiple()
-
-        tg_self.dataset = ds_self
-        tg_other.dataset = ds_other
-
-        return tg_self, tg_other
-
-    def harmonic_analysis_utide(
-        self, min_datapoints=1000, nodal=False, trend=False, method="ols", conf_int="linear", Rayleigh_min=0.95
-    ):
-        """
-        Does a harmonic analysis for each timeseries inside this object using
-        the utide library. All arguments except min_datapoints are arguments
-        that are passed to ut.solve(). Please see the utide  website for more
-        information:
-
-            https://pypi.org/project/UTide/
-
-        Utide will by default do it's harmonic analysis using a set of harmonics
-        determined using the Rayleigh criterion. This changes the number of
-        harmonics depending on the length and frequency of the time series.
-
-        Output from this routine is not a new dataset, but a list of utide
-        analysis object. These are structures containing, amongst other things,
-        amplitudes, phases, constituent names and confidence intervals. This
-        list can be passed to reconstruct_tide_utide() in this object to create
-        a new TidegaugeMultiple object containing reconstructed tide data.
-
-        INPUTS
-         min_datapoints : If a time series has less than this value number of
-                          datapoints, then omit from the analysis.
-         <all_others>   : Inputs to utide.solve(). See website above.
-
-        OUTPUTS
-         A list of utide structures from the solve() routine. If a location
-         is omitted, it will contain [] for it's entry.
-        """
-        ds = self.dataset
-        n_port = ds.dims["id"]
-        n_time = ds.dims["t_dim"]
-        # Harmonic analysis datenums
-        time = mdates.date2num(ds.time.values)
-
-        analyses = []
-
-        for pp in range(0, n_port):
-
-            # Temporary in-loop datasets
-            ds_port = ds.isel(id=pp).load()
-            ssh = ds_port.ssh
-
-            number_of_nan = np.sum(np.isnan(ssh.values))
-
-            if number_of_nan == n_time:
-                analyses.append([])
-                continue
-
-            if (n_time - number_of_nan) < min_datapoints:
-                analyses.append([])
-                continue
-
-            # Do harmonic analysis using UTide
-            uts_obs = ut.solve(
-                time,
-                ssh.values,
-                lat=ssh.latitude.values,
-                nodal=nodal,
-                trend=trend,
-                method=method,
-                conf_int=conf_int,
-                Rayleigh_min=Rayleigh_min,
-            )
-
-            analyses.append(uts_obs)
-
-        return analyses
-
-    def reconstruct_tide_utide(self, utide_solution_list, constit=None):
-        """
-        Use the time information inside this object to construct a tidal time
-        series using a list of utide analysis objects. This list can be obtained
-        using harmonic_analysis_utide(). Specify constituents to use in the
-        reconstruction by passing a list of strings such as 'M2' to the constit
-        argument. This won't work if a specified constituent is not present in
-        the analysis.
-        """
-
-        ds = self.dataset
-        n_port = ds.dims["id"]
-        n_time = ds.dims["t_dim"]
-        # Harmonic analysis datenums
-        time = mdates.date2num(ds.time.values)
-
-        coords = xr.Dataset(ds[list(ds.coords.keys())])
-        reconstructed = np.zeros((n_port, n_time)) * np.nan
-
-        for pp in np.arange(n_port):
-
-            # Reconstruct full tidal signal
-            pp_solution = utide_solution_list[pp]
-
-            if len(pp_solution) == 0:
-                continue
-
-            tide = np.array(ut.reconstruct(time, pp_solution, constit=constit).h)
-            reconstructed[pp] = tide
-
-        coords["ssh_tide"] = (["id", "t_dim"], reconstructed)
-        tg_return = TidegaugeMultiple()
-        tg_return.dataset = coords
-
-        return tg_return
-
-    def calculate_residuals(self, tg_tide, apply_filter=True, window_length=25, polyorder=3):
-        """
-        Calculate non tidal residuals using the Total water level in THIS object
-        and the tide data in another object. This assumes that this object
-        contains a 'ssh' variable and the tide object contains a 'ssh_tide'
-        variable. This tide variable can be constructed using e.g.
-        reconstruct_tide_utide().
-        """
-
-        # NTR: Calculate non tidal residuals
-        ntr = self.dataset.ssh - tg_tide.dataset.ssh_tide
-        n_port = self.dataset.dims["id"]
-
-        # NTR: Apply filter if wanted
-        if apply_filter:
-            for pp in range(n_port):
-                ntr[pp, :] = signal.savgol_filter(ntr[pp, :], 25, 3)
-
-        coords = xr.Dataset(self.dataset[list(self.dataset.coords.keys())])
-        coords["ntr"] = ntr
-
-        tg_return = TidegaugeMultiple()
-        tg_return.dataset = coords
-        return tg_return
-
-    def threshold_statistics(self, thresholds=np.arange(-0.4, 2, 0.1), peak_separation=12):
-        """
-        Do some threshold statistics for all variables with a time dimension
-        inside this tidegauge_multiple object. Specifically, this routine will
-        calculate:
-
-                peak_count          : The number of indepedent peaks over
-                                      each specified threshold. Independent peaks
-                                      are defined using the peak_separation
-                                      argument. This is the number of datapoints
-                                      either side of a peak within which data
-                                      is ommited for further peak search.
-                time_over_threshold : The total time spent over each threshold
-                                      This is NOT an integral, but simple a count
-                                      of all points over threshold.
-                dailymax_count      : A count of the number of daily maxima over
-                                      each threshold
-                monthlymax_count    : A count of the number of monthly maxima
-                                      over each threshold.
-
-        Output is a xarray dataset containing analysed variables. The name of
-        each analysis variable is constructed using the original variable name
-        and one of the above analysis categories.
-        """
-
-        ds = self.dataset
-        ds_thresh = xr.Dataset(ds[list(ds.coords.keys())])
-        ds_thresh["threshold"] = ("threshold", thresholds)
-        var_list = list(ds.keys())
-        n_thresholds = len(thresholds)
-        n_port = ds.dims["id"]
-
-        for vv in var_list:
-
-            empty_thresh = np.zeros((n_port, n_thresholds)) * np.nan
-            ds_thresh["peak_count_" + vv] = (["id", "threshold"], np.array(empty_thresh))
-            ds_thresh["time_over_threshold_" + vv] = (["id", "threshold"], np.array(empty_thresh))
-            ds_thresh["dailymax_count_" + vv] = (["id", "threshold"], np.array(empty_thresh))
-            ds_thresh["monthlymax_count_" + vv] = (["id", "threshold"], np.array(empty_thresh))
-
-            for pp in range(n_port):
-
-                # Identify NTR peaks for threshold analysis
-                data_pp = ds[vv].isel(id=pp)
-                if np.sum(np.isnan(data_pp.values)) == ds.dims["t_dim"]:
-                    continue
-
-                pk_ind, _ = signal.find_peaks(data_pp.values, distance=peak_separation)
-                pk_values = data_pp[pk_ind]
-
-                # Threshold Analysis
-                for nn in range(0, n_thresholds):
-
-                    # Calculate daily and monthly maxima for threshold analysis
-                    ds_daily = data_pp.groupby("time.day")
-                    ds_daily_max = ds_daily.max(skipna=True)
-                    ds_monthly = data_pp.groupby("time.month")
-                    ds_monthly_max = ds_monthly.max(skipna=True)
-
-                    threshn = thresholds[nn]
-                    # NTR: Threshold Frequency (Peaks)
-                    ds_thresh["peak_count_" + vv][pp, nn] = np.sum(pk_values >= threshn)
-
-                    # NTR: Threshold integral (Time over threshold)
-                    ds_thresh["time_over_threshold_" + vv][pp, nn] = np.sum(data_pp >= threshn)
-
-                    # NTR: Number of daily maxima over threshold
-                    ds_thresh["dailymax_count_" + vv][pp, nn] = np.sum(ds_daily_max.values >= threshn)
-
-                    # NTR: Number of monthly maxima over threshold
-                    ds_thresh["monthlymax_count_" + vv][pp, nn] = np.sum(ds_monthly_max.values >= threshn)
-
-        return ds_thresh
-
-    def demean_timeseries(self):
-        """
-        Subtract time means from all variables within this tidegauge_multiple
-        object. This is done independently for each id location.
-        """
-        return self.dataset - self.dataset.mean(dim="t_dim")
-
-    def obs_operator(self, gridded, time_interp="nearest"):
-        """
-        Regrids a Gridded object onto a tidegauge_multiple object. A nearest
-        neighbour interpolation is done for spatial interpolation and time
-        interpolation can be specified using the time_interp argument. This
-        takes any scipy interpolation string. If Gridded object contains a
-        landmask variables, then the nearest WET point is taken for each tide
-        gauge.
-
-        Output is a new tidegauge_multiple object containing interpolated data.
-        """
-
-        gridded = gridded.dataset
-        ds = self.dataset
-
-        # Determine spatial indices
-        print("Calculating spatial indices.", flush=True)
-        ind_x, ind_y = general_utils.nearest_indices_2d(
-            gridded.longitude, gridded.latitude, ds.longitude, ds.latitude, mask=gridded.landmask
-        )
-
-        # Extract spatial time series
-        print("Calculating time indices.", flush=True)
-        extracted = gridded.isel(x_dim=ind_x, y_dim=ind_y)
-        extracted = extracted.swap_dims({"dim_0": "id"})
-
-        # Compute data (takes a while..)
-        print(" Indexing model data at tide gauge locations.. ", flush=True)
-        extracted.load()
-
-        # Check interpolation distances
-        print("Calculating interpolation distances.", flush=True)
-        interp_dist = general_utils.calculate_haversine_distance(
-            extracted.longitude, extracted.latitude, ds.longitude.values, ds.latitude.values
-        )
-
-        # Interpolate model onto obs times
-        print("Interpolating in time...", flush=True)
-        extracted = extracted.rename({"time": "t_dim"})
-        extracted = extracted.interp(t_dim=ds.time.values, method=time_interp)
-
-        # Put interp_dist into dataset
-        extracted["interp_dist"] = interp_dist
-        extracted = extracted.rename_vars({"t_dim": "time"})
-
-        tg_out = TidegaugeMultiple()
-        tg_out.dataset = extracted
-        return tg_out
-
-    def difference(self, other, absolute_diff=True, square_diff=True):
-        """
-        Calculates differences between two tide gauge objects. Will calculate
-        differences, absolute differences and square differences between all
-        common variables within each object. Each object should have the same
-        sized dimensions. When calling this routine, the differencing is done
-        as follows:
-
-            dataset1.difference(dataset2)
-
-        This will do dataset1 - dataset2.
-
-        Output is a new tidegauge object containing differenced variables.
-        """
-
-        differenced = self.dataset - other.dataset
-        diff_vars = list(differenced.keys())
-        save_coords = list(self.dataset.coords.keys())
-
-        for vv in diff_vars:
-            differenced = differenced.rename({vv: "diff_" + vv})
-
-        if absolute_diff:
-            abs_tmp = uf.fabs(differenced)
-            diff_vars = list(abs_tmp.keys())
-            for vv in diff_vars:
-                abs_tmp = abs_tmp.rename({vv: "abs_" + vv})
-        else:
-            abs_tmp = xr.Dataset()
-
-        if square_diff:
-            sq_tmp = uf.square(differenced)
-            diff_vars = list(sq_tmp.keys())
-            for vv in diff_vars:
-                sq_tmp = sq_tmp.rename({vv: "square_" + vv})
-        else:
-            sq_tmp = xr.Dataset()
-
-        differenced = xr.merge((differenced, abs_tmp, sq_tmp, self.dataset[save_coords]))
-
-        return_differenced = TidegaugeMultiple()
-        return_differenced.dataset = differenced
-
-        return return_differenced
diff --git a/coast/timeseries.py b/coast/timeseries.py
index 2e069fd4..56cdb1a4 100644
--- a/coast/timeseries.py
+++ b/coast/timeseries.py
@@ -1,5 +1,6 @@
 """Timeseries Class"""
 from .index import Indexed
+from . import general_utils
 
 
 class Timeseries(Indexed):
diff --git a/coast/transect.py b/coast/transect.py
index ea3500a8..7e83bcb2 100644
--- a/coast/transect.py
+++ b/coast/transect.py
@@ -1022,10 +1022,10 @@ def construct_pressure(self, ref_density=None, z_levels=None, extrapolate=False)
             z_levels = z_levels[:active_z_levels]
 
         # Absolute Pressure (depth must be negative)
-        pressure_absolute = np.ma.masked_invalid(gsw.p_from_z(-z_levels[:, np.newaxis], self.data.latitude))
+        pressure_absolute = np.ma.masked_invalid(gsw.p_from_z(-z_levels[:, np.newaxis], self.data.latitude.values))
         # Absolute Salinity
         salinity_absolute = np.ma.masked_invalid(
-            gsw.SA_from_SP(salinity_z, pressure_absolute, self.data.longitude, self.data.latitude)
+            gsw.SA_from_SP(salinity_z, pressure_absolute, self.data.longitude.values, self.data.latitude.values)
         )
         salinity_absolute = np.ma.masked_less(salinity_absolute, 0)
         # Conservative Temperature
diff --git a/coast/xesmf_convert.py b/coast/xesmf_convert.py
new file mode 100644
index 00000000..30d9ea9f
--- /dev/null
+++ b/coast/xesmf_convert.py
@@ -0,0 +1,162 @@
+import os.path as path_lib
+import warnings
+from coast import Gridded
+
+# from dask import delayed, compute, visualize
+# import graphviz
+import numpy as np
+import xarray as xr
+
+
+class xesmf_convert:
+    """
+    Converts the main dataset within a COAsT.Gridded object to be suitable
+    for input to XESMF for regridding to either a curvilinear or rectilienar
+    grid. All you need to do if provide a Gridded object and a grid type when
+    creating a new instance of this class. It will then contain an appropriate
+    input dataset. You may also provide a second COAsT gridded object if
+    regridding between two objects. For using xesmf, please see the package's
+    documentation website here:
+
+    https://xesmf.readthedocs.io/en/latest/index.html
+
+    You can install XESMF using:
+
+        conda install -c conda-forge xesmf.
+
+    The setup used by this class has been tested for xesmf v0.6.2 alongside
+    esmpy v8.0.0. It was installed using:
+
+        conda install -c conda-forge xesmf esmpy=8.0.0
+
+    INPUTS
+     input_gridded_obj (Gridded) :: Gridded object to be regridded
+     output_gridded_obj(Gridded) :: (optional) Gridded object to regrid TO
+     reorder_dims      (bool)    :: Xesmf requires that lat/lon dimensions are
+                                    the last dimensions. If this is True, then
+                                    will attempt to reorder dimensions. Not
+                                    recommended for large datasets.
+                                    [Default = False]
+
+    >>> EXAMPLE USEAGE <<<
+    If regridding a Gridded object to an arbitrarily defined rectilinear
+    or curvilinear grid, you just need to do the following:
+
+        import xesmf as xe
+
+        # Create your gridded object
+        gridded = coast.Gridded(*args, **kwargs)
+
+        # Pass the gridded object over to xesmf_convert
+        xesmf_ready = coast.xesmf_convert(gridded, input_grid_type = 'curvilinear')
+
+        # Now this object will contain a dataset called xesmf_input, which can
+        # be passed over to xesmf. E.G:
+
+        destination_grid = xesmf.util.grid_2d(-15, 15, 1, 45, 65, 1)
+        regridder = xe.Regridder(xesmf_ready.input_grid, destination_grid,
+                                 "bilinear")
+        regridded_dataset = regridder(xesmf_ready.input_data)
+
+    XESMF contains a couple of difference functions for quickly creating output
+    grids, such as xesmf.util.grid_2d and xesmf.util.grid_global(). See their
+    website for more info.
+
+    The process is almost the same if regridding from one COAsT.Gridded object
+    to another (gridded0 -> gridded1):
+
+        xesmf_ready = coast.xesmf_convert(gridded0, gridded1,
+                                          input_grid_type = "curvilinear",
+                                          output_grid_type = "curvilinear")
+        regridder = xe.Regridder(xesmf_ready.input_grid,
+                                 xesmf_ready.output_grid, "bilinear")
+        regridded_dataset = regridder(xesmf_ready.input_data)
+
+    Note that you can select which variables you want to regrid, either prior
+    to using this tool or by indexing the input_data dataset. e.g.:
+
+        regridded_dataset = regridder(xesmf_ready.input_data['temperature'])
+
+    If your input datasets were lazy loaded, then so will the regridded dataset.
+    At this point you can either load the data or (recomended) save the regridded
+    data to file:
+
+        regridded_dataset.to_netcdf(<filename_to_save>)
+
+    Before saving back to file, call xesmf_ready.to_gridded() to convert
+    the regridded xesmf object back to a gridded object
+
+    """
+
+    def __init__(
+        self,
+        input_gridded_obj=None,
+        output_gridded_obj=None,
+        input_grid_type="curvilinear",
+        output_grid_type="curvilinear",
+        reorder_dims=False,
+    ):
+        """
+        Handles the inputs and reformatting of data. See Class docstring for
+        more info.
+        """
+
+        # Set grid types
+        self.input_grid_type = input_grid_type
+        self.output_grid_type = output_grid_type
+
+        # If an input object is provided, convert to grid/data for xesmf
+        if input_gridded_obj is not None:
+            input_dataset = input_gridded_obj.dataset
+            self.input_grid, self.input_data = self._get_xesmf_datasets(input_dataset, input_grid_type)
+
+        # If an output object is provided, convert to grid/data for xesmf
+        if output_gridded_obj is not None:
+            output_dataset = output_gridded_obj.dataset
+            self.output_grid, _ = self._get_xesmf_datasets(output_dataset, output_grid_type)
+
+    @classmethod
+    def _get_xesmf_datasets(cls, dataset, grid_type, reorder_dims=False):
+        """For a given dataset taken from a Gridded object and a grid_type
+        (curvilinear or rectilinear), determine the xesmf formatted dataset.
+        This method does some checks to make sure the dataset is suitable and
+        renames the relevant dimensions/coordinates. Any vars that don't have
+        both x_dim and y_dim will be dropped. If x_dim and y_dim are present
+        BUT they are not the last dimensions AND reorder_dims=True then
+        the dimensions will be reordered (not good for lazy loading/chunking).
+        """
+
+        # Check that x_dim and y_dim are in the dataset. If not, raise error
+        if "x_dim" not in dataset.dims or "y_dim" not in dataset.dims:
+            raise AttributeError("Gridded.dataset must  contain two spatial dimensions called x_dim and y_dim")
+
+        # Check each variable has x_dim AND y_dim and that they are the
+        # last dimensions. If not, then drop the variables if reorder_dims=False.
+        # Otherwise, attempt to reorder dimensions.
+
+        # Rename Dimensions and coordinates
+        if grid_type == "curvilinear":
+            dataset = dataset.swap_dims({"x_dim": "x", "y_dim": "y"})
+            dataset = dataset.rename_vars({"latitude": "lat", "longitude": "lon"})
+
+        # Define grid and data
+        grid = dataset[["lon", "lat"]]
+        data = dataset
+        return grid, data
+
+    @staticmethod
+    def to_gridded(xesmf_dataset):
+        """Converts an xesmf_dataset back to a Coast.Gridded() object. Returns
+        a Gridded object."""
+
+        # Create empty gridded object
+        gridded_obj = Gridded()
+
+        # Rename dimensions and coordinates
+        xesmf_dataset = xesmf_dataset.swap_dims({"x": "x_dim", "y": "y_dim"})
+        xesmf_dataset = xesmf_dataset.rename_vars({"lat": "latitude", "lon": "longitude"})
+
+        # Place renamed dataset into new gridded object.
+        gridded_obj.dataset = xesmf_dataset
+
+        return gridded_obj
diff --git a/coast_v1/cdf.py b/coast_v1/cdf.py
deleted file mode 100755
index 42769164..00000000
--- a/coast_v1/cdf.py
+++ /dev/null
@@ -1,230 +0,0 @@
-import numpy as np
-import matplotlib.pyplot as plt
-from . import stats_util
-from .logging_util import get_slug, debug, error
-
-
-class CDF:
-    """
-    An object for storing Cumulative Distribution Function information.
-    Used primarily for calculating the Continuous Ranked Probability Score.
-    The object is initalisated by passing in a 'sample' vector of data for
-    which to determine a CDF. This sample is used to construct either a
-    empirical or theoretical CDF, depending on the cdf_type argument.
-    """
-
-    def __init__(self, sample, cdf_type: str = "empirical", cdf_func: str = "gaussian"):
-        """Initialisation of CDF object.
-
-        Args:
-            sample (array): Data sample over which to estimate CDF
-            cdf_type (str): Either 'empirical' or 'theoretical'.
-            cdf_func (str): Function type if cdf_type='theoretical'. Presently
-                            only 'gaussian'.
-
-        Returns:
-            New CDF object.
-        """
-        debug(f"Creating a new {get_slug(self)}")
-        self.cdf_type = cdf_type
-        self.cdf_func = cdf_func
-        self.sample = sample
-        self.sample_size = len(sample)
-        self.mu = np.nanmean(sample)
-        self.sigma = np.nanstd(sample)
-        self._cdf_type_options = ["empirical", "theoretical"]
-        self._cdf_func_options = ["gaussian"]
-        self.plot_xmin, self.plot_xmax = self.set_x_bounds()
-        debug(f"{get_slug(self)} initialised")
-
-    def set_x_bounds(self):
-        """Calculate x bounds for CDF plotting"""
-        # Is input a single value (st. dev == 0)
-        single_value = True if self.sigma == 0 else False  # TODO This could just be: single_value = self.sigma == 0
-        if single_value:
-            self.cdf_type = "empirical"
-
-        # Calculate x bounds as 5 std dev. either side of the mean
-        debug(f"Calculating x bounds for {get_slug(self)}")
-        if single_value:
-            xmin = self.mu - 1
-            xmax = self.mu + 1
-        elif self.cdf_func == "gaussian":
-            xmin = self.mu - 5 * self.sigma
-            xmax = self.mu + 5 * self.sigma
-        return xmin, xmax  # TODO What should happen if neither condition is True?
-
-    def build_discrete_cdf(self, x: np.ndarray = None, n_pts: int = 1000):
-        """Builds a discrete CDF for plotting and direct comparison.
-
-        Args:
-            x (array): x-values over which to calculate discrete CDF values.
-                       If none, these will be determined using mu and sigma.
-            n_pts (int): n_pts to use for x if x=None.
-
-        Returns:
-            x and y arrays for discrete CDF.
-        """
-        debug(f"Discrete CDF will be built for {get_slug(self)}")
-        # Build discrete X bounds
-        if x is None:
-            debug(f"Building discrete X bounds for {get_slug(self)}")
-            x = np.linspace(self.plot_xmin, self.plot_xmax, n_pts)
-
-        if self.cdf_type == "empirical":
-            y = stats_util.empirical_distribution(x, self.sample)
-
-        elif self.cdf_type == "theoretical":
-            if self.cdf_func == "gaussian":
-                y = stats_util.cumulative_distribution(mu=self.mu, sigma=self.sigma, x=x, cdf_func=self.cdf_func)
-            else:
-                raise NotImplementedError
-        else:
-            error(f'CDF type for {get_slug(self)} is "{self.cdf_type}", which is not acceptable, raising exception!')
-            raise Exception(f"CDF Type must be empirical or theoretical")  # TODO This should probably be a ValueError
-        debug(f'CDF type for {get_slug(self)} is "{self.cdf_type}"')
-
-        return x, y
-
-    def crps(self, xa):
-        """Calculate CRPS as outlined in Hersbach et al. 2000
-
-        Args:
-            xa (float): A single 'observation' value which to compare against
-                        CDF.
-
-        Returns:
-            A single CRPS value.
-        """
-
-        debug(f"Calculating {get_slug(self)} CRPS, compare against {xa}")
-
-        def calc(alpha, beta, p):
-            return alpha * p ** 2 + beta * (1 - p) ** 2
-
-        crps_sum = 0
-        sample = np.array(self.sample).flatten()
-        sample = sample[~np.isnan(sample)]
-        sample = np.sort(sample)
-        sample_size = len(sample)
-
-        # All intervals within range of the sample distribution
-        for ii in range(0, sample_size - 1):
-            p = (ii + 1) / sample_size
-            if xa > sample[ii + 1]:
-                alpha = sample[ii + 1] - sample[ii]
-                beta = 0
-            elif xa < sample[ii]:
-                alpha = 0
-                beta = sample[ii + 1] - sample[ii]
-            else:
-                alpha = xa - sample[ii]
-                beta = sample[ii + 1] - xa
-            crps = calc(alpha, beta, p)
-            crps_sum = crps_sum + crps
-        # Intervals 0 and N, where p = 0 and 1 respectively
-        if xa < sample[0]:
-            p = 0
-            alpha = 0
-            beta = sample[0] - xa
-            crps = calc(alpha, beta, p)
-            crps_sum = crps_sum + crps
-        elif xa > sample[-1]:
-            p = 1
-            alpha = xa - sample[-1]
-            beta = 0
-            crps = calc(alpha, beta, p)
-            crps_sum = crps_sum + crps
-
-        return crps_sum
-
-    def crps_fast(self, xa):
-        """Faster version of crps method, using multiple numpy arrays.
-           For large sample sizes, this is considerably faster but uses more
-           memory.
-
-        Args:
-            xa (float): A single 'observation' value which to compare against
-                        CDF.
-
-        Returns:
-            A single CRPS value.
-        """
-        debug(f"Calculating {get_slug(self)} fast CRPS, compare against {xa}")
-
-        def calc(alpha, beta, p):
-            return alpha * p ** 2 + beta * (1 - p) ** 2
-
-        xa = float(xa)
-        crps_sum = 0
-        sample = np.array(self.sample).flatten()
-        sample = sample[~np.isnan(sample)]
-        sample = np.sort(sample)
-        sample_size = len(sample)
-
-        alpha = np.zeros(sample_size - 1)
-        beta = np.zeros(sample_size - 1)
-        # sample[1:] = upper bounds, and vice versa
-
-        tmp = sample[1:] - sample[:-1]
-        tmp_logic = sample[1:] < xa
-        alpha[tmp_logic] = tmp[tmp_logic]
-
-        tmp_logic = sample[:-1] > xa
-        beta[tmp_logic] = tmp[tmp_logic]
-
-        tmp_logic = (sample[1:] > xa) * (sample[:-1] < xa)
-        tmp = xa - sample[:-1]
-        alpha[tmp_logic] = tmp[tmp_logic]
-        tmp = sample[1:] - xa
-        beta[tmp_logic] = tmp[tmp_logic]
-
-        p = np.arange(1, sample_size) / sample_size
-        c = alpha * p ** 2 + beta * (1 - p) ** 2
-        crps_sum = np.sum(c)
-
-        # Intervals 0 and N, where p = 0 and 1 respectively
-        if xa < sample[0]:
-            p = 0
-            alpha = 0
-            beta = sample[0] - xa
-            crps = calc(alpha, beta, p)
-            crps_sum = crps_sum + crps
-        elif xa > sample[-1]:
-            p = 1
-            alpha = xa - sample[-1]
-            beta = 0
-            crps = calc(alpha, beta, p)
-            crps_sum = crps_sum + crps
-
-        return crps_sum
-
-    def get_common_x(self, other, n_pts=1000):
-        """Generates a common x vector for two CDF objects."""
-        debug(f"Generating common X vector for {get_slug(self)} and {get_slug(other)}")
-        xmin = min(self.plot_xmin, other.plot_xmin)
-        xmax = max(self.plot_xmax, other.plot_xmax)
-        common_x = np.linspace(xmin, xmax, n_pts)
-        return common_x
-
-    def quick_plot(self):
-        """A quick plot showing the CDF contained in this object."""
-        debug(f"Generating quick plot for {get_slug(self)}")
-        ax = plt.subplot(111)
-        x, y = self.build_discrete_cdf()
-        ax.plot(x, y)
-        ax.grid()
-        return
-
-    def diff_plot(self, other):
-        """Plots two CDFS on one plot, with the difference shaded"""
-        debug(f"Generating diff plot for {get_slug(self)} and {get_slug(other)}")
-        fig = plt.figure()
-        ax = plt.subplot(111)
-        x = self.get_common_x(other)
-        dum, mod_y = self.build_discrete_cdf(x)
-        ax.plot(x, mod_y, c="k", linestyle="--")
-        dum, obs_y = other.build_discrete_cdf(x)
-        ax.plot(x, obs_y, linestyle="--")
-        ax.fill_between(x, mod_y, obs_y, alpha=0.5)
-        return fig, ax
diff --git a/coast_v1/distribution.py b/coast_v1/distribution.py
deleted file mode 100644
index d4c249ef..00000000
--- a/coast_v1/distribution.py
+++ /dev/null
@@ -1,188 +0,0 @@
-import numpy as np
-import matplotlib.pyplot as plt
-from . import stats_util
-from .logging_util import get_slug, debug, error
-import scipy.stats
-import xarray as xr
-
-
-class Distribution:
-    """
-    An object for storing Cumulative Distribution Function information.
-    Used primarily for calculating the Continuous Ranked Probability Score.
-    The object is instantiated by passing in a 'sample' vector of data for
-    which to determine a CDF. This sample is used to construct either a
-    empirical or theoretical CDF, depending on the cdf_type argument.
-    """
-
-    def __init__(self, sample):
-        """Initialisation of CDF object.
-        Args:
-            sample (array): Data sample over which to estimate CDF
-        Returns:
-            New CDF object.
-        """
-        debug(f"Creating a new {get_slug(self)}")
-        self.sample = sample
-        self.sample_size = len(sample)
-        self.mu = np.nanmean(sample)
-        self.sigma = np.nanstd(sample)
-        self.plot_xmin, self.plot_xmax = self.set_x_bounds()
-        debug(f"{get_slug(self)} initialised")
-
-    def set_x_bounds(self):
-        """Calculate x bounds for CDF plotting"""
-        # Is input a single value (st. dev == 0)
-        single_value = self.sigma == 0
-        if single_value:
-            self.cdf_type = "empirical"
-
-        # Calculate x bounds as 5 std dev. either side of the mean
-        debug(f"Calculating x bounds for {get_slug(self)}")
-        if single_value:
-            x_min = self.mu - 1
-            x_max = self.mu + 1
-        else:
-            x_min = self.mu - 5 * self.sigma
-            x_max = self.mu + 5 * self.sigma
-        return x_min, x_max
-
-    def build_discrete_cdf(self, x: np.ndarray = None, n_pts: int = 1000):
-        """Builds a discrete CDF for plotting and direct comparison.
-        Args:
-            x (array): x-values over which to calculate discrete CDF values.
-                       If none, these will be determined using mu and sigma.
-            n_pts (int): n_pts to use for x if x=None.
-        Returns:
-            x and y arrays for discrete CDF.
-        """
-        debug(f"Discrete CDF will be built for {get_slug(self)}")
-        # Build discrete X bounds
-        if x is None:
-            debug(f"Building discrete X bounds for {get_slug(self)}")
-            x = np.linspace(self.plot_xmin, self.plot_xmax, n_pts)
-
-        if self.cdf_type == "empirical":
-            y = stats_util.empirical_distribution(x, self.sample)  # TODO Should this come from distribution.py?
-
-        else:
-            error(f"CDF type for {get_slug(self)} is , which is not acceptable, raising exception!")
-
-        return x, y  # TODO What if we haven't defined y? (i.e. if cdf_type is not "empirical")
-
-    @staticmethod
-    def normal_distribution(mu: float = 0, sigma: float = 1, x: np.ndarray = None, n_pts: int = 1000):
-        """Generates a discrete normal distribution.
-
-        Keyword arguments:
-        x     -- Arbitrary array of x-values
-        mu    -- Distribution mean
-        sigma -- Distribution standard deviation
-
-        return: Array of len(x) containing the normal values calculated from
-                the elements of x.
-        """
-        if x is None:
-            x = np.linspace(mu - 5 * sigma, mu + 5 * sigma, n_pts)
-        debug(f"Generating normal distribution for {get_slug(x)}")
-        term1 = sigma * np.sqrt(2 * np.pi)
-        term1 = 1 / term1
-        exponent = -0.5 * ((x - mu) / sigma) ** 2
-        return term1 * np.exp(exponent)
-
-    @staticmethod
-    def cumulative_distribution(mu: float = 0, sigma: float = 1, x: np.ndarray = None, cdf_func: str = "gaussian"):
-        """Integrates under a discrete PDF to obtain an estimated CDF.
-
-        Keyword arguments:
-        x   -- Arbitrary array of x-values
-        pdf -- PDF corresponding to values in x. E.g. as generated using
-               normal_distribution.
-
-        return: Array of len(x) containing the discrete cumulative values
-                estimated using the integral under the provided PDF.
-        """
-        debug(f"Estimating CDF using {get_slug(x)}")
-        if cdf_func == "gaussian":  # If Gaussian, integrate under pdf
-            pdf = Distribution.normal_distribution(mu=mu, sigma=sigma, x=x)
-            cdf = [np.trapz(pdf[:ii], x[:ii]) for ii in range(0, len(x))]
-        else:
-            raise NotImplementedError
-        return np.array(cdf)
-
-    @staticmethod
-    def empirical_distribution(x, sample):
-        """Estimates a CDF empirically.
-
-        Keyword arguments:
-        x      -- Array of x-values over which to generate distribution
-        sample -- Sample to use to generate distribution
-
-        return: Array of len(x) containing corresponding EDF values
-        """
-        debug(f"Estimating empirical distribution with {get_slug(x)}")
-        sample = np.array(sample)
-        sample = sample[~np.isnan(sample)]
-        sample = np.sort(sample)
-        edf = np.zeros(len(x))
-        n_sample = len(sample)
-        for ss in sample:
-            edf[x > ss] = edf[x > ss] + 1 / n_sample
-        return xr.DataArray(edf)
-
-    def get_common_x(self, other, n_pts=2000):
-        """Generates a common x vector for two CDF objects."""
-        debug(f"Generating common X vector for {get_slug(self)} and {get_slug(other)}")
-        xmin = min(self.plot_xmin, other.plot_xmin)
-        xmax = max(self.plot_xmax, other.plot_xmax)
-        common_x = np.linspace(xmin, xmax, n_pts)
-        return common_x
-
-    def plot_cdf(self):
-        """A quick plot showing the CDF contained in this object."""
-        debug(f"Generating quick plot for {get_slug(self)}")
-        ax = plt.subplot(111)
-        x, y = self.build_discrete_cdf()
-        ax.plot(x, y)
-        ax.grid()
-        return
-
-    def integrate_cdf(self, other=None, plot=False):
-        """Returns the integral under CDF or between two CDFs. This is
-        equivalent to the first order Wasserstein metric for two
-        probability distributions"""
-        debug(f"Generating diff plot for {get_slug(self)} and {get_slug(other)}")
-
-        if other is None:
-            integral = scipy.stats.wasserstein_distance(self.sample, [0])
-            if plot:
-                x = self.get_common_x(other)
-                x, y1 = self.build_discrete_cdf(x)
-                dum, y2 = other.build_discrete_cdf(x)
-                fig = plt.figure()
-                ax = plt.subplot(111)
-                ax.plot(x, y1, c="k", linestyle="--")
-                ax.plot(x, y2, linestyle="--")
-                ax.fill_between(x, y1, y2, alpha=0.5)
-                ax.set_title("Area: " + str(integral))
-                plt.legend(("1", "2"))
-
-        else:
-            integral = scipy.stats.wasserstein_distance(self.sample, other.sample)
-
-            if plot:
-                x = self.get_common_x(other)
-                x, y1 = self.build_discrete_cdf(x)
-                dum, y2 = other.build_discrete_cdf(x)
-                fig = plt.figure()
-                ax = plt.subplot(111)
-                ax.plot(x, y1, c="k", linestyle="--")
-                ax.plot(x, y2, linestyle="--")
-                ax.fill_between(x, y1, y2, alpha=0.5)
-                ax.set_title("Area: " + str(integral))
-                plt.legend(("1", "2"))
-
-        if plot:
-            return integral, fig, ax  # TODO fig and ax might not be defined
-        else:
-            return integral
diff --git a/coast_v1/nemo.py b/coast_v1/nemo.py
deleted file mode 100644
index c67278ba..00000000
--- a/coast_v1/nemo.py
+++ /dev/null
@@ -1,929 +0,0 @@
-from .coast import Coast
-from . import general_utils, stats_util
-import xarray as xr
-import numpy as np
-import gsw
-import warnings
-import traceback
-from .logging_util import get_slug, debug, info, warn, error
-
-
-class Nemo(Coast):  # TODO Complete this docstring
-    """
-    Words to describe the NEMO class
-
-    kwargs -- define addition keyworded arguemts for domain file. E.g. ln_sco=1
-    if using s-scoord in an old domain file that does not carry this flag.
-    """
-
-    def __init__(  # TODO Super init not called + add a docstring
-        self,
-        fn_data=None,
-        fn_domain=None,
-        grid_ref="t-grid",
-        chunks: dict = None,
-        multiple=False,
-        workers=2,
-        threads=2,
-        memory_limit_per_worker="2GB",
-        **kwargs,
-    ):
-        debug(f"Creating new {get_slug(self)}")
-        self.dataset = xr.Dataset()
-        self.grid_ref = grid_ref.lower()
-        self.domain_loaded = False
-
-        self.set_grid_vars()
-        self.set_dimension_mapping()
-        self.set_variable_mapping()
-        if fn_data is not None:
-            self.load(fn_data, chunks, multiple)
-        self.set_dimension_names(self.dim_mapping)
-        self.set_variable_names(self.var_mapping)
-
-        if fn_domain is None:
-            self.filename_domain = ""  # empty store for domain fileanme
-            warn("No NEMO domain specified, only limited functionality" + " will be available")
-        else:
-            self.filename_domain = fn_domain  # store domain fileanme
-            dataset_domain = self.load_domain(fn_domain, chunks)
-
-            # Define extra domain attributes using kwargs dictionary
-            # TODO This is a bit of a placeholder. Some domain/nemo files will have missing variables
-            for key, value in kwargs.items():
-                dataset_domain[key] = value
-
-            if fn_data is not None:
-                dataset_domain = self.trim_domain_size(dataset_domain)
-            self.set_timezero_depths(
-                dataset_domain
-            )  # THIS ADDS TO dataset_domain. Should it be 'return'ed (as in trim_domain_size) or is implicit OK?
-            self.merge_domain_into_dataset(dataset_domain)
-            debug(f"Initialised {get_slug(self)}")
-
-    def set_grid_vars(self):
-        """Define the variables to map from the domain file to the NEMO obj"""
-        # Define grid specific variables to pull across
-        if self.grid_ref == "u-grid":
-            self.grid_vars = [
-                "glamu",
-                "gphiu",
-                "e1u",
-                "e2u",
-                "e3u_0",
-                "depthu_0",
-            ]  # TODO What about e3vw?
-        elif self.grid_ref == "v-grid":
-            self.grid_vars = ["glamv", "gphiv", "e1v", "e2v", "e3v_0", "depthv_0"]
-        elif self.grid_ref == "t-grid":
-            self.grid_vars = ["glamt", "gphit", "e1t", "e2t", "e3t_0", "deptht_0", "tmask", "bottom_level"]
-        elif self.grid_ref == "w-grid":
-            self.grid_vars = ["glamt", "gphit", "e1t", "e2t", "e3w_0", "depthw_0", "bottom_level"]
-        elif self.grid_ref == "f-grid":
-            self.grid_vars = ["glamf", "gphif", "e1f", "e2f", "e3f_0", "depthf_0"]
-
-    def set_dimension_mapping(self):  # TODO Add a docstring
-        self.dim_mapping = {
-            "time_counter": "t_dim",
-            "deptht": "z_dim",
-            "depthu": "z_dim",
-            "depthv": "z_dim",
-            "y": "y_dim",
-            "x": "x_dim",
-            "x_grid_T": "x_dim",
-            "y_grid_T": "y_dim",
-        }
-        debug(f"{get_slug(self)} dim_mapping set to {self.dim_mapping}")
-        self.dim_mapping_domain = {"t": "t_dim0", "x": "x_dim", "y": "y_dim", "z": "z_dim"}
-        debug(f"{get_slug(self)} dim_mapping_domain set to {self.dim_mapping_domain}")
-
-    def set_variable_mapping(self):  # TODO Add a docstring
-        # Variable names remapped  within NEMO object
-        self.var_mapping = {
-            "time_counter": "time",
-            "votemper": "temperature",
-            "thetao": "temperature",
-            "temp": "temperature",
-            "toce": "temperature",
-            "so": "salinity",
-            "vosaline": "salinity",
-            "soce": "salinity",
-            "voce": "v_velocity",
-            "vomecrty": "v_velocity",
-            "uoce": "u_velocity",
-            "vozocrtx": "u_velocity",
-            "sossheig": "ssh",
-            "zos": "ssh",
-        }
-        # Variable names mapped from domain to NEMO object
-        # NAMES NOT SET IN STONE.
-        self.var_mapping_domain = {
-            "time_counter": "time0",
-            "glamt": "longitude",
-            "glamu": "longitude",
-            "glamv": "longitude",
-            "glamf": "longitude",
-            "gphit": "latitude",
-            "gphiu": "latitude",
-            "gphiv": "latitude",
-            "gphif": "latitude",
-            "e1t": "e1",
-            "e1u": "e1",
-            "e1v": "e1",
-            "e1f": "e1",
-            "e2t": "e2",
-            "e2u": "e2",
-            "e2v": "e2",
-            "e2f": "e2",
-            "ff_t": "ff",
-            "ff_f": "ff",
-            "e3t_0": "e3_0",
-            "e3w_0": "e3_0",
-            "e3u_0": "e3_0",
-            "e3v_0": "e3_0",
-            "e3f_0": "e3_0",
-            "tmask": "mask",
-            "depthf_0": "depth_0",
-            "depthu_0": "depth_0",
-            "depthv_0": "depth_0",
-            "depthw_0": "depth_0",
-            "deptht_0": "depth_0",
-            "ln_sco": "ln_sco",
-            "bottom_level": "bottom_level",
-        }
-
-    # TODO Add parameter type hints and a docstring
-    def load_domain(self, fn_domain, chunks):  # TODO Do something with this unused parameter or remove it
-        """Loads domain file and renames dimensions with dim_mapping_domain"""
-        # Load xarray dataset
-        info(f'Loading domain: "{fn_domain}"')
-        dataset_domain = xr.open_dataset(fn_domain)
-        self.domain_loaded = True
-        # Rename dimensions
-        for key, value in self.dim_mapping_domain.items():
-            mapping = {key: value}
-            try:
-                dataset_domain = dataset_domain.rename_dims(mapping)
-            except:  # FIXME Catch specific exception(s)
-                error(f"Exception while renaming dimensions from domain in NEMO object with key:value {mapping}")
-
-        return dataset_domain
-
-    def merge_domain_into_dataset(self, dataset_domain):
-        """Merge domain dataset variables into self.dataset, using grid_ref"""
-        debug(f"Merging {get_slug(dataset_domain)} into {get_slug(self)}")
-        # Define grid independent variables to pull across
-        not_grid_vars = ["jpiglo", "jpjglo", "jpkglo", "jperio", "ln_zco", "ln_zps", "ln_sco", "ln_isfcav"]
-
-        # Trim domain DataArray area if necessary.
-        self.copy_domain_vars_to_dataset(dataset_domain, self.grid_vars)
-
-        # Reset & set specified coordinates
-        coord_vars = ["longitude", "latitude", "time", "depth_0"]
-        self.dataset = self.dataset.reset_coords()
-        for var in coord_vars:
-            try:
-                self.dataset = self.dataset.set_coords(var)
-            except:  # FIXME Catch specific exception(s)
-                pass  # TODO Do we need to log something here?
-
-        # Delete specified variables
-        # TODO MIGHT NEED TO DELETE OTHER DEPTH VARS ON OTHER GRIDS?
-        delete_vars = ["nav_lat", "nav_lon", "deptht"]
-        for var in delete_vars:
-            try:
-                self.dataset = self.dataset.drop(var)
-            except:  # FIXME Catch specific exception(s)
-                pass  # TODO Do we need to log something here?
-
-    def __getitem__(self, name: str):
-        return self.dataset[name]
-
-    def set_grid_ref_attribute(self):
-        debug(f"{get_slug(self)} grid_ref_attr set to {self.grid_ref_attr_mapping}")
-        self.grid_ref_attr_mapping = {
-            "temperature": "t-grid",
-            "coast_name_for_u_velocity": "u-grid",
-            "coast_name_for_v_velocity": "v-grid",
-            "coast_name_for_w_velocity": "w-grid",
-            "coast_name_for_vorticity": "f-grid",
-        }
-
-    def get_contour_complex(self, var, points_x, points_y, points_z, tolerance: int = 0.2):
-        debug(f"Fetching contour complex from {get_slug(self)}")
-        smaller = self.dataset[var].sel(z=points_z, x=points_x, y=points_y, method="nearest", tolerance=tolerance)
-        return smaller
-
-    def set_timezero_depths(self, dataset_domain):
-        """
-        Calculates the depths at time zero (from the domain_cfg input file)
-        for the appropriate grid.
-        The depths are assigned to domain_dataset.depth_0
-        """
-        debug(f"Setting timezero depths for {get_slug(self)} with {get_slug(dataset_domain)}")
-
-        try:
-            bathymetry = dataset_domain.bathy_metry.squeeze()
-        except AttributeError:
-            bathymetry = xr.zeros_like(dataset_domain.e1t.squeeze())
-            (
-                warnings.warn(
-                    f"The model domain loaded, '{self.filename_domain}', does not contain the "
-                    "bathy_metry' variable. This will result in the "
-                    "NEMO.dataset.bathymetry variable being set to zero, which "
-                    "may result in unexpected behaviour from routines that require "
-                    "this variable."
-                )
-            )
-            debug(
-                f"The bathy_metry variable was missing from the domain_cfg for "
-                f"{get_slug(self)} with {get_slug(dataset_domain)}"
-            )
-        try:
-            if self.grid_ref == "t-grid":
-                e3w_0 = np.squeeze(dataset_domain.e3w_0.values)
-                depth_0 = np.zeros_like(e3w_0)
-                depth_0[0, :, :] = 0.5 * e3w_0[0, :, :]
-                depth_0[1:, :, :] = depth_0[0, :, :] + np.cumsum(e3w_0[1:, :, :], axis=0)
-            elif self.grid_ref == "w-grid":
-                e3t_0 = np.squeeze(dataset_domain.e3t_0.values)
-                depth_0 = np.zeros_like(e3t_0)
-                depth_0[0, :, :] = 0.0
-                depth_0[1:, :, :] = np.cumsum(e3t_0, axis=0)[:-1, :, :]
-            elif self.grid_ref == "u-grid":
-                e3w_0 = dataset_domain.e3w_0.values.squeeze()
-                e3w_0_on_u = 0.5 * (e3w_0[:, :, :-1] + e3w_0[:, :, 1:])
-                depth_0 = np.zeros_like(e3w_0)
-                depth_0[0, :, :-1] = 0.5 * e3w_0_on_u[0, :, :]
-                depth_0[1:, :, :-1] = depth_0[0, :, :-1] + np.cumsum(e3w_0_on_u[1:, :, :], axis=0)
-                bathymetry[:, :-1] = 0.5 * (bathymetry[:, :-1] + bathymetry[:, 1:])
-            elif self.grid_ref == "v-grid":
-                e3w_0 = dataset_domain.e3w_0.values.squeeze()
-                e3w_0_on_v = 0.5 * (e3w_0[:, :-1, :] + e3w_0[:, 1:, :])
-                depth_0 = np.zeros_like(e3w_0)
-                depth_0[0, :-1, :] = 0.5 * e3w_0_on_v[0, :, :]
-                depth_0[1:, :-1, :] = depth_0[0, :-1, :] + np.cumsum(e3w_0_on_v[1:, :, :], axis=0)
-                bathymetry[:-1, :] = 0.5 * (bathymetry[:-1, :] + bathymetry[1:, :])
-            elif self.grid_ref == "f-grid":
-                e3w_0 = dataset_domain.e3w_0.values.squeeze()
-                e3w_0_on_f = 0.25 * (e3w_0[:, :-1, :-1] + e3w_0[:, :-1, 1:] + e3w_0[:, 1:, :-1] + e3w_0[:, 1:, 1:])
-                depth_0 = np.zeros_like(e3w_0)
-                depth_0[0, :-1, :-1] = 0.5 * e3w_0_on_f[0, :, :]
-                depth_0[1:, :-1, :-1] = depth_0[0, :-1, :-1] + np.cumsum(e3w_0_on_f[1:, :, :], axis=0)
-                bathymetry[:-1, :-1] = 0.25 * (
-                    bathymetry[:-1, :-1] + bathymetry[:-1, 1:] + bathymetry[1:, :-1] + bathymetry[1:, 1:]
-                )
-            else:
-                raise ValueError(str(self) + ": " + self.grid_ref + " depth calculation not implemented")
-            # Write the depth_0 variable to the domain_dataset DataSet, with grid type
-            dataset_domain[f"depth{self.grid_ref.replace('-grid','')}_0"] = xr.DataArray(
-                depth_0,
-                dims=["z_dim", "y_dim", "x_dim"],
-                attrs={"units": "m", "standard_name": "Depth at time zero on the {}".format(self.grid_ref)},
-            )
-            self.dataset["bathymetry"] = bathymetry
-            self.dataset["bathymetry"].attrs = {
-                "units": "m",
-                "standard_name": "bathymetry",
-                "description": "depth of last wet w-level on the horizontal {}".format(self.grid_ref),
-            }
-        except ValueError as err:
-            error(traceback.format_exc())
-
-    # Add subset method to NEMO class
-    def subset_indices(self, start: tuple, end: tuple) -> tuple:
-        """
-        based on transect_indices, this method looks to return all indices between the given points.
-        This results in a 'box' (Quadrilateral) of indices.
-        consequently the returned lists may have different lengths.
-        :param start: A lat/lon pair
-        :param end: A lat/lon pair
-        :return: list of y indices, list of x indices,
-        """
-        debug(f"Subsetting {get_slug(self)} indices from {start} to {end}")
-        [j1, i1] = self.find_j_i(start[0], start[1])  # lat , lon
-        [j2, i2] = self.find_j_i(end[0], end[1])  # lat , lon
-
-        return list(np.arange(j1, j2 + 1)), list(np.arange(i1, i2 + 1))
-
-    def find_j_i(self, lat: float, lon: float):
-        """
-        A routine to find the nearest y x coordinates for a given latitude and longitude
-        Usage: [y,x] = find_j_i(49, -12)
-
-        :param lat: latitude
-        :param lon: longitude
-        :return: the y and x coordinates for the NEMO object's grid_ref, i.e. t,u,v,f,w.
-        """
-        debug(f"Finding j,i for {lat},{lon} from {get_slug(self)}")
-        dist2 = xr.ufuncs.square(self.dataset.latitude - lat) + xr.ufuncs.square(self.dataset.longitude - lon)
-        [y, x] = np.unravel_index(dist2.argmin(), dist2.shape)
-        return [y, x]
-
-    def find_j_i_domain(self, lat: float, lon: float, dataset_domain: xr.DataArray):
-        # TODO add dataset_domain to docstring and remove nonexistent grid_ref
-        """
-        A routine to find the nearest y x coordinates for a given latitude and longitude
-        Usage: [y,x] = find_j_i(49, -12, dataset_domain)
-
-        :param lat: latitude
-        :param lon: longitude
-        :param grid_ref: the gphi/glam version a user wishes to search over
-        :return: the y and x coordinates for the given grid_ref variable within the domain file
-        """
-        debug(f"Finding j,i domain for {lat},{lon} from {get_slug(self)} using {get_slug(dataset_domain)}")
-        internal_lat = dataset_domain[self.grid_vars[1]]  # [f"gphi{self.grid_ref.replace('-grid','')}"]
-        internal_lon = dataset_domain[self.grid_vars[0]]  # [f"glam{self.grid_ref.replace('-grid','')}"]
-        dist2 = xr.ufuncs.square(internal_lat - lat) + xr.ufuncs.square(internal_lon - lon)
-        [_, y, x] = np.unravel_index(dist2.argmin(), dist2.shape)
-        return [y, x]
-
-    def transect_indices(self, start: tuple, end: tuple) -> tuple:
-        """
-        This method returns the indices of a simple straight line transect between two
-        lat lon points defined on the NEMO object's grid_ref, i.e. t,u,v,f,w.
-
-        :type start: tuple A lat/lon pair
-        :type end: tuple A lat/lon pair
-        :return: array of y indices, array of x indices, number of indices in transect
-        """
-        debug(f"Fetching transect indices for {start} to {end} from {get_slug(self)}")
-        [j1, i1] = self.find_j_i(start[0], start[1])  # lat , lon
-        [j2, i2] = self.find_j_i(end[0], end[1])  # lat , lon
-
-        line_length = max(np.abs(j2 - j1), np.abs(i2 - i1)) + 1
-
-        jj1 = [int(jj) for jj in np.round(np.linspace(j1, j2, num=line_length))]
-        ii1 = [int(ii) for ii in np.round(np.linspace(i1, i2, num=line_length))]
-
-        return jj1, ii1, line_length
-
-    @staticmethod
-    def interpolate_in_space(model_array, new_lon, new_lat, mask=None):
-        """
-        Interpolates a provided xarray.DataArray in space to new longitudes
-        and latitudes using a nearest neighbour method (BallTree).
-
-        Example Usage
-        ----------
-        # Get an interpolated DataArray for temperature onto two locations
-        interpolated = nemo.interpolate_in_space(nemo.dataset.votemper,
-                                                 [0,1], [45,46])
-        Parameters
-        ----------
-        model_array (xr.DataArray): Model variable DataArray to interpolate
-        new_lons (1Darray): Array of longitudes (degrees) to compare with model
-        new_lats (1Darray): Array of latitudes (degrees) to compare with model
-        mask (2D array): Mask array. Where True (or 1), elements of array will
-                     not be included. For example, use to mask out land in
-                     case it ends up as the nearest point.
-
-        Returns
-        -------
-        Interpolated DataArray
-        """
-        debug(f"Interpolating {get_slug(model_array)} in space with nearest neighbour")
-        # Get nearest indices
-        ind_x, ind_y = general_utils.nearest_indices_2d(
-            model_array.longitude, model_array.latitude, new_lon, new_lat, mask=mask
-        )
-
-        # Geographical interpolation (using BallTree indices)
-        interpolated = model_array.isel(x_dim=ind_x, y_dim=ind_y)
-        if "dim_0" in interpolated.dims:
-            interpolated = interpolated.rename({"dim_0": "interp_dim"})
-        return interpolated
-
-    @staticmethod
-    def interpolate_in_time(model_array, new_times, interp_method="nearest", extrapolate=True):
-        """
-        Interpolates a provided xarray.DataArray in time to new python
-        datetimes using a specified scipy.interpolate method.
-
-        Example Useage
-        ----------
-        # Get an interpolated DataArray for temperature onto altimetry times
-        new_times = altimetry.dataset.time
-        interpolated = nemo.interpolate_in_space(nemo.dataset.votemper,
-                                                 new_times)
-        Parameters
-        ----------
-        model_array (xr.DataArray): Model variable DataArray to interpolate
-        new_times (array): New times to interpolate to (array of datetimes)
-        interp_method (str): Interpolation method
-
-        Returns
-        -------
-        Interpolated DataArray
-        """
-        debug(f'Interpolating {get_slug(model_array)} in time with method "{interp_method}"')
-        # Time interpolation
-        interpolated = model_array.swap_dims({"t_dim": "time"})
-        if extrapolate:
-            interpolated = interpolated.interp(
-                time=new_times, method=interp_method, kwargs={"fill_value": "extrapolate"}
-            )
-        else:
-            interpolated = interpolated.interp(time=new_times, method=interp_method)
-        # interpolated = interpolated.swap_dims({'time':'t_dim'})  # TODO Do something with this or delete it
-
-        return interpolated
-
-    def construct_density(self, eos="EOS10"):
-
-        """
-            Constructs the in-situ density using the salinity, temperature and
-            depth_0 fields and adds a density attribute to the t-grid dataset
-
-            Requirements: The supplied t-grid dataset must contain the
-            Practical Salinity and the Potential Temperature variables. The depth_0
-            field must also be supplied. The GSW package is used to calculate
-            The Absolute Pressure, Absolute Salinity and Conservate Temperature.
-
-            Note that currently density can only be constructed using the EOS10
-            equation of state.
-
-        Parameters
-        ----------
-        eos : equation of state, optional
-            DESCRIPTION. The default is 'EOS10'.
-
-
-        Returns
-        -------
-        None.
-        adds attribute NEMO.dataset.density
-
-        """
-        debug(f'Constructing in-situ density for {get_slug(self)} with EOS "{eos}"')
-        try:
-            if eos != "EOS10":
-                raise ValueError(str(self) + ": Density calculation for " + eos + " not implemented.")
-            if self.grid_ref != "t-grid":
-                raise ValueError(
-                    str(self)
-                    + ": Density calculation can only be performed for a t-grid object,\
-                                 the tracer grid for NEMO."
-                )
-
-            try:
-                shape_ds = (
-                    self.dataset.t_dim.size,
-                    self.dataset.z_dim.size,
-                    self.dataset.y_dim.size,
-                    self.dataset.x_dim.size,
-                )
-                sal = self.dataset.salinity.to_masked_array()
-                temp = self.dataset.temperature.to_masked_array()
-            except AttributeError:
-                shape_ds = (1, self.dataset.z_dim.size, self.dataset.y_dim.size, self.dataset.x_dim.size)
-                sal = self.dataset.salinity.to_masked_array()[np.newaxis, ...]
-                temp = self.dataset.temperature.to_masked_array()[np.newaxis, ...]
-
-            density = np.ma.zeros(shape_ds)
-
-            s_levels = self.dataset.depth_0.to_masked_array()
-            lat = self.dataset.latitude.values
-            lon = self.dataset.longitude.values
-            # Absolute Pressure
-            pressure_absolute = np.ma.masked_invalid(gsw.p_from_z(-s_levels, lat))  # depth must be negative
-            # Absolute Salinity
-            sal_absolute = np.ma.masked_invalid(gsw.SA_from_SP(sal, pressure_absolute, lon, lat))
-            sal_absolute = np.ma.masked_less(sal_absolute, 0)
-            # Conservative Temperature
-            temp_conservative = np.ma.masked_invalid(gsw.CT_from_pt(sal_absolute, temp))
-            # In-situ density
-            density = np.ma.masked_invalid(gsw.rho(sal_absolute, temp_conservative, pressure_absolute))
-
-            coords = {
-                "depth_0": (("z_dim", "y_dim", "x_dim"), self.dataset.depth_0.values),
-                "latitude": (("y_dim", "x_dim"), self.dataset.latitude.values),
-                "longitude": (("y_dim", "x_dim"), self.dataset.longitude.values),
-            }
-            dims = ["z_dim", "y_dim", "x_dim"]
-            attributes = {"units": "kg / m^3", "standard name": "In-situ density"}
-
-            if shape_ds[0] != 1:
-                coords["time"] = ("t_dim", self.dataset.time.values)
-                dims.insert(0, "t_dim")
-
-            self.dataset["density"] = xr.DataArray(np.squeeze(density), coords=coords, dims=dims, attrs=attributes)
-
-        except AttributeError as err:
-            error(err)
-
-    def trim_domain_size(self, dataset_domain):
-        """
-        Trim the domain variables if the dataset object is a spatial subset
-
-        Note: This breaks if the SW & NW corner values of nav_lat and nav_lon
-        are masked, as can happen if on land...
-        """
-        debug(f"Trimming {get_slug(self)} variables with {get_slug(dataset_domain)}")
-        if (self.dataset["x_dim"].size != dataset_domain["x_dim"].size) or (
-            self.dataset["y_dim"].size != dataset_domain["y_dim"].size
-        ):
-            info(
-                "The domain  and dataset objects are different sizes:"
-                " [{},{}] cf [{},{}]. Trim domain.".format(
-                    dataset_domain["x_dim"].size,
-                    dataset_domain["y_dim"].size,
-                    self.dataset["x_dim"].size,
-                    self.dataset["y_dim"].size,
-                )
-            )
-
-            # Find the corners of the cut out domain.
-            [j0, i0] = self.find_j_i_domain(self.dataset.nav_lat[0, 0], self.dataset.nav_lon[0, 0], dataset_domain)
-            [j1, i1] = self.find_j_i_domain(self.dataset.nav_lat[-1, -1], self.dataset.nav_lon[-1, -1], dataset_domain)
-
-            dataset_subdomain = dataset_domain.isel(y_dim=slice(j0, j1 + 1), x_dim=slice(i0, i1 + 1))
-            return dataset_subdomain
-        else:
-            return dataset_domain
-
-    def copy_domain_vars_to_dataset(self, dataset_domain, grid_vars):
-        """
-        Map the domain coordand metric variables to the dataset object.
-        Expects the source and target DataArrays to be same sizes.
-        """
-        debug(f"Copying domain vars from {get_slug(dataset_domain)}/{get_slug(grid_vars)} to {get_slug(self)}")
-        for var in grid_vars:
-            try:
-                new_name = self.var_mapping_domain[var]
-                self.dataset[new_name] = dataset_domain[var].squeeze()
-                debug("map: {} --> {}".format(var, new_name))
-            except:  # FIXME Catch specific exception(s)
-                pass  # TODO Should we log something here?
-
-    def differentiate(self, in_varstr, dim="z_dim", out_var_str=None, out_obj=None):
-        """
-        Derivatives are computed in x_dim, y_dim, z_dim (or i,j,k) directions
-        wrt lambda, phi, or z coordinates (with scale factor in metres not degrees).
-
-        Derivatives are calculated using the approach adopted in NEMO,
-        specifically using the 1st order accurate central difference
-        approximation. For reference see section 3.1.2 (sec. Discrete operators)
-        of the NEMO v4 Handbook.
-
-        Currently the method does not accomodate all possible eventualities. It
-        covers:
-        1) d(grid_t)/dz --> grid_w
-
-        Returns  an object (with the appropriate target grid_ref) containing
-        derivative (out_varstr) as xr.DataArray
-
-        This is hardwired to expect:
-        1) depth_0 and e3_0 fields exist
-        2) xr.DataArrays are 4D
-        3) self.filename_domain if out_obj not specified
-        4) If out_obj is not specified, one is built that is  the size of
-            self.filename_domain. I.e. automatic subsetting of out_obj is not
-            supported.
-
-        Example usage:
-        --------------
-        # Initialise DataArrays
-        nemo_t = coast.NEMO( fn_data, fn_domain, grid_ref='t-grid' )
-        # Compute dT/dz
-        nemo_w_1 = nemo_t.differentiate( 'temperature', dim='z_dim' )
-
-        # For f(z)=-z. Compute df/dz = -1. Surface value is set to zero
-        nemo_t.dataset['depth4D'],_ = xr.broadcast( nemo_t.dataset['depth_0'], nemo_t.dataset['temperature'] )
-        nemo_w_4 = nemo_t.differentiate( 'depth4D', dim='z_dim', out_varstr='dzdz' )
-
-        Provide an existing target NEMO object and target variable name:
-        nemo_w_1 = nemo_t.differentiate( 'temperature', dim='z_dim', out_varstr='dTdz', out_obj=nemo_w_1 )
-
-
-        Parameters
-        ----------
-        in_varstr : str, name of variable to differentiate
-        dim : str, dimension to operate over. E.g. {'z_dim', 'y_dim', 'x_dim', 't_dim'}
-        out_var_str : str, (optional) name of the target xr.DataArray
-        out_obj : exiting NEMO obj to store xr.DataArray (optional)
-
-        """
-        # import xarray as xr
-
-        new_units = ""
-
-        # Check in_varstr exists in self.
-        if hasattr(self.dataset, in_varstr):
-            # self.dataset[in_varstr] exists
-
-            var = self.dataset[in_varstr]  # for convenience
-
-            # Compute d(t_grid)/dz --> w-grid
-            # Check grid_ref and dir. Determine target grid_ref.
-            if (self.grid_ref == "t-grid") and (dim == "z_dim"):
-                out_grid = "w-grid"
-
-                # If out_obj exists check grid_ref, else create out_obj.
-                if (out_obj is None) or (out_obj.grid_ref != out_grid):
-                    try:
-                        out_obj = Nemo(fn_domain=self.filename_domain, grid_ref=out_grid)
-                    except:  # TODO Catch specific exception(s)
-                        warn(
-                            "Failed to create target NEMO obj. Perhaps self.",
-                            "filename_domain={} is empty?".format(self.filename_domain),
-                        )
-
-                # Check is out_varstr is defined, else create it
-                if out_var_str is None:
-                    out_var_str = in_varstr + "_dz"
-
-                # Create new DataArray with the same dimensions as the parent
-                # Crucially have a coordinate value that is appropriate to the target location.
-                blank = xr.zeros_like(var.isel(z_dim=[0]))  # Using "z_dim=[0]" as a list preserves z-dimension
-                blank.coords["depth_0"] -= blank.coords["depth_0"]  # reset coord vals to zero
-                # Add blank slice to the 'surface'. Concat over the 'dim' coords
-                diff = xr.concat([blank, var.diff(dim)], dim)
-                diff_ndim, e3w_ndim = xr.broadcast(diff, out_obj.dataset.e3_0.squeeze())
-                # Compute the derivative
-                out_obj.dataset[out_var_str] = -diff_ndim / e3w_ndim
-
-                # Assign attributes
-                new_units = var.units + "/" + out_obj.dataset.depth_0.units
-                # Convert to a xr.DataArray and return
-                out_obj.dataset[out_var_str].attrs = {"units": new_units, "standard_name": out_var_str}
-
-                # Return in object.
-                return out_obj
-
-            else:
-                warn("Not ready for that combination of grid ({}) and " "derivative ({})".format(self.grid_ref, dim))
-                return None
-        else:
-            warn(f"{in_varstr} does not exist in {get_slug(self)} dataset")
-            return None
-
-    def apply_doodson_x0_filter(self, var_str):
-        """Applies Doodson X0 filter to a variable.
-
-        Input variable is expected to be hourly.
-        Output is saved back to original dataset as {var_str}_dxo
-
-        !!WARNING: Will load in entire variable to memory. If dataset large,
-        then subset before using this method or ensure you have enough free
-        RAM to hold the variable (twice).
-
-        DB:: Currently not tested in unit_test.py"""
-        var = self.dataset[var_str]
-        new_var_str = var_str + "_dx0"
-        old_dims = var.dims
-        time_index = old_dims.index("t_dim")
-        filtered = stats_util.doodson_x0_filter(var, ax=time_index)
-        if filtered is not None:
-            self.dataset[new_var_str] = (old_dims, filtered)
-        return
-
-    @staticmethod
-    def get_e3_from_ssh(nemo_t, e3t=True, e3u=False, e3v=False, e3f=False, e3w=False, dom_fn: str = None):
-        """
-        Where the model has been run with a nonlinear free surface
-        and z* variable volumne (ln_vvl_zstar=True) then the vertical scale factors
-        will vary in time (and space). This function will compute the vertical
-        scale factors e3t, e3u, e3v, e3f and e3w by using the sea surface height
-        field (ssh variable) and initial scale factors from the domain_cfg file.
-        The vertical scale factors will be computed at the same model time as the
-        ssh and if the ssh field is averaged in time then the scale factors will
-        also be time averages.
-
-        A t-grid NEMO object containing the ssh variable must be passed in. Either
-        the domain_cfg path must have been passed in as an argument when the NEMO
-        object was created or it must be passed in here using the dom_fn argument.
-
-        e.g. e3t,e3v,e3f = coast.NEMO.get_e3_from_ssh(nemo_t,true,false,true,true,false)
-
-        Parameters
-        ----------
-        nemo_t : (COAsT.NEMO), NEMO object on the t-grid containing the ssh variable
-        e3t : (boolean), true if e3t is to be returned. Default True.
-        e3u : (boolean), true if e3u is to be returned. Default False.
-        e3v : (boolean), true if e3v is to be returned. Default False.
-        e3f : (boolean), true if e3f is to be returned. Default False.
-        e3w : (boolean), true if e3w is to be returned. Default False.
-        dom_fn : (str), Optional, path to domain_cfg file.
-
-        Returns
-        -------
-        Tuple of xarray.DataArrays
-        (e3t, e3u, e3v, e3f, e3w)
-        Only those requested will be returned, but the ordering is always the same.
-
-        """
-        e3_return = []
-        try:
-            ssh = nemo_t.dataset.ssh
-        except AttributeError:
-            print("The nemo_t dataset must contain the ssh variable.")
-            return
-        if "t_dim" not in ssh.dims:
-            ssh = ssh.expand_dims("t_dim", axis=0)
-
-        # Load domain_cfg
-        if dom_fn is None:
-            dom_fn = nemo_t.filename_domain
-        try:
-            ds_dom = xr.open_dataset(dom_fn).squeeze().rename({"z": "z_dim", "x": "x_dim", "y": "y_dim"})
-        except OSError:
-            print(f"Problem opening domain_cfg file: {dom_fn}")
-            return
-
-        e3t_0 = ds_dom.e3t_0
-
-        # Water column thickness, i.e. depth of bottom w-level on horizontal t-grid
-        H = e3t_0.cumsum(dim="z_dim").isel(z_dim=ds_dom.bottom_level.astype("int") - 1)
-        # Add correction to e3t_0 due to change in ssh
-        e3t_new = e3t_0 * (1 + ssh / H)
-        # preserve dimension ordering
-        e3t_new = e3t_new.transpose("t_dim", "z_dim", "y_dim", "x_dim")
-        # mask out correction at layers below bottom level
-        e3t_new = e3t_new.where(e3t_new.z_dim < ds_dom.bottom_level, e3t_0.data)
-        # preserve any other t mask
-        e3t_new = e3t_new.where(~np.isnan(ssh))
-        if e3t:
-            e3_return.append(e3t_new.squeeze())
-
-        if np.any([e3u, e3v, e3f]):
-            e1e2t = ds_dom.e1t * ds_dom.e2t
-        if np.any([e3u, e3v, e3w]):
-            e3t_dt = e3t_new - e3t_0
-
-        # area averaged interpolation onto the u-grid to get e3u
-        if np.any([e3u, e3f]):
-            e1e2u = ds_dom.e1u * ds_dom.e2u
-            # interpolate onto u-grid
-            e3u_temp = (
-                (0.5 / e1e2u[:, :-1]) * ((e1e2t[:, :-1] * e3t_dt[:, :, :, :-1]) + (e1e2t[:, 1:] * e3t_dt[:, :, :, 1:]))
-            ).transpose("t_dim", "z_dim", "y_dim", "x_dim")
-            # u mask
-            e3u_temp = e3u_temp.where(e3t_dt[:, :, :, 1:] != 0, 0)
-            # mask out correction at layers below bottom level
-            e3u_temp = e3u_temp.where(e3u_temp.z_dim < ds_dom.bottom_level[:, :-1], 0)
-            # Add correction to e3u_0
-            e3u_temp = e3u_temp + ds_dom.e3u_0[:, :, :-1]
-            e3u_new = xr.zeros_like(e3t_new)
-            e3u_new[:, :, :, :-1] = e3u_temp
-            e3u_new[:, :, :, -1] = ds_dom.e3u_0[:, :, -1]
-            e3u_new["longitude"] = ds_dom.glamu
-            e3u_new["latitude"] = ds_dom.gphiu
-            if e3u:
-                e3_return.append(e3u_new.squeeze())
-
-        # area averaged interpolation onto the u-grid to get e3v
-        if e3v:
-            e1e2v = ds_dom.e1v * ds_dom.e2v
-            e3v_temp = (
-                (0.5 / e1e2v[:-1, :]) * ((e1e2t[:-1, :] * e3t_dt[:, :, :-1, :]) + (e1e2t[1:, :] * e3t_dt[:, :, 1:, :]))
-            ).transpose("t_dim", "z_dim", "y_dim", "x_dim")
-            e3v_temp = e3v_temp.where(e3t_dt[:, :, 1:, :] != 0, 0)
-            e3v_temp = e3v_temp.where(e3v_temp.z_dim < ds_dom.bottom_level[:-1, :], 0)
-            e3v_temp = e3v_temp + ds_dom.e3v_0[:, :-1, :]
-            e3v_new = xr.zeros_like(e3t_new)
-            e3v_new[:, :, :-1, :] = e3v_temp
-            e3v_new[:, :, -1, :] = ds_dom.e3v_0[:, -1, :]
-            e3v_new["longitude"] = ds_dom.glamv
-            e3v_new["latitude"] = ds_dom.gphiv
-            e3_return.append(e3v_new.squeeze())
-
-        # area averaged interpolation onto the u-grid to get e3f
-        if e3f:
-            e1e2f = ds_dom.e1f * ds_dom.e2f
-            e3u_dt = e3u_new - ds_dom.e3u_0
-            e3f_temp = (
-                (0.5 / e1e2f[:-1, :]) * ((e1e2u[:-1, :] * e3u_dt[:, :, :-1, :]) + (e1e2u[1:, :] * e3u_dt[:, :, 1:, :]))
-            ).transpose("t_dim", "z_dim", "y_dim", "x_dim")
-            e3f_temp = e3f_temp.where(e3u_dt[:, :, 1:, :] != 0, 0)
-            e3f_temp = e3f_temp.where(e3f_temp.z_dim < ds_dom.bottom_level[:-1, :], 0)
-            e3f_temp = e3f_temp + ds_dom.e3f_0[:, :-1, :]
-            e3f_new = xr.zeros_like(e3t_new)
-            e3f_new[:, :, :-1, :] = e3f_temp
-            e3f_new[:, :, -1, :] = ds_dom.e3f_0[:, -1, :]
-            e3f_new["longitude"] = ds_dom.glamf
-            e3f_new["latitude"] = ds_dom.gphif
-            e3_return.append(e3f_new.squeeze())
-
-        # simple vertical interpolation for e3w. Special treatment of top and bottom levels
-        if e3w:
-            # top levels correction same at e3t
-            e3w_new = (ds_dom.e3w_0 + e3t_dt).transpose("t_dim", "z_dim", "y_dim", "x_dim")
-            # levels between top and bottom
-            e3w_new[dict(z_dim=slice(1, None))] = (
-                0.5 * e3t_dt[:, :-1, :, :] + 0.5 * e3t_dt[:, 1:, :, :] + ds_dom.e3w_0[1:, :, :]
-            )
-            # bottom and below levels
-            e3w_new = e3w_new.where(e3w_new.z_dim < ds_dom.bottom_level, e3t_dt.shift(z_dim=1) + ds_dom.e3w_0)
-            e3_return.append(e3w_new.squeeze())
-
-        return tuple(e3_return)
-
-    def harmonics_combine(self, constituents, components=["x", "y"]):  # TODO This [list] should probably be a (tuple)
-        """
-        Contains a new NEMO object containing combined harmonic information
-        from the original object.
-
-        NEMO saves harmonics to individual variables such as M2x, M2y... etc.
-        This routine will combine these variables (depending on constituents)
-        into a single data array. This new array will have the new dimension
-        'constituent' and a new data coordinate 'constituent_name'.
-
-        Parameters
-        ----------
-        constituents : List of strings containing constituent names to combine.
-                       The case of these strings should match that used in
-                       NEMO output. If a constituent is not found, no problem,
-                       it just won't be in the combined dataset.
-        components   : List of strings containing harmonic components to look
-                       for. By default, this looks for the complex components
-                       'x' and 'y'. E.g. if constituents = ['M2'] and
-                       components is left as default, then the routine looks
-                       for ['M2x', and 'M2y'].
-
-        Returns
-        -------
-        NEMO() object, containing combined harmonic variables in a new dataset.
-        """
-
-        # Select only the specified constituents. NEMO model harmonics names are
-        # things like "M2x" and "M2y". Ignore current harmonics. Start by constructing
-        # the possible variable names
-        names_x = np.array([cc + components[0] for cc in constituents])
-        names_y = np.array([cc + components[1] for cc in constituents])
-        constituents = np.array(constituents, dtype="str")
-
-        # Compare against names in file
-        var_keys = np.array(list(self.dataset.keys()))
-        indices = [np.where(names_x == ss) for ss in names_x if ss in var_keys]
-        indices = np.array(indices).T.squeeze()
-
-        # Index the possible names to match file names
-        print(indices)
-        names_x = names_x[indices]
-        names_y = names_y[indices]
-        constituents = constituents[indices]
-
-        # Concatenate x and y variables into one array
-        x_arrays = [self.dataset[ss] for ss in names_x]
-        harmonic_x = "harmonic_" + components[0]
-        x_data = xr.concat(x_arrays, dim="constituent").rename(harmonic_x)
-        y_arrays = [self.dataset[ss] for ss in names_y]
-        harmonic_y = "harmonic_" + components[1]
-        y_data = xr.concat(y_arrays, dim="constituent").rename(harmonic_y)
-
-        nemo_harmonics = Nemo()
-        nemo_harmonics.dataset = xr.merge([x_data, y_data])
-        nemo_harmonics.dataset["constituent"] = constituents
-
-        return nemo_harmonics
-
-    def harmonics_convert(
-        self,
-        direction="cart2polar",
-        x_var="harmonic_x",
-        y_var="harmonic_y",
-        a_var="harmonic_a",
-        g_var="harmonic_g",
-        degrees=True,
-    ):
-        """
-        Converts NEMO harmonics from cartesian to polar or vice versa.
-        Make sure this NEMO object contains combined harmonic variables
-        obtained using harmonics_combine().
-
-        *Note:
-
-        Parameters
-        ----------
-        direction (str) : Choose 'cart2polar' or 'polar2cart'. If 'cart2polar'
-                          Then will look for variables x_var and y_var. If
-                          polar2cart, will look for a_var (amplitude) and
-                          g_var (phase).
-        x_var (str)     : Harmonic x variable name in dataset (or output)
-                          default = 'harmonic_x'.
-        y_var (str)     : Harmonic y variable name in dataset (or output)
-                          default = 'harmonic_y'.
-        a_var (str)     : Harmonic amplitude variable name in dataset (or output)
-                          default = 'harmonic_a'.
-        g_var (str)     : Harmonic phase variable name in dataset (or output)
-                          default = 'harmonic_g'.
-        degrees (bool)  : Whether input/output phase are/will be in degrees.
-                          Default is True.
-
-        Returns
-        -------
-        Modifies NEMO() dataset in place. New variables added.
-        """
-        if direction == "cart2polar":
-            a, g = general_utils.cartesian_to_polar(self.dataset[x_var], self.dataset[y_var], degrees=degrees)
-            self.dataset[a_var] = a
-            self.dataset[g_var] = g
-        elif direction == "polar2cart":
-            x, y = general_utils.polar_to_cartesian(self.dataset[a_var], self.dataset[g_var], degrees=degrees)
-            self.dataset[x_var] = x
-            self.dataset[y_var] = y
-        else:
-            print("Unknown direction setting. Choose cart2polar or polar2cart")
-
-        return
diff --git a/coast_v1/observation.py b/coast_v1/observation.py
deleted file mode 100644
index c9ecd569..00000000
--- a/coast_v1/observation.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from .coast import Coast
-from .logging_util import get_slug, debug
-
-
-class Observation(Coast):
-    def set_dimension_mapping(self):
-        self.dim_mapping = None
-        debug(f"{get_slug(self)} dim_mapping set to {self.dim_mapping}")
-
-    def adjust_longitudes(self, lonbounds=[-180, 180]):  # TODO This [list] should probably be a (tuple)
-        debug(f"Adjusting {get_slug(self)} longitudes with lonbounds {lonbounds}")
-        bool0 = self["longitude"] < lonbounds[0]
-        bool1 = self["longitude"] > lonbounds[1]
-        self["longitude"][bool0] = self["longitude"][bool0] + 360
-        self["longitude"][bool1] = self["longitude"][bool1] - 360
diff --git a/coast_v1/tide_gauge.py b/coast_v1/tide_gauge.py
deleted file mode 100755
index 7f1322f0..00000000
--- a/coast_v1/tide_gauge.py
+++ /dev/null
@@ -1,1292 +0,0 @@
-import numpy as np
-import xarray as xr
-import matplotlib.pyplot as plt
-import pandas as pd
-import glob
-import re
-import pytz
-import sklearn.metrics as metrics
-from . import general_utils, plot_util, crps_util, stats_util
-from .logging_util import get_slug, debug, error, info
-
-
-class TideGauge:
-    """
-    An object for reading, storing and manipulating tide gauge data.
-    Functionality available for reading and organisation of GESLA files.
-    (Source: https://www.gesla.org/).  However, any fixed time series data can
-    be used if in the correct format.
-
-    The data format used for this object is as follows:
-
-    *Data Format Overview*
-
-        1. Data for a single tide gauge is stored in an xarray Dataset object.
-           This can be accessed using TIDEGAUGE.dataset.
-        2. The dataset has a single dimension: time.
-        3. Latitude/Longitude and other single values parameters are stored as
-           attributes or single float variables.
-        4. Time is a coordinate variable and time dimension.
-        5. Data variables are stored along the time dimension.
-        6. The attributes: site_name, latitude, longitude are expected. If they
-            are missing functionality may be reduced.
-
-
-    *Methods Overview*
-
-        *Initialisation and File Reading*
-        -> __init__: Can be initialised with a GESLA file or empty.
-        -> obs_operator: Interpolates model data to time series locations
-           and times (not yet implemented).
-        -> read_gesla_to_xarray_v3: Reads a format version 3.0
-           GESLA file to an xarray Dataset.
-        -> read_gesla_header_v3: Reads the header of a version 3
-           GESLA file.
-        -> read_gesla_data_v3: Reads data from a version 3 GESLA
-           file.
-        -> create_multiple_tidegauge: Creates multiple tide gauge objects
-           objects from a list of filenames or directory and returns them
-           in a list.
-
-        *Plotting*
-        -> plot_on_map: Plots location of TIDEGAUGE object on map.
-        -> plot_timeseries: Plots a specified time series.
-
-        *Model Comparison*
-        -> obs_operator(): For interpolating model data to this object.
-        -> cprs(): Calculates the CRPS between a model and obs variable.
-        -> difference(): Differences two specified variables
-        -> absolute_error(): Absolute difference, two variables
-        -> mean_absolute_error(): MAE between two variables
-        -> root_mean_square_error(): RMSE between two variables
-        -> time_mean(): Mean of a variable in time
-        -> time_std(): St. Dev of a variable in time
-        -> time_correlation(): Correlation between two variables
-        -> time_covariance(): Covariance between two variables
-        -> basic_stats(): Calculates multiple of the above metrics.
-
-        *Analysis*
-        -> resample_mean(): For resampling data in time using averaging
-        -> apply_doodson_xo_filter(): Remove tidal signal using Doodson XO
-        -> find_high_and_low_water(): Find maxima and minima of time series
-    """
-
-    ##############################################################################
-    ###                ~ Initialisation and File Reading ~                     ###
-    ##############################################################################
-
-    def __init__(self, file_path=None, date_start=None, date_end=None):
-        """
-        Initialise TIDEGAUGE object either as empty (no arguments) or by
-        reading GESLA data from a directory between two datetime objects.
-
-        Example usage:
-        --------------
-        # Read tide gauge data for data in January 1990
-        date0 = datetime.datetime(1990,1,1)
-        date1 = datetime.datetime(1990,2,1)
-        tg = coast.TIDEGAUGE(<'path_to_file'>, date0, date1)
-
-        # Access the data
-        tg.dataset
-
-        Parameters
-        ----------
-        file_path (list of str) : Filename to read from directory.
-        date_start (datetime) : Start date for data read. Optional
-        date_end (datetime) : end date for data read. Optional
-
-        Returns
-        -------
-        Self
-        """
-        debug(f"Creating a new {get_slug(self)}")
-
-        # If file list is supplied, read files from directory
-        if file_path is None:
-            self.dataset = None
-        else:
-            self.dataset = self.read_gesla_to_xarray_v3(file_path, date_start, date_end)
-        debug(f"{get_slug(self)} initialised")
-        return
-
-    ############ tide gauge methods ##############################################
-    @classmethod
-    def read_gesla_to_xarray_v3(cls, fn_gesla, date_start=None, date_end=None):
-        """
-        For reading from a single GESLA2 (Format version 3.0) file into an
-        xarray dataset. Formatting according to Woodworth et al. (2017).
-        Website: https://www.gesla.org/
-        If no data lies between the specified dates, a dataset is still created
-        containing information on the tide gauge, but the time dimension will
-        be empty.
-        Parameters
-        ----------
-        fn_gesla (str) : path to gesla tide gauge file
-        date_start (datetime) : start date for returning data
-        date_end (datetime) : end date for returning data
-
-        Returns
-        -------
-        xarray.Dataset object.
-        """
-        debug(f'Reading "{fn_gesla}" as a GESLA file with {get_slug(cls)}')  # TODO Maybe include start/end dates
-        try:
-            header_dict = cls.read_gesla_header_v3(fn_gesla)
-            dataset = cls.read_gesla_data_v3(fn_gesla, date_start, date_end)
-        except:
-            raise Exception("Problem reading GESLA file: " + fn_gesla)
-        # Attributes
-        dataset["longitude"] = header_dict["longitude"]
-        dataset["latitude"] = header_dict["latitude"]
-        del header_dict["longitude"]
-        del header_dict["latitude"]
-
-        dataset.attrs = header_dict
-
-        return dataset
-
-    @staticmethod
-    def read_gesla_header_v3(fn_gesla):
-        """
-        Reads header from a GESLA file (format version 3.0).
-
-        Parameters
-        ----------
-        fn_gesla (str) : path to gesla tide gauge file
-
-        Returns
-        -------
-        dictionary of attributes
-        """
-        debug(f'Reading GESLA header from "{fn_gesla}"')
-        fid = open(fn_gesla)
-
-        # Read lines one by one (hopefully formatting is consistent)
-        fid.readline()  # Skip first line
-        # Geographical stuff
-        site_name = fid.readline().split()[3:]
-        site_name = "_".join(site_name)
-        country = fid.readline().split()[2:]
-        country = "_".join(country)
-        contributor = fid.readline().split()[2:]
-        contributor = "_".join(contributor)
-        # Coordinates
-        latitude = float(fid.readline().split()[2])
-        longitude = float(fid.readline().split()[2])
-        coordinate_system = fid.readline().split()[3]
-        # Dates
-        start_date = fid.readline().split()[3:5]
-        start_date = " ".join(start_date)
-        start_date = pd.to_datetime(start_date)
-        end_date = fid.readline().split()[3:5]
-        end_date = " ".join(end_date)
-        end_date = pd.to_datetime(end_date)
-        time_zone_hours = float(fid.readline().split()[4])
-        # Other
-        fid.readline()  # Datum
-        fid.readline()  # Instrument
-        precision = float(fid.readline().split()[2])
-        null_value = float(fid.readline().split()[3])
-
-        debug(f'Read done, close file "{fn_gesla}"')
-        fid.close()
-        # Put all header info into an attributes dictionary
-        header_dict = {
-            "site_name": site_name,
-            "country": country,
-            "contributor": contributor,
-            "latitude": latitude,
-            "longitude": longitude,
-            "coordinate_system": coordinate_system,
-            "original_start_date": start_date,
-            "original_end_date": end_date,
-            "time_zone_hours": time_zone_hours,
-            "precision": precision,
-            "null_value": null_value,
-        }
-        return header_dict
-
-    @staticmethod
-    def read_gesla_data_v3(fn_gesla, date_start=None, date_end=None, header_length: int = 32):
-        """
-        Reads observation data from a GESLA file (format version 3.0).
-
-        Parameters
-        ----------
-        fn_gesla (str) : path to gesla tide gauge file
-        date_start (datetime) : start date for returning data
-        date_end (datetime) : end date for returning data
-        header_length (int) : number of lines in header (to skip when reading)
-
-        Returns
-        -------
-        xarray.Dataset containing times, sealevel and quality control flags
-        """
-        # Initialise empty dataset and lists
-        debug(f'Reading GESLA data from "{fn_gesla}"')
-        dataset = xr.Dataset()
-        time = []
-        sea_level = []
-        qc_flags = []
-        # Open file and loop until EOF
-        with open(fn_gesla) as file:
-            line_count = 1
-            for line in file:
-                # Read all data. Date boundaries are set later.
-                if line_count > header_length:
-                    working_line = line.split()
-                    if working_line[0] != "#":
-                        time.append(working_line[0] + " " + working_line[1])
-                        sea_level.append(float(working_line[2]))
-                        qc_flags.append(int(working_line[3]))
-
-                line_count = line_count + 1
-            debug(f'Read done, close file "{fn_gesla}"')
-
-        # Convert time list to datetimes using pandas
-        time = np.array(pd.to_datetime(time))
-
-        # Return only values between stated dates
-        start_index = 0
-        end_index = len(time)
-        if date_start is not None:
-            date_start = np.datetime64(date_start)
-            start_index = np.argmax(time >= date_start)
-        if date_end is not None:
-            date_end = np.datetime64(date_end)
-            end_index = np.argmax(time > date_end)
-        time = time[start_index:end_index]
-        sea_level = sea_level[start_index:end_index]
-        qc_flags = qc_flags[start_index:end_index]
-
-        # Set null values to nan
-        sea_level = np.array(sea_level)
-        qc_flags = np.array(qc_flags)
-        sea_level[qc_flags == 5] = np.nan
-
-        # Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
-        dataset["qc_flags"] = xr.DataArray(qc_flags, dims=["time"])
-        dataset = dataset.assign_coords(time=("time", time))
-
-        # Assign local dataset to object-scope dataset
-        return dataset
-
-    @classmethod
-    def create_multiple(cls, file_list, date_start=None, date_end=None):
-        """
-        Reads multiple GESLA tide gauge files from file_list (can include
-        wildcards) and return them in a list. date_start and date_end should
-        be datetime like objects. For a lot of files/data, this may take a
-        while.
-
-        Example usage:
-        --------------
-            # Read all data in directory in January 1990
-            date0 = datetime.datetime(1990,1,1)
-            date1 = datetime.datetime(1990,2,1)
-            tg = coast.TIDEGAUGE('gesla_directory/*', date0, date1)
-        Returns
-        -------
-        List of TIDEGAUGE objects.
-        """
-        # If single string is given then put into a single element list
-        if type(file_list) is str:
-            file_list = [file_list]
-
-        # Check file_list for wildcards and make list of files to read
-        file_to_read = []
-        for file in file_list:
-            if "*" in file:
-                wildcard_list = glob.glob(file)
-                file_to_read = file_to_read + wildcard_list
-            else:
-                file_to_read.append(file)
-
-        # Loop over files to read and read them into datasets
-        tidegauge_list = []
-        for file in file_to_read:
-            try:
-                dataset = cls.read_gesla_to_xarray_v3(file, date_start, date_end)
-                new_object = TideGauge()
-                new_object.dataset = dataset
-                tidegauge_list.append(new_object)
-            except:
-                # Problem with reading file: file TODO: add debug message here
-                pass
-        return tidegauge_list
-
-    ############ tide table methods (HLW) #########################################
-    @classmethod
-    def read_hlw_to_xarray(cls, fn_hlw, date_start=None, date_end=None):
-        """
-        For reading from a file of tidetable High and Low Waters (HLW) data into an
-        xarray dataset. File contains high water and low water heights and times
-
-        If no data lies between the specified dates, a dataset is still created
-        containing information on the tide gauge, but the time dimension will
-        be empty.
-
-        The data takes the form:
-        LIVERPOOL (GLADSTONE DOCK)    TZ: UT(GMT)/BST     Units: METRES    Datum: Chart Datum
-        01/10/2020  06:29    1.65
-        01/10/2020  11:54    9.01
-        01/10/2020  18:36    1.87
-        ...
-
-        Parameters
-        ----------
-        fn_hlw (str) : path to tabulated High Low Water file
-        date_start (datetime) : start date for returning data
-        date_end (datetime) : end date for returning data
-
-        Returns
-        -------
-        xarray.Dataset object.
-        """
-        debug(f'Reading "{fn_hlw}" as a HLW file with {get_slug(cls)}')  # TODO Maybe include start/end dates
-        try:
-            header_dict = cls.read_hlw_header(fn_hlw)
-            dataset = cls.read_hlw_data(fn_hlw, header_dict, date_start, date_end)
-            if header_dict["field"] == "TZ:UT(GMT)/BST":
-                debug("Read in as BST, stored as UTC")
-            elif header_dict["field"] == "TZ:GMTonly":
-                debug("Read and store as GMT/UTC")
-            else:
-                debug("Not expecting that timezone")
-
-        except:  # TODO Replace this bear except with something more specific.
-            raise Exception("Problem reading HLW file: " + fn_hlw)
-
-        dataset.attrs = header_dict
-
-        return dataset
-
-    @staticmethod
-    def read_hlw_header(filnam):
-        """
-        Reads header from a HWL file.
-
-        The data takes the form:
-        LIVERPOOL (GLADSTONE DOCK)    TZ: UT(GMT)/BST     Units: METRES    Datum: Chart Datum
-        01/10/2020  06:29    1.65
-        01/10/2020  11:54    9.01
-        01/10/2020  18:36    1.87
-        ...
-
-        Parameters
-        ----------
-        filnam (str) : path to file
-
-        Returns
-        -------
-        dictionary of attributes
-        """
-        debug(f'Reading HLW header from "{filnam}" ')
-        fid = open(filnam)
-
-        # Read lines one by one (hopefully formatting is consistent)
-        header = re.split(r"\s{2,}", fid.readline())
-        site_name = header[0]
-        site_name = site_name.replace(" ", "")
-
-        field = header[1]
-        field = field.replace(" ", "")
-
-        units = header[2]
-        units = units.replace(" ", "")
-
-        datum = header[3]
-        datum = datum.replace(" ", "")
-
-        debug(f'Read done, close file "{filnam}"')
-        fid.close()
-        # Put all header info into an attributes dictionary
-        header_dict = {"site_name": site_name, "field": field, "units": units, "datum": datum}
-        return header_dict
-
-    @staticmethod
-    def read_hlw_data(filnam, header_dict, date_start=None, date_end=None, header_length: int = 1):
-        """
-        Reads HLW data from a tidetable file.
-
-        Parameters
-        ----------
-        filnam (str) : path to HLW tide gauge file
-        date_start (np.datetime64) : start date for returning data.
-        date_end (np.datetime64) : end date for returning data.
-        header_length (int) : number of lines in header (to skip when reading)
-
-        Returns
-        -------
-        xarray.Dataset containing times, High and Low water values
-        """
-        import datetime
-
-        # Initialise empty dataset and lists
-        debug(f'Reading HLW data from "{filnam}"')
-        dataset = xr.Dataset()
-        time = []
-        sea_level = []
-
-        if header_dict["field"] == "TZ:UT(GMT)/BST":
-            localtime_flag = True
-        else:
-            localtime_flag = False
-
-        # Open file and loop until EOF
-        with open(filnam) as file:
-            line_count = 1
-            for line in file:
-                # Read all data. Date boundaries are set later.
-                if line_count > header_length:
-                    working_line = line.split()
-                    if working_line[0] != "#":
-                        time_str = working_line[0] + " " + working_line[1]
-                        # Read time as datetime.datetime because it can handle local timezone easily AND the unusual date format
-                        datetime_obj = datetime.datetime.strptime(time_str, "%d/%m/%Y %H:%M")
-                        if localtime_flag == True:
-                            time.append(np.datetime64(datetime_obj.astimezone()))
-                        else:
-                            time.append(np.datetime64(datetime_obj))
-                        sea_level.append(float(working_line[2]))
-                line_count = line_count + 1
-            debug(f'Read done, close file "{filnam}"')
-
-        # Return only values between stated dates
-        start_index = 0
-        end_index = len(time)
-        if date_start is not None:
-            # start_index = general_utils.nearest_datetime_ind(time, date_start)
-            date_start = np.datetime64(date_start)
-            start_index = np.argmax(time >= date_start)
-            debug(f"date_start: {date_start}. start_index: {start_index}")
-        if date_end is not None:
-            # end_index = general_utils.nearest_datetime_ind(time, date_end)
-            date_end = np.datetime64(date_end)
-            end_index = np.argmax(time > date_end)
-            debug(f"date_end: {date_end}. end_index: {end_index}")
-        time = time[start_index:end_index]
-        sea_level = sea_level[start_index:end_index]
-        debug(f"sea_level: {sea_level}")
-        # Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
-        dataset = dataset.assign_coords(time=("time", time))
-        # Assign local dataset to object-scope dataset
-        return dataset
-
-    def show(self, timezone: str = None):
-        """
-        Print out the values in the xarray
-        Displays with specified timezone
-        """
-        if timezone is None:
-            for i in range(len(self.dataset.sea_level)):
-                #               print('time:', self.dataset.time[i].values,
-                print(
-                    "time (UTC):",
-                    general_utils.day_of_week(self.dataset.time[i].values),
-                    np.datetime_as_string(self.dataset.time[i], unit="m"),
-                    "height:",
-                    self.dataset.sea_level[i].values,
-                    "m",
-                )
-        else:  # display timezone aware times
-            for i in range(len(self.dataset.sea_level)):
-                #               print('time:', self.dataset.time[i].values,
-                print(
-                    "time (" + timezone + "):",
-                    general_utils.day_of_week(self.dataset.time[i].values),
-                    np.datetime_as_string(self.dataset.time[i], unit="m", timezone=pytz.timezone(timezone)),
-                    "height:",
-                    self.dataset.sea_level[i].values,
-                    "m",
-                )
-
-    def get_tide_table_times(
-        self,
-        time_guess: np.datetime64 = None,
-        time_var: str = "time",
-        measure_var: str = "sea_level",
-        method: str = "window",
-        winsize=None,
-    ):
-        """
-        Get tide times and heights from tide table.
-        input:
-        time_guess : np.datetime64 or datetime
-                assumes utc
-        time_var : name of time variable [default: 'time']
-        measure_var : name of sea_level variable [default: 'sea_level']
-
-        method =
-            window:  +/- hours window size, winsize, (int) return values in that window
-                uses additional variable winsize (int) [default 2hrs]
-            nearest_1: return only the nearest event, if in winsize [default:None]
-            nearest_2: return nearest event in future and the nearest in the past (i.e. high and a low), if in winsize [default:None]
-            nearest_HW: return nearest High Water event (computed as the max of `nearest_2`), if in winsize [default:None]
-
-        returns: xr.DataArray( measure_var, coords=time_var)
-            E.g. sea_level (m), time (utc)
-            If value is not found, it returns a NaN with time value as the
-            guess value.
-
-        """
-        # Ensure the date objects are datetime
-        if type(time_guess) is not np.datetime64:
-            debug("Convert date to np.datetime64")
-            time_guess = np.datetime64(time_guess)
-
-        if time_guess == None:
-            debug("Use today's date")
-            time_guess = np.datetime64("now")
-
-        if method == "window":
-            if winsize == None:
-                winsize = 2
-            # initialise start_index and end_index
-            start_index = 0
-            end_index = len(self.dataset[time_var])
-
-            date_start = time_guess - np.timedelta64(winsize, "h")
-            start_index = np.argmax(self.dataset[time_var].values >= date_start)
-
-            date_end = time_guess + np.timedelta64(winsize, "h")
-            end_index = np.argmax(self.dataset[time_var].values > date_end)
-
-            sea_level = self.dataset[measure_var][start_index:end_index]
-
-            return sea_level
-
-        elif method == "nearest_1":
-            dt = np.abs(self.dataset[time_var] - time_guess)
-            index = np.argsort(dt).values
-            if winsize is not None:  # if search window trucation exists
-                if np.timedelta64(dt[index[0]].values, "m").astype("int") <= 60 * winsize:  # compare in minutes
-                    debug(f"dt:{np.timedelta64(dt[index[0]].values,'m').astype('int')}")
-                    debug(f"winsize:{winsize}")
-                    return self.dataset[measure_var][index[0]]
-                else:
-                    # return a NaN in an xr.Dataset
-                    # The rather odd trailing zero is to remove the array layer
-                    # on both time and measurement, and to match the other
-                    # alternative for a return object
-                    return xr.DataArray([np.NaN], dims=(time_var), coords={time_var: [time_guess]})[0]
-            else:  # give the closest without window search truncation
-                return self.dataset[measure_var][index[0]]
-
-        elif method == "nearest_2":
-            index = np.argsort(np.abs(self.dataset[time_var] - time_guess)).values
-            nearest_2 = self.dataset[measure_var][index[0 : 1 + 1]]  # , self.dataset.time[index[0:1+1]]
-            return nearest_2
-
-        elif method == "nearest_HW":
-            index = np.argsort(np.abs(self.dataset[time_var] - time_guess)).values
-            # return self.dataset.sea_level[ index[np.argmax( self.dataset.sea_level[index[0:1+1]]] )] #, self.dataset.time[index[0:1+1]]
-            nearest_2 = self.dataset[measure_var][index[0 : 1 + 1]]  # , self.dataset.time[index[0:1+1]]
-            return nearest_2[nearest_2.argmax()]
-
-        else:
-            print("Not expecting that option / method")
-
-    ############ environment.data.gov.uk gauge methods ###########################
-    @classmethod
-    def read_ea_api_to_xarray(
-        cls, n_days: int = 5, date_start: np.datetime64 = None, date_end: np.datetime64 = None, station_id="E70124"
-    ):
-        """
-        load gauge data via environment.data.gov.uk EA API
-        Either loads last n_days, or from date_start:date_end
-
-        API Source:
-        https://environment.data.gov.uk/flood-monitoring/doc/reference
-
-        Details of available tidal stations are recovered with:
-        https://environment.data.gov.uk/flood-monitoring/id/stations?type=TideGauge
-        Recover the "stationReference" for the gauge of interest and pass as
-        station_id:str. The default station_id="E70124" is Liverpool.
-
-        INPUTS:
-            n_days : int. Extact the last n_days from now.
-            date_start : datetime. UTC format string "yyyy-MM-dd" E.g 2020-01-05
-            date_end : datetime
-            station_id : int. Station id. Also referred to as stationReference in
-             EA API. Default value is for Liverpool.
-        OUTPUT:
-            sea_level, time : xr.Dataset
-        """
-        import requests
-        import json
-
-        cls.n_days = n_days
-        cls.date_start = date_start
-        cls.date_end = date_end
-        cls.station_id = station_id  # EA id: stationReference  # TODO This should happen in the __init__!
-
-        #%% Obtain and process header information
-        info("load station info")
-        url = "https://environment.data.gov.uk/flood-monitoring/id/stations/" + cls.station_id + ".json"
-        try:
-            request_raw = requests.get(url)
-            header_dict = json.loads(request_raw.content)
-        except ValueError:
-            print(f"Failed request for station {cls.station_id}")
-            return
-
-        try:
-            header_dict["site_name"] = header_dict["items"]["label"]
-            header_dict["latitude"] = header_dict["items"]["lat"]
-            header_dict["longitude"] = header_dict["items"]["long"]
-        except:  # TODO Replace this bare except with something more specific
-            info(f"possible missing some header info: site_name,latitude,longitude")
-        try:
-            # Define url call with parameter from station info
-            htmlcall_station_id = header_dict["items"]["measures"]["@id"] + "/readings?"
-        except:  # TODO Replace this bare except with something more specific
-            debug(f"problem defining the parameter to read")
-
-        # Construct API request for data recovery
-        info("load station data")
-        if (cls.date_start is None) & (cls.date_end is None):
-            # TODO I am a little troubled by these cls attribute comparisons... They should really be static.
-            info(f"GETting n_days= {cls.n_days} of data")
-            url = (
-                htmlcall_station_id
-                + "since="
-                + (np.datetime64("now") - np.timedelta64(n_days, "D")).item().strftime("%Y-%m-%dT%H:%M:%SZ")
-            )
-            debug(f"url request: {url}")
-        else:
-            # Check date_start and date_end are timetime objects
-            if (type(cls.date_start) is np.datetime64) & (type(cls.date_end) is np.datetime64):
-                info(f"GETting data from {cls.date_start} to {cls.date_end}")
-                startdate = cls.date_start.item().strftime("%Y-%m-%d")
-                enddate = cls.date_end.item().strftime("%Y-%m-%d")
-                url = htmlcall_station_id + "startdate=" + startdate + "&enddate=" + enddate
-                debug(f"url request: {url}")
-
-            else:
-                debug("Expecting date_start and date_end as datetime objects")
-
-        #%% Get the data
-        try:
-            request_raw = requests.get(url)
-            request = json.loads(request_raw.content)
-            debug(f"EA API request: {request_raw.text}")
-        except ValueError:
-            debug(f"Failed request: {request_raw}")
-            return
-
-        #%% Process timeseries data
-        dataset = xr.Dataset()
-        time = []
-        sea_level = []
-        nvals = len(request["items"])
-        time = np.array([np.datetime64(request["items"][i]["dateTime"]) for i in range(nvals)])
-        sea_level = np.array([request["items"][i]["value"] for i in range(nvals)])
-
-        #%% Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
-        dataset = dataset.assign_coords(time=("time", time))
-        dataset.attrs = header_dict
-        debug(f"EA API request headers: {header_dict}")
-        # debug(f"EA API request 1st time: {time[0]} and value: {sea_level[0]}")
-
-        # Assign local dataset to object-scope dataset
-        return dataset
-
-    ############ BODC tide gauge methods ##############################################
-    @classmethod
-    def read_bodc_to_xarray(cls, fn_bodc, date_start=None, date_end=None):
-        """
-        For reading from a single BODC (processed) file into an
-        xarray dataset.
-        If no data lies between the specified dates, a dataset is still created
-        containing information on the tide gauge, but the time dimension will
-        be empty.
-
-        Data name: UK Tide Gauge Network, processed data.
-        Source: https://www.bodc.ac.uk/
-        See data notes from source for description of QC flags.
-
-        The data takes the form:
-            Port:              P234
-            Site:              Liverpool, Gladstone Dock
-            Latitude:          53.44969
-            Longitude:         -3.01800
-            Start Date:        01AUG2020-00.00.00
-            End Date:          31AUG2020-23.45.00
-            Contributor:       National Oceanography Centre, Liverpool
-            Datum information: The data refer to Admiralty Chart Datum (ACD)
-            Parameter code:    ASLVBG02 = Surface elevation (unspecified datum) of the water body by bubbler tide gauge (second sensor)
-              Cycle    Date      Time    ASLVBG02   Residual
-             Number yyyy mm dd hh mi ssf         f          f
-                 1) 2020/08/01 00:00:00     5.354M     0.265M
-                 2) 2020/08/01 00:15:00     5.016M     0.243M
-                 3) 2020/08/01 00:30:00     4.704M     0.241M
-                 4) 2020/08/01 00:45:00     4.418M     0.255M
-                 5) 2020/08/01 01:00:00     4.133      0.257
-                 ...
-
-        Parameters
-        ----------
-        fn_bodc (str) : path to bodc tide gauge file
-        date_start (datetime) : start date for returning data
-        date_end (datetime) : end date for returning data
-
-        Returns
-        -------
-        xarray.Dataset object.
-        """
-        debug(f'Reading "{fn_bodc}" as a BODC file with {get_slug(cls)}')  # TODO Maybe include start/end dates
-        try:
-            header_dict = cls.read_bodc_header(fn_bodc)
-            dataset = cls.read_bodc_data(fn_bodc, date_start, date_end)
-        except:
-            raise Exception("Problem reading BODC file: " + fn_bodc)
-        # Attributes
-        dataset["longitude"] = header_dict["longitude"]
-        dataset["latitude"] = header_dict["latitude"]
-        del header_dict["longitude"]
-        del header_dict["latitude"]
-
-        dataset.attrs = header_dict
-
-        return dataset
-
-    @staticmethod
-    def read_bodc_header(fn_bodc):
-        """
-        Reads header from a BODC file (format version 3.0).
-
-        Parameters
-        ----------
-        fn_bodc (str) : path to bodc tide gauge file
-
-        Returns
-        -------
-        dictionary of attributes
-        """
-        debug(f'Reading BODC header from "{fn_bodc}"')
-        fid = open(fn_bodc)
-
-        # Read lines one by one (hopefully formatting is consistent)
-        # Geographical stuff
-        header_dict = {}
-        header = True
-        for line in fid:
-            if ":" in line and header == True:
-                (key, val) = line.split(":")
-                key = key.lower().strip().replace(" ", "_")
-                val = val.lower().strip().replace(" ", "_")
-                header_dict[key] = val
-                debug(f"Header key: {key} and value: {val}")
-            else:
-                # print('No colon')
-                header = False
-        header_dict["site_name"] = header_dict["site"]  # duplicate as standard name
-        debug(f'Read done, close file "{fn_bodc}"')
-        fid.close()
-
-        header_dict["latitude"] = float(header_dict["latitude"])
-        header_dict["longitude"] = float(header_dict["longitude"])
-
-        return header_dict
-
-    @staticmethod
-    def read_bodc_data(fn_bodc, date_start=None, date_end=None, header_length: int = 11):
-        """
-        Reads observation data from a BODC file.
-
-        Parameters
-        ----------
-        fn_bodc (str) : path to bodc tide gauge file
-        date_start (datetime) : start date for returning data
-        date_end (datetime) : end date for returning data
-        header_length (int) : number of lines in header (to skip when reading)
-
-        Returns
-        -------
-        xarray.Dataset containing times, sealevel and quality control flags
-        """
-        # Initialise empty dataset and lists
-        debug(f'Reading BODC data from "{fn_bodc}"')
-        dataset = xr.Dataset()
-        time = []
-        sea_level = []
-        qc_flags = []
-        residual = []
-        # Open file and loop until EOF
-        with open(fn_bodc) as file:
-            line_count = 1
-            for line in file:
-                # Read all data. Date boundaries are set later.
-                if line_count > header_length:
-                    try:
-                        working_line = line.split()
-                        time_str = working_line[1] + " " + working_line[2]  # Empty lines cause trouble
-                        sea_level_str = working_line[3]
-                        residual_str = working_line[4]
-                        if sea_level_str[-1].isalpha():
-                            qc_flag_str = sea_level_str[-1]
-                            sea_level_str = sea_level_str.replace(qc_flag_str, "")
-                            residual_str = residual_str.replace(qc_flag_str, "")
-                        elif residual_str[-1].isalpha():  # sometimes residual has a
-                            # flag when elevation does not
-                            qc_flag_str = residual_str[-1]
-                            sea_level_str = sea_level_str.replace(qc_flag_str, "")
-                            residual_str = residual_str.replace(qc_flag_str, "")
-                        else:
-                            qc_flag_str = ""
-                        # print(line_count-header_length, residual_str, float(residual_str))
-                        # print(working_line, sea_level_str, qc_flag_str)
-                        time.append(time_str)
-                        qc_flags.append(qc_flag_str)
-                        sea_level.append(float(sea_level_str))
-                        residual.append(float(residual_str))
-                    except:
-                        debug(f"{file} probably empty line at end. Breaks split()")
-                line_count = line_count + 1
-            debug(f'Read done, close file "{fn_bodc}"')
-
-        # Convert time list to datetimes using pandas
-        time = np.array(pd.to_datetime(time))
-
-        # Return only values between stated dates
-        start_index = 0
-        end_index = len(time)
-        if date_start is not None:
-            date_start = np.datetime64(date_start)
-            start_index = np.argmax(time >= date_start)
-        if date_end is not None:
-            date_end = np.datetime64(date_end)
-            end_index = np.argmax(time > date_end)
-        time = time[start_index:end_index]
-        sea_level = sea_level[start_index:end_index]
-        qc_flags = qc_flags[start_index:end_index]
-
-        # Set null values to nan
-        sea_level = np.array(sea_level)
-        qc_flags = np.array(qc_flags)
-        # sea_level[qc_flags==5] = np.nan
-
-        # Assign arrays to Dataset
-        dataset["sea_level"] = xr.DataArray(sea_level, dims=["time"])
-        dataset["qc_flags"] = xr.DataArray(qc_flags, dims=["time"])
-        dataset = dataset.assign_coords(time=("time", time))
-
-        # Assign local dataset to object-scope dataset
-        return dataset
-
-    ##############################################################################
-    ###                ~            Plotting             ~                     ###
-    ##############################################################################
-
-    def plot_on_map(self):
-        """
-        Show the location of a tidegauge on a map.
-
-        Example usage:
-        --------------
-        # For a TIDEGAUGE object tg
-        tg.plot_map()
-
-        """
-
-        debug(f"Plotting tide gauge locations for {get_slug(self)}")
-
-        title = "Location: " + self.dataset.attrs["site_name"]
-        x = self.dataset.longitude
-        y = self.dataset.latitude
-        fig, ax = plot_util.geo_scatter(x, y, title=title)
-        ax.set_xlim((x - 10, x + 10))
-        ax.set_ylim((y - 10, y + 10))
-        return fig, ax
-
-    @classmethod
-    def plot_on_map_multiple(cls, tidegauge_list, color_var_str=None):
-        """
-        Show the location of a tidegauge on a map.
-
-        Example usage:
-        --------------
-        # For a TIDEGAUGE object tg
-        tg.plot_map()
-
-        """
-
-        debug(f"Plotting tide gauge locations for {get_slug(cls)}")
-
-        x = []
-        y = []
-        c = []
-        for tg in tidegauge_list:
-            x.append(tg.dataset.longitude)
-            y.append(tg.dataset.latitude)
-            if color_var_str is not None:
-                c.append(tg.dataset[color_var_str].values)
-
-        title = ""
-        if color_var_str is None:
-            fig, ax = plot_util.geo_scatter(x, y, title=title)
-        else:
-            fig, ax = plot_util.geo_scatter(x, y, title=title, c=c)
-
-        ax.set_xlim((min(x) - 10, max(x) + 10))
-        ax.set_ylim((min(y) - 10, max(y) + 10))
-        return fig, ax
-
-    def plot_timeseries(self, var_list=["sea_level"], date_start=None, date_end=None, plot_line=False):
-        # TODO Could var_list be a tuple?
-        """
-        Quick plot of time series stored within object's dataset
-        Parameters
-        ----------
-        date_start (datetime) : Start date for plotting
-        date_end (datetime) : End date for plotting
-        var_list  (str)  : List of variables to plot. Default: just sea_level  TODO Can this be a tuple?
-        plot_line (bool) : If true, draw line between markers
-
-        Returns
-        -------
-        matplotlib figure and axes objects
-        """
-        debug(f"Plotting timeseries for {get_slug(self)}")
-        fig = plt.figure(figsize=(10, 10))
-        # Check input is a list (even for one variable)
-        if isinstance(var_list, str):
-            var_list = [var_list]
-
-        for var_str in var_list:
-            dim_str = self.dataset[var_str].dims[0]
-            x = np.array(self.dataset[dim_str])
-            y = np.array(self.dataset[var_str])
-
-            # Use only values between stated dates
-            start_index = 0
-            end_index = len(x)
-            if date_start is not None:
-                date_start = np.datetime64(date_start)
-                start_index = np.argmax(x >= date_start)
-            if date_end is not None:
-                date_end = np.datetime64(date_end)
-                end_index = np.argmax(x > date_end)
-            x = x[start_index:end_index]
-            y = y[start_index:end_index]
-
-            # Plot lines first if needed
-            if plot_line:
-                plt.plot(x, y, c=[0.5, 0.5, 0.5], linestyle="--", linewidth=0.5)
-
-            ax = plt.scatter(x, y, s=10)
-
-        plt.grid()
-        plt.xticks(rotation=45)
-        plt.legend(var_list)
-        # Title and axes
-        plt.xlabel("Date")
-        plt.title("Site: " + self.dataset.site_name)
-
-        return fig, ax  # TODO ax might not be defined
-
-    ##############################################################################
-    ###                ~        Model Comparison         ~                     ###
-    ##############################################################################
-
-    def obs_operator(self, model, mod_var_name: str, time_interp="nearest", model_mask=None):
-        # TODO time_interp is unused.
-        """
-        Interpolates a model array (specified using a model object and variable
-        string) to TIDEGAUGE location and times. Takes the nearest model grid
-        cell to the tide gauge.
-
-        Parameters
-        ----------
-        model : MODEL object (e.g. NEMO)
-        model_var_name (str) : Name of variable (inside MODEL) to interpolate.
-        time_interp (str) : type of scipy time interpolation (e.g. linear)
-        model_mask : Mask to apply to model data in geographical interpolation
-                     of model. For example, use to ignore land points.
-                     If None, no mask is applied. If 'bathy', model variable
-                     (bathymetry==0) is used. Custom 2D mask arrays can be
-                     supplied.
-
-        Returns
-        -------
-        Saves interpolated array to TIDEGAUGE.dataset
-        """
-        # Determine mask
-        if model_mask == "bathy":
-            model_mask = model.dataset.bathymetry.values == 0
-
-        # Get data arrays
-        mod_var_array = model.dataset[mod_var_name]
-
-        # Depth interpolation -> for now just take 0 index
-        if "z_dim" in mod_var_array.dims:
-            mod_var_array = mod_var_array.isel(z_dim=0).squeeze()
-
-        # Cast lat/lon to numpy arrays
-        obs_lon = np.array([self.dataset.longitude])
-        obs_lat = np.array([self.dataset.latitude])
-
-        interpolated = model.interpolate_in_space(mod_var_array, obs_lon, obs_lat, mask=model_mask)
-
-        interpolated = model.interpolate_in_time(interpolated, self.dataset.time)
-
-        # Store interpolated array in dataset
-        new_var_name = "interp_" + mod_var_name
-        self.dataset[new_var_name] = interpolated.drop(["longitude", "latitude"])
-
-    def crps(
-        self,
-        model_object,
-        model_var_name,
-        obs_var_name: str = "sea_level",
-        nh_radius: float = 20,
-        time_interp: str = "linear",
-        create_new_obj=True,
-    ):
-        """
-        Comparison of observed variable to modelled using the Continuous
-        Ranked Probability Score. This is done using this TIDEGAUGE object.
-        This method specifically performs a single-observation neighbourhood-
-        forecast method.
-
-        Parameters
-        ----------
-        model_object (model) : Model object (NEMO) containing model data
-        model_var_name (str) : Name of model variable to compare.
-        obs_var_name (str)   : Name of observed variable to compare.
-        nh_radius (float)    : Neighbourhood rad
-        cdf_type (str)       : Type of cumulative distribution to use for the
-                               model data ('empirical' or 'theoretical').
-                               Observations always use empirical.
-        time_interp (str)    : Type of time interpolation to use (s)
-        create_new_obj (bool): If True, save output to new TIDEGAUGE obj.
-                               Otherwise, save to this obj.
-
-        Returns
-        -------
-        xarray.Dataset containing times, sealevel and quality control flags
-
-        Example Useage
-        -------
-        # Compare modelled 'sossheig' with 'sea_level' using CRPS
-        crps = altimetry.crps(nemo, 'sossheig', 'sea_level')
-        """
-
-        mod_var = model_object.dataset[model_var_name]
-        obs_var = self.dataset[obs_var_name]
-
-        crps_list, n_model_pts, contains_land = crps_util.crps_sonf_fixed(
-            mod_var,
-            self.dataset.longitude.values,
-            self.dataset.latitude.values,
-            obs_var.values,
-            obs_var.time.values,
-            nh_radius,
-            time_interp,
-        )
-        if create_new_obj:
-            new_object = TideGauge()
-            new_dataset = self.dataset[["longitude", "latitude", "time"]]
-            new_dataset["crps"] = (("time"), crps_list)
-            new_dataset["crps_n_model_pts"] = (("time"), n_model_pts)
-            new_object.dataset = new_dataset
-            return new_object
-        else:
-            self.dataset["crps"] = (("time"), crps_list)
-            self.dataset["crps_n_model_pts"] = (("time"), n_model_pts)
-
-    def difference(self, var_str0: str, var_str1: str, date0=None, date1=None):
-        """Difference two variables defined by var_str0 and var_str1 between
-        two dates date0 and date1. Returns xr.DataArray"""
-        # TODO general_utils.data_array_time_slice doesn't seem to exist?
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        diff = var0 - var1
-        return xr.DataArray(diff, dims="time", name="error", coords={"time": self.dataset.time})
-
-    def absolute_error(self, var_str0, var_str1, date0=None, date1=None):
-        """Absolute difference two variables defined by var_str0 and var_str1
-        between two dates date0 and date1. Return xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        adiff = np.abs(var0 - var1)
-        return xr.DataArray(adiff, dims="time", name="absolute_error", coords={"time": self.dataset.time})
-
-    def mean_absolute_error(self, var_str0, var_str1, date0=None, date1=None):
-        """Mean absolute difference two variables defined by var_str0 and
-        var_str1 between two dates date0 and date1. Return xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        mae = metrics.mean_absolute_error(var0, var1)
-        return mae
-
-    def root_mean_square_error(self, var_str0, var_str1, date0=None, date1=None):
-        """Root mean square difference two variables defined by var_str0 and
-        var_str1 between two dates date0 and date1. Return xr.DataArray"""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = general_utils.data_array_time_slice(var0, date0, date1).values
-        var1 = general_utils.data_array_time_slice(var1, date0, date1).values
-        rmse = metrics.mean_squared_error(var0, var1)
-        return np.sqrt(rmse)
-
-    def time_mean(self, var_str, date0=None, date1=None):
-        """Time mean of variable var_str between dates date0, date1"""
-        var = self.dataset[var_str]
-        var = general_utils.data_array_time_slice(var, date0, date1)
-        return np.nanmean(var)
-
-    def time_std(self, var_str, date0=None, date1=None):
-        """Time st. dev of variable var_str between dates date0 and date1"""
-        var = self.dataset[var_str]
-        var = general_utils.data_array_time_slice(var, date0, date1)
-        return np.nanstd(var)
-
-    def time_correlation(self, var_str0, var_str1, date0=None, date1=None, method="pearson"):
-        """Time correlation between two variables defined by var_str0,
-        var_str1 between dates date0 and date1. Uses Pandas corr()."""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = var0.rename("var1")
-        var1 = var1.rename("var2")
-        var0 = general_utils.data_array_time_slice(var0, date0, date1)
-        var1 = general_utils.data_array_time_slice(var1, date0, date1)
-        pdvar = xr.merge((var0, var1))
-        pdvar = pdvar.to_dataframe()
-        corr = pdvar.corr(method=method)
-        return corr.iloc[0, 1]
-
-    def time_covariance(self, var_str0, var_str1, date0=None, date1=None):
-        """Time covariance between two variables defined by var_str0,
-        var_str1 between dates date0 and date1. Uses Pandas corr()."""
-        var0 = self.dataset[var_str0]
-        var1 = self.dataset[var_str1]
-        var0 = var0.rename("var1")
-        var1 = var1.rename("var2")
-        var0 = general_utils.data_array_time_slice(var0, date0, date1)
-        var1 = general_utils.data_array_time_slice(var1, date0, date1)
-        pdvar = xr.merge((var0, var1))
-        pdvar = pdvar.to_dataframe()
-        cov = pdvar.cov()
-        return cov.iloc[0, 1]
-
-    def basic_stats(self, var_str0, var_str1, date0=None, date1=None, create_new_object=True):
-        """Calculates a selection of statistics for two variables defined by
-        var_str0 and var_str1, between dates date0 and date1. This will return
-        their difference, absolute difference, mean absolute error, root mean
-        square error, correlation and covariance. If create_new_object is True
-        then this method returns a new TIDEGAUGE object containing statistics,
-        otherwise variables are saved to the dateset inside this object."""
-
-        diff = self.difference(var_str0, var_str1, date0, date1)
-        ae = self.absolute_error(var_str0, var_str1, date0, date1)
-        mae = self.mean_absolute_error(var_str0, var_str1, date0, date1)
-        rmse = self.root_mean_square_error(var_str0, var_str1, date0, date1)
-        corr = self.time_correlation(var_str0, var_str1, date0, date1)
-        cov = self.time_covariance(var_str0, var_str1, date0, date1)
-
-        if create_new_object:
-            new_object = TideGauge()
-            new_dataset = self.dataset[["longitude", "latitude", "time"]]
-            new_dataset["absolute_error"] = ae
-            new_dataset["error"] = diff
-            new_dataset["mae"] = mae
-            new_dataset["rmse"] = rmse
-            new_dataset["corr"] = corr
-            new_dataset["cov"] = cov
-            new_object.dataset = new_dataset
-            return new_object
-        else:
-            self.dataset["absolute_error"] = ae
-            self.dataset["error"] = diff
-            self.dataset["mae"] = mae
-            self.dataset["rmse"] = rmse
-            self.dataset["corr"] = corr
-            self.dataset["cov"] = cov
-
-    ##############################################################################
-    ###                ~            Analysis             ~                     ###
-    ##############################################################################
-
-    def resample_mean(self, var_str: str, time_freq: str, **kwargs):
-        """Resample a TIDEGAUGE variable in time by calculating the mean
-            of all data points at a given frequency.
-
-        Parameters
-        ----------
-        var_str (str)    : Variable name to resample
-        time_freq (str)  : Time frequency. e.g. '1H' for hourly, '1D' for daily
-                           Can also be a timedelta object. See Pandas resample
-                           method for more info.
-        **kwargs (other) : Other arguments to pass to xarray.Dataset.resample
-        (http://xarray.pydata.org/en/stable/generated/xarray.Dataset.resample.html)
-
-        Returns
-        -------
-        New variable (var_str_freq) and dimension (time_freq) in tg.dataset
-        """
-        # Define new variable and dimension names
-        var = self.dataset[var_str]
-        new_var_str = var_str + "_" + time_freq
-        new_dim_str = "time_" + time_freq
-
-        # Resample using xarray.resample
-        resampled = var.resample(time=time_freq, **kwargs).mean()
-
-        # Rename dimensions and variables. Put into original dataset.
-        resampled = resampled.rename({"time": new_dim_str})
-        resampled = resampled.rename(new_var_str)
-        self.dataset[new_var_str] = resampled
-
-    def apply_doodson_x0_filter(self, var_str):
-        """Applies doodson X0 filter to a specified TIDEGAUGE variable
-        Input ius expected to be hourly. Use resample_mean to average data
-        to hourly frequency."""
-        filtered = stats_util.doodson_x0_filter(self.dataset[var_str], ax=0)
-        self.dataset[var_str + "_dx0"] = ("time_1H", filtered)
-
-    def find_high_and_low_water(self, var_str, method="comp", **kwargs):
-        """
-        Finds high and low water for a given variable.
-        Returns in a new TIDEGAUGE object with similar data format to
-        a TIDETABLE.
-
-        Methods:
-        'comp' :: Find maxima by comparison with neighbouring values.
-                  Uses scipy.signal.find_peaks. **kwargs passed to this routine
-                  will be passed to scipy.signal.find_peaks.
-        'cubic':: Find the maxima using the roots of cubic spline.
-                  Uses scipy.interpolate.InterpolatedUnivariateSpline
-                  and scipy.signal.argrelmax. **kwargs are not activated.
-        NOTE: Currently only the 'comp' and 'cubic' methods implemented. Future
-                  methods include linear interpolation or refinements.
-        """
-
-        x = self.dataset.time
-        y = self.dataset[var_str]
-
-        time_max, values_max = stats_util.find_maxima(x, y, method=method, **kwargs)
-        time_min, values_min = stats_util.find_maxima(x, -y, method=method, **kwargs)
-
-        new_dataset = xr.Dataset()
-        new_dataset.attrs = self.dataset.attrs
-        new_dataset[var_str + "_highs"] = ("time_highs", values_max.values)
-        new_dataset[var_str + "_lows"] = ("time_lows", -values_min.values)
-        new_dataset["time_highs"] = ("time_highs", time_max.values)
-        new_dataset["time_lows"] = ("time_lows", time_min.values)
-
-        new_object = TideGauge()
-        new_object.dataset = new_dataset
-
-        return new_object
diff --git a/conda/conda_build_config.yaml b/conda/conda_build_config.yaml
index c9163001..f24ea150 100644
--- a/conda/conda_build_config.yaml
+++ b/conda/conda_build_config.yaml
@@ -1,6 +1,6 @@
 python:
-  - 3.7
   - 3.8
+  - 3.9
 numpy:
   - 1.16
   - 1.17
@@ -8,3 +8,4 @@ numpy:
   - 1.19
   - 1.20
   - 1.21
+  - 1.22
diff --git a/conda_dev_requirements.txt b/conda_dev_requirements.txt
new file mode 100644
index 00000000..cb9be384
--- /dev/null
+++ b/conda_dev_requirements.txt
@@ -0,0 +1,14 @@
+numpy>=1.22.3
+dask>=2022.3.0
+dask[complete]>=2022.3.0
+xarray>=2022.3.0
+matplotlib>=3.5.1
+netCDF4>=1.5.8
+scipy>=1.8.0
+gsw>=3.4.0
+utide>=0.2.6
+scikit-learn>=1.0.2
+scikit-image>=0.19.2
+statsmodels>=0.13.2
+cartopy>=0.20.2
+spyder>=5.1.5
diff --git a/conda_requirements.txt b/conda_requirements.txt
deleted file mode 100644
index ad732181..00000000
--- a/conda_requirements.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-numpy>=1.16
-dask>=2
-dask[complete]>=2
-xarray>=0.19
-matplotlib>=3.4.3
-netCDF4>=1
-scipy>=1
-gsw==3.3.1
-utide>=0.2
-scikit-learn>=0.2
-scikit-image>=0.15
-cartopy>=0.20.1
-spyder>=4
diff --git a/config/example_cmems_grid_t.json b/config/example_cmems_grid_t.json
new file mode 100644
index 00000000..6fe0363e
--- /dev/null
+++ b/config/example_cmems_grid_t.json
@@ -0,0 +1,42 @@
+{
+	"type": "gridded",
+	"dimensionality": 3,
+	"chunks": {"time":1},
+	"grid_ref": {
+		"t-grid": []
+	},
+	"dataset": {
+		"dimension_map": {
+			"time": "t_dim",
+			"depth": "z_dim",
+			"lon": "x_dim",
+			"lat": "y_dim"
+		},
+		"variable_map": {
+			"time": "time",
+			"lon": "longitude",
+			"lat": "latitude",
+			"thetao": "temperature",
+			"so": "salinity",
+                        "depth": "z_dim",
+			"zos": "ssh"
+		},
+		"coord_vars": [
+			"time",
+                        "depth",
+                        "latitude",
+			"longitude"
+		]
+	},
+	"domain": {
+		"dimension_map": {},
+		"variable_map": {}
+	},
+	"static_variables": {
+		"not_grid_vars": [],
+		"delete_vars": []
+	},
+	"processing_flags": []
+}
+
+
diff --git a/config/example_cmems_grid_uv.json b/config/example_cmems_grid_uv.json
new file mode 100644
index 00000000..52f25a3b
--- /dev/null
+++ b/config/example_cmems_grid_uv.json
@@ -0,0 +1,40 @@
+{
+	"type": "gridded",
+	"dimensionality": 3,
+	"chunks": {"time":1},
+	"grid_ref": {
+		"t-grid": []
+	},
+	"dataset": {
+		"dimension_map": {
+			"time": "t_dim",
+			"depth": "z_dim",
+			"lon": "x_dim",
+			"lat": "y_dim"
+		},
+		"variable_map": {
+			"time": "time",
+			"lon": "longitude",
+			"lat": "latitude",
+			"uo": "u_velocity",
+			"vo": "v_velocity"
+		},
+		"coord_vars": [
+			"time",
+                        "depth",
+                        "latitude",
+			"longitude"
+		]
+	},
+	"domain": {
+		"dimension_map": {},
+		"variable_map": {}
+	},
+	"static_variables": {
+		"not_grid_vars": [],
+		"delete_vars": []
+	},
+	"processing_flags": []
+}
+
+
diff --git a/config/example_en4_profiles.json b/config/example_en4_profiles.json
index 3c52295f..6817b37d 100644
--- a/config/example_en4_profiles.json
+++ b/config/example_en4_profiles.json
@@ -4,7 +4,7 @@
 	"chunks": {},
 	"dataset": {
 		"dimension_map": {
-			"N_PROF": "profile",
+			"N_PROF": "id_dim",
 			"N_LEVELS": "z_dim"
 		},
 		"variable_map": {
diff --git a/config/example_nemo_bgc.json b/config/example_nemo_bgc.json
new file mode 100644
index 00000000..09ae80df
--- /dev/null
+++ b/config/example_nemo_bgc.json
@@ -0,0 +1,89 @@
+{
+	"type": "gridded",
+	"dimensionality": 3,
+	"chunks": {"time_counter":5},
+	"grid_ref": {
+		"t-grid": [
+			"glamt",
+			"gphit",
+			"e1t",
+			"e2t",
+			"e3t_0",
+			"deptht_0",
+			"tmask",
+			"bottom_level",
+			"mbathy",
+			"hbatt"
+		]
+	},
+	"dataset": {
+		"dimension_map": {
+			"time_counter": "t_dim",
+			"deptht": "z_dim",
+			"y": "y_dim",
+			"x": "x_dim",
+			"x_grid_T": "x_dim",
+			"y_grid_T": "y_dim"
+		},
+		"variable_map": {
+			"time_counter": "time",
+			"N1_p": "phosphate",
+                        "N3_n": "nitrogen_nitrate",
+			"N5_s": "silicate",
+                        "N4_n": "nitrogen_ammonium",
+			"O3_c": "dic",
+                        "O2_o": "oxygen",
+                        "O3_TA": "alkalinity",
+                        "O3_pH": "ph",
+                        "O3_fair": "co2_flux"
+		},
+		"coord_vars": [
+			"longitude",
+			"latitude",
+			"time",
+			"depth_0"
+		]
+	},
+	"domain": {
+		"dimension_map": {
+			"t": "t_dim0",
+			"x": "x_dim",
+			"y": "y_dim",
+			"z": "z_dim"
+		},
+		"variable_map": {
+			"time_counter": "time0",
+			"glamt": "longitude",
+			"gphit": "latitude",
+			"e1t": "e1",
+			"e2t": "e2",
+			"e3t_0": "e3_0",
+			"tmask":"mask",
+			"deptht_0": "depth_0",
+			"bottom_level": "bottom_level",
+			"mbathy":"bottom_level",
+			"hbatt":"bathymetry"
+		}
+	},
+	"static_variables": {
+		"not_grid_vars": [
+			"jpiglo",
+			"jpjglo",
+			"jpkglo",
+			"jperio",
+			"ln_zco",
+			"ln_zps",
+			"ln_sco",
+			"ln_isfcav"
+		],
+		"delete_vars": [
+			"nav_lat",
+			"nav_lon",
+			"deptht"
+		]
+	},
+	"processing_flags": [
+		"example_flag1",
+		"example_flag2"
+	]
+}
diff --git a/config/example_nemo_grid_f.json b/config/example_nemo_grid_f.json
index 65899560..59a0f5cb 100644
--- a/config/example_nemo_grid_f.json
+++ b/config/example_nemo_grid_f.json
@@ -9,7 +9,8 @@
 			"e1f",
 			"e2f",
 			"e3f_0",
-			"depthf_0"
+			"depthf_0",
+			"hbatf"
 		]
 	},
 	"dataset": {
@@ -49,7 +50,8 @@
 			"ff_f": "ff",
 			"e3f_0": "e3_0",
 			"depthf_0": "depth_0",
-			"bottom_level": "bottom_level"
+			"bottom_level": "bottom_level",
+			"hbatf":"bathymetry"
 		}
 	},
 	"static_variables": {
diff --git a/config/example_wod_profiles.json b/config/example_wod_profiles.json
new file mode 100644
index 00000000..5f38b356
--- /dev/null
+++ b/config/example_wod_profiles.json
@@ -0,0 +1,38 @@
+{
+	"type": "indexed",
+	"dimensionality": 1,
+	"chunks": {},
+	"dataset": {
+		"dimension_map": {
+			"casts": "casts",
+			"z_obs": "z_N",
+                        "Temperature_obs": "temperature_N",
+                        "Salinity_obs": "salinity_N",
+                        "Oxygen_obs": "oxygen_N",
+                        "Phosphate_obs": "phosphate_N",
+                        "tCO2_obs": "dic_N"
+		},
+		"variable_map": {
+			"lat": "latitude",
+			"lon": "longitude",
+			"depth": "depth",
+			"time": "time",
+                        "z": "depth",
+                        "z_row_size": "z_row_size",
+			"Temperature": "temperature",
+                        "Temperature_row_size": "temperature_row_size",
+                        "Salinity": "salinity",
+                        "Salinity_row_size": "salinity_row_size",
+                        "Oxygen": "oxygen",
+                        "Oxygen_row_size": "oxygen_row_size",
+                        "Phosphate": "phosphate",
+                        "Phosphate_row_size": "phosphate_row_size",
+                        "tCO2": "dic",
+                        "tCO2_row_size": "dic_row_size"
+		},
+		"coord_vars": []
+	},
+	"processing_flags": [
+		"example_flag"
+	]
+}
diff --git a/example_scripts/dask_wrapper_TEMPLATE.txt b/example_scripts/dask_wrapper_TEMPLATE.txt
new file mode 100644
index 00000000..95ba2ba6
--- /dev/null
+++ b/example_scripts/dask_wrapper_TEMPLATE.txt
@@ -0,0 +1,58 @@
+# Template and instruction for create a dask wrapper
+
+# Motivation
+# Start with an xarray.DataArray, myDataArray, that we want to pass into a
+# function. That function will perform eager evaluation and return a numpy array,
+# but we want lazy evaluation with the possibility to allow dask parallelism
+# see worked example in Process_data.seasonal_decomposition
+
+# Imports
+import dask.array as da
+from dask import delayed
+import xarray as xr
+import numpy as np
+
+# Step 1. (optional: allows dask to distribute computation across multiple cores, if not interested see comment 2)
+# Partition data in myDataArray by chunking it up as desired. Note that chunking dimensions
+# need to make sense for your particular problem! Here we just chunk along dim_2
+myDataArray = myDataArray.chunk({"dim_1": myDataArray.dim_1.size, "dim_2": chunksize})  # can be more dimensions
+# Then create a list containing all the array chunks as dask.delayed objects
+# (e.g. 4 chunks => list contain 4 delayed objects)
+myDataArray_partitioned = myDataArray.data.to_delayed().ravel()
+
+# Comment 1
+# There are different ways to partition your data. For example, if you start off with a numpy array
+# rather than an xarray DataArray you can just iterate over the array and partition it that way
+# (the partitions do NOT need to be dask.delayed objects). For example see the very simple case here:
+# https://docs.dask.org/en/stable/delayed.html
+# The method described in 1 is just very convenient for DataArrays where the multi-dimensional
+# chunks may be the desired way to partition the data.
+
+# Step 2.
+# Call your eager evaluating function using dask.delayed and pass in your data. This returns
+# a list containing the outputs from the function as dask.delayed objects. The list will have the same length as
+# myDataArray_partitioned
+delayed_myFunction_output = [
+    delayed(myFunction)(aChunk, other_args_for_myFunction) for aChunk in myDataArray_partitioned
+]
+
+# Step 3.
+# convert the lists of delayed objects to lists of dask arrays to allow array operations
+# It's possible this step is not necessary!
+dask_array_list = []
+for chunk_idx, aChunk in enumerate(delayed_myFunction_output):
+    # When converting from dask.delayed to dask.array, you must know the shape of the
+    # array. In this example we know this from the chunk sizes of the original DataArray
+    chunk_shape = (myDataArray.chunks[0][0], myDataArray.chunks[1][chunk_idx])
+    dask_array_list.append(da.from_delayed(aChunk, shape=chunk_shape, dtype=float))
+
+# Step 4.
+# Concatenate the array chunks together to get a single dask.array. This can be assigned to a
+# new DataArray as desired.
+myOutputArray = da.concatenate(dask_array_list, axis=1)
+
+# Comment 2
+# If you skipped step 1., i.e. just want a lazy operation and no parallelism, you can just do this
+myOutputArray = da.from_delayed(
+    delayed(myFunction)(myDataArray, other_args_for_myFunction), shape=myDataArray.shape, dtype=float
+)
diff --git a/example_scripts/internal_tide_pycnocline_diagnostics.py b/example_scripts/internal_tide_pycnocline_diagnostics.py
index 987f3eed..e338a1d7 100755
--- a/example_scripts/internal_tide_pycnocline_diagnostics.py
+++ b/example_scripts/internal_tide_pycnocline_diagnostics.py
@@ -240,8 +240,8 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100):
 # skipna = True --> ignore masked events when averaging
 # skipna = False --> if once masked then mean is masked.
 
-fig = plt.figure()
-plt.rcParams["figure.figsize"] = (8.0, 8.0)
+fig = plt.figure(figsize=(8, 9))
+# plt.rcParams["figure.figsize"] = (8.0, 12.0)
 
 ax = fig.add_subplot(111)
 cz = plt.contour(lon, lat, H, levels=[11, 50, 100, 200], colors=["k", "k", "k", "k"], linewidths=[1, 1, 1, 1])
@@ -260,7 +260,8 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100):
 labels = [str(int(cz.levels[i])) + "m" for i in range(1, len(cz.levels))]
 # labels = ['80m','200m','800m']
 
-plt.legend(lines, labels, loc="lower right")
+# Supress legend
+# plt.legend(lines, labels, loc="lower right")
 
 # I expect to see RuntimeWarnings in this block
 title_str = (
@@ -273,6 +274,6 @@ def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100):
 plt.title(title_str)
 plt.xlabel("longitude")
 plt.ylabel("latitude")
-plt.show()
+# plt.show()
 
-# fig.savefig(fig_dir+'strat_1st_mom.png', dpi=120)
+fig.savefig("strat_1st_mom.png", dpi=300)
diff --git a/example_scripts/profile_validation/analysis_average_into_grid_boxes.py b/example_scripts/profile_validation/analysis_average_into_grid_boxes.py
new file mode 100644
index 00000000..a419aef7
--- /dev/null
+++ b/example_scripts/profile_validation/analysis_average_into_grid_boxes.py
@@ -0,0 +1,72 @@
+"""
+Script for showing use of Profile.average_into_grid_boxes(). This routines
+takes all data in a Profile obejct and averages it into lat/lon grid boxes.
+
+This script can be used for comparing observed and modelled climatologies. 
+It should be run AFTER the nearest profiles have been extracted from the model
+data, such as shown in analysis_extract_and_compare.py. 
+
+Input and output files should be provided as a list. If you only have
+one input file, then just enclose the string in []. 
+
+You can use this script with example files by setting:
+    
+    # fn_prof = path.join('./example_files', "coast_example_EN4_201008.nc")
+    # fn_cfg_prof = path.join('./config', "example_en4_profiles.json")
+    # fn_out = path.join('./example_files', 'mask_mean.nc')
+"""
+
+import coast
+import numpy as np
+import xarray as xr
+import os
+
+# List of input files
+fn_prof = "<PATH_TO_NETCDF_PROFILE_OBJECT>"
+fn_cfg_prof = "<PATH_TO_COAST_PROFILE_CONFIG_FILE_IF_NEEDED>"
+
+# Names of output files (coresponding to fn_in_list), include ".nc"
+fn_out = "<OUTPUT_FILENAME.nc>"
+
+# Define longitude and latitude grid
+grid_lon = np.arange(-15, 15, 0.5)
+grid_lat = np.arange(45, 65, 0.5)
+
+# Load in data for averaging (e.g. surface data).
+prof_data = coast.Profile(config=fn_cfg_prof)
+prof_data.read_en4(fn_prof)
+profile_analysis = coast.ProfileAnalysis()
+
+# Take just the data we want so it is faster
+prof_data.dataset = prof_data.dataset[["temperature", "practical_salinity"]]
+
+# Average all data across all seasons
+prof_gridded = profile_analysis.average_into_grid_boxes(prof_data, grid_lon, grid_lat)
+
+# Average data for each season
+prof_gridded_DJF = profile_analysis.average_into_grid_boxes(
+    prof_data, grid_lon, grid_lat, season="DJF", var_modifier="_DJF"
+)
+prof_gridded_MAM = profile_analysis.average_into_grid_boxes(
+    prof_data, grid_lon, grid_lat, season="MAM", var_modifier="_MAM"
+)
+prof_gridded_JJA = profile_analysis.average_into_grid_boxes(
+    prof_data, grid_lon, grid_lat, season="JJA", var_modifier="_JJA"
+)
+prof_gridded_SON = profile_analysis.average_into_grid_boxes(
+    prof_data, grid_lon, grid_lat, season="SON", var_modifier="_SON"
+)
+
+# Merge together
+ds_prof_gridded = xr.merge(
+    (
+        prof_gridded.dataset,
+        prof_gridded_DJF.dataset,
+        prof_gridded_MAM.dataset,
+        prof_gridded_JJA.dataset,
+        prof_gridded_SON.dataset,
+    )
+)
+
+# Save to file
+ds_prof_gridded.to_netcdf(fn_out)
diff --git a/example_scripts/profile_validation/analysis_extract_and_compare.py b/example_scripts/profile_validation/analysis_extract_and_compare.py
new file mode 100644
index 00000000..5d2d485e
--- /dev/null
+++ b/example_scripts/profile_validation/analysis_extract_and_compare.py
@@ -0,0 +1,169 @@
+# This script demonstrates how to use the Profile and Gridded objects to
+# extract model profiles and do some comparisons with observed profiles.
+# It will do a nearest neighbour extraction of model data (with time interpolation
+# of your choice). It will then calculate differences between the model
+# and obs and averaged profiles and errors into surface and bottom layers.
+#
+#   This script will result in five new files being written:
+#
+#   1. extracted_profiles: Model data on model levels extracted at obs locs
+#   2. interpolated_profiles: Model data on ref depth level
+#   3. interpolated_obs: Obs data on ref depth levels
+#   4. profile_errors: Differences between interpolated_profiles and _obs
+#   5. surface_data: Surface data and errors
+#   6. bottom_data: Bottom data and errors
+#
+# If you are dealing with very large datasets, you should take a look at the
+# script analysis_extract_and_compare_single_process.py. This script demonstrates
+# a single process that can be used to build a parallel scheme.
+#
+# This script can be used with COAsT example data. Please set:
+#
+# fn_dom = path.join('./example_files', "coast_example_nemo_domain.nc")
+# fn_dat = path.join('./example_files', "coast_example_nemo_data.nc")
+# dn_out = "./example_files"
+# fn_prof = path.join('./example_files', "coast_example_EN4_201008.nc")
+# fn_cfg_nemo = path.join('./config', "example_nemo_grid_t.json")
+# fn_cfg_prof = path.join('./config', "example_en4_profiles.json")
+
+import sys
+
+# IF USING A DEVELOPMENT BRANCH OF COAST, ADD THE REPOSITORY TO PATH:
+# sys.path.append('<PATH_TO_COAST_REPO')
+import coast
+import xarray as xr
+import numpy as np
+import datetime
+from dateutil.relativedelta import relativedelta
+import os.path as path
+
+print("Modules loaded", flush=True)
+
+# Name of the run -- used mainly for naming output files
+run_name = "co7"
+
+# Figure out what the date range is for this analysis process
+start_date = datetime.datetime(2007, 1, 1)
+end_date = datetime.datetime(2010, 12, 1)
+print("Analysis Range: {0} -->> {1}".format(start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d")), flush=True)
+
+# Depth averaging settings
+ref_depth = np.concatenate((np.arange(1, 100, 2), np.arange(100, 300, 5), np.arange(300, 1000, 50)))
+surface_def = 5  # in metres
+bottom_height = [10, 30, 100]  # Use bottom heights of 10m, 30m and 100m for...
+bottom_thresh = [100, 500, np.inf]  # ...depths less than 100m, 500m and infinite
+
+# File paths (All) -- use format suggestions
+fn_dom = "<PATH_TO_NEMO_DOMAIN_FILE>"
+fn_dat = "<PATH_TO_NEMO_DATA_FILE(S)>"  # .format(run_name, start_date.year)
+dn_out = "<PATH_TO_OUTPUT_DIRECTORY>"  # .format(run_name)
+fn_prof = "<PATH_TO_PROCESSED_EN4_DATA>"
+fn_cfg_nemo = "<PATH_TO_COAST_GRIDDED_CONFIG_FILE>"
+fn_cfg_prof = "<PATH_TO_CODE_PROFILE_CONFIG_FILE>"
+
+# CREATE NEMO OBJECT and read in NEMO data. Extract latitude and longitude array
+print("Reading model data..", flush=True)
+nemo = coast.Gridded(fn_dat, fn_dom, multiple=True, config=fn_cfg_nemo)
+lon = nemo.dataset.longitude.values.squeeze()
+lat = nemo.dataset.latitude.values.squeeze()
+print("NEMO object created", flush=True)
+
+# Extract time indices between start and end dates
+nemo = nemo.time_slice(start_date, end_date)
+
+# Create a landmask array -- important for obs_operator. We can
+# get a landmask from bottom_level.
+nemo.dataset["landmask"] = nemo.dataset.bottom_level == 0
+nemo.dataset = nemo.dataset.rename({"depth_0": "depth"})
+print("Landmask calculated", flush=True)
+
+# CREATE EN4 PROFILE OBJECT
+# If you have not already processed the data:
+profile = coast.Profile(config=fn_cfg_prof)
+profile.read_en4(fn_prof)
+profile = profile.process_en4()
+
+# If you have already processed then uncomment:
+# profile = coast.Profile(dataset = xr.open_dataset(fn_prof, chunks={"id_dim": 10000}))
+print("Profile object created", flush=True)
+
+# Slice out the Profile times
+profile = profile.time_slice(start_date, end_date)
+
+# Extract only the variables that we want
+nemo.dataset = nemo.dataset[["temperature", "bathymetry", "bottom_level", "landmask"]]
+profile.dataset = profile.dataset[["potential_temperature", "practical_salinity", "depth"]]
+profile.dataset = profile.dataset.rename({"potential_temperature": "temperature", "practical_salinity": "salinity"})
+
+# Create Profile analysis object
+profile_analysis = coast.ProfileAnalysis()
+
+# Interpolate model to obs using obs_operator()
+model_profiles = profile.obs_operator(nemo)
+print("Obs_operator successful.", flush=True)
+
+# Throw away profiles where the interpolation distance is larger than 5km.
+keep_indices = model_profiles.dataset.interp_dist <= 5
+model_profiles = model_profiles.isel(id_dim=keep_indices)
+profile = profile.isel(id_dim=keep_indices)
+
+# Load the profiles (careful with memory)
+profile.dataset.load()
+print("Model interpolated to obs locations", flush=True)
+
+# Vertical Interpolation of model profiles to obs depths
+model_profiles_interp = profile_analysis.interpolate_vertical(model_profiles, profile, interp_method="linear")
+print("Model interpolated to obs depths", flush=True)
+
+# Vertical interpolation of model profiles to reference depths
+model_profiles_interp = profile_analysis.interpolate_vertical(model_profiles_interp, ref_depth)
+model_profiles.dataset.to_netcdf(
+    dn_out
+    + "extracted_profiles_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+model_profiles_interp.dataset.to_netcdf(
+    dn_out
+    + "interpolated_profiles_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Model interpolated to ref depths", flush=True)
+
+# Interpolation of obs profiles to reference depths
+profile_interp = profile_analysis.interpolate_vertical(profile, ref_depth)
+profile_interp.dataset.to_netcdf(
+    dn_out + "interpolated_obs_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Obs interpolated to reference depths", flush=True)
+
+# Difference between Model and Obs
+differences = profile_analysis.difference(profile_interp, model_profiles_interp)
+differences.dataset.load()
+differences.dataset.to_netcdf(
+    dn_out + "profile_errors_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Calculated errors and written", flush=True)
+
+# Surface Values and errors
+model_profiles_surface = profile_analysis.depth_means(model_profiles, [0, surface_def])
+obs_profiles_surface = profile_analysis.depth_means(profile, [0, surface_def])
+surface_errors = profile_analysis.difference(obs_profiles_surface, model_profiles_surface)
+surface_data = xr.merge(
+    (surface_errors.dataset, model_profiles_surface.dataset, obs_profiles_surface.dataset), compat="override"
+)
+surface_data.to_netcdf(
+    dn_out + "surface_data_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+
+# Bottom values and errors
+model_profiles_bottom = profile_analysis.bottom_means(model_profiles, bottom_height, bottom_thresh)
+obs_bathymetry = model_profiles.dataset["bathymetry"].values
+profile.dataset["bathymetry"] = (["id_dim"], obs_bathymetry)
+obs_profiles_bottom = profile_analysis.bottom_means(profile, bottom_height, bottom_thresh)
+bottom_errors = profile_analysis.difference(model_profiles_bottom, obs_profiles_bottom)
+bottom_data = xr.merge(
+    (bottom_errors.dataset, model_profiles_bottom.dataset, obs_profiles_bottom.dataset), compat="override"
+)
+bottom_data.to_netcdf(
+    dn_out + "bottom_data_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Bottom and surface data estimated and written", flush=True)
+print("DONE", flush=True)
diff --git a/example_scripts/profile_validation/analysis_extract_and_compare_single_process.py b/example_scripts/profile_validation/analysis_extract_and_compare_single_process.py
new file mode 100644
index 00000000..debf7972
--- /dev/null
+++ b/example_scripts/profile_validation/analysis_extract_and_compare_single_process.py
@@ -0,0 +1,171 @@
+# This runs the same analysis as analysis_extract_and_compare.py however it
+# does so in time blocks (multiples of months) to avoid memory problems.
+# At the top of this file are two variables called min_date and end_date
+# and freq_monthgs. These are the dates that this script will run an analysis
+# between and the monthly multiples to run per block. You must pass an index
+# to this file at the command line, telling the script WHICH month index to run.
+#
+# For example... If the dates are between 20040101 and 20050101, then an index
+# of 0 will run for the period 20040101 -> 20040201. An index of 4 will run
+# for the period 20040301 -> 20040401.
+#
+# This script exists to be used as part of a parallel scheme on a platform like
+# JASMIN. For example, using a command interface such as jug. This script
+# should be run on each process, being passed just a single index.
+#
+# If uneditted, this script will output five files PER PROCESS to the output
+# directory specified by dn_out:
+#
+#   1. extracted_profiles: Model data on model levels extracted at obs locs
+#   2. interpolated_profiles: Model data on ref depth level
+#   3. interpolated_obs: Obs data on ref depth levels
+#   4. profile_errors: Differences between interpolated_profiles and _obs
+#   5. surface_data: Surface data and errors
+#   6. bottom_data: Bottom data and errors
+#
+# The files can then be concatenated and given to an averaging routine such
+# as analysis_mask_means.py or analysis_average_into_grid.py.
+
+import sys
+
+# IF USING A DEVELOPMENT BRANCH OF COAST, ADD THE REPOSITORY TO PATH:
+# sys.path.append('<PATH_TO_COAST_REPO')
+import coast
+import xarray as xr
+import numpy as np
+import datetime
+from dateutil.relativedelta import relativedelta
+
+print("Modules loaded", flush=True)
+
+# This is the index number of this process. Passed as command line argument
+index = int(sys.argv[1])
+
+# Name of the run -- used mainly for naming output files
+run_name = "co7"
+
+# Start and end dates for the analysis. The script will cut down model
+# and EN4 data to be witin this range.
+min_date = datetime.datetime(2004, 1, 1)
+freq_months = 12
+end_date = datetime.datetime(2004, 3, 1)
+
+# Figure out what the date range is for this analysis process
+start_date = min_date + relativedelta(months=int(index * freq_months))
+end_date = start_date + relativedelta(months=int(freq_months))
+print("Analysis Range: {0} -->> {1}".format(start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d")), flush=True)
+
+# Depth averaging settings
+ref_depth = np.concatenate((np.arange(1, 100, 2), np.arange(100, 300, 5), np.arange(300, 1000, 50)))
+surface_def = 5  # in metres
+bottom_height = [10, 30, 100]  # Use bottom heights of 10m, 30m and 100m for...
+bottom_thresh = [100, 500, np.inf]  # ...depths less than 100m, 500m and infinite
+
+# File paths (All) -- use format suggestions
+fn_dom = "<PATH_TO_NEMO_DOMAIN_FILE>"
+fn_dat = "<PATH_TO_NEMO_DATA_FILE(S)>"  # .format(run_name, start_date.year)
+dn_out = "<PATH_TO_OUTPUT_DIRECTORY>"  # .format(run_name)
+fn_prof = "<PATH_TO_PROCESSED_EN4_DATA>"
+fn_cfg_nemo = "<PATH_TO_COAST_GRIDDED_CONFIG_FILE>"
+fn_cfg_prof = "<PATH_TO_CODE_PROFILE_CONFIG_FILE>"
+
+# CREATE NEMO OBJECT and read in NEMO data. Extract latitude and longitude array
+print("Reading model data..", flush=True)
+nemo = coast.Gridded(fn_dat, fn_dom, multiple=True, config=fn_cfg_nemo)
+lon = nemo.dataset.longitude.values.squeeze()
+lat = nemo.dataset.latitude.values.squeeze()
+print("NEMO object created", flush=True)
+
+# Extract time indices between start and end dates
+nemo = nemo.time_slice(start_date, end_date)
+
+# Create a landmask array -- important for obs_operator. We can
+# get a landmask from bottom_level.
+nemo.dataset["landmask"] = nemo.dataset.bottom_level == 0
+nemo.dataset = nemo.dataset.rename({"depth_0": "depth"})
+print("Landmask calculated", flush=True)
+
+# CREATE EN4 PROFILE OBJECT containing processed data. We just need to
+# create a Profile object and place the data straight into its dataset
+profile = coast.Profile()
+profile.dataset = xr.open_dataset(fn_prof, chunks={"id_dim": 10000})
+profile = profile.time_slice(start_date, end_date)
+print("Profile object created", flush=True)
+
+# Extract only the variables that we want
+nemo.dataset = nemo.dataset[["temperature", "salinity", "bathymetry", "bottom_level", "landmask"]]
+profile.dataset = profile.dataset[["potential_temperature", "practical_salinity", "depth"]]
+profile.dataset = profile.dataset.rename({"potential_temperature": "temperature", "practical_salinity": "salinity"})
+
+# Create Profile analysis object
+profile_analysis = coast.ProfileAnalysis()
+
+# Interpolate model to obs using obs_operator()
+model_profiles = profile.obs_operator(nemo)
+print("Obs_operator successful.", flush=True)
+
+# Throw away profiles where the interpolation distance is larger than 5km.
+keep_indices = model_profiles.dataset.interp_dist <= 5
+model_profiles = model_profiles.isel(profile=keep_indices)
+profile = profile.isel(profile=keep_indices)
+
+# Load the profiles (careful with memory)
+profile.dataset.load()
+print("Model interpolated to obs locations", flush=True)
+
+# Vertical Interpolation of model profiles to obs depths
+model_profiles_interp = profile_analysis.interpolate_vertical(model_profiles, profile, interp_method="linear")
+print("Model interpolated to obs depths", flush=True)
+
+# Vertical interpolation of model profiles to reference depths
+model_profiles_interp = profile_analysis.interpolate_vertical(model_profiles_interp, ref_depth)
+model_profiles.dataset.to_netcdf(
+    dn_out
+    + "extracted_profiles_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+model_profiles_interp.dataset.to_netcdf(
+    dn_out
+    + "interpolated_profiles_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Model interpolated to ref depths", flush=True)
+
+# Interpolation of obs profiles to reference depths
+profile_interp = profile_analysis.interpolate_vertical(profile, ref_depth)
+profile_interp.dataset.to_netcdf(
+    dn_out + "interpolated_obs_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Obs interpolated to reference depths", flush=True)
+
+# Difference between Model and Obs
+differences = profile_analysis.difference(profile_interp, model_profiles_interp)
+differences.dataset.load()
+differences.dataset.to_netcdf(
+    dn_out + "profile_errors_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Calculated errors and written", flush=True)
+
+# Surface Values and errors
+model_profiles_surface = profile_analysis.depth_means(model_profiles, [0, surface_def])
+obs_profiles_surface = profile_analysis.depth_means(profile, [0, surface_def])
+surface_errors = profile_analysis.difference(obs_profiles_surface, model_profiles_surface)
+surface_data = xr.merge(
+    (surface_errors.dataset, model_profiles_surface.dataset, obs_profiles_surface.dataset), compat="override"
+)
+surface_data.to_netcdf(
+    dn_out + "surface_data_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+
+# Bottom values and errors
+model_profiles_bottom = profile_analysis.bottom_means(model_profiles, bottom_height, bottom_thresh)
+obs_bathymetry = model_profiles.dataset["bathymetry"].values
+profile.dataset["bathymetry"] = (["id_dim"], obs_bathymetry)
+obs_profiles_bottom = profile_analysis.bottom_means(profile, bottom_height, bottom_thresh)
+bottom_errors = profile_analysis.difference(model_profiles_bottom, obs_profiles_bottom)
+bottom_data = xr.merge(
+    (bottom_errors.dataset, model_profiles_bottom.dataset, obs_profiles_bottom.dataset), compat="override"
+)
+bottom_data.to_netcdf(
+    dn_out + "bottom_data_{0}_{1}_{2}.nc".format(run_name, start_date.strftime("%Y%m"), end_date.strftime("%Y%m"))
+)
+print("Bottom and surface data estimated and written", flush=True)
+print("DONE", flush=True)
diff --git a/example_scripts/profile_validation/analysis_mask_means.py b/example_scripts/profile_validation/analysis_mask_means.py
new file mode 100644
index 00000000..a8f7b175
--- /dev/null
+++ b/example_scripts/profile_validation/analysis_mask_means.py
@@ -0,0 +1,90 @@
+"""
+Calculate mask means (regional means) of variables within a Profile object.
+
+Provide paths to four files:
+    
+    fn_dom : NEMO domain file defining mask lon/lat.
+    fn_cfg_nemo : NEMO config file.
+    fn_profile : Path to netCDF containing profile data.
+    fn_out : Path to netCDF output file.
+    
+* This script is set up for mask regions within the AMM model region.
+
+You can use this script with example files by setting:
+    
+    # fn_dom = path.join('./example_files', "coast_example_nemo_domain.nc")
+    # fn_prof = path.join('./example_files', "coast_example_EN4_201008.nc")
+    # fn_cfg_nemo = path.join('./config', "example_nemo_grid_t.json")
+    # fn_cfg_prof = path.join('./config', "example_en4_profiles.json")
+"""
+
+import coast
+import numpy as np
+import xarray as xr
+
+fn_dom = "<PATH_TO_NEMO_DOMAIN_FILE>"
+fn_cfg_nemo = "<PATH_TO_COAST_GRIDDED_CONFIG_FILE>"
+fn_cfg_prof = "<PATH_TO_COAST_PROFILE_CONFIG_FILE>"
+fn_prof = "<PATH_TO_COAST_PROFILE_NETCDF>"
+fn_out = "<PATH_TO_OUTPUT_FILE>"
+
+# CREATE NEMO OBJECT and read in NEMO data. Extract latitude and longitude array
+print("Reading model data..", flush=True)
+nemo = coast.Gridded(fn_domain=fn_dom, multiple=True, config=fn_cfg_nemo)
+lon = nemo.dataset.longitude.values.squeeze()
+lat = nemo.dataset.latitude.values.squeeze()
+print("NEMO object created", flush=True)
+
+# Create analysis object and mask maker object
+profile_analysis = coast.ProfileAnalysis()
+
+# Make Profile object from Profile data or analysis data from analysis_extract_and_compare
+# Here let's make it from EN4 data:
+profile = coast.Profile(config=fn_cfg_prof)
+profile.read_en4(fn_prof)
+
+# Or if reading analysis straight from netCDF, uncomment the following
+# profile = coast.Profile(dataset=xr.open_mfdataset(fn_prof, chunks={"id_dim": 10000}))
+
+# Make MaskMaker object
+mm = coast.MaskMaker()
+
+print("Doing regional analysis..")
+# Define Regional Masks
+regional_masks = []
+bath = nemo.dataset.bathymetry.values
+regional_masks.append(np.ones(lon.shape))
+regional_masks.append(mm.region_def_nws_north_sea(lon, lat, bath))
+regional_masks.append(mm.region_def_nws_outer_shelf(lon, lat, bath))
+regional_masks.append(mm.region_def_nws_english_channel(lon, lat, bath))
+regional_masks.append(mm.region_def_nws_norwegian_trench(lon, lat, bath))
+regional_masks.append(mm.region_def_kattegat(lon, lat, bath))
+regional_masks.append(mm.region_def_south_north_sea(lon, lat, bath))
+off_shelf = mm.region_def_off_shelf(lon, lat, bath)
+off_shelf[regional_masks[3].astype(bool)] = 0
+off_shelf[regional_masks[4].astype(bool)] = 0
+regional_masks.append(off_shelf)
+regional_masks.append(mm.region_def_irish_sea(lon, lat, bath))
+
+region_names = [
+    "whole_domain",
+    "north_sea",
+    "outer_shelf",
+    "eng_channel",
+    "nor_trench",
+    "kattegat",
+    "southern_north_sea",
+    "irish_sea",
+    "off_shelf",
+]
+
+mask_list = mm.make_mask_dataset(lon, lat, regional_masks)
+mask_indices = profile_analysis.determine_mask_indices(profile, mask_list)
+
+# Do mask averaging
+mask_means = profile_analysis.mask_means(profile, mask_indices)
+print("Regional means calculated.")
+
+# SAVE mask dataset to file
+mask_means.to_netcdf(fn_out)
+print("done")
diff --git a/example_scripts/profile_validation/analysis_preprocess_en4.py b/example_scripts/profile_validation/analysis_preprocess_en4.py
new file mode 100644
index 00000000..bacad674
--- /dev/null
+++ b/example_scripts/profile_validation/analysis_preprocess_en4.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""
+# Script for processing raw EN4 data prior to analysis.
+# See docstring of Profile.process_en4() for more specifics on what it does.
+#
+# This script will just load modules, read in raw EN4 data, cut out
+# a geographical box, call the processing routine and write the
+# processed data to file.
+#
+# You don't have to do this for each EN4 file individually if you
+# don't want, you can read in multiple using multiple = True on
+# the creation of the profile object. However, if analysing model
+# data in parallel chunks, you may want to split up the processing
+# into smaller files to make the analysis faster.
+"""
+
+### Start script
+import sys
+
+# IF USING A DEVELOPMENT BRANCH OF COAST, ADD THE REPOSITORY TO PATH:
+# sys.path.append('<PATH_TO_COAST_REPO')
+import coast
+import pandas as pd
+from datetime import datetime
+
+print("Modules loaded")
+
+# File paths - input en4, output processed file and read config file
+fn_prof = "<PATH_TO_RAW_EN4_DATA_FILE(S)>"
+fn_out = "<PATH_TO_OUTPUT_LOCATION_FOR_PROCESSED_PROFILES>"
+fn_cfg_prof = "<PATH_TO_COAST_PROFILE_CONFIG_FILE>"
+
+# Some important settings, easier to get at here
+longitude_bounds = [-15, 15]  # Geo box to cut out from data (match to model)
+latitude_bounds = [40, 65]
+multiple = True  # Reading multple files?
+
+
+# Create profile object containing data
+profile = coast.Profile(config=fn_cfg_prof)
+profile.read_en4(fn_prof, multiple=multiple)
+
+# Get geographical indices to extract
+profile = profile.subset_indices_lonlat_box(longitude_bounds, latitude_bounds)
+
+# Cut out a time slice of the data
+profile = profile.time_slice(date0=datetime(2010, 1, 1), date1=datetime(2010, 1, 20))
+
+# Process the extracted data into new processed profile
+processed_profile = profile.process_en4()
+
+# Sometimes the following line is needed to avoid an error::
+# processed_profile.dataset["time"] = ("id_dim", pd.to_datetime(processed_profile.dataset.time.values))
+
+# Write processed profiles to file
+processed_profile.dataset.to_netcdf(fn_out)
diff --git a/example_scripts/profile_validation/plot_validation_gridded_data.py b/example_scripts/profile_validation/plot_validation_gridded_data.py
new file mode 100644
index 00000000..9c18aa82
--- /dev/null
+++ b/example_scripts/profile_validation/plot_validation_gridded_data.py
@@ -0,0 +1,150 @@
+"""
+Plot up surface or bottom (or any fixed level) errors from a profile object
+with no z_dim (vertical dimension). Provide an array of netcdf files and 
+mess with the options to get a figure you like.
+
+You can define how many rows and columns the plot will have. This script will
+plot the provided list of netcdf datasets from left to right and top to bottom.
+
+A colorbar will be placed right of the figure.
+"""
+
+import xarray as xr
+import matplotlib.pyplot as plt
+import numpy as np
+import sys
+
+sys.path.append("/Users/dbyrne/code/COAsT")
+import coast
+import pandas as pd
+
+#%% File settings
+run_name = "test"
+
+# List of analysis output files. Profiles from each will be plotted
+# on each axis of the plot
+fn_list = [
+    "~/transfer/test_grid.nc",
+    "~/transfer/test_grid.nc",
+]
+
+# Filename for the output
+fn_out = "/Users/dbyrne/transfer/surface_gridded_errors_{0}.png".format(run_name)
+
+#%% General Plot Settings
+var_name = "abs_diff_temperature"  # Variable name in analysis file to plot
+# If you used var modified to make gridded data
+# then this is where to select season etc.
+save_plot = False
+
+# Masking out grid cells that don't contain many points
+min_points_in_average = 5
+name_of_count_variable = "grid_N"
+
+# Subplot axes settings
+n_r = 2  # Number of subplot rows
+n_c = 2  # Number of subplot columns
+figsize = (10, 5)  # Figure size
+lonbounds = [-15, 9.5]  # Longitude bounds
+latbounds = [45, 64]  # Latitude bounds
+subplot_padding = 0.5  # Amount of vertical and horizontal padding between plots
+fig_pad = (0.075, 0.075, 0.1, 0.1)  # Figure padding (left, top, right, bottom)
+# Leave some space on right for colorbar
+# Scatter opts
+marker_size = 3  # Marker size
+cmap = "bwr"  # Colormap for normal points
+clim = (-1, 1)  # Color limits for normal points
+discrete_cmap = True  # Discretize colormap
+cmap_levels = 14
+
+# Labels and Titles
+fig_title = "SST Errors"  # Whole figure title
+title_fontsize = 13  # Fontsize of title
+title_fontweight = "bold"  # Fontweight to use for title
+dataset_names = ["CO9p0", "CO9p0", "CO9p0"]  # Names to use for labelling plots
+subtitle_fontsize = 11  # Fontsize for dataset subtitles
+subtitle_fontweight = "normal"  # Fontweight for dataset subtitles
+
+# PLOT SEASONS. Make sure n_r = 2 and n_c = 2
+# If this option is true, only the first dataset will be plotted, with seasonal
+# variables on each subplot. The season_suffixes will be added to var_name
+# for each subplot panel.
+plot_seasons = True
+season_suffixes = ["DJF", "MAM", "JJA", "SON"]
+
+#%% Read and plotdata
+
+# Read all datasets into list
+ds_list = [xr.open_dataset(dd) for dd in fn_list]
+n_ds = len(ds_list)
+n_ax = n_r * n_c
+
+# Create plot and flatten axis array
+f, a = coast.plot_util.create_geo_subplots(lonbounds, latbounds, n_r, n_c, figsize=figsize)
+a_flat = a.flatten()
+
+# Dicretize colormap maybe
+if discrete_cmap:
+    cmap = plt.cm.get_cmap(cmap, cmap_levels)
+
+# Determine if we will extend the colormap or not
+extend_cbar = []
+
+# Loop over dataset
+for ii in range(n_ax):
+    ur_index = np.unravel_index(ii, (n_r, n_c))
+
+    # Select season if required
+    if plot_seasons:
+        ds = ds_list[0]
+        var_ii = var_name + "_{0}".format(season_suffixes[ii])
+        N_var = "{0}_{1}".format(name_of_count_variable, season_suffixes[ii])
+        a_flat[ii].text(0.05, 1.02, season_suffixes[ii], transform=a_flat[ii].transAxes, fontweight="bold")
+    else:
+        ds = ds_list[ii]
+        var_ii = var_name
+        a_flat[ii].set_title(dataset_names[ii], fontsize=subtitle_fontsize, fontweight=subtitle_fontweight)
+        N_var = name_of_count_variable
+
+    data = ds[var_ii].values
+    count_var = ds[N_var]
+    data[count_var < min_points_in_average] = np.nan
+
+    # Scatter and set title
+    pc = a_flat[ii].pcolormesh(
+        ds.longitude,
+        ds.latitude,
+        data,
+        cmap=cmap,
+        vmin=clim[0],
+        vmax=clim[1],
+    )
+
+    # Will we extend the colorbar for this dataset?
+    extend_cbar.append(coast.plot_util.determine_colorbar_extension(data, clim[0], clim[1]))
+
+# Set Figure title
+f.suptitle(fig_title, fontsize=title_fontsize, fontweight=title_fontweight)
+
+
+# Set tight figure layout
+f.tight_layout(w_pad=subplot_padding, h_pad=subplot_padding)
+f.subplots_adjust(left=(fig_pad[0]), bottom=(fig_pad[1]), right=(1 - fig_pad[2]), top=(1 - fig_pad[3]))
+
+# Handle colorbar -- will we extend it?
+if "both" in extend_cbar:
+    extend = "both"
+elif "max" in extend_cbar and "min" in extend_cbar:
+    extend = "both"
+elif "max" in extend_cbar:
+    extend = "max"
+elif "min" in extend_cbar:
+    extend = "min"
+else:
+    extend = "neither"
+cbar_ax = f.add_axes([(1 - fig_pad[2] + fig_pad[2] * 0.15), 0.15, 0.025, 0.7])
+f.colorbar(pc, cax=cbar_ax, extend=extend)
+
+# Save plot maybe
+if save_plot:
+    f.savefig(fn_out)
diff --git a/example_scripts/profile_validation/plot_validation_mask_means.py b/example_scripts/profile_validation/plot_validation_mask_means.py
new file mode 100644
index 00000000..09eaf9d6
--- /dev/null
+++ b/example_scripts/profile_validation/plot_validation_mask_means.py
@@ -0,0 +1,132 @@
+"""
+For plotting analysis data from a netcdf file created using COAsT.Profile.mask_means().
+This will plot multiple datasets onto a set of subplots. Each subplot is for
+a different averaging region.
+
+At the top of this script, you can set the paths to the netcdf files to plot
+and where to save. If you have multiple model runs to plot, provide a list
+of file paths (strings).
+
+Below this section are a bunch of parameters you can set, with explanations in
+comments. Edit this as much as you like or even go into the plotting code below.
+"""
+
+import xarray as xr
+import matplotlib.pyplot as plt
+import numpy as np
+
+#%% File settings
+run_name = "test"
+
+# List of analysis output files. Profiles from each will be plotted
+# on each axis of the plot
+fn_list = ["/Users/dbyrne/transfer/mask_means_daily_test.nc", "/Users/dbyrne/transfer/mask_means_daily_test.nc"]
+
+# Filename for the output
+fn_out = "/Users/dbyrne/transfer/regional_means_{0}.png".format(run_name)
+
+#%% General Plot Settings
+region_ind = [0, 1, 2, 3, 4, 5, 6, 7, 8]  # Region indices (in analysis) to plot
+region_names = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]  # Region names, will be used for titles in plot
+var_name = "profile_average_abs_diff_temperature"  # Variable name in analysis file to plot
+plot_zero_line = True  # Plot a black vertical line at x = 0
+plot_mean_depth = False  # Plot the mean bathymetric depth. Make sure 'bathymetry' is in the analysis dataset
+save_plot = False  # Boolean to save plot or not
+
+ref_depth = np.concatenate((np.arange(1, 100, 2), np.arange(100, 300, 5), np.arange(300, 1000, 50)))  # Data depths
+
+# Subplot axes settings
+n_r = 2  # Number of subplot rows
+n_c = 5  # Number of subplot columns
+figsize = (7, 7)  # Figure size
+sharey = True  # Align y axes
+sharex = False  # Align x axes
+subplot_padding = 0.5  # Amount of vertical and horizontal padding between plots
+fig_pad = (0.075, 0.075, 0.075, 0.1)  # Whole figure padding as % (left, top, right, bottom)
+max_depth = 200  # Maximum plot depth
+
+# Legend settings
+legend_str = ["CO9p0", "CO9p0_2"]  # List of strings to use in legend (match with fn_list ordering)
+legend_index = 9  # Axis index to put legend (flattened index, start from 0).
+# Good to place in an empty subplot
+legend_pos = "upper right"  # Position for legend, using matplitlib legend string
+legend_fontsize = 9
+
+# Labels and Titles
+xlabel = "Absolute Error (degC)"  # Xlabel string
+xlabelpos = (figsize[0] / 2, 0)  # (x,y) position of xlabel
+ylabel = "Depth (m)"  # Ylabel string
+ylabelpos = (figsize[1] / 2, 0)  # (x,y) position of ylabel
+fig_title = "Regional MAE || All Seasons"  # Whole figure title
+label_fontsize = 11  # Fontsize of all labels
+label_fontweight = "normal"  # Fontweight to use for labels and subtitles
+title_fontsize = 13  # Fontsize of title
+title_fontweight = "bold"  # Fontweight to use for title
+
+
+#%% SCRIPT: READ AND PLOT DATA
+
+# Read all datasets into list
+ds_list = [xr.open_dataset(dd) for dd in fn_list]
+n_ds = len(ds_list)
+n_reg = len(region_ind)
+n_ax = n_r * n_c
+
+# Create plot and flatten axis array
+f, a = plt.subplots(n_r, n_c, figsize=figsize, sharex=sharex, sharey=sharey)
+a_flat = a.flatten()
+
+# Loop over regions
+for ii in range(n_ax):
+
+    if ii >= n_reg:
+        a_flat[ii].axis("off")
+        continue
+
+    # Get the index of this region
+    index = region_ind[ii]
+
+    # Loop over datasets and plot their variable
+    p = []
+    for pp in range(n_ds):
+        ds = ds_list[pp]
+        p.append(a_flat[ii].plot(ds[var_name][index], ref_depth)[0])
+
+    # Do some plot things
+    a_flat[ii].set_title(region_names[ii])
+    a_flat[ii].grid()
+    a_flat[ii].set_ylim(0, max_depth)
+
+    # Plot fixed lines at 0 and mean depth
+    if plot_zero_line:
+        a_flat[ii].plot([0, 0], [0, max_depth], c="k", linewidth=1, linestyle="-")
+    if plot_mean_depth:
+        a_flat[ii].plot()
+
+    # Invert y axis
+    a_flat[ii].invert_yaxis()
+
+# Make legend
+a_flat[legend_index].legend(p, legend_str, fontsize=legend_fontsize)
+
+# Set Figure title
+f.suptitle(fig_title, fontsize=title_fontsize, fontweight=title_fontweight)
+
+# Set x and y labels
+f.text(
+    xlabelpos[0],
+    xlabelpos[1],
+    xlabel,
+    va="center",
+    rotation="horizontal",
+    fontweight=label_fontweight,
+    fontsize=label_fontsize,
+)
+
+# Set tight figure layout
+f.tight_layout(w_pad=subplot_padding, h_pad=subplot_padding)
+f.subplots_adjust(left=(fig_pad[0]), bottom=(fig_pad[1]), right=(1 - fig_pad[2]), top=(1 - fig_pad[3]))
+
+# Save plot maybe
+if save_plot:
+    f.savefig(fn_out)
diff --git a/example_scripts/profile_validation/plot_validation_surface_errors.py b/example_scripts/profile_validation/plot_validation_surface_errors.py
new file mode 100644
index 00000000..ea16f9c7
--- /dev/null
+++ b/example_scripts/profile_validation/plot_validation_surface_errors.py
@@ -0,0 +1,132 @@
+"""
+Plot up surface or bottom (or any fixed level) errors from a profile object
+with no z_dim (vertical dimension). Provide an array of netcdf files and 
+mess with the options to get a figure you like.
+
+You can define how many rows and columns the plot will have. This script will
+plot the provided list of netcdf datasets from left to right and top to bottom.
+
+A colorbar will be placed right of the figure.
+"""
+
+import xarray as xr
+import matplotlib.pyplot as plt
+import numpy as np
+import sys
+
+sys.path.append("/Users/dbyrne/code/COAsT")
+import coast
+import pandas as pd
+
+#%% File settings
+run_name = "test"
+
+# List of analysis output files. Profiles from each will be plotted
+# on each axis of the plot
+fn_list = [
+    "/Users/dbyrne/transfer/surface_data_test.nc",
+    "/Users/dbyrne/transfer/surface_data_test.nc",
+]
+
+# Filename for the output
+fn_out = "/Users/dbyrne/transfer/surface_errors_{0}.png".format(run_name)
+
+#%% General Plot Settings
+var_name = "diff_temperature"  # Variable name in analysis file to plot
+save_plot = False
+
+# Subplot axes settings
+n_r = 1  # Number of subplot rows
+n_c = 2  # Number of subplot columns
+figsize = (10, 5)  # Figure size
+lonbounds = [-18, 9.5]  # Longitude bounds
+latbounds = [45, 64]  # Latitude bounds
+subplot_padding = 0.5  # Amount of vertical and horizontal padding between plots
+fig_pad = (0.075, 0.075, 0.1, 0.1)  # Figure padding (left, top, right, bottom)
+# Leave some space on right for colorbar
+# Scatter opts
+marker_size = 3  # Marker size
+cmap = "bwr"  # Colormap for normal points
+clim = (-0.35, 0.35)  # Color limits for normal points
+discrete_cmap = True  # Discretize colormap
+cmap_levels = 13
+
+# Labels and Titles
+fig_title = "SST Errors"  # Whole figure title
+title_fontsize = 13  # Fontsize of title
+title_fontweight = "bold"  # Fontweight to use for title
+dataset_names = ["CO9p0", "CO9p0", "CO9p0"]  # Names to use for labelling plots
+subtitle_fontsize = 11  # Fontsize for dataset subtitles
+subtitle_fontweight = "normal"  # Fontweight for dataset subtitles
+
+# Season opts
+select_season = True  # Only plot data from specified season
+season_str = "DJF"  # DJF, MAM, JJA or SON
+
+
+#%% Read and plotdata
+
+# Read all datasets into list
+ds_list = [xr.open_dataset(dd)[var_name] for dd in fn_list]
+n_ds = len(ds_list)
+n_ax = n_r * n_c
+
+# Create plot and flatten axis array
+f, a = coast.plot_util.create_geo_subplots(lonbounds, latbounds, n_r, n_c, figsize=figsize)
+a_flat = a.flatten()
+
+# Dicretize colormap maybe
+if discrete_cmap:
+    cmap = plt.cm.get_cmap(cmap, cmap_levels)
+
+# Determine if we will extend the colormap or not
+extend_cbar = []
+
+# Loop over dataset
+for ii in range(n_ax):
+    ur_index = np.unravel_index(ii, (n_r, n_c))
+
+    # If we are not differencing datasets
+    ds = ds_list[ii]
+
+    # Select season if required
+    if select_season:
+        seasons = coast.general_utils.determine_season(ds.time)
+        s_ind = seasons == season_str
+        ds = ds.isel(profile=s_ind)
+
+    # Scatter and set title
+    sc = a_flat[ii].scatter(
+        ds.longitude, ds.latitude, c=ds, s=marker_size, cmap=cmap, vmin=clim[0], vmax=clim[1], linewidths=0
+    )
+    a_flat[ii].set_title(dataset_names[ii], fontsize=subtitle_fontsize, fontweight=subtitle_fontweight)
+
+    # Will we extend the colorbar for this dataset?
+    extend_cbar.append(coast.plot_util.determine_colorbar_extension(ds, clim[0], clim[1]))
+
+
+# Set Figure title
+f.suptitle(fig_title, fontsize=title_fontsize, fontweight=title_fontweight)
+
+
+# Set tight figure layout
+f.tight_layout(w_pad=subplot_padding, h_pad=subplot_padding)
+f.subplots_adjust(left=(fig_pad[0]), bottom=(fig_pad[1]), right=(1 - fig_pad[2]), top=(1 - fig_pad[3]))
+
+# Handle colorbar -- will we extend it?
+if "both" in extend_cbar:
+    extend = "both"
+elif "max" in extend_cbar and "min" in extend_cbar:
+    extend = "both"
+elif "max" in extend_cbar:
+    extend = "max"
+elif "min" in extend_cbar:
+    extend = "min"
+else:
+    extend = "neither"
+cbar_ax = f.add_axes([(1 - fig_pad[2] + fig_pad[2] * 0.15), 0.15, 0.025, 0.7])
+f.colorbar(sc, cax=cbar_ax, extend=extend)
+
+# Save plot maybe
+if save_plot:
+    f.savefig(fn_out)
diff --git a/example_scripts/profile_validation_tutorial.py b/example_scripts/profile_validation_tutorial.py
deleted file mode 100644
index 53215d81..00000000
--- a/example_scripts/profile_validation_tutorial.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""
-This script will take you through the key methods and workflows associated with
-COAsT's Profile object. This is a data object for storing multiple profiles
-and comparing them to Gridded objects.
-
-You may want to take bits and pieces from this script to use as you wish.
-
-Many of these routines utilise xarray and Dask's lazy loading and chunking
-capabilities. Not all of them do however. If working with very large datasets
-it is recommended that the resulting datasets are saved to file at every step
-and read into a new profile object, with new chunking, before continuing. This
-can be done easily by calling xarrays to_netcdf().
-"""
-
-#%% >> 1. Import some things to make this script work
-import coast
-import xarray as xr
-import numpy as np
-import coast.general_utils as general_utils
-import matplotlib.pyplot as plt
-
-#%% >> 2. Define file paths
-
-fn_dom = "/Users/dbyrne/Projects/CO9_AMM15/data/nemo/CO7_EXACT_CFG_FILE.nc"
-fn_dat = "/Users/dbyrne/Projects/CO9_AMM15/data/nemo/co7/2004*"
-fn_out = "/Users/dbyrne/test.nc"
-fn_en4 = "/Users/dbyrne/Projects/coast/workshops/07092021/data/EN.4*"
-fn_p = "/Users/dbyrne/Projects/CO9_AMM15/obs/en4/proc.nc"
-
-#%% >> 3. Create a Gridded object using your data and domain files.
-
-nemo = coast.Gridded(fn_dat, fn_dom, multiple=True, config="./config/example_nemo_grid_t.json")
-
-# Create a landmask array and put it into the nemo object.
-# Here, using the bottom_level == 0 variable from the domain file is enough.
-nemo.dataset["landmask"] = nemo.dataset.bottom_level == 0
-nemo.dataset = nemo.dataset.rename({"depth_0": "depth"})
-
-#%% >> 4. Create a Profile object. Here we use EN4 data.
-
-profile = coast.Profile(fn_en4, multiple=True, config="./config/example_en4_profiles.json")
-
-
-#%% >> 5. Do some processing of the EN4 data. Here, we cut out a box of the
-#         EN4 data for the region over out model domain. The process_en4()
-#         routine is called to handle quality control of the data.
-
-# Get indices of obs within a geographical box and index the profile
-ind = profile.subset_indices_lonlat_box([-25.47, 16.25], [44, 63.5])[0]
-profile = profile.isel(profile=ind)
-
-# Just for this example script, thin the data (remove before any serious runs)
-n_profiles = profile.dataset.dims["profile"]
-profile.dataset = profile.dataset.isel(profile=np.arange(0, n_profiles, 5))
-
-# Create a new profile object called processed, containing the quality
-# controlled data. NOTE: .compute() or .load() will need to be called on
-# processed.dataset to access data
-processed = profile.process_en4()
-
-# At this point, you can continue is the dataset is small, or save this
-# processed data to file using processed.dataset.to_netcdf(<filename>)
-
-#%% >> 6. Extract profiles from the model at nearest locations and times
-#         For this we use Profile.obs_operator().
-model_profiles = processed.obs_operator(nemo)
-
-# Inside this new profile object, there will be the extracted data and 5 new
-# variables describing the spatial and time differences in the interpolation
-# and the x, y and t indices used in the nearest neighbour.
-
-# Lets remove any points where the nearest neighbour was further than x km from
-# the original point.
-too_far = 5
-keep_indices = model_profiles.dataset.interp_dist <= too_far
-model_profiles = model_profiles.isel(profile=keep_indices)
-processed = processed.isel(profile=keep_indices)
-
-#%% >> 7. Interpolate both model and observed profiles onto reference depths.
-
-# Define our reference depths
-reference_depths = np.arange(0, 500, 2)
-
-# Take just temperature for this example
-model_profiles.dataset = model_profiles.dataset[["temperature"]]
-
-# Vertical interpolation of model profiles using interpolate_vertical().
-model_profiles_interp = model_profiles.interpolate_vertical(reference_depths, interp_method="linear")
-
-
-# Vertical interpolation of processed observations
-processed.dataset = processed.dataset[["temperature", "depth"]]
-processed_interp = processed.interpolate_vertical(reference_depths, interp_method="linear")
-
-
-#%% >> 8. Data differences/errors
-#         Now that we have our model and observed data on the same depths,
-#         we can do some differencing to get errors. We use the
-#         Profile.difference() routine, which will calculate differences,
-#         absolute differences and square differences
-
-# Calculate differences as obs minus model
-differences = processed_interp.difference(model_profiles_interp)
-
-# Load the differences to memory.
-differences.dataset.load()
-
-#%% >> 9. Average the differences into masked regions
-#         By averaging the differences into regions, we can get an idea of the
-#         errors or anomalies in, for example, the North Sea. For this we use
-#         the coast.Mask_maker() object as an aid
-
-# First let's make some masks to define regions of the model domain
-mm = coast.MaskMaker()
-
-# Make some variables easier to access
-bath = nemo.dataset.bathymetry.values
-lon = nemo.dataset.longitude.values
-lat = nemo.dataset.latitude.values
-
-# Make a North Sea and Whole Domain mask
-mm_north_sea = mm.region_def_nws_north_sea(lon, lat, bath)
-mm_whole_domain = np.ones(lon.shape)
-mask_list = [mm_north_sea, mm_whole_domain]
-mask_names = ["North Sea", "Whole Domain"]
-
-# Turn mask list into an xarray dataset
-mask_list = coast.MaskMaker.make_mask_dataset(lon, lat, mask_list)
-
-# Determine whether each profile is in each masked region or not
-mask_indices = model_profiles_interp.determine_mask_indices(mask_list)
-
-# Do average differences for each region
-mask_means = differences.mask_means(mask_indices)
-
-#%% >> 10. Surface and bottom averaging.
-#      We can use a couple of routines to get surface and bottom values
-#      for the profile data. This can be used to give us maps of e.g. SST.
-#      It also allows us to calculate more statistics such as the CRPS
-
-# Lets get surface values by averaging over the top 5m of data
-surface = 5
-model_profiles_surface = model_profiles.depth_means([0, surface])
-obs_profiles_surface = processed.depth_means([0, surface])
-
-# Get differences
-surface_errors = obs_profiles_surface.difference(model_profiles_surface)
-
-
-# Lets get bottom values by averaging over the bottom 30m, except whether
-# depth is <100m, then average over the bottom 10m
-model_profiles_bottom = model_profiles.bottom_means([10, 30], [100, np.inf])
-obs_bathymetry = model_profiles.dataset["bathymetry"].values
-processed.dataset["bathymetry"] = (["profile"], obs_bathymetry)
-obs_profiles_bottom = processed.bottom_means([10, 30], [100, np.inf])
-
-# Get differences
-bottom_errors = obs_profiles_bottom.difference(model_profiles_bottom)
diff --git a/example_scripts/seasia_dic_example_plot.py b/example_scripts/seasia_dic_example_plot.py
new file mode 100644
index 00000000..469290b6
--- /dev/null
+++ b/example_scripts/seasia_dic_example_plot.py
@@ -0,0 +1,40 @@
+"""
+seasia_r12_example_plot_bgc.py
+
+Make simple SEAsia 1/12 deg DIC plot.
+
+"""
+#%%
+import coast
+import matplotlib.pyplot as plt
+
+
+#################################################
+#%%  Loading  data
+#################################################
+path_examples = "./example_files/"
+## data local in livljobs : /projectsa/COAsT/NEMO_example_data/SEAsia_R12/
+path_config = "./config/"
+
+fn_seasia_domain = path_examples + "coast_example_domain_SEAsia.nc"
+fn_seasia_config_bgc = path_config + "example_nemo_bgc.json"
+fn_seasia_var = path_examples + "coast_example_SEAsia_BGC_1990.nc"
+
+seasia_bgc = coast.Gridded(fn_data=fn_seasia_var, fn_domain=fn_seasia_domain, config=fn_seasia_config_bgc)
+
+#%% Plot DIC
+fig = plt.figure()
+plt.pcolormesh(
+    seasia_bgc.dataset.longitude,
+    seasia_bgc.dataset.latitude,
+    seasia_bgc.dataset.dic.isel(t_dim=0).isel(z_dim=0),
+    cmap="RdYlBu_r",
+    vmin=1600,
+    vmax=2080,
+)
+plt.colorbar()
+plt.title("DIC, mmol/m^3")
+plt.xlabel("longitude")
+plt.ylabel("latitude")
+plt.show()
+# fig.savefig("seasia_DIC_surface.png")
diff --git a/example_scripts/seasia_r12_example_plot.py b/example_scripts/seasia_r12_example_plot.py
index 36eefcb7..d68495d5 100644
--- a/example_scripts/seasia_r12_example_plot.py
+++ b/example_scripts/seasia_r12_example_plot.py
@@ -1,7 +1,7 @@
 """
 seasia_r12_example_plot.py
 
-Make simple SEAsia 1/12 deg SSS plot.
+Make simple seasia 1/12 deg SSS plot.
 
 """
 
diff --git a/example_scripts/tidegauge_tutorial.py b/example_scripts/tidegauge_tutorial.py
deleted file mode 100755
index 3b6868ec..00000000
--- a/example_scripts/tidegauge_tutorial.py
+++ /dev/null
@@ -1,184 +0,0 @@
-"""
-This is a demonstration script for using the Tidegauge object in the COAsT
-package. This object has strict data formatting requirements, which are
-outlined in Tidegauge.py.
-"""
-
-# Begin by importing coast and other packages
-import coast
-import datetime
-import numpy as np
-import matplotlib.pyplot as plt
-
-# And by defining some file paths
-dn_files = "./example_files/"
-fn_nemo_dat = dn_files + "coast_example_nemo_data.nc"
-fn_nemo_dom = dn_files + "coast_example_nemo_domain.nc"
-fn_nemo_config = "./config/example_nemo_grid_t.json"
-
-fn_tidegauge = dn_files + "tide_gauges/lowestoft-p024-uk-bodc"
-fn_tidegauge_mult = dn_files + "tide_gauges/l*"
-fn_tidegauge_config = "./config/example_tidegauge.json"
-
-# We need to load in a NEMO object for doing NEMO things.
-nemo = coast.Gridded(fn_nemo_dat, fn_nemo_dom, config=fn_nemo_config)
-
-# And now we can load in our ALTIMETRY data. By default, Tidegauge is set up
-# to read in GESLA ASCII files. However, if no path is supplied, then the
-# object's dataset will be initialised as None. Custom data can then be loaded
-# if desired, as long as it follows the data formatting for Tidegauge. Here
-# we load data between two specified dates:
-date_0 = datetime.datetime(2007, 1, 10)
-date_1 = datetime.datetime(2007, 1, 16)
-tidegauge = coast.Tidegauge(file_path=fn_tidegauge, date_start=date_0, date_end=date_1, config=fn_tidegauge_config)
-
-# Before comparing our observations to the model, we will interpolate a model
-# variable to the same time and geographical space as the tidegauge. This is
-# done using the obs_operator() method:
-tidegauge.obs_operator(nemo, mod_var_name="ssh", time_interp="nearest")
-
-# Doing this has created a new interpolated variable called interp_ssh and
-# saved it back into our Tidegauge object. Take a look at tidegauge.dataset
-# to see for yourself.
-#
-# Next we will compare this interpolated variable to an observed variable
-# using some basic metrics. The basic_stats() routine can be used for this,
-# which calculates some simple metrics including differences, RMSE and
-# correlations. NOTE: This may not be a wise choice of variables.
-stats = tidegauge.basic_stats("interp_ssh", "sea_level")
-
-# Take a look inside stats.dataset to see all of the new variables. When using
-# basic stats, the returned object is also an Tidegauge object, so all of the
-# same methods can be applied. Alternatively, if you want to save the new
-# metrics to the original altimetry object, set create_new_object = False.
-#
-# Now we will do a more complex comparison using the Continuous Ranked
-# Probability Score (CRPS). For this, we need to hand over the model object,
-# a model variable and an observed variable. We also give it a neighbourhood
-# radius in km (nh_radius). This may take a minute to run.
-crps = tidegauge.crps(nemo, model_var_name="ssh", obs_var_name="sea_level", nh_radius=20)
-
-# Again, take a look inside crps.dataset to see some new variables. Similarly
-# to basic_stats, create_new_object can be set to false to save output to
-# the original tidegauge object.
-#
-# Tidegauge has ready made quick plotting routines for viewing time series
-# and tide gauge location. To look at the tide gauge location:
-fig, ax = tidegauge.plot_on_map()
-
-# Or to look at a time series of the sea_level variable:
-fig, ax = tidegauge.plot_timeseries("sea_level")
-
-# Note that start and end dates can also be specified for plot_timeseries().
-#
-# As stats and crps are also Tidegauge objects, the same time series plotting
-# functionality can be used:
-crps.plot_timeseries("crps")
-stats.plot_timeseries("absolute_error")
-
-# Lets do some analysis on just the data in Tidegauge. We can attempt to remove
-# the tidal signal using a Doodson x0 filter. To do this, we must first
-# resample the data to an hourly frequency. Tidegauge.resample_mean() can do
-# just this using averaging.
-tidegauge.resample_mean("sea_level", "1H")
-
-# Here we have resampled the 'sea_level' object. Now, in tidegauge.dataset
-# there is a new variable sea_level_1H, along a new dimension time_1H. 1H
-# can be subsitituted for other strings (e.g. 1D = 1 day) or using timedelta
-# object.
-#
-# Now, we can apply the doodson x0 filter to the new variable:
-tidegauge.apply_doodson_x0_filter("sea_level_1H")
-
-# The new variable tidegauge.sea_level_1H_dx0 contains the filtered data.
-# Now lets do another time series plot, but this time looking at the three
-# variables sea_level, sea_level_1H and sea_level_1H_dx0. We can do this
-# by providing a list of variable names:
-f, a = tidegauge.plot_timeseries(["sea_level", "sea_level_1H", "sea_level_1H_dx0"])
-
-# Each Tidegauge object only holds data for a single tidegauge. There is some
-# functionality for dealing with multiple gauges in COAsT. To load multiple
-# GESLA tidegauge files, we use the static method create_multiple_tidegauge().
-# This routine takes a list of files or a wildcard string and loads them all
-# into a list of Tidegauge objects.
-
-date0 = datetime.datetime(2007, 1, 10)
-date1 = datetime.datetime(2007, 1, 12)
-tidegauge_list = coast.Tidegauge.create_multiple_tidegauge(fn_tidegauge_mult, date0, date1)
-
-# Now that we have tidegauge_list, we can plot the locations of all tide gauges
-# as follows:
-fig, ax = coast.Tidegauge.plot_on_map_multiple(tidegauge_list)
-
-# To do analysis on multiple gauges, a simple looping script can be setup.
-# For example, to obtain basic stats:
-for tg in tidegauge_list:
-    tg.obs_operator(nemo, "ssh")
-    tg.basic_stats("interp_ssh", "sea_level", create_new_object=False)
-
-# And now some of these new values can be plotted on a map, again using
-# plot_on_map_multiple:
-fig, ax = coast.Tidegauge.plot_on_map_multiple(tidegauge_list, color_var_str="rmse")
-
-
-#%% Alternatively load in data obtained using the Environment Agency (England)
-#  API. These are only accessible for the last 28 days. This does not require
-# an API key.
-#  Details of available tidal stations are recovered with:
-#  https://environment.data.gov.uk/flood-monitoring/id/stations?type=Tidegauge
-# Recover the "stationReference" for the gauge of interest and pass as
-# stationId:str. The default gauge is Liverpool: stationId="E70124"
-# Construct a recent 10 days period and extract these data
-date_start = np.datetime64("now") - np.timedelta64(20, "D")
-date_end = np.datetime64("now") - np.timedelta64(10, "D")
-eg = coast.Tidegauge()
-# Extract the data between explicit dates
-eg.dataset = eg.read_ea_api_to_xarray(date_start=date_start, date_end=date_end)
-eg.plot_timeseries()
-
-# Alternatively extract the data for the last ndays, here for a specific
-# (the default) station.
-eg.dataset = eg.read_ea_api_to_xarray(n_days=1, station_id="E70124")
-eg.plot_timeseries()
-
-#%%  Additionally, alternative data streams can be read in and similarly
-# processed. For example the BODC processed data from the UK Tidegauge network.
-# Data name: UK Tide Gauge Network, processed data.
-# Source: https://www.bodc.ac.uk/
-
-#%% Tide table extrema can be extracted from the timeseries
-#        Finds high and low water for a given variable.
-#        Returns in a new Tidegauge object with similar data format to
-#        a TIDETABLE.
-# There are two methods for extracting extrema. A neightbour comparison method,
-# (method="comp") and a cubic spline fitting method (method="cubic")
-
-# Load and plot BODC processed data
-fn_bodc = "example_files/tide_gauges/LIV2010.txt"
-
-# Set the start and end dates
-date_start = np.datetime64("2020-10-13 20:00")
-date_end = np.datetime64("2020-10-13 21:00")
-
-# Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA
-# type object
-del tg
-tg = coast.Tidegauge()
-tg.dataset = tg.read_bodc_to_xarray(fn_bodc, date_start, date_end)
-
-# Use comparison of neighbourhood method (method="comp" is assumed)
-extrema_comp = tg.find_high_and_low_water("sea_level", method="comp")
-
-# Use cubic spline fitting method
-extrema_cubc = tg.find_high_and_low_water("sea_level", method="cubic")
-
-# Plot to show the difference between maxima find methods for high tide example.
-plt.figure()
-plt.plot(tg.dataset.time, tg.dataset.sea_level, "k")
-plt.scatter(extrema_comp.dataset.time_highs.values, extrema_comp.dataset.sea_level_highs, marker="o", c="g")
-plt.scatter(extrema_cubc.dataset.time_highs.values, extrema_cubc.dataset.sea_level_highs, marker="+", c="g")
-plt.xlim([date_start, date_end])
-plt.ylim([7.75, 8.0])
-plt.legend(["Time Series", "Maxima by comparison", "Maxima by cubic spline"])
-plt.title("Tide Gauge Optima at Gladstone")
-plt.show()
diff --git a/example_scripts/tidegauge_validation_tutorial.py b/example_scripts/tidegauge_validation/tidegauge_validation_tutorial.py
similarity index 50%
rename from example_scripts/tidegauge_validation_tutorial.py
rename to example_scripts/tidegauge_validation/tidegauge_validation_tutorial.py
index 0b70cad2..f31b3f0d 100644
--- a/example_scripts/tidegauge_validation_tutorial.py
+++ b/example_scripts/tidegauge_validation/tidegauge_validation_tutorial.py
@@ -7,17 +7,25 @@
 #%% 1. Import necessary libraries
 import xarray as xr
 import numpy as np
-import matplotlib.pyplot as plt
 import coast
 import datetime
 
 #%% 2. Define paths
-fn_dom = "/Users/dbyrne/Projects/coast/workshops/07092021/data/mesh_mask.nc"
-fn_dat = "/Users/dbyrne/Projects/coast/workshops/07092021/data/sossheig*"
-fn_tg = "/Users/dbyrne/Projects/coast/workshops/07092021/data/tg_amm15.nc"
+fn_dom = "<PATH_TO_NEMO_DOMAIN>"
+fn_dat = "<PATH_TO_NEMO_DATA>"
+fn_config = "<PATH_TO_CONFIG.json>"
+fn_tg = "<PATH_TO_TIDEGAUGE_NETCDF>"  # This should already be processed, on the same time dimension
+
+if 1:
+    print(f"Use default files")
+    dir = "./example_files/"
+    fn_dom = dir + "coast_example_nemo_domain.nc"
+    fn_dat = dir + "coast_example_nemo_data.nc"
+    fn_config = "./config/example_nemo_grid_t.json"
+    fn_tg = dir + "tg_amm15.nc"
 
 #%% 3. Create gridded object and load data
-nemo = coast.Gridded(fn_dat, fn_dom, multiple=True, config="./config/example_nemo_grid_t.json")
+nemo = coast.Gridded(fn_dat, fn_dom, multiple=True, config=fn_config)
 
 # Create a landmask array and put it into the nemo object.
 # Here, using the bottom_level == 0 variable from the domain file is enough.
@@ -31,14 +39,16 @@
 #%% 4. Create TidegaugeMultiple object
 
 # Create the object and then inset the netcdf dataset
-obs = coast.TidegaugeMultiple()
-obs.dataset = xr.open_dataset(fn_tg)
+obs = coast.Tidegauge(dataset=xr.open_dataset(fn_tg))
+obs.dataset = obs.dataset.set_coords("time")
 
-# Cut down data to be only in 2018 to match model data.
-start_date = datetime.datetime(2018, 1, 1)
-end_date = datetime.datetime(2018, 12, 31)
-obs.dataset = coast.general_utils.data_array_time_slice(obs.dataset, start_date, end_date)
+# Shift the timestamp so it overlaps with the tidegauge data - Not ideal but this is only a demo
+obs.dataset.coords["time"] = obs.dataset.coords["time"] + 1000000000 * 3600 * 24 * 365 * 3
 
+# Cut down data to be only in 2018 to match model data.
+start_date = datetime.datetime(2007, 1, 1)
+end_date = datetime.datetime(2007, 1, 31)
+obs = obs.time_slice(start_date, end_date)
 
 #%% 5. Interpolate model data onto obs locations
 model_timeseries = obs.obs_operator(nemo)
@@ -50,11 +60,12 @@
 #%% 6. Do some processing
 # This routine searches for missing values in each dataset and applies them
 # equally to each corresponding dataset
-obs, model_timeseries = obs.match_missing_values(model_timeseries)
+tganalysis = coast.TidegaugeAnalysis()
+obs_new, model_new = tganalysis.match_missing_values(obs.dataset.ssh, model_timeseries.dataset.ssh)
 
 # Subtract means from all time series
-obs = obs.demean_timeseries()
-model_timeseries = model_timeseries.demean_timeseries()
+obs_new = tganalysis.demean_timeseries(obs_new.dataset)
+model_new = tganalysis.demean_timeseries(model_new.dataset)
 
 # Now you have equivalent and comparable sets of time series that can be
 # easily compared.
@@ -62,16 +73,16 @@
 #%% Calculate non tidal residuals
 
 # First, do a harmonic analysis. This routine uses utide
-ha_mod = model_timeseries.harmonic_analysis_utide()
-ha_obs = obs.harmonic_analysis_utide()
+ha_mod = tganalysis.harmonic_analysis_utide(model_new.dataset.ssh, min_datapoints=1)
+ha_obs = tganalysis.harmonic_analysis_utide(obs_new.dataset.ssh, min_datapoints=1)
 
-# Create new TidegaugeMultiple objects containign reconstructed tides
-tide_mod = model_timeseries.reconstruct_tide_utide(ha_mod)
-tide_obs = obs.reconstruct_tide_utide(ha_obs)
+# Create new TidegaugeMultiple objects containing reconstructed tides
+tide_mod = tganalysis.reconstruct_tide_utide(model_new.dataset.time, ha_mod)
+tide_obs = tganalysis.reconstruct_tide_utide(obs_new.dataset.time, ha_obs)
 
 # Get new TidegaugeMultiple objects containing non tidal residuals.
-ntr_mod = model_timeseries.calculate_residuals(tide_mod)
-ntr_obs = obs.calculate_residuals(tide_obs)
+ntr_mod = tganalysis.calculate_non_tidal_residuals(model_new.dataset.ssh, tide_mod.dataset.reconstructed)
+ntr_obs = tganalysis.calculate_non_tidal_residuals(obs_new.dataset.ssh, tide_obs.dataset.reconstructed)
 
 # Other interesting applications here included only reconstructing specified
 # tidal frequency bands and validating this.
@@ -80,8 +91,8 @@
 
 # The difference() routine will calculate differences, absolute_differences
 # and squared differenced for all variables:
-ntr_diff = ntr_obs.difference(ntr_mod)
-ssh_diff = obs.difference(model_timeseries)
+ntr_diff = tganalysis.difference(ntr_obs.dataset, ntr_mod.dataset)
+ssh_diff = tganalysis.difference(obs_new.dataset, model_new.dataset)
 
 # We can then easily get mean errors, MAE and MSE
 mean_stats = ntr_diff.dataset.mean(dim="t_dim", skipna=True)
@@ -93,5 +104,5 @@
 # threshold provided. It will also count the numbers of daily and monthly
 # maxima over each threshold
 
-thresh_mod = ntr_mod.threshold_statistics(thresholds=np.arange(0, 2, 0.2))
-thresh_obs = ntr_obs.threshold_statistics(thresholds=np.arange(0, 2, 0.2))
+thresh_mod = tganalysis.threshold_statistics(ntr_mod.dataset, thresholds=np.arange(0, 2, 0.2))
+thresh_obs = tganalysis.threshold_statistics(ntr_obs.dataset, thresholds=np.arange(0, 2, 0.2))
diff --git a/example_scripts/tidetable_tutorial.py b/example_scripts/tidegauge_validation/tidetable_tutorial.py
similarity index 94%
rename from example_scripts/tidetable_tutorial.py
rename to example_scripts/tidegauge_validation/tidetable_tutorial.py
index 372305c2..7be20943 100755
--- a/example_scripts/tidetable_tutorial.py
+++ b/example_scripts/tidegauge_validation/tidetable_tutorial.py
@@ -28,7 +28,7 @@
 
 #%% Load and plot High and Low Water data
 print("load and plot HLW data")
-filnam = "example_files/Gladstone_2020-10_HLW.txt"
+filnam = "./example_files/Gladstone_2020-10_HLW.txt"
 
 # Set the start and end dates
 date_start = np.datetime64("2020-10-12 23:59")
@@ -37,12 +37,12 @@
 # Initiate a TideGauge object, if a filename is passed it assumes it is a GESLA type object
 tg = coast.Tidegauge()
 # specify the data read as a High Low Water dataset
-tg.dataset = tg.read_hlw_to_xarray(filnam, date_start, date_end)
+tg.read_hlw(filnam, date_start, date_end)
 # Show dataset. If timezone is specified then it is presented as requested, otherwise uses UTC
 print("Try the TideGauge.show() method:")
 tg.show(timezone="Europe/London")
 # Do a basic plot of these points
-tg.dataset.plot.scatter(x="time", y="sea_level")
+tg.dataset.plot.scatter(x="time", y="ssh")
 
 #%%
 
diff --git a/example_scripts/wod_bgc_ragged_example.py b/example_scripts/wod_bgc_ragged_example.py
new file mode 100644
index 00000000..a921241a
--- /dev/null
+++ b/example_scripts/wod_bgc_ragged_example.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Wed Mar 23 13:53:33 2022
+
+@author: annkat
+"""
+
+#%reset
+# ====================== LOAD MODULES =========================
+import coast
+import glob  # For getting file paths
+import gsw
+import matplotlib.pyplot as plt
+import datetime
+import numpy as np
+import xarray as xr
+import coast.general_utils as general_utils
+import scipy as sp
+
+# ====================== UNIV PARAMS ===========================
+path_examples = "./example_files/"
+## data local in livljobs : /projectsa/COAsT/NEMO_example_data/SEAsia_R12/
+path_config = "./config/"
+
+# ====================== load my data ===========================
+fn_wod_var = path_examples + "WOD_example_ragged_standard_level.nc"  #'wod_example_ragged_OBSdepth.nc')
+fn_wod_config = path_config + "example_wod_profiles.json"
+
+wod_profile_1d = coast.Profile(config=fn_wod_config)
+wod_profile_1d.read_wod(fn_wod_var)
+
+# ===================== reshape TO 2D=====================
+# choose which observed variables you want
+var_user_want = ["salinity", "temperature", "nitrate", "oxygen", "dic", "phosphate", "alkalinity"]
+wod_profile = coast.Profile.reshape_2d(wod_profile_1d, var_user_want)
+
+# ===================== keep subset =====================
+ind = wod_profile.subset_indices_lonlat_box([90, 120], [-5, 5])[0]
+wod_profile = wod_profile.isel(profile=ind)
+
+# ===================== SEAsia read BGC =====================
+# note in this simple test nemo data are only for 3 months from 1990 so the
+# comparisons are not going to be correct but just as a demo
+
+fn_seasia_domain = path_examples + "coast_example_domain_SEAsia.nc"
+fn_seasia_config_bgc = path_config + "example_nemo_bgc.json"
+fn_seasia_var = path_examples + "coast_example_SEAsia_BGC_1990.nc"
+
+seasia_bgc = coast.Gridded(
+    fn_data=fn_seasia_var, fn_domain=fn_seasia_domain, config=fn_seasia_config_bgc, multiple=True
+)
+
+# My domain file does not have mask so this is just a trick
+seasia_bgc.dataset["landmask"] = seasia_bgc.dataset.bottom_level == 0
+seasia_bgc.dataset = seasia_bgc.dataset.rename({"depth_0": "depth"})
+model_profiles = wod_profile.obs_operator(seasia_bgc)
+
+# remove any points that are farmodel
+too_far = 5
+keep_indices = model_profiles.dataset.interp_dist <= too_far
+model_profiles = model_profiles.isel(profile=keep_indices)
+wod_profile = wod_profile.isel(profile=keep_indices)
+
+# transform observed DIC from mmol/l to mmol C/ m^3 that the model has
+fig = plt.figure()
+plt.plot(1000 * wod_profile.dataset.dic[8, :], wod_profile.dataset.depth[8, :], linestyle="", marker="o")
+plt.plot(model_profiles.dataset.dic[8, :], model_profiles.dataset.depth[:, 8], linestyle="", marker="o")
+plt.ylim([2500, 0])
+plt.title("DIC vs depth")
+plt.show()
+
+fig = plt.figure()
+plt.plot(wod_profile.dataset.oxygen[8, :], wod_profile.dataset.depth[8, :], linestyle="", marker="o")
+plt.plot(model_profiles.dataset.oxygen[8, :], model_profiles.dataset.depth[:, 8], linestyle="", marker="o")
+plt.ylim([2500, 0])
+plt.title("Oxygen vs depth")
+plt.show()
+
+fig = plt.figure()
+plt.plot(wod_profile.dataset.phosphate[8, :], wod_profile.dataset.depth[8, :], linestyle="", marker="o")
+plt.plot(model_profiles.dataset.phosphate[8, :], model_profiles.dataset.depth[:, 8], linestyle="", marker="o")
+plt.ylim([2500, 0])
+plt.title("Phosphate vs depth")
+plt.show()
+
+
+# interpolate seasia to profile depths
+reference_depths = wod_profile.dataset.depth[20, :].values
+model_profiles.dataset = model_profiles.dataset[["dic"]] / 1000
+model_profiles_interp = model_profiles.interpolate_vertical(reference_depths, interp_method="linear")
+
+#!!NOTE INTERPOLATE does not work with wod_profiles (maybe due to all nans in some points)
+# wod_profile.dataset = wod_profile.dataset[["DIC", "depth"]]
+# wod_interp = wod_profile.interpolate_vertical(reference_depths, interp_method="linear")
+
+# calculate differences
+differences = wod_profile.difference(model_profiles_interp)
+differences.dataset.load()
diff --git a/pyproject.toml b/pyproject.toml
index 94ac980c..eea5b8fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,3 @@
 [tool.black]
 line-length = 120
-target-version = ['py37', 'py38', 'py39']
+target-version = ['py38', 'py39']
diff --git a/requirements.txt b/requirements.txt
index 4cd1927b..3d17093d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,16 @@
 .
-PyYAML==5.4
+PyYAML==6.0
 oyaml==1.0
-pytest==6.0.2
-numpy>=1.16
-dask>=2
-dask[complete]>=2
-xarray>=0.19
-matplotlib>=3.4.3
-netCDF4>=1
-scipy>=1
-gsw==3.3.1
-utide>=0.2
-scikit-learn>=0.2
-scikit-image>=0.15
+pytest==7.1.1
+numpy>=1.22.3
+dask>=2022.3.0
+dask[complete]>=2022.3.0
+xarray>=2022.3.0
+matplotlib>=3.5.1
+netCDF4>=1.5.8
+scipy>=1.8.0
+gsw>=3.4.0
+utide>=0.2.6
+scikit-learn>=1.0.2
+scikit-image>=0.19.2
+statsmodels>=0.13.2
diff --git a/setup.py b/setup.py
index 84a5d75b..dfaa9047 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@
 PACKAGE = SimpleNamespace(
     **{
         "name": "COAsT",
-        "version": "1.2.7",
+        "version": "2.0.0",
         "description": "This is the Coast Ocean Assessment Tool",
         "url": "https://www.bodc.ac.uk",
         "download_url": "https://github.com/British-Oceanographic-Data-Centre/COAsT/",
@@ -20,24 +20,25 @@
             "Intended Audience :: Science/Research",
             "Topic :: Scientific/Engineering :: Hydrology",
             "Programming Language :: Python :: 3",
-            "Programming Language :: Python :: 3.7",
+            "Programming Language :: Python :: 3.8",
         ],
         "keywords": ["NEMO", "shallow water", "ocean assessment"],
         "project_urls": {"documentation": "https://british-oceanographic-data-centre.github.io/COAsT/"},
         "install_requires": [
-            "numpy>=1.16",
-            "dask>=2",
-            "dask[complete]>=2",
-            "xarray>=0.19",
-            "matplotlib>=3.4.3",
-            "netCDF4>=1",
-            "scipy>=1",
-            "gsw==3.3.1",
-            "utide>=0.2",
-            "scikit-learn>=0.2",
-            "scikit-image>=0.15",
+            "numpy>=1.22.3",
+            "dask>=2022.3.0",
+            "dask[complete]>=2022.3.0",
+            "xarray>=2022.3.0",
+            "matplotlib>=3.5.1",
+            "netCDF4>=1.5.8",
+            "scipy>=1.8.0",
+            "gsw>=3.4.0",
+            "utide>=0.2.6",
+            "scikit-learn>=1.0.2",
+            "scikit-image>=0.19.2",
+            "statsmodels>=0.13.2",
         ],
-        "python_requires": ">=3.7",
+        "python_requires": ">=3.8",
         "packages": ["coast"],
         "include_package_data": True,
         "github": "British-Oceanographic-Data-Centre",


Test patch:
diff --git a/unit_testing/generate_unit_test_contents.py b/unit_testing/generate_unit_test_contents.py
new file mode 100644
index 00000000..fe46be35
--- /dev/null
+++ b/unit_testing/generate_unit_test_contents.py
@@ -0,0 +1,99 @@
+"""
+Run this file from the main COAsT directory to generate a new unit_test_contents
+file based on the imported modules in tests_to_do
+
+If you have added new tests to the unit test, you should do this before pushing
+to github.
+
+The easiest way to do this is to copy and paste your new tests modules below
+(or straight from unit_test.py).
+"""
+
+# Import modules, including unittest
+import coast
+import sys
+
+sys.path.append("./unit_testing")
+import logging
+
+# Import tests to test. From a module (file) import a testCase class (See template)
+from test_TEMPLATE import test_TEMPLATE
+from test_gridded_initialisation import test_gridded_initialisation
+from test_gridded_harmonics import test_gridded_harmonics
+from test_general_utils import test_general_utils
+from test_xesmf_convert import test_xesmf_convert
+from test_diagnostic_methods import test_diagnostic_methods
+from test_transect_methods import test_transect_methods
+from test_object_manipulation import test_object_manipulation
+from test_altimetry_methods import test_altimetry_methods
+from test_tidegauge_methods import test_tidegauge_methods
+from test_isobath_contour_methods import test_contour_f_methods
+from test_isobath_contour_methods import test_contour_t_methods
+from test_eof_methods import test_eof_methods
+from test_profile_methods import test_profile_methods
+from test_plot_utilities import test_plot_utilities
+from test_stats_utilities import test_stats_utilities
+from test_maskmaker_methods import test_maskmaker_methods
+from test_climatology import test_climatology
+from test_example_scripts import test_example_scripts
+from test_wod_read_data import test_wod_read_data
+from test_bgc_gridded_initialisation import test_bgc_gridded_initialisation
+
+# Open log file
+log_file = open("unit_testing/unit_test.log", "w")  # Need log_file.close()
+coast.logging_util.setup_logging(stream=log_file, level=logging.CRITICAL)
+
+# Test list -- comment out ones you don't want maybe (or add your own)
+tests_to_do = [
+    # test_TEMPLATE,
+    test_gridded_initialisation,
+    test_general_utils,
+    test_gridded_harmonics,
+    test_xesmf_convert,
+    test_diagnostic_methods,
+    test_transect_methods,
+    test_object_manipulation,
+    test_altimetry_methods,
+    test_tidegauge_methods,
+    test_eof_methods,
+    test_contour_f_methods,
+    test_contour_t_methods,
+    test_profile_methods,
+    test_plot_utilities,
+    test_stats_utilities,
+    test_maskmaker_methods,
+    test_climatology,
+    test_wod_read_data,
+    test_bgc_gridded_initialisation,
+    test_example_scripts,
+]
+
+# Auto generate contents file. Define output file:
+fn_contents = "./unit_testing/unit_test_contents.txt"
+
+# Initialize counters
+test_count = 1
+
+# Open output file
+with open(fn_contents, "w") as file:
+
+    # Write title things
+    file.write("     UNIT TEST CONTENTS FILE TEST    \n")
+    file.write("\n")
+
+    # Loop over tests in tests_to_do and get name of module as string
+    for test in tests_to_do:
+        test_name = test.__name__
+        file.write("{0}. {1}\n".format(test_count, test_name))
+        method_count = 97
+
+        # Loop over methods in module. If begins with 'test_' then write to file
+        for method in dir(test):
+            if method[:5] != "test_":
+                continue
+            file.write("      {0}. {1}\n".format(chr(method_count), method[5:]))
+            method_count = method_count + 1
+        file.write("\n")
+        test_count = test_count + 1
+print("Written modules and methods to: \n \n")
+print("{0}".format(fn_contents))
diff --git a/unit_testing/test_TEMPLATE.py b/unit_testing/test_TEMPLATE.py
new file mode 100644
index 00000000..93f417f5
--- /dev/null
+++ b/unit_testing/test_TEMPLATE.py
@@ -0,0 +1,70 @@
+"""
+TEMPLATE testing file, usin unittest package.
+Please save this file with a name starting with "test_".
+TestCase classes have a whole bunch of methods available to them. Some of them
+are showcased below. You can also add your own methods to them. Anything you
+want tested by the unit testing system should start with "test_".
+
+For more info on assert test cases, see:
+    https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertTrue
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import os.path as path
+
+# IMPORT THIS TO HAVE ACCESS TO EXAMPLE FILE PATHS:
+import unit_test_files as files
+
+# Define a testing class. Absolutely fine to have one or multiple per file.
+# Each class must inherit unittest.TestCase
+class test_TEMPLATE(unittest.TestCase):
+    def setUp(self):
+        # This is called at the beginning of every test_ method. Optional.
+        return
+
+    def tearDown(self):
+        # This is called at the end of every test_ method. Optional.
+        return
+
+    @classmethod
+    def setUpClass(cls):
+        # This is called at the beginning of every test CLASS. Optional
+        return
+
+    @classmethod
+    def tearDownClass(cls):
+        # This is called at the end of every test CLASS. Optional.
+        return
+
+    # TEST METHODS
+    def test_successful_method(self):
+        # Test things. Use assert methods from unittest. Some true tests:
+        self.assertTrue(1 == 1, "Will not see this.")
+        self.assertEqual(1, 1, "Will not see this.")
+        self.assertIsNone(None, "Will not see this.")
+
+    def test_bad_method(self):
+        # Test things. Use assert methods from unittest. This will fail
+        self.assertTrue(1 == 2, "1 does not equal 2")
+        self.assertEqual(1, 2, "1 does not equal 2.")
+        self.assertIsNone(1, "1 does not none.")
+
+    def test_with_subtests(self):
+        # Sometimes you might want to do a test within a parameter loop or
+        # a bunch of sequential tests (so you don't have to read data
+        # repeatedly). Subtest can be used for this as follows:
+
+        with self.subTest("First subtest."):
+            a = 1
+            self.assertEqual(a, 1, "Will not see this.")
+
+        with self.subTest("Second subtest"):
+            b = 1
+            self.assertEqual(a, b, "will not see this")
+
+        with self.subTest("Third subtest"):
+            c = 50000000
+            self.assertAlmostEqual(a, c, msg="0 is not almost equal to 50000000")
diff --git a/unit_testing/test_altimetry_methods.py b/unit_testing/test_altimetry_methods.py
new file mode 100644
index 00000000..0cf62c07
--- /dev/null
+++ b/unit_testing/test_altimetry_methods.py
@@ -0,0 +1,75 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+from coast import general_utils
+import unittest
+import numpy as np
+import os.path as path
+import xarray as xr
+import matplotlib.pyplot as plt
+import unit_test_files as files
+
+
+class test_altimetry_methods(unittest.TestCase):
+    def test_altimetry_load_subset_and_comparison(self):
+
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+
+        with self.subTest("Load example altimetry file"):
+            altimetry = coast.Altimetry(files.fn_altimetry, config=files.fn_altimetry_config)
+
+            # Test the data has loaded using attribute comparison, as for nemo_data
+            alt_attrs_ref = dict(
+                [
+                    ("source", "Jason-1 measurements"),
+                    ("date_created", "2019-02-20T11:20:56Z"),
+                    ("institution", "CLS, CNES"),
+                    ("Conventions", "CF-1.6"),
+                ]
+            )
+
+            # checking is LHS is a subset of RHS
+            check1 = alt_attrs_ref.items() <= altimetry.dataset.attrs.items()
+            self.assertTrue(check1, "check1")
+
+        with self.subTest("Subset altimetry data"):
+            ind = altimetry.subset_indices_lonlat_box([-10, 10], [45, 60])
+            ind = ind[::4]
+            altimetry_nwes = altimetry.isel(t_dim=ind)  # nwes = northwest europe shelf
+
+            check1 = altimetry_nwes.dataset.dims["t_dim"] == 54
+            self.assertTrue(check1, "check1")
+
+        with self.subTest("Interpolate Model to altimetry"):
+            altimetry_nwes.obs_operator(sci, "ssh")
+            # Check new variable is in altimetry dataset and isn't all NaNs
+            check1 = False in np.isnan(altimetry_nwes.dataset.interp_ssh)
+            self.assertTrue(check1, "check1")
+
+        with self.subTest("Calculate CRPS for altimetry"):
+            crps = altimetry_nwes.crps(sci, "ssh", "ocean_tide_standard_name")
+
+            # TEST: Check length of crps and that it contains values
+            check1 = crps.dataset.crps.shape[0] == altimetry_nwes.dataset.ocean_tide_standard_name.shape[0]
+            check2 = False in np.isnan(crps.dataset.crps)
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Altimetry stats methods"):
+            stats = altimetry_nwes.basic_stats("ocean_tide_standard_name", "interp_ssh")
+            altimetry_nwes.basic_stats("ocean_tide_standard_name", "interp_ssh", create_new_object=False)
+
+            # TEST: Check new object resembles internal object
+            check1 = all(stats.dataset.error == altimetry_nwes.dataset.error)
+            # TEST: Check lengths and values
+            check2 = stats.dataset.absolute_error.shape[0] == altimetry_nwes.dataset.ocean_tide_standard_name.shape[0]
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Altimetry quick plot"):
+            fig, ax = crps.quick_plot("crps")
+            fig.savefig(files.dn_fig + "altimetry_crps_quick_plot.png")
+            plt.close("all")
diff --git a/unit_testing/test_bgc_gridded_initialisation.py b/unit_testing/test_bgc_gridded_initialisation.py
new file mode 100644
index 00000000..c8e023b3
--- /dev/null
+++ b/unit_testing/test_bgc_gridded_initialisation.py
@@ -0,0 +1,69 @@
+"""
+TEST reading of wod profiles
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import os.path as path
+import xarray as xr
+
+# IMPORT THIS TO HAVE ACCESS TO EXAMPLE FILE PATHS:
+import unit_test_files as files
+
+# Define a testing class. Absolutely fine to have one or multiple per file.
+# Each class must inherit unittest.TestCase
+class test_bgc_gridded_initialisation(unittest.TestCase):
+    def test_gridded_load_bgc_data_and_domain(self):
+        # Check successfully load example data and domain
+        sci = coast.Gridded(files.fn_nemo_bgc, files.fn_nemo_dom_bgc, config=files.fn_nemo_config_bgc)
+        sci_attrs_ref = dict(
+            [
+                ("name", "SEAsia_HAD_1m_19900101_19901231_ptrc_T"),
+                ("description", "tracer variables"),
+                ("title", "tracer variables"),
+                ("Conventions", "CF-1.6"),
+                ("timeStamp", "2020-Oct-07 10:11:58 GMT"),
+                ("uuid", "701bb916-558d-4ee8-9cf6-89454c7bc99f"),
+            ]
+        )
+
+        # checking is LHS is a subset of RHS
+        check1 = sci_attrs_ref.items() <= sci.dataset.attrs.items()
+        self.assertTrue(check1, msg="Check1")
+
+    def test_gridded_load_bgc_data(self):
+        # Check load only data
+        ds = xr.open_dataset(files.fn_nemo_bgc)
+        sci_load_ds = coast.Gridded(config=files.fn_nemo_config_bgc)
+        sci_load_ds.load_dataset(ds)
+        sci_load_file = coast.Gridded(config=files.fn_nemo_config_bgc)
+        sci_load_file.load(files.fn_nemo_bgc)
+        check1 = sci_load_ds.dataset.identical(sci_load_file.dataset)
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_bgc_variables_correctly_renamed(self):
+        # Check temperature is correctly renamed
+        sci = coast.Gridded(files.fn_nemo_bgc, files.fn_nemo_dom_bgc, config=files.fn_nemo_config_bgc)
+        check1 = "dic" in sci.dataset
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_bgc_dimensions_correctly_renamed(self):
+        # Check gridded dimensions are correctly renamed
+        sci = coast.Gridded(files.fn_nemo_bgc, files.fn_nemo_dom_bgc, config=files.fn_nemo_config_bgc)
+        check1 = sci.dataset.dic.dims == ("t_dim", "z_dim", "y_dim", "x_dim")
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_bgc_domain_only(self):
+        # Check gridded load domain only
+        nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom_bgc, config=files.fn_nemo_config_bgc)
+
+        check1 = False
+        if nemo_f.dataset._coord_names == {"depth_0", "latitude", "longitude"}:
+            var_name_list = []
+            for var_name in nemo_f.dataset.data_vars:
+                var_name_list.append(var_name)
+            if var_name_list == ["bathymetry", "e1", "e2", "e3_0", "bottom_level"]:
+                check1 = True
+        self.assertTrue(check1, msg="check1")
diff --git a/unit_testing/test_climatology.py b/unit_testing/test_climatology.py
new file mode 100644
index 00000000..228f5b82
--- /dev/null
+++ b/unit_testing/test_climatology.py
@@ -0,0 +1,37 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import unittest
+import coast
+import numpy as np
+import unit_test_files as files
+import os
+import xarray as xr
+
+
+class test_climatology(unittest.TestCase):
+    def test_monthly_and_seasonal_climatology(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        ds = sci.dataset[["temperature", "ssh"]].isel(z_dim=0)
+        clim = coast.Climatology()
+        fn_out = os.path.join(files.dn_files, "test_climatology.nc")
+        monthly = clim.make_climatology(ds, "month").load()
+        seasonal = clim.make_climatology(ds, "season", fn_out=fn_out)
+
+        # create dataset with missing values
+        ds2 = ds.copy(deep=True)
+        ds2["temperature"][::2, :100, :100] = np.nan
+        ds2["ssh"][::2, :100, :100] = np.nan
+        seaC = clim.make_climatology(ds2, "season")
+        seaX = ds2.groupby("time.season").mean("t_dim")
+        # throws error is not close
+        xr.testing.assert_allclose(seaC, seaX)
+
+        mn = mn = np.nanmean(ds.temperature, axis=0)
+        check1 = np.nanmax(np.abs(mn - monthly.temperature[0])) < 1e-6
+        check2 = os.path.isfile(fn_out)
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
diff --git a/unit_testing/test_diagnostic_methods.py b/unit_testing/test_diagnostic_methods.py
new file mode 100644
index 00000000..0dc8b5d3
--- /dev/null
+++ b/unit_testing/test_diagnostic_methods.py
@@ -0,0 +1,118 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import xarray as xr
+import matplotlib.pyplot as plt
+import unit_test_files as files
+
+
+class test_diagnostic_methods(unittest.TestCase):
+    def test_compute_vertical_spatial_derivative(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        nemo_w = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_w_grid)
+
+        log_str = ""
+        # Compute dT/dz
+        nemo_w_1 = nemo_t.differentiate("temperature", config_path=files.fn_config_w_grid, dim="z_dim")
+        if nemo_w_1 is None:  # Test whether object was returned
+            log_str += "No object returned\n"
+        # Make sure the hardwired grid requirements are present
+        if not hasattr(nemo_w.dataset, "depth_0"):
+            log_str += "Missing depth_0 variable\n"
+        if not hasattr(nemo_w.dataset, "e3_0"):
+            log_str += "Missing e3_0 variable\n"
+        if not hasattr(nemo_w.dataset.depth_0, "units"):
+            log_str += "Missing depth units\n"
+        # Test attributes of derivative. This are generated last so can indicate earlier problems
+        nemo_w_2 = nemo_t.differentiate("temperature", dim="z_dim", out_var_str="dTdz", out_obj=nemo_w)
+        if not nemo_w_2.dataset.dTdz.attrs == {"units": "degC/m", "standard_name": "dTdz"}:
+            log_str += "Did not write correct attributes\n"
+        # Test auto-naming derivative. Again test expected attributes.
+        nemo_w_3 = nemo_t.differentiate("temperature", dim="z_dim", config_path=files.fn_config_w_grid)
+        if not nemo_w_3.dataset.temperature_dz.attrs == {"units": "degC/m", "standard_name": "temperature_dz"}:
+            log_str += "Problem with auto-naming derivative field\n"
+
+        ## Test numerical calculation. Differentiate f(z)=-z --> -1
+        # Construct a depth variable - needs to be 4D
+        nemo_t.dataset["depth4D"], _ = xr.broadcast(nemo_t.dataset["depth_0"], nemo_t.dataset["temperature"])
+        nemo_w_4 = nemo_t.differentiate("depth4D", dim="z_dim", out_var_str="dzdz", config_path=files.fn_config_w_grid)
+        if not np.isclose(
+            nemo_w_4.dataset.dzdz.isel(z_dim=slice(1, nemo_w_4.dataset.dzdz.sizes["z_dim"])).max(), -1
+        ) or not np.isclose(nemo_w_4.dataset.dzdz.isel(z_dim=slice(1, nemo_w_4.dataset.dzdz.sizes["z_dim"])).min(), -1):
+            log_str += "Problem with numerical derivative of f(z)=-z\n"
+
+        check1 = log_str == ""
+        self.assertTrue(check1, msg="check1")
+
+    def test_construct_density(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        nemo_t.construct_density()
+        yt, xt, length_of_line = nemo_t.transect_indices([54, -15], [56, -12])
+
+        check1 = np.allclose(
+            nemo_t.dataset.density.sel(x_dim=xr.DataArray(xt, dims=["r_dim"]), y_dim=xr.DataArray(yt, dims=["r_dim"]))
+            .sum(dim=["t_dim", "r_dim", "z_dim"])
+            .item(),
+            11185010.518671108,
+        )
+        self.assertTrue(check1, msg="check1")
+
+    def test_construct_pycnocline_depth_and_thickness(self):
+        nemo_t = None
+        nemo_w = None
+        nemo_t = coast.Gridded(files.fn_nemo_grid_t_dat_summer, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        # create an empty w-grid object, to store stratification
+        nemo_w = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_w_grid)
+
+        with self.subTest("Construct pycnocline depth"):
+            log_str = ""
+            # initialise Internal Tide object
+            IT = coast.InternalTide(nemo_t, nemo_w)
+            if IT is None:  # Test whether object was returned
+                log_str += "No object returned\n"
+            # Construct pycnocline variables: depth and thickness
+            IT.construct_pycnocline_vars(nemo_t, nemo_w)
+
+            if not hasattr(nemo_t.dataset, "density"):
+                log_str += "Did not create density variable\n"
+            if not hasattr(nemo_w.dataset, "rho_dz"):
+                log_str += "Did not create rho_dz variable\n"
+
+            if not hasattr(IT.dataset, "strat_1st_mom"):
+                log_str += "Missing strat_1st_mom variable\n"
+            if not hasattr(IT.dataset, "strat_1st_mom_masked"):
+                log_str += "Missing strat_1st_mom_masked variable\n"
+            if not hasattr(IT.dataset, "strat_2nd_mom"):
+                log_str += "Missing strat_2nd_mom variable\n"
+            if not hasattr(IT.dataset, "strat_2nd_mom_masked"):
+                log_str += "Missing strat_2nd_mom_masked variable\n"
+            if not hasattr(IT.dataset, "mask"):
+                log_str += "Missing mask variable\n"
+
+            # Check the calculations are as expected
+            check1 = np.isclose(IT.dataset.strat_1st_mom.sum(), 3.74214231e08)
+            check2 = np.isclose(IT.dataset.strat_2nd_mom.sum(), 2.44203298e08)
+            check3 = np.isclose(IT.dataset.mask.sum(), 450580)
+            check4 = np.isclose(IT.dataset.strat_1st_mom_masked.sum(), 3.71876949e08)
+            check5 = np.isclose(IT.dataset.strat_2nd_mom_masked.sum(), 2.42926865e08)
+
+            self.assertTrue(check1, msg=log_str)
+            self.assertTrue(check2, msg=log_str)
+            self.assertTrue(check3, msg=log_str)
+            self.assertTrue(check4, msg=log_str)
+            self.assertTrue(check5, msg=log_str)
+
+        with self.subTest("Plot pycnocline depth"):
+            fig, ax = IT.quick_plot("strat_1st_mom_masked")
+            fig.tight_layout()
+            fig.savefig(files.dn_fig + "strat_1st_mom.png")
+            plt.close("all")
diff --git a/unit_testing/test_eof_methods.py b/unit_testing/test_eof_methods.py
new file mode 100644
index 00000000..036abbd2
--- /dev/null
+++ b/unit_testing/test_eof_methods.py
@@ -0,0 +1,52 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import matplotlib.pyplot as plt
+import unit_test_files as files
+import datetime
+
+
+class test_eof_methods(unittest.TestCase):
+    def test_compute_regular_eof(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        eofs = coast.compute_eofs(nemo_t.dataset.ssh)
+
+        ssh_reconstruction = (eofs.EOF * eofs.temporal_proj).sum(dim="mode").sum(dim=["x_dim", "y_dim"])
+        ssh_anom = (nemo_t.dataset.ssh - nemo_t.dataset.ssh.mean(dim="t_dim")).sum(dim=["x_dim", "y_dim"])
+
+        # Check ssh anomaly is reconstructed at each time point
+        check1 = np.allclose(ssh_reconstruction, ssh_anom, rtol=0.0001)
+        var_cksum = eofs.variance.sum(dim="mode").compute().item()
+        check2 = np.isclose(var_cksum, 100)
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+
+    def test_compute_heofs(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        heofs = coast.compute_hilbert_eofs(nemo_t.dataset.ssh)
+
+        ssh_reconstruction = (
+            (heofs.EOF_amp * heofs.temporal_amp * np.exp(1j * np.radians(heofs.EOF_phase + heofs.temporal_phase)))
+            .sum(dim="mode")
+            .real.sum(dim=["x_dim", "y_dim"])
+        )
+
+        ssh_anom = (nemo_t.dataset.ssh - nemo_t.dataset.ssh.mean(dim="t_dim")).sum(dim=["x_dim", "y_dim"])
+
+        # Check ssh anomaly is reconstructed at each time point
+        check1 = np.allclose(ssh_reconstruction, ssh_anom, rtol=0.0001)
+        var_cksum = heofs.variance.sum(dim="mode").item()
+        check2 = np.isclose(var_cksum, 100)
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
diff --git a/unit_testing/test_example_scripts.py b/unit_testing/test_example_scripts.py
new file mode 100644
index 00000000..85e5a0d9
--- /dev/null
+++ b/unit_testing/test_example_scripts.py
@@ -0,0 +1,94 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import unittest
+import matplotlib.pyplot as plt
+from socket import gethostname
+
+
+class test_example_scripts(unittest.TestCase):
+    def test_altimetry_tutorial(self):
+        from example_scripts import altimetry_tutorial
+
+        plt.close("all")
+
+    def test_tidetable_tutorial(self):
+        from example_scripts.tidegauge_validation import tidetable_tutorial
+
+        plt.close("all")
+
+    def test_tidegauge_validation_tutorial(self):
+        from example_scripts.tidegauge_validation import tidegauge_validation_tutorial
+
+        plt.close("all")
+
+    def test_export_to_netcdf_tutorial(self):
+        from example_scripts import export_to_netcdf_tutorial
+
+        plt.close("all")
+
+    def test_transect_tutorial(self):
+        from example_scripts import transect_tutorial
+
+        plt.close("all")
+
+    def test_contour_tutorial(self):
+        from example_scripts import contour_tutorial
+
+        plt.close("all")
+
+    def test_internal_tide_pycnocline_diagnostics(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import internal_tide_pycnocline_diagnostics
+
+            plt.close("all")
+
+    def test_amm15_example_plot(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import amm15_example_plot
+
+            plt.close("all")
+
+    def test_anchor_plots_of_nsea_wvel(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import anchor_plots_of_nsea_wvel
+
+            plt.close("all")
+
+    def test_blz_example_plot(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import blz_example_plot
+
+            plt.close("all")
+
+    def test_seasia_r12_example_plot(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import seasia_r12_example_plot
+
+            plt.close("all")
+
+    def test_wcssp_india_example_plot(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import wcssp_india_example_plot
+
+            plt.close("all")
+
+    def test_internal_tide_pycnocline_diagnostics(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import internal_tide_pycnocline_diagnostics
+
+            plt.close("all")
+
+    def test_wod_bgc_ragged_example(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import wod_bgc_ragged_example
+
+            plt.close("all")
+
+    def test_seasia_dic_example_plot(self):
+        if "livljobs" in gethostname().lower():
+            from example_scripts import seasia_dic_example_plot
+
+            plt.close("all")
diff --git a/unit_testing/test_general_utils.py b/unit_testing/test_general_utils.py
new file mode 100644
index 00000000..a912bc3f
--- /dev/null
+++ b/unit_testing/test_general_utils.py
@@ -0,0 +1,45 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+from coast import general_utils
+import unittest
+import numpy as np
+import pytz
+import datetime
+import unit_test_files as files
+
+
+class test_general_utils(unittest.TestCase):
+    def test_copy_coast_object(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        sci_copy = sci.copy()
+        check1 = sci_copy.dataset == sci.dataset
+        self.assertTrue(check1, msg="check1")
+
+    def test_getitem(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        check1 = sci.dataset["ssh"].equals(sci["ssh"])
+        self.assertTrue(check1, msg="check1")
+
+    def test_coast_variable_renaming(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        sci_copy = sci.copy()
+        sci_copy.rename({"ssh": "renamed"})
+        check1 = sci["ssh"].equals(sci_copy["renamed"])
+        self.assertTrue(check1, "check1")
+
+    def test_day_of_week(self):
+        check1 = general_utils.day_of_week(np.datetime64("2020-10-16")) == "Fri"
+        self.assertTrue(check1, msg="check1")
+
+    def test_bst_to_gmt(self):
+        time_str = "11/10/2020 12:00"
+        datetime_obj = datetime.datetime.strptime(time_str, "%d/%m/%Y %H:%M")
+        bst_obj = pytz.timezone("Europe/London")
+        check1 = np.datetime64(bst_obj.localize(datetime_obj).astimezone(pytz.utc)) == np.datetime64(
+            "2020-10-11T11:00:00"
+        )
+        self.assertTrue(check1, msg="check1")
diff --git a/unit_testing/test_gridded_harmonics.py b/unit_testing/test_gridded_harmonics.py
new file mode 100644
index 00000000..b1390072
--- /dev/null
+++ b/unit_testing/test_gridded_harmonics.py
@@ -0,0 +1,39 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import unit_test_files as files
+
+
+class test_gridded_harmonics(unittest.TestCase):
+    def test_combine_and_convert_harmonics(self):
+
+        harmonics = coast.Gridded(files.fn_nemo_harmonics, files.fn_nemo_harmonics_dom, config=files.fn_config_t_grid)
+
+        with self.subTest("test_combine_harmonics"):
+            constituents = ["K1", "M2", "S2", "K2"]
+            harmonics_combined = harmonics.harmonics_combine(constituents)
+
+            # TEST: Check values in arrays and constituents
+            check1 = list(harmonics_combined.dataset.constituent.values) == constituents
+            check2 = np.array_equal(harmonics_combined.dataset.harmonic_x[1].values, harmonics.dataset.M2x.values)
+
+            self.assertTrue(check1, msg="check1")
+            self.assertTrue(check2, msg="check2")
+
+        with self.subTest("test_convert_harmonics"):
+
+            harmonics_combined.harmonics_convert(direction="cart2polar")
+            harmonics_combined.harmonics_convert(direction="polar2cart", x_var="x_test", y_var="y_test")
+
+            # TEST: Check variables and differences
+            check1 = "x_test" in harmonics_combined.dataset.keys()
+            diff = harmonics_combined.dataset.harmonic_x[0].values - harmonics_combined.dataset.x_test[0].values
+            check2 = np.max(np.abs(diff)) < 1e-6
+
+            self.assertTrue(check1, msg="check1")
+            self.assertTrue(check2, msg="check2")
diff --git a/unit_testing/test_gridded_initialisation.py b/unit_testing/test_gridded_initialisation.py
new file mode 100644
index 00000000..8dafbef4
--- /dev/null
+++ b/unit_testing/test_gridded_initialisation.py
@@ -0,0 +1,113 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import os.path as path
+import xarray as xr
+import unit_test_files as files
+
+
+class test_gridded_initialisation(unittest.TestCase):
+    def test_gridded_load_of_data_and_domain(self):
+        # Check successfully load example data and domain
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        sci_attrs_ref = dict(
+            [
+                ("name", "AMM7_1d_20070101_20070131_25hourm_grid_T"),
+                ("description", "ocean T grid variables, 25h meaned"),
+                ("title", "ocean T grid variables, 25h meaned"),
+                ("Conventions", "CF-1.6"),
+                ("timeStamp", "2019-Dec-26 04:35:28 GMT"),
+                ("uuid", "96cae459-d3a1-4f4f-b82b-9259179f95f7"),
+            ]
+        )
+
+        # checking is LHS is a subset of RHS
+        check1 = sci_attrs_ref.items() <= sci.dataset.attrs.items()
+
+        self.assertTrue(check1, msg="Check1")
+
+    def test_gridded_load_of_data_only(self):
+        # Check load only data
+        ds = xr.open_dataset(files.fn_nemo_dat)
+        sci_load_ds = coast.Gridded(config=files.fn_config_t_grid)
+        sci_load_ds.load_dataset(ds)
+        sci_load_file = coast.Gridded(config=files.fn_config_t_grid)
+        sci_load_file.load(files.fn_nemo_dat)
+        check1 = sci_load_ds.dataset.identical(sci_load_file.dataset)
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_variables_correctly_renamed(self):
+        # Check temperature is correctly renamed
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        check1 = "temperature" in sci.dataset
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_dimensions_correctly_renamed(self):
+        # Check gridded dimensions are correctly renamed
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        check1 = sci.dataset.temperature.dims == ("t_dim", "z_dim", "y_dim", "x_dim")
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_domain_only(self):
+        # Check gridded load domain only
+        nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+
+        check1 = False
+        if nemo_f.dataset._coord_names == {"depth_0", "latitude", "longitude"}:
+            var_name_list = []
+            for var_name in nemo_f.dataset.data_vars:
+                var_name_list.append(var_name)
+            if var_name_list == ["bathymetry", "e1", "e2", "e3_0"]:
+                check1 = True
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_calculate_depth0_for_tuvwf(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        if not np.isclose(np.nansum(nemo_t.dataset.depth_0.values), 1705804300.0):
+            raise ValueError(" X - Gridded depth_0 failed on t-grid failed")
+        nemo_u = coast.Gridded(
+            fn_data=files.fn_nemo_grid_u_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_u_grid
+        )
+        if not np.isclose(np.nansum(nemo_u.dataset.depth_0.values), 1705317600.0):
+            raise ValueError(" X - Gridded depth_0 failed on u-grid failed")
+        nemo_v = coast.Gridded(
+            fn_data=files.fn_nemo_grid_v_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_v_grid
+        )
+        if not np.isclose(np.nansum(nemo_v.dataset.depth_0.values), 1705419100.0):
+            raise ValueError(" X - Gridded depth_0 failed on v-grid failed")
+        nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+        if not np.isclose(np.nansum(nemo_f.dataset.depth_0.values), 1704932600.0):
+            raise ValueError(" X - Gridded depth_0 failed on f-grid failed")
+
+    def test_gridded_load_subregion_with_domain(self):
+        amm7 = coast.Gridded(files.fn_nemo_dat_subset, files.fn_nemo_dom, config=files.fn_config_t_grid)
+
+        # checking all the coordinates mapped correctly to the dataset object
+        check1 = amm7.dataset._coord_names == {"depth_0", "latitude", "longitude", "time"}
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_load_multiple(self):
+        amm7 = coast.Gridded(files.file_names_amm7, files.fn_nemo_dom, config=files.fn_config_t_grid, multiple=True)
+
+        # checking all the coordinates mapped correctly to the dataset object
+        check1 = amm7.dataset.time.size == 14
+        self.assertTrue(check1, msg="check1")
+
+    def test_gridded_compute_e3_from_ssh(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+
+        e3t, e3u, e3v, e3f, e3w = coast.Gridded.get_e3_from_ssh(nemo_t, True, True, True, True, True)
+        cksum = np.array([e3t.sum(), e3u.sum(), e3v.sum(), e3f.sum(), e3w.sum()])
+        # these references are based on the example file's ssh field
+        reference = np.array([8.337016e08, 8.333972e08, 8.344886e08, 8.330722e08, 8.265948e08])
+        check1 = np.allclose(cksum, reference)
+        self.assertTrue(check1, msg="check1")
diff --git a/unit_testing/test_index_classes_load_datasets.py b/unit_testing/test_index_classes_load_datasets.py
index 785f52fb..cf425927 100644
--- a/unit_testing/test_index_classes_load_datasets.py
+++ b/unit_testing/test_index_classes_load_datasets.py
@@ -39,6 +39,10 @@
 fn_tidegauge_config = "config/example_tidegauge.json"
 fn_tidegauge_multiple = "./example_files/tide_gauges/m*"
 
+# wod profile data 1D (NetCDF)
+fn_wod = "./example_files/WOD_example_ragged_standard_level.nc"
+fn_wod_config = "config/example_wod_profiles.json"
+
 
 def test_load_altimetry_no_config():
     altimetry = Altimetry(file_path=fn_altimetry)
@@ -73,6 +77,17 @@ def test_load_profile_config():
     ]
 
 
+def test_load_wod_config():
+    wod_profile_1d = Profile(config=fn_wod_config)
+    wod_profile_1d.read_wod(fn_wod)
+    assert wod_profile_1d is not None
+    # assert that we have the coordinate and data variable names as specified in the json config file
+    assert list(wod_profile_1d.dataset.coords) == ["casts", "Z_N"]
+    assert list(wod_profile_1d.dataset.data_vars) == [
+        "depth",
+    ]
+
+
 def test_load_glider_no_config():
     glider = Glider(file_path=fn_glider)
     assert glider is not None
diff --git a/unit_testing/test_isobath_contour_methods.py b/unit_testing/test_isobath_contour_methods.py
new file mode 100644
index 00000000..cd31b9c5
--- /dev/null
+++ b/unit_testing/test_isobath_contour_methods.py
@@ -0,0 +1,132 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import matplotlib.pyplot as plt
+import unit_test_files as files
+import datetime
+
+
+class test_contour_f_methods(unittest.TestCase):
+    def test_extract_isobath_contour_between_two_points(self):
+
+        with self.subTest("Extract contour"):
+            nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+            contours, no_contours = coast.Contour.get_contours(nemo_f, 200)
+            y_ind, x_ind, contour = coast.Contour.get_contour_segment(nemo_f, contours[0], [50, -10], [60, 3])
+            cont_f = coast.ContourF(nemo_f, y_ind, x_ind, 200)
+            check1 = np.isclose(cont_f.y_ind.sum() + cont_f.y_ind.sum(), 190020)
+            check2 = np.isclose(cont_f.data_contour.bathymetry.sum().item(), 69803.78125)
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Plot contour"):
+            coast.Contour.plot_contour(nemo_f, contour)
+            cont_path = files.dn_fig + "contour.png"
+            plt.savefig(cont_path)
+            plt.close("all")
+
+    def test_calculate_flow_across_contour(self):
+        nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+        nemo_u = coast.Gridded(
+            fn_data=files.fn_nemo_grid_u_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_u_grid
+        )
+        nemo_v = coast.Gridded(
+            fn_data=files.fn_nemo_grid_v_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_v_grid
+        )
+        contours, no_contours = coast.Contour.get_contours(nemo_f, 200)
+        y_ind, x_ind, contour = coast.Contour.get_contour_segment(nemo_f, contours[0], [50, -10], [60, 3])
+        cont_f = coast.ContourF(nemo_f, y_ind, x_ind, 200)
+        cont_f.calc_cross_contour_flow(nemo_u, nemo_v)
+        check1 = np.allclose(
+            (cont_f.data_cross_flow.normal_velocities + cont_f.data_cross_flow.depth_integrated_normal_transport).sum(),
+            -1152.3771,
+        )
+        self.assertTrue(check1, "check1")
+
+    def test_calculate_pressure_gradient_driven_flow(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+        contours, no_contours = coast.Contour.get_contours(nemo_f, 200)
+        y_ind, x_ind, contour = coast.Contour.get_contour_segment(nemo_f, contours[0], [50, -10], [60, 3])
+        cont_f = coast.ContourF(nemo_f, y_ind, x_ind, 200)
+        cont_f.calc_geostrophic_flow(
+            nemo_t, config_u=files.fn_config_u_grid, config_v=files.fn_config_v_grid, ref_density=1027
+        )
+        check1 = np.allclose(
+            (
+                cont_f.data_cross_flow.normal_velocity_hpg
+                + cont_f.data_cross_flow.normal_velocity_spg
+                + cont_f.data_cross_flow.transport_across_AB_hpg
+                + cont_f.data_cross_flow.transport_across_AB_spg
+            ).sum(),
+            74.65002414,
+        )
+        self.assertTrue(check1, "check1")
+
+
+class test_contour_t_methods(unittest.TestCase):
+    def setUp(self):
+        # This is called at the beginning of every test_ method.
+        # load gridded data
+        self.nemo_t = coast.Gridded(files.fn_nemo_grid_t_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        # create contour dataset along 200 m isobath
+        contours, no_contours = coast.Contour.get_contours(self.nemo_t, 200)
+        y_ind, x_ind, contour = coast.Contour.get_contour_segment(self.nemo_t, contours[0], [50, -10], [60, 3])
+        self.cont_t = coast.ContourT(self.nemo_t, y_ind, x_ind, 200)
+
+    def test_along_contour_flow(self):
+        # calculate flow along contour
+        nemo_u = coast.Gridded(files.fn_nemo_grid_u_dat, files.fn_nemo_dom, config=files.fn_config_u_grid)
+        nemo_v = coast.Gridded(files.fn_nemo_grid_v_dat, files.fn_nemo_dom, config=files.fn_config_v_grid)
+        self.cont_t.calc_along_contour_flow(nemo_u, nemo_v)
+
+        with self.subTest("Check on velocities"):
+            cksum = (
+                (
+                    self.cont_t.data_along_flow.velocities
+                    * self.cont_t.data_along_flow.e3
+                    * self.cont_t.data_along_flow.e4
+                )
+                .sum()
+                .values
+            )
+            self.assertTrue(
+                np.isclose(cksum, 116660850), "velocities checksum: " + str(cksum) + ", should be 116660850"
+            )
+
+        with self.subTest("Check on transport"):
+            cksum = (self.cont_t.data_along_flow.transport * self.cont_t.data_along_flow.e4).sum().values
+            self.assertTrue(
+                np.isclose(cksum, 116660850), "transports checksum: " + str(cksum) + ", should be 116660850"
+            )
+
+    def test_along_contour_2d_flow(self):
+        # calculate flow along contour
+        nemo_u = coast.Gridded(files.fn_nemo_grid_u_dat, files.fn_nemo_dom, config=files.fn_config_u_grid)
+        nemo_v = coast.Gridded(files.fn_nemo_grid_v_dat, files.fn_nemo_dom, config=files.fn_config_v_grid)
+        nemo_u.dataset = nemo_u.dataset.isel(z_dim=0).squeeze()
+        nemo_v.dataset = nemo_v.dataset.isel(z_dim=0).squeeze()
+        self.cont_t.calc_along_contour_flow_2d(nemo_u, nemo_v)
+
+        cksum = (
+            (self.cont_t.data_along_flow.velocities * self.cont_t.data_along_flow.e3_0 * self.cont_t.data_along_flow.e4)
+            .sum()
+            .values
+        )
+        self.assertTrue(np.isclose(cksum, 293910.94), "velocities checksum: " + str(cksum) + ", should be 293910.94")
+
+    def test_calculate_pressure_along_contour(self):
+        self.cont_t.construct_pressure(1027)
+        check1 = np.allclose(
+            (self.cont_t.data_contour.pressure_s + self.cont_t.data_contour.pressure_h_zlevels).sum().compute().item(),
+            27490693.20181531,
+        )
+        self.assertTrue(check1)
diff --git a/unit_testing/test_maskmaker_methods.py b/unit_testing/test_maskmaker_methods.py
new file mode 100644
index 00000000..56e0cd85
--- /dev/null
+++ b/unit_testing/test_maskmaker_methods.py
@@ -0,0 +1,51 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import unittest
+import coast
+import numpy as np
+import unit_test_files as files
+
+
+class test_maskmaker_methods(unittest.TestCase):
+    def test_fill_polygon_by_index(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        mm = coast.MaskMaker()
+        # Draw and fill a square
+        vertices_r = [50, 150, 150, 50]
+        vertices_c = [50, 50, 150, 150]
+        mask00 = np.zeros((sci.dataset.dims["y_dim"], sci.dataset.dims["x_dim"]))
+        mask01 = np.ones((sci.dataset.dims["y_dim"], sci.dataset.dims["x_dim"]))
+        filled0 = mm.fill_polygon_by_index(mask00, vertices_r, vertices_c)
+        filled1 = mm.fill_polygon_by_index(mask01, vertices_r, vertices_c, additive=True)
+
+        # TEST: Check some data
+        check1 = filled0[49, 49] == 0 and filled0[51, 51] == 1
+        check2 = filled1[49, 49] == 1 and filled1[51, 51] == 2
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+
+    def test_fill_polygon_by_lonlat(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        mm = coast.MaskMaker()
+        # Draw and fill a square
+        vertices_lon = [-5, -5, 5, 5]
+        vertices_lat = [40, 60, 60, 40]
+        mask00 = np.zeros((sci.dataset.dims["y_dim"], sci.dataset.dims["x_dim"]))
+        mask01 = np.ones((sci.dataset.dims["y_dim"], sci.dataset.dims["x_dim"]))
+        filled0 = mm.fill_polygon_by_lonlat(
+            mask00, sci.dataset.longitude, sci.dataset.latitude, vertices_lon, vertices_lat
+        )
+        filled1 = mm.fill_polygon_by_lonlat(
+            mask01, sci.dataset.longitude, sci.dataset.latitude, vertices_lon, vertices_lat, additive=True
+        )
+
+        # TEST: Check some data
+        check1 = filled0[50, 50] == 0 and filled0[50, 150] == 1
+        check2 = filled1[50, 50] == 1 and filled1[50, 150] == 2
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
diff --git a/unit_testing/test_object_manipulation.py b/unit_testing/test_object_manipulation.py
new file mode 100644
index 00000000..f39d5aa4
--- /dev/null
+++ b/unit_testing/test_object_manipulation.py
@@ -0,0 +1,146 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+from coast import general_utils
+import unittest
+import numpy as np
+import os.path as path
+import xarray as xr
+import unit_test_files as files
+
+
+class test_object_manipulation(unittest.TestCase):
+    def test_subset_single_variable(self):
+        yt_ref = [
+            164,
+            163,
+            162,
+            162,
+            161,
+            160,
+            159,
+            158,
+            157,
+            156,
+            156,
+            155,
+            154,
+            153,
+            152,
+            152,
+            151,
+            150,
+            149,
+            148,
+            147,
+            146,
+            146,
+            145,
+            144,
+            143,
+            142,
+            142,
+            141,
+            140,
+            139,
+            138,
+            137,
+            136,
+            136,
+            135,
+            134,
+        ]
+        xt_ref = [
+            134,
+            133,
+            132,
+            131,
+            130,
+            129,
+            128,
+            127,
+            126,
+            125,
+            124,
+            123,
+            122,
+            121,
+            120,
+            119,
+            118,
+            117,
+            116,
+            115,
+            114,
+            113,
+            112,
+            111,
+            110,
+            109,
+            108,
+            107,
+            106,
+            105,
+            104,
+            103,
+            102,
+            101,
+            100,
+            99,
+            98,
+        ]
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        data_t = sci.get_subset_as_xarray("temperature", xt_ref, yt_ref)
+
+        # Test shape and exteme values
+        check1 = np.shape(data_t) == (51, 37)
+        check2 = np.nanmin(data_t) - 11.267578 < 1e-6
+        check3 = np.nanmax(data_t) - 11.834961 < 1e-6
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+        self.assertTrue(check3, "check3")
+
+    def test_indices_by_distance(self):
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        ind = sci.subset_indices_by_distance(0, 51, 111)
+
+        # Test size of indices array
+        check1 = np.shape(ind) == (2, 674)
+        self.assertTrue(check1, "check1")
+
+    def test_interpolation_to_altimetry(self):
+
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+
+        with self.subTest("Find nearest xy indices"):
+            altimetry = coast.Altimetry(files.fn_altimetry)
+            ind = altimetry.subset_indices_lonlat_box([-10, 10], [45, 60])
+            altimetry_nwes = altimetry.isel(time=ind)  # nwes = northwest europe shelf
+            ind_x, ind_y = general_utils.nearest_indices_2d(
+                sci.dataset.longitude,
+                sci.dataset.latitude,
+                altimetry_nwes.dataset.longitude,
+                altimetry_nwes.dataset.latitude,
+            )
+            check1 = ind_x.shape == altimetry_nwes.dataset.longitude.shape
+            self.assertTrue(check1, "check1")
+
+        with self.subTest("Interpolate in space"):
+            interp_lon = np.array(altimetry_nwes.dataset.longitude).flatten()
+            interp_lat = np.array(altimetry_nwes.dataset.latitude).flatten()
+            interpolated = sci.interpolate_in_space(sci.dataset.ssh, interp_lon, interp_lat)
+
+            # Check that output array longitude has same shape as altimetry
+            check1 = interpolated.longitude.shape == altimetry_nwes.dataset.longitude.shape
+            self.assertTrue(check1, "check1")
+
+        with self.subTest("Interpolate in time"):
+            interpolated = sci.interpolate_in_time(interpolated, altimetry_nwes.dataset.time)
+
+            # Check time in interpolated object has same shape
+            check1 = interpolated.time.shape == altimetry_nwes.dataset.time.shape
+            self.assertTrue(check1, "check1")
diff --git a/unit_testing/test_plot_utilities.py b/unit_testing/test_plot_utilities.py
new file mode 100644
index 00000000..12111706
--- /dev/null
+++ b/unit_testing/test_plot_utilities.py
@@ -0,0 +1,61 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+from coast import plot_util
+import unittest
+import numpy as np
+import matplotlib.pyplot as plt
+import unit_test_files as files
+import os
+
+
+class test_plot_utilities(unittest.TestCase):
+    def test_scatter_with_fit(self):
+        x = np.arange(0, 50)
+        y = np.arange(0, 50) / 1.5
+        f, a = plot_util.scatter_with_fit(x, y)
+        a.set_title("Test: Scatter_with_fit()")
+
+        f.savefig(os.path.join(files.dn_fig, "scatter_with_fit_test.png"))
+        plt.close("all")
+
+    def test_geo_axes(self):
+        lonbounds = [-20, 20]
+        latbounds = [30, 60]
+        f, a = plot_util.create_geo_axes(lonbounds, latbounds)
+        a.set_title("Test: create_geo_axes()")
+        a.scatter([0, -10], [50, 50])
+
+        f.savefig(os.path.join(files.dn_fig, "create_geo_axes_test.png"))
+        plt.close()
+
+    def test_determine_colorbar_extension(self):
+        pretend_data = np.arange(0, 50)
+        test1 = plot_util.determine_colorbar_extension(pretend_data, -50, 100)
+        test2 = plot_util.determine_colorbar_extension(pretend_data, 1, 100)
+        test3 = plot_util.determine_colorbar_extension(pretend_data, -50, 48)
+        test4 = plot_util.determine_colorbar_extension(pretend_data, 1, 48)
+
+        # TEST: <description here>
+        check1 = test1 == "neither"
+        check2 = test2 == "min"
+        check3 = test3 == "max"
+        check4 = test4 == "both"
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+        self.assertTrue(check3, "check3")
+        self.assertTrue(check4, "check4")
+
+    def test_determine_clim_by_stdev(self):
+        pretend_data = np.arange(0, 100)
+        pretend_data[-1] = 200
+        clim = plot_util.determine_clim_by_standard_deviation(pretend_data, n_std_dev=2)
+
+        # TEST: <description here>
+        check1 = clim[0] == -13.808889915793792
+        check2 = clim[1] == 114.82888991579378
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
diff --git a/unit_testing/test_process_data.py b/unit_testing/test_process_data.py
new file mode 100644
index 00000000..2b1151a6
--- /dev/null
+++ b/unit_testing/test_process_data.py
@@ -0,0 +1,54 @@
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import xarray as xr
+import unit_test_files as files
+
+
+class test_process_data_methods(unittest.TestCase):
+    def test_statsmodel_seasonal_decompose(self):
+        gd_t = coast.Gridded(files.fn_nemo_grid_t_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        fake_time = np.arange(
+            np.datetime64("2010-01-01"), np.datetime64("2014-01-01"), np.timedelta64(1, "M"), dtype="datetime64[M]"
+        ).astype("datetime64[s]")
+        fake_temp = (
+            (np.arange(0, 48) * 0.05)[:, np.newaxis, np.newaxis, np.newaxis]
+            + np.random.normal(0, 0.1, 48)[:, np.newaxis, np.newaxis, np.newaxis]
+            + np.tile(gd_t.dataset.temperature[:-1, :2, :, :], (8, 1, 1, 1))
+        )
+        fake_data_array = xr.DataArray(
+            fake_temp,
+            coords={
+                "t_dim": fake_time,
+                "depth_0": gd_t.dataset.depth_0[:2, :, :],
+                "longitude": gd_t.dataset.longitude,
+                "latitude": gd_t.dataset.latitude,
+            },
+            dims=["t_dim", "z_dim", "y_dim", "x_dim"],
+        )
+
+        # Coast version
+        proc_data = coast.Process_data()
+        grd = proc_data.seasonal_decomposition(fake_data_array, 4, model="additive", period=6, extrapolate_trend="freq")
+        # statsmodel version
+        from statsmodels.tsa.seasonal import seasonal_decompose
+
+        stm_result = seasonal_decompose(
+            fake_data_array.fillna(0).values.reshape((48, 2 * 375 * 297)),
+            model="additive",
+            period=6,
+            extrapolate_trend="freq",
+        )
+
+        check_trend = np.allclose(stm_result.trend.reshape((48, 2, 375, 297)), grd.dataset.trend.compute().fillna(0))
+        check_seasonal = np.allclose(
+            stm_result.seasonal.reshape((48, 2, 375, 297)), grd.dataset.seasonal.compute().fillna(0)
+        )
+        check_residual = np.allclose(
+            stm_result.resid.reshape((48, 2, 375, 297)), grd.dataset.residual.compute().fillna(0)
+        )
+
+        self.assertTrue(check_trend, "trends don't match")
+        self.assertTrue(check_seasonal, "seasonals don't match")
+        self.assertTrue(check_residual, "residuals don't match")
diff --git a/unit_testing/test_profile_methods.py b/unit_testing/test_profile_methods.py
new file mode 100644
index 00000000..4b44981a
--- /dev/null
+++ b/unit_testing/test_profile_methods.py
@@ -0,0 +1,132 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import matplotlib.pyplot as plt
+import unit_test_files as files
+import datetime
+
+
+class test_profile_methods(unittest.TestCase):
+    def test_load_process_and_compare_profile_data(self):
+
+        with self.subTest("Load profile data from EN4"):
+            profile = coast.Profile(config=files.fn_profile_config)
+            profile.read_en4(files.fn_profile)
+            profile.dataset = profile.dataset.isel(id_dim=np.arange(0, profile.dataset.dims["id_dim"], 10)).load()
+
+            check1 = type(profile) == coast.Profile
+            check2 = np.isclose(profile.dataset.temperature.values[0, 0], 8.981)
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Process EN4 profile data"):
+            processed = profile.process_en4()
+            processed.dataset.load()
+
+            check1 = type(processed) == coast.profile.Profile
+            check2 = np.isnan(processed.dataset.temperature.values[0, 0])
+            check3 = processed.dataset.dims["id_dim"] == 111
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+            self.assertTrue(check3, "check3")
+
+    def test_compare_processed_profile_with_model(self):
+
+        profile = coast.Profile(config=files.fn_profile_config)
+        profile.read_en4(files.fn_profile)
+        profile.dataset = profile.dataset.isel(id_dim=np.arange(0, profile.dataset.dims["id_dim"], 10)).load()
+        processed = profile.process_en4()
+        processed.dataset.load()
+
+        with self.subTest("Gridded obs_operator"):
+            nemo_t = coast.Gridded(
+                fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+            )
+            nemo_t.dataset["landmask"] = nemo_t.dataset.bottom_level == 0
+            nemo_profiles = processed.obs_operator(nemo_t)
+
+            check1 = type(nemo_profiles) == coast.Profile
+            check2 = "nearest_index_x" in list(nemo_profiles.dataset.keys())
+            check3 = np.isclose(nemo_profiles.dataset.interp_dist.values[0], 151.4443554515237)
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+            self.assertTrue(check3, "check3")
+
+        with self.subTest("Vertical interpolation"):
+            pa = coast.ProfileAnalysis()
+            reference_depths = np.arange(0, 500, 2)
+            nemo_profiles.dataset = nemo_profiles.dataset.rename({"depth_0": "depth"})
+            model_interpolated = pa.interpolate_vertical(nemo_profiles, processed)
+
+            check1 = type(model_interpolated) == coast.Profile
+            check2 = np.isclose(nemo_profiles.dataset.temperature.values[0, 0], np.float32(1.7324219))
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Vertical interpolation with NAN profiles"):
+            nemo_profiles2 = coast.Profile(dataset=nemo_profiles.dataset.copy())
+            nemo_profiles2.dataset.temperature[:10, :] = np.nan
+            model_interpolated = pa.interpolate_vertical(nemo_profiles2, processed)
+
+            check1 = type(model_interpolated) == coast.Profile
+            check2 = np.isnan(nemo_profiles.dataset.temperature.values[:10, :]).all()
+            check3 = ~np.isnan(nemo_profiles.dataset.temperature.values[11, 0])
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Profile Differencing"):
+            difference = pa.difference(processed, model_interpolated)
+            difference.dataset.load()
+
+            check1 = type(difference) == coast.profile.Profile
+            check2 = np.isclose(difference.dataset.diff_temperature.values[22, 2], 2.2567890882492065)
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Regional Averaging"):
+            mm = coast.MaskMaker()
+
+            # Make some variables easier to access
+            bath = nemo_t.dataset.bathymetry.values
+            lon = nemo_t.dataset.longitude.values
+            lat = nemo_t.dataset.latitude.values
+
+            mm_north_sea = mm.region_def_nws_north_sea(lon, lat, bath)
+            mm_whole_domain = np.ones(lon.shape)
+            mask_list = [mm_north_sea, mm_whole_domain]
+            mask_names = ["North Sea", "Whole Domain"]
+
+            # Turn mask list into an xarray dataset
+            mask_list = coast.MaskMaker.make_mask_dataset(lon, lat, mask_list)
+
+            # Determine whether each profile is in each masked region or not
+            mask_indices = pa.determine_mask_indices(model_interpolated, mask_list)
+
+            # Do average differences for each region
+            mask_means = pa.mask_means(difference, mask_indices)
+
+            check1 = np.isclose(mask_means.all_mean_diff_temperature.values[0], -0.8169331445866469)
+            self.assertTrue(check1, "check1")
+
+        with self.subTest("Surface/Bottom averaging"):
+            surface = 5
+            model_profiles_surface = pa.depth_means(nemo_profiles, [0, surface])
+
+            # Lets get bottom values by averaging over the bottom 30m, except whether
+            # depth is <100m, then average over the bottom 10m
+            model_profiles_bottom = pa.bottom_means(nemo_profiles, [10, 30], [100, np.inf])
+
+            check1 = type(model_profiles_surface) == coast.Profile
+            check2 = type(model_profiles_bottom) == coast.Profile
+            check3 = np.isclose(model_profiles_surface.dataset.temperature.values[11], 6.6600165)
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+            self.assertTrue(check3, "check3")
diff --git a/unit_testing/test_stats_utilities.py b/unit_testing/test_stats_utilities.py
new file mode 100644
index 00000000..f3403843
--- /dev/null
+++ b/unit_testing/test_stats_utilities.py
@@ -0,0 +1,32 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import unittest
+import coast
+from coast import stats_util
+import numpy as np
+import unit_test_files as files
+import datetime
+
+
+class test_stats_utilities(unittest.TestCase):
+    def test_find_maxima(self):
+        date0 = datetime.datetime(2007, 1, 15)
+        date1 = datetime.datetime(2007, 1, 16)
+        tg = coast.Tidegauge()
+        tg.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+
+        tt, hh = stats_util.find_maxima(tg.dataset.time, tg.dataset.ssh[0], method="comp")
+        check1 = np.isclose((tt.values[0] - np.datetime64("2007-01-15T00:15:00")) / np.timedelta64(1, "s"), 0)
+        check2 = np.isclose(hh.values[0], 1.027)
+
+        tt, hh = stats_util.find_maxima(tg.dataset.time, tg.dataset.ssh[0], method="cubic")
+        check3 = np.isclose((tt[0] - np.datetime64("2007-01-15T00:07:49")) / np.timedelta64(1, "s"), 0)
+        check4 = np.isclose(hh[0], 1.0347638302097757)
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+        self.assertTrue(check3, "check3")
+        self.assertTrue(check4, "check4")
diff --git a/unit_testing/test_tidegauge_methods.py b/unit_testing/test_tidegauge_methods.py
new file mode 100644
index 00000000..d16491c4
--- /dev/null
+++ b/unit_testing/test_tidegauge_methods.py
@@ -0,0 +1,340 @@
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import matplotlib.pyplot as plt
+import unit_test_files as files
+import datetime
+import pandas as pd
+
+
+class test_tidegauge_analysis(unittest.TestCase):
+    def test_match_missing_values(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+        lowestoft2 = coast.Tidegauge(dataset=lowestoft.dataset)
+
+        # Insert some missing values into lowestoft 2
+        ds2 = lowestoft.dataset.ssh.values.copy()
+        ds2[0, 0] = np.nan
+        ds2[0, 100] = np.nan
+        lowestoft2.dataset["ssh"] = (["id", "t_dim"], ds2)
+        ds1 = lowestoft.dataset.ssh.values.copy()
+        ds1[0, 50] = np.nan
+        ds1[0, 150] = np.nan
+        lowestoft.dataset["ssh"] = (["id", "t_dim"], ds1)
+
+        # Call match_missing_values
+        tg1, tg2 = tganalysis.match_missing_values(lowestoft.dataset.ssh, lowestoft2.dataset.ssh)
+
+        self.assertTrue(np.isnan(tg1.dataset.ssh[0, 0].values), "check1")
+        self.assertTrue(np.isnan(tg1.dataset.ssh[0, 100].values), "check1")
+        self.assertTrue(np.isnan(tg2.dataset.ssh[0, 50].values), "check1")
+        self.assertTrue(np.isnan(tg2.dataset.ssh[0, 150].values), "check1")
+
+    def test_demean_timeseries(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+
+        # Call match_missing_values
+        tg1 = tganalysis.demean_timeseries(lowestoft.dataset)
+
+        # Test that the mean has been removed
+        timeseries = lowestoft.dataset.ssh[0, :].values
+        demeaned = timeseries - np.nanmean(timeseries)
+        self.assertTrue(np.array_equal(tg1.dataset.ssh[0, :].values, demeaned), "check1")
+
+    def test_harmonic_analysis_utide(self):
+
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+
+        with self.subTest("Do harmonic analysis"):
+            ha = tganalysis.harmonic_analysis_utide(lowestoft.dataset.ssh, min_datapoints=10)
+            self.assertTrue("M2" in ha[0].name, "check1")
+            self.assertTrue(np.isclose(ha[0].A[0], 0.8927607454203988), "check2")
+
+        # Reconstruct for a different time period
+        with self.subTest("Reconstruct time series"):
+            new_times = pd.date_range(datetime.datetime(2008, 1, 1), datetime.datetime(2008, 12, 31), freq="1H")
+            tg_recon = coast.Tidegauge(dataset=lowestoft.dataset, new_time_coords=new_times)
+            reconstructed = tganalysis.reconstruct_tide_utide(tg_recon.dataset, ha, constit="M2", output_name="test")
+
+            self.assertTrue("test" in reconstructed.dataset, "check1")
+            self.assertTrue(
+                reconstructed.dataset.time.values[0] == np.datetime64("2008-01-01T00:00:00.000000000"), "check2"
+            )
+            self.assertTrue(np.isclose(reconstructed.dataset.test[0, 0].values, 1.92296937), "check3")
+
+        with self.subTest("Calculate non-tidal residuals"):
+            reconstructed = tganalysis.reconstruct_tide_utide(lowestoft.dataset.ssh, ha)
+            ntr = tganalysis.calculate_non_tidal_residuals(lowestoft.dataset.ssh, reconstructed.dataset.reconstructed)
+            self.assertTrue("ntr" in ntr.dataset)
+            self.assertTrue(np.isclose(ntr.dataset.ntr[0, 0], 0.00129846), "check1")
+
+    def test_threshold_statistics(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+
+        thresh = tganalysis.threshold_statistics(lowestoft.dataset)
+
+        check1 = "peak_count_ssh" in thresh
+        check2 = "peak_count_qc_flags" in thresh
+        check3 = np.isclose(thresh.peak_count_ssh[0, 0], 5)
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+        self.assertTrue(check3, "check3")
+
+    def test_difference(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+        lowestoft2 = coast.Tidegauge(dataset=lowestoft.dataset)
+
+        diff = tganalysis.difference(lowestoft.dataset, lowestoft2.dataset)
+
+        check1 = "diff_ssh" in diff.dataset
+        check2 = "abs_diff_ssh" in diff.dataset
+        check3 = (diff.dataset.diff_ssh == 0).all()
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+        self.assertTrue(check3, "check3")
+
+
+class test_tidegauge_methods(unittest.TestCase):
+    def test_read_gesla_and_compare_to_model(self):
+
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+        sci.dataset["landmask"] = sci.dataset.bottom_level == 0
+        tganalysis = coast.TidegaugeAnalysis()
+
+        with self.subTest("Read GESLA file"):
+            date0 = datetime.datetime(2007, 1, 10)
+            date1 = datetime.datetime(2007, 1, 12)
+            lowestoft = coast.Tidegauge()
+            lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+
+            # TEST: Check attribute dictionary and length of sea_level.
+            check1 = len(lowestoft.dataset.ssh.isel(id_dim=0)) == 193
+            check2 = lowestoft.dataset.id_name.values[0] == "Lowestoft"
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Plot Single GESLA"):
+            f, a = lowestoft.plot_on_map()
+            f.savefig(files.dn_fig + "tidegauge_map.png")
+            plt.close("all")
+
+        with self.subTest("Obs operator"):
+            lowestoft_interp = lowestoft.obs_operator(sci, time_interp="linear")
+
+            # TEST: Check that the resulting interp_sossheig variable is of the same
+            # length as sea_level and that it is populated.
+            interp_len = lowestoft_interp.dataset.ssh.shape[0]
+            orig_len = lowestoft.dataset.ssh.shape[0]
+            check1 = interp_len == orig_len
+            check2 = False in np.isnan(lowestoft_interp.dataset.ssh)
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Tidegauge CRPS"):
+            crps = tganalysis.crps(lowestoft.dataset.ssh, sci.dataset.ssh)
+
+            # TEST: Check length of crps and that it contains values
+            check1 = crps.dataset.crps.shape[0] == lowestoft.dataset.ssh.shape[0]
+            check2 = False in np.isnan(crps.dataset.crps)
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+    def test_time_mean(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+        tmean = tganalysis.time_mean(lowestoft.dataset, date0, datetime.datetime(2007, 1, 11))
+        check1 = np.isclose(tmean.dataset.ssh.values[0], 1.91486598)
+        self.assertTrue(check1, "check1")
+
+    def test_time_std(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+        tstd = tganalysis.time_std(lowestoft.dataset, date0, datetime.datetime(2007, 1, 11))
+        check1 = np.isclose(tstd.dataset.ssh.values[0], 0.5419484060517006)
+        self.assertTrue(check1, "check1")
+
+    def test_time_slice(self):
+        tganalysis = coast.TidegaugeAnalysis()
+        date0 = datetime.datetime(2007, 1, 10)
+        date1 = datetime.datetime(2007, 1, 12)
+        lowestoft = coast.Tidegauge()
+        lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+        tslice = tganalysis.time_slice(lowestoft.dataset, date0=date0, date1=datetime.datetime(2007, 1, 11))
+        check1 = pd.to_datetime(tslice.dataset.time.values[0]) == date0
+        check2 = pd.to_datetime(tslice.dataset.time.values[-1]) == datetime.datetime(2007, 1, 11)
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+
+    def test_tidegauge_resample_and_apply_doodsonx0(self):
+
+        with self.subTest("Resample to 1H"):
+            tganalysis = coast.TidegaugeAnalysis()
+            date0 = datetime.datetime(2007, 1, 10)
+            date1 = datetime.datetime(2007, 1, 12)
+            lowestoft = coast.Tidegauge()
+            lowestoft.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+            resampled = tganalysis.resample_mean(lowestoft.dataset, "1H")
+            check1 = pd.to_datetime(resampled.dataset.time.values[2]) == datetime.datetime(2007, 1, 10, 2, 0, 0)
+            check2 = resampled.dataset.dims["t_dim"] == 49
+            self.assertTrue(check1)
+            self.assertTrue(check2)
+
+        with self.subTest("Apply Doodson x0 filter"):
+            dx0 = tganalysis.doodson_x0_filter(resampled.dataset, "ssh")
+
+            # TEST: Check new times are same length as variable
+            check1 = dx0.dataset.time.shape == resampled.dataset.time.shape
+            # TEST: Check there are number values in output
+            check2 = False in np.isnan(dx0.dataset.ssh)
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+    def test_load_multiple_tidegauge(self):
+
+        with self.subTest("Load multiple gauge"):
+            date0 = datetime.datetime(2007, 1, 10)
+            date1 = datetime.datetime(2007, 1, 12)
+            lowestoft = coast.Tidegauge()
+            lowestoft.read_gesla_v3(files.fn_tidegauge, date0, date1)
+            multi_tg = coast.Tidegauge()
+            tg_list = multi_tg.read_gesla_v3(files.fn_multiple_tidegauge, date_start=date0, date_end=date1)
+
+            # TEST: Check length of list
+            check1 = len(tg_list) == 2
+            # TEST: Check lowestoft matches
+            check2 = all(tg_list[1].dataset == lowestoft.dataset)
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Plot multiple gauge"):
+            f, a = coast.Tidegauge.plot_on_map_multiple(tg_list)
+            f.savefig(files.dn_fig + "tidegauge_multiple_map.png")
+            plt.close("all")
+
+    def test_tidegauge_for_tabulated_data(self):
+        date_start = np.datetime64("2020-10-11 07:59")
+        date_end = np.datetime64("2020-10-20 20:21")
+
+        # Initiate a Tidegauge object, if a filename is passed it assumes
+        # it is a GESLA type object
+        tg = coast.Tidegauge()
+        tg.read_hlw(files.fn_gladstone, date_start, date_end)
+
+        check1 = len(tg.dataset.ssh[0]) == 37
+        check2 = tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="nearest_HW").values == 8.01
+        check3 = tg.get_tide_table_times(
+            np.datetime64("2020-10-13 12:48"), method="nearest_1"
+        ).time.values == np.datetime64("2020-10-13 14:36")
+        check4 = np.array_equal(
+            tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="nearest_2").values, [2.83, 8.01]
+        )
+        check5 = np.array_equal(
+            tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="window", winsize=24).values,
+            [3.47, 7.78, 2.8, 8.01, 2.83, 8.45, 2.08, 8.71],
+        )
+
+        self.assertTrue(check1, "check1")
+        self.assertTrue(check2, "check2")
+        self.assertTrue(check3, "check3")
+        self.assertTrue(check4, "check4")
+        self.assertTrue(check5, "check5")
+
+    def test_tidegauge_finding_extrema(self):
+
+        with self.subTest("Find extrema"):
+            date0 = datetime.datetime(2007, 1, 10)
+            date1 = datetime.datetime(2007, 1, 20)
+            lowestoft2 = coast.Tidegauge()
+            lowestoft2.read_gesla_v3(files.fn_tidegauge, date_start=date0, date_end=date1)
+            tganalysis = coast.TidegaugeAnalysis()
+
+            # Use comparison of neighbourhood method (method="comp" is assumed)
+            extrema_comp = tganalysis.find_high_and_low_water(lowestoft2.dataset.ssh, distance=40)
+            # Check actual maximum/minimum is in output dataset
+            check1 = np.nanmax(lowestoft2.dataset.ssh) in extrema_comp.dataset.ssh_highs
+            check2 = np.nanmin(lowestoft2.dataset.ssh) in extrema_comp.dataset.ssh_lows
+            # Check new time dimensions have correct length (hardcoded here)
+            check3 = len(extrema_comp.dataset.time_highs) == 19
+            check4 = len(extrema_comp.dataset.time_lows) == 18
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+            self.assertTrue(check3, "check3")
+            self.assertTrue(check4, "check4")
+
+        with self.subTest("Plot extrema"):
+            # Attempt a plot
+            f = plt.figure()
+            plt.plot(lowestoft2.dataset.time, lowestoft2.dataset.ssh[0])
+            plt.scatter(extrema_comp.dataset.time_highs.values, extrema_comp.dataset.ssh_highs, marker="o", c="g")
+            plt.scatter(extrema_comp.dataset.time_lows.values, extrema_comp.dataset.ssh_lows, marker="o", c="r")
+
+            plt.legend(["Time Series", "Maxima", "Minima"])
+            plt.title("Tide Gauge Optima at Lowestoft")
+            f.savefig(files.dn_fig + "tidegauge_optima.png")
+            plt.close("all")
+
+    def test_tidegauge_cubic_spline_extrema(self):
+
+        with self.subTest("Fit cubic spline"):
+            date_start = np.datetime64("2020-10-12 23:59")
+            date_end = np.datetime64("2020-10-14 00:01")
+            tganalysis = coast.TidegaugeAnalysis()
+
+            # Initiate a Tidegauge object, if a filename is passed
+            # it assumes it is a GESLA  type object
+            tg = coast.Tidegauge()
+            # specify the data read as a High Low Water dataset
+            tg.read_bodc(files.fn_tidegauge2, date_start, date_end)
+
+            # Use cubic spline fitting method
+            extrema_cubc = tganalysis.find_high_and_low_water(tg.dataset.ssh, method="cubic")
+
+            # Check actual maximum/minimum is in output dataset
+            check1 = np.isclose(extrema_cubc.dataset.ssh_highs, [7.774, 7.91]).all()
+            check2 = np.isclose(extrema_cubc.dataset.ssh_lows, [2.636, 2.547]).all()
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
+
+        with self.subTest("Attempt plot"):
+            f = plt.figure()
+            plt.plot(tg.dataset.time, tg.dataset.ssh[0])
+            plt.scatter(extrema_cubc.dataset.time_highs.values, extrema_cubc.dataset.ssh_highs, marker="o", c="g")
+            plt.scatter(extrema_cubc.dataset.time_lows.values, extrema_cubc.dataset.ssh_lows, marker="o", c="r")
+
+            plt.legend(["Time Series", "Maxima", "Minima"])
+            plt.title("Tide Gauge Optima at Gladstone, fitted cubic spline")
+            f.savefig(files.dn_fig + "tidegauge_optima.png")
+            plt.close("all")
diff --git a/unit_testing/test_transect_methods.py b/unit_testing/test_transect_methods.py
new file mode 100644
index 00000000..cc593d69
--- /dev/null
+++ b/unit_testing/test_transect_methods.py
@@ -0,0 +1,189 @@
+"""
+
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+from coast import general_utils
+import unittest
+import numpy as np
+import os.path as path
+import xarray as xr
+import matplotlib.pyplot as plt
+import unit_test_files as files
+
+
+class test_transect_methods(unittest.TestCase):
+    def test_determine_extract_transect_indices(self):
+        nemo_t = coast.Gridded(files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid)
+        yt, xt, length_of_line = nemo_t.transect_indices([51, -5], [49, -9])
+
+        # Test transect indices
+        yt_ref = [
+            164,
+            163,
+            162,
+            162,
+            161,
+            160,
+            159,
+            158,
+            157,
+            156,
+            156,
+            155,
+            154,
+            153,
+            152,
+            152,
+            151,
+            150,
+            149,
+            148,
+            147,
+            146,
+            146,
+            145,
+            144,
+            143,
+            142,
+            142,
+            141,
+            140,
+            139,
+            138,
+            137,
+            136,
+            136,
+            135,
+            134,
+        ]
+        xt_ref = [
+            134,
+            133,
+            132,
+            131,
+            130,
+            129,
+            128,
+            127,
+            126,
+            125,
+            124,
+            123,
+            122,
+            121,
+            120,
+            119,
+            118,
+            117,
+            116,
+            115,
+            114,
+            113,
+            112,
+            111,
+            110,
+            109,
+            108,
+            107,
+            106,
+            105,
+            104,
+            103,
+            102,
+            101,
+            100,
+            99,
+            98,
+        ]
+        length_ref = 37
+
+        check1 = xt == xt_ref
+        check2 = yt == yt_ref
+        check3 = length_of_line == length_ref
+        self.assertTrue(check1, msg="check1")
+        self.assertTrue(check2, msg="check2")
+        self.assertTrue(check3, msg="check3")
+
+    def test_calculate_transport_velocity_and_depth(self):
+        with self.subTest("Calculate_transports and velocties and depth"):
+            nemo_t = coast.Gridded(
+                fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+            )
+            nemo_u = coast.Gridded(
+                fn_data=files.fn_nemo_grid_u_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_u_grid
+            )
+            nemo_v = coast.Gridded(
+                fn_data=files.fn_nemo_grid_v_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_v_grid
+            )
+            nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+
+            tran_f = coast.TransectF(nemo_f, (54, -15), (56, -12))
+            tran_f.calc_flow_across_transect(nemo_u, nemo_v)
+            cksum1 = tran_f.data_cross_tran_flow.normal_velocities.sum(dim=("t_dim", "z_dim", "r_dim")).item()
+            cksum2 = tran_f.data_cross_tran_flow.normal_transports.sum(dim=("t_dim", "r_dim")).item()
+            check1 = np.isclose(cksum1, -253.6484375)
+            check2 = np.isclose(cksum2, -48.67562136873888)
+            self.assertTrue(check1, msg="check1")
+            self.assertTrue(check2, msg="check2")
+
+        with self.subTest("plot_transect_on_map"):
+            fig, ax = tran_f.plot_transect_on_map()
+            ax.set_xlim([-20, 0])  # Problem: nice to make the land appear.
+            ax.set_ylim([45, 65])  # But can not call plt.show() before adjustments are made...
+            # fig.tight_layout()
+            fig.savefig(files.dn_fig + "transect_map.png")
+            plt.close("all")
+
+        with self.subTest("plot_normal_velocity"):
+            plot_dict = {"fig_size": (5, 3), "title": "Normal velocities"}
+            fig, ax = tran_f.plot_normal_velocity(time=0, cmap="seismic", plot_info=plot_dict, smoothing_window=2)
+            fig.tight_layout()
+            fig.savefig(files.dn_fig + "transect_velocities.png")
+            plt.close("all")
+
+        with self.subTest("plot_depth_integrated_transport"):
+            plot_dict = {"fig_size": (5, 3), "title": "Transport across AB"}
+            fig, ax = tran_f.plot_depth_integrated_transport(time=0, plot_info=plot_dict, smoothing_window=2)
+            fig.tight_layout()
+            fig.savefig(files.dn_fig + "transect_transport.png")
+            plt.close("all")
+
+    def test_transect_density_and_pressure(self):
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        tran_t = coast.TransectT(nemo_t, (54, -15), (56, -12))
+        tran_t.construct_pressure()
+        cksum1 = tran_t.data.density_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).compute().item()
+        cksum2 = tran_t.data.pressure_h_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).compute().item()
+        cksum3 = tran_t.data.pressure_s.sum(dim=["t_dim", "r_dim"]).compute().item()
+        check1 = np.isclose(cksum1, 23800545.87457855)
+        check2 = np.isclose(cksum2, 135536478.93335825)
+        check3 = np.isclose(cksum3, -285918.5625)
+        self.assertTrue(check1, msg="check1")
+        self.assertTrue(check2, msg="check2")
+        self.assertTrue(check3, msg="check3")
+
+    def test_cross_transect_geostrophic_flow(self):
+        nemo_f = coast.Gridded(fn_domain=files.fn_nemo_dom, config=files.fn_config_f_grid)
+        tran_f = coast.TransectF(nemo_f, (54, -15), (56, -12))
+        nemo_t = coast.Gridded(
+            fn_data=files.fn_nemo_grid_t_dat, fn_domain=files.fn_nemo_dom, config=files.fn_config_t_grid
+        )
+        tran_f.calc_geostrophic_flow(nemo_t, config_u=files.fn_config_u_grid, config_v=files.fn_config_v_grid)
+        cksum1 = tran_f.data_cross_tran_flow.normal_velocity_hpg.sum(dim=("t_dim", "depth_z_levels", "r_dim")).item()
+        cksum2 = tran_f.data_cross_tran_flow.normal_velocity_spg.sum(dim=("t_dim", "r_dim")).item()
+        cksum3 = tran_f.data_cross_tran_flow.normal_transport_hpg.sum(dim=("t_dim", "r_dim")).item()
+        cksum4 = tran_f.data_cross_tran_flow.normal_transport_spg.sum(dim=("t_dim", "r_dim")).item()
+
+        check1 = np.isclose(cksum1, 84.8632969783)
+        check2 = np.isclose(cksum2, -5.09718418121)
+        check3 = np.isclose(cksum3, 115.2587369660)
+        check4 = np.isclose(cksum4, -106.7897376093)
+
+        self.assertTrue(check1, msg="check1")
+        self.assertTrue(check2, msg="check2")
+        self.assertTrue(check3, msg="check3")
+        self.assertTrue(check4, msg="check4")
diff --git a/unit_testing/test_wod_read_data.py b/unit_testing/test_wod_read_data.py
new file mode 100644
index 00000000..bb9bb03a
--- /dev/null
+++ b/unit_testing/test_wod_read_data.py
@@ -0,0 +1,39 @@
+"""
+TEST reading of wod profiles
+"""
+
+# IMPORT modules. Must have unittest, and probably coast.
+import coast
+import unittest
+import numpy as np
+import os.path as path
+
+# IMPORT THIS TO HAVE ACCESS TO EXAMPLE FILE PATHS:
+import unit_test_files as files
+
+# Define a testing class. Absolutely fine to have one or multiple per file.
+# Each class must inherit unittest.TestCase
+class test_wod_read_data(unittest.TestCase):
+    def test_load_wod(self):
+
+        with self.subTest("Load profile data from WOD"):
+
+            wod_profile_1D = coast.Profile(config=files.fn_wod_config)
+            wod_profile_1D.read_wod(files.fn_wod)
+
+            check1 = type(wod_profile_1D) == coast.Profile
+            self.assertTrue(check1, "check1")
+
+    def test_reshape_wod(self):
+        wod_profile_1D = coast.Profile(config=files.fn_wod_config)
+        wod_profile_1D.read_wod(files.fn_wod)
+
+        with self.subTest("Check reshape"):
+            my_list = ["dic", "temperature", "alkalinity"]
+            wod_profile = coast.Profile.reshape_2d(wod_profile_1D, my_list)
+
+            check1 = type(wod_profile) == coast.profile.Profile
+            check2 = list(wod_profile.dataset.coords) == ["time", "latitude", "longitude"]
+
+            self.assertTrue(check1, "check1")
+            self.assertTrue(check2, "check2")
diff --git a/unit_testing/test_xesmf_convert.py b/unit_testing/test_xesmf_convert.py
new file mode 100644
index 00000000..7ec9dd5a
--- /dev/null
+++ b/unit_testing/test_xesmf_convert.py
@@ -0,0 +1,41 @@
+import coast
+import unittest
+import numpy as np
+import os.path as path
+import unit_test_files as files
+
+# Single unit test. Can contain multiple test methods and subTests.
+class test_xesmf_convert(unittest.TestCase):
+
+    # Test for conversion from gridded to xesmf.
+    # Here I've used one test and then subtests for each smaller test.
+    # This could also be split into multiple methods but the file would need
+    # to be loaded multiple times. Using subtests allows a sequential testing.
+    def test_basic_conversion_to_xesmf(self):
+
+        # Read data files
+        sci = coast.Gridded(files.fn_nemo_dat, files.fn_nemo_dom, config=files.fn_config_t_grid)
+
+        # Convert for single file
+        with self.subTest("xesmf_convert for single gridded obj"):
+            xesmf_ready = coast.xesmf_convert(sci)
+            check_grid = xesmf_ready.input_grid
+            check_data = xesmf_ready.input_data
+            check1 = np.array_equal(check_grid.lat.values, sci.dataset.latitude.values)
+            check2 = np.array_equal(
+                check_data.temperature[0, 0].values, sci.dataset.temperature[0, 0].values, equal_nan=True
+            )
+            self.assertTrue(check1, "Test")
+            self.assertTrue(check2, "Test")
+
+        # Convert for two files
+        with self.subTest("xesmf_convert for two gridded obj"):
+            xesmf_ready = coast.xesmf_convert(sci, sci)
+            check_grid = xesmf_ready.input_grid
+            check_data = xesmf_ready.input_data
+            check1 = np.array_equal(check_grid.lat.values, sci.dataset.latitude.values)
+            check2 = np.array_equal(
+                check_data.temperature[0, 0].values, sci.dataset.temperature[0, 0].values, equal_nan=True
+            )
+            self.assertTrue(check1, "Test")
+            self.assertTrue(check2, "Test")
diff --git a/unit_testing/unit_test.py b/unit_testing/unit_test.py
old mode 100755
new mode 100644
index e6623a64..08cd6f4c
--- a/unit_testing/unit_test.py
+++ b/unit_testing/unit_test.py
@@ -1,2128 +1,135 @@
-##
-"""
-Script to do unit testing
-Written as procedural code that plods through the code snippets and tests the
-outputs or expected metadata.
-***SECTIONS***
-This script is separated into Sections and Subsections, for which there are two
-counters to keep track: sec and subsec respectively.  At the beginning of each
-section, the sec counter should be incremented by 1 and the subsec counter
-should be reset to 96 (code for one below 'a'). At the beginning of each
-subsection, subsec should be incremented by one.
-***OTHER FILES***
-There are two accompaniment files to this unit testing script:
-    - unit_test_contents: A list of sections and subsections.
-    - unit_test_guidelines: Further guidelines to creating unit tests.
-Run:
-ipython: cd COAsT; run unit_testing/unit_test.py  # I.e. from the git repo.
-Unit template:
-#-----------------------------------------------------------------------------#
-# ( ## ) Subsection title                                                     #
-#                                                                             #
-subsec = subsec+1
-# <Introduction>
-try:
-    # Do a thing
-    #TEST: <description here>
-    check1 = #<Boolean>
-    check2 = #<Boolean>
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - ")
-    else:
-        print(str(sec) + chr(subsec) + " X - ")
-except:
-    print(str(sec) + chr(subsec) +' FAILED.')
-"""
-
-import coast
-import os
-import numpy as np
-import xarray as xr
-import matplotlib.pyplot as plt
-import datetime
-import os.path as path
-import logging
-import coast.general_utils as general_utils
-import coast.plot_util as plot_util
-import coast.stats_util as stats_util
-from socket import gethostname  # to get hostname
-import traceback
-import xarray.ufuncs as uf
-
-"""
-#################################################
-## ( 0 ) Files, directories for unit testing   ##
-#################################################
-"""
-## Initialise logging and save to log file
-log_file = open("unit_testing/unit_test.log", "w")  # Need log_file.close()
-coast.logging_util.setup_logging(stream=log_file, level=logging.CRITICAL)
-## Alternative logging levels
-# ..., level=logging.DEBUG) # Detailed information, typically of interest only when diagnosing problems.
-# ..., level=logging.INFO) # Confirmation that things are working as expected.
-# ..., level=logging.WARNING) # An indication that something unexpected happened, or indicative of some problem in the near future (e.g. ‘disk space low’). The software is still working as expected.
-# ..., level=logging.ERROR) # Due to a more serious problem, the software has not been able to perform some function
-# ..., level=logging.CRITICAL) # A serious error, indicating that the program itself may be unable to continue running
-
-dn_files = "./example_files/"
-
-if not os.path.isdir(dn_files):
-    print("please go download the examples file from https://linkedsystems.uk/erddap/files/COAsT_example_files/")
-    dn_files = input("what is the path to the example files:\n")
-    if not os.path.isdir(dn_files):
-        print(f"location f{dn_files} cannot be found")
-
-dn_fig = "unit_testing/figures/"
-fn_nemo_grid_t_dat_summer = "nemo_data_T_grid_Aug2015.nc"
-fn_nemo_grid_t_dat = "nemo_data_T_grid.nc"
-fn_nemo_grid_u_dat = "nemo_data_U_grid.nc"
-fn_nemo_grid_v_dat = "nemo_data_V_grid.nc"
-fn_nemo_dat = "coast_example_nemo_data.nc"
-fn_nemo_dat_subset = "coast_example_nemo_subset_data.nc"
-fn_nemo_dom = "coast_example_nemo_domain.nc"
-fn_altimetry = "coast_example_altimetry_data.nc"
-fn_tidegauge = dn_files + "tide_gauges/lowestoft-p024-uk-bodc"
-fn_tidegauge2 = dn_files + "tide_gauges/LIV2010.txt"
-fn_nemo_harmonics = "coast_nemo_harmonics.nc"
-fn_nemo_harmonics_dom = "coast_nemo_harmonics_dom.nc"
-# EN4 profile data (NetCDF)
-fn_profile = dn_files + "coast_example_EN4_201008.nc"
-fn_profile_config = "config/example_en4_profiles.json"
-fn_config_t_grid = path.join("./config", "example_nemo_grid_t.json")
-fn_config_f_grid = path.join("./config", "example_nemo_grid_f.json")
-fn_config_u_grid = path.join("./config", "example_nemo_grid_u.json")
-fn_config_v_grid = path.join("./config", "example_nemo_grid_v.json")
-fn_config_w_grid = path.join("./config", "example_nemo_grid_w.json")
-
-sec = 1
-subsec = 96  # Code for '`' (1 below 'a')
-"""
-#################################################
-## ( 1 ) Loading/Initialisation                ##
-#################################################
-"""
-# This section is for testing the loading and initialisation of Gridded objects.
-
-# -----------------------------------------------------------------------------#
-# %% ( 1a ) Load example Gridded Nemo data (Temperature, Salinity, SSH)                  #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    sci = coast.Gridded(path.join(dn_files, fn_nemo_dat), path.join(dn_files, fn_nemo_dom), config=fn_config_t_grid)
-
-    # Test the data has loaded
-    sci_attrs_ref = dict(
-        [
-            ("name", "AMM7_1d_20070101_20070131_25hourm_grid_T"),
-            ("description", "ocean T grid variables, 25h meaned"),
-            ("title", "ocean T grid variables, 25h meaned"),
-            ("Conventions", "CF-1.6"),
-            ("timeStamp", "2019-Dec-26 04:35:28 GMT"),
-            ("uuid", "96cae459-d3a1-4f4f-b82b-9259179f95f7"),
-        ]
-    )
-
-    # checking is LHS is a subset of RHS
-    if sci_attrs_ref.items() <= sci.dataset.attrs.items():
-        print(str(sec) + chr(subsec) + " OK - Nemo data loaded: " + fn_nemo_dat)
-    else:
-        print(str(sec) + chr(subsec) + " X - There is an issue with loading " + fn_nemo_dat)
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1b ) Load data from existing dataset                                      #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    ds = xr.open_dataset(dn_files + fn_nemo_dat)
-    sci_load_ds = coast.Gridded(config=fn_config_t_grid)
-    sci_load_ds.load_dataset(ds)
-    sci_load_file = coast.Gridded(config=fn_config_t_grid)
-    sci_load_file.load(dn_files + fn_nemo_dat)
-    if sci_load_ds.dataset.identical(sci_load_file.dataset):
-        print(str(sec) + chr(subsec) + " OK - coast.load_dataset()")
-    else:
-        print(
-            str(sec)
-            + chr(subsec)
-            + " X - coast.load_dataset() ERROR - not identical to dataset loaded via coast.load()"
-        )
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1c ) Set Gridded variable name                                               #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-    try:
-        sci.dataset.temperature
-    except NameError:
-        print(str(sec) + chr(subsec) + " X - variable name (to temperature) not reset")
-    else:
-        print(str(sec) + chr(subsec) + " OK - variable name reset (to temperature)")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1d ) Set Gridded grid attributes - dimension names                           #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    if sci.dataset.temperature.dims == ("t_dim", "z_dim", "y_dim", "x_dim"):
-        print(str(sec) + chr(subsec) + " OK - dimension names reset")
-    else:
-        print(str(sec) + chr(subsec) + " X - dimension names not reset")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1e ) Load only domain data in Gridded                                        #
-#                                                                             #
-
-subsec = subsec + 1
-
-pass_test = False
-nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_f_grid)
-
-if nemo_f.dataset._coord_names == {"depth_0", "latitude", "longitude"}:
-    var_name_list = []
-    for var_name in nemo_f.dataset.data_vars:
-        var_name_list.append(var_name)
-    if var_name_list == ["bathymetry", "e1", "e2", "e3_0"]:
-        pass_test = True
-
-if pass_test:
-    print(str(sec) + chr(subsec) + " OK - Gridded loaded domain data only")
-else:
-    print(str(sec) + chr(subsec) + " X - Gridded didn't load domain data correctly")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1f ) Calculate depth_0 for t,u,v,w,f grids                                #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    nemo_t = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
-    )
-    if not np.isclose(np.nansum(nemo_t.dataset.depth_0.values), 1705804300.0):
-        raise ValueError(" X - Gridded depth_0 failed on t-grid failed")
-    nemo_u = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_u_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_u_grid
-    )
-    if not np.isclose(np.nansum(nemo_u.dataset.depth_0.values), 1705317600.0):
-        raise ValueError(" X - Gridded depth_0 failed on u-grid failed")
-    nemo_v = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_v_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_v_grid
-    )
-    if not np.isclose(np.nansum(nemo_v.dataset.depth_0.values), 1705419100.0):
-        raise ValueError(" X - Gridded depth_0 failed on v-grid failed")
-    nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_f_grid)
-    if not np.isclose(np.nansum(nemo_f.dataset.depth_0.values), 1704932600.0):
-        raise ValueError(" X - Gridded depth_0 failed on f-grid failed")
-
-    print(str(sec) + chr(subsec) + " OK - Gridded depth_0 calculations correct")
-except ValueError as err:
-    print(str(sec) + chr(subsec) + str(err))
-
-# -----------------------------------------------------------------------------#
-# %% ( 1g ) Load a subregion dataset with a full domain (AMM7)                   #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-
-    amm7 = coast.Gridded(dn_files + fn_nemo_dat_subset, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-
-    # checking all the coordinates mapped correctly to the dataset object
-    if amm7.dataset._coord_names == {"depth_0", "latitude", "longitude", "time"}:
-        print(
-            str(sec) + chr(subsec) + " OK - Gridded data subset loaded ", "with correct coords: " + fn_nemo_dat_subset
-        )
-    else:
-        print(
-            str(sec) + chr(subsec) + " X - There is an issue with ",
-            "loading and subsetting the data " + fn_nemo_dat_subset,
-        )
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED. Test data in: {}.".format(fn_nemo_dat_subset))
-
-# -----------------------------------------------------------------------------#
-# %% ( 1h ) Load and combine (by time) multiple files  (AMM7)                    #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    file_names_amm7 = "nemo_data_T_grid*.nc"
-    amm7 = coast.Gridded(dn_files + file_names_amm7, dn_files + fn_nemo_dom, config=fn_config_t_grid, multiple=True)
-
-    # checking all the coordinates mapped correctly to the dataset object
-    if amm7.dataset.time.size == 14:
-        print(str(sec) + chr(subsec) + " OK - Gridded data loaded combine ", "over time: " + file_names_amm7)
-    else:
-        print(str(sec) + chr(subsec) + " X - There is an issue with loading", "multiple data files " + file_names_amm7)
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED. Test data in: {} on {}.".format(dn_files, file_names_amm7))
-
-# -----------------------------------------------------------------------------#
-# %% ( 1i ) Load and combine harmonics                                         #
-#                                                                             #
-
-subsec = subsec + 1
-# Load in a Gridded data file containing harmonics and combine them into a new
-# Gridded object and dataset.
-
-try:
-    harmonics = coast.Gridded(dn_files + fn_nemo_harmonics, dn_files + fn_nemo_harmonics_dom, config=fn_config_t_grid)
-    constituents = ["K1", "M2", "S2", "K2"]
-    harmonics_combined = harmonics.harmonics_combine(constituents)
-
-    # TEST: Check values in arrays and constituents
-    check1 = list(harmonics_combined.dataset.constituent.values) == constituents
-    check2 = harmonics_combined.dataset.harmonic_x[1].values == harmonics.dataset.M2x.values
-    if check1 and check2.all():
-        print(str(sec) + chr(subsec) + " OK - Harmonics loaded and combined")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem combining harmonics")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1j ) Convert harmonics to a/g and back                                  #
-#                                                                             #
-
-subsec = subsec + 1
-# Convert the harmonics loaded in 1i to amplitude and phase
-
-try:
-    harmonics_combined.harmonics_convert(direction="cart2polar")
-    harmonics_combined.harmonics_convert(direction="polar2cart", x_var="x_test", y_var="y_test")
-
-    # TEST: Check variables and differences
-    check1 = "x_test" in harmonics_combined.dataset.keys()
-    diff = harmonics_combined.dataset.harmonic_x[0].values - harmonics_combined.dataset.x_test[0].values
-    check2 = np.max(np.abs(diff)) < 1e-6
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Harmonics converted")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem converting harmonics")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 1k ) Compute e3 from SSH field                                      #
-#
-subsec = subsec + 1
-try:
-    nemo_t = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
-    )
-
-    e3t, e3u, e3v, e3f, e3w = coast.Gridded.get_e3_from_ssh(nemo_t, True, True, True, True, True)
-    cksum = np.array([e3t.sum(), e3u.sum(), e3v.sum(), e3f.sum(), e3w.sum()])
-    # these references are based on the example file's ssh field
-    reference = np.array([8.337016e08, 8.333972e08, 8.344886e08, 8.330722e08, 8.265948e08])
-    if np.allclose(cksum, reference):
-        print(str(sec) + chr(subsec) + " OK - computed e3[t,u,v,f,w] as expected")
-    else:
-        print(str(sec) + chr(subsec) + " X - computed e3[t,u,v,f,w] not as expected")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-
-"""
-#################################################
-## ( 2 ) Test general utility methods in Coast ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# -----------------------------------------------------------------------------#
-# %% ( 2a ) Copying a Coast object                                               #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    sci_copy = sci.copy()
-    if sci_copy.dataset == sci.dataset:
-        print(str(sec) + chr(subsec) + " OK - Copied Coast object ")
-    else:
-        print(str(sec) + chr(subsec) + " X - Copy Failed ")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 2b ) Coast __getitem__ returns variable                                   #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    if sci.dataset["ssh"].equals(sci["ssh"]):
-        print(str(sec) + chr(subsec) + " OK - coast.__getitem__ works correctly ")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem with coast.__getitem__ ")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 2c ) Renaming variables inside a Coast object                             #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    sci_copy.rename({"ssh": "renamed"})
-    if sci["ssh"].equals(sci_copy["renamed"]):
-        print(str(sec) + chr(subsec) + " OK - Renaming of variable in dataset ")
-    else:
-        print(str(sec) + chr(subsec) + " X - Variable renaming failed ")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 2d ) day of the week function                                           #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    check = general_utils.day_of_week(np.datetime64("2020-10-16")) == "Fri"
-
-    if check:
-        print(str(sec) + chr(subsec) + " OK - day of the week method")
-    else:
-        print(str(sec) + chr(subsec) + " X - day of the week method")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED: day of the week method")
-
-"""
-#################################################
-## ( 3 ) Test Diagnostic methods               ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# -----------------------------------------------------------------------------#
-# %% ( 3a ) Computing a vertical spatial derivative                              #
-#                                                                             #
-
-subsec = subsec + 1
-
-# Initialise DataArrays
-nemo_t = coast.Gridded(fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid)
-nemo_w = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_w_grid)
-
-try:
-    log_str = ""
-    # Compute dT/dz
-    nemo_w_1 = nemo_t.differentiate("temperature", config_path=fn_config_w_grid, dim="z_dim")
-    if nemo_w_1 is None:  # Test whether object was returned
-        log_str += "No object returned\n"
-    # Make sure the hardwired grid requirements are present
-    if not hasattr(nemo_w.dataset, "depth_0"):
-        log_str += "Missing depth_0 variable\n"
-    if not hasattr(nemo_w.dataset, "e3_0"):
-        log_str += "Missing e3_0 variable\n"
-    if not hasattr(nemo_w.dataset.depth_0, "units"):
-        log_str += "Missing depth units\n"
-    # Test attributes of derivative. This are generated last so can indicate earlier problems
-    nemo_w_2 = nemo_t.differentiate("temperature", dim="z_dim", out_var_str="dTdz", out_obj=nemo_w)
-    if not nemo_w_2.dataset.dTdz.attrs == {"units": "degC/m", "standard_name": "dTdz"}:
-        log_str += "Did not write correct attributes\n"
-    # Test auto-naming derivative. Again test expected attributes.
-    nemo_w_3 = nemo_t.differentiate("temperature", dim="z_dim", config_path=fn_config_w_grid)
-    if not nemo_w_3.dataset.temperature_dz.attrs == {"units": "degC/m", "standard_name": "temperature_dz"}:
-        log_str += "Problem with auto-naming derivative field\n"
-
-    ## Test numerical calculation. Differentiate f(z)=-z --> -1
-    # Construct a depth variable - needs to be 4D
-    nemo_t.dataset["depth4D"], _ = xr.broadcast(nemo_t.dataset["depth_0"], nemo_t.dataset["temperature"])
-    nemo_w_4 = nemo_t.differentiate("depth4D", dim="z_dim", out_var_str="dzdz", config_path=fn_config_w_grid)
-    if not np.isclose(
-        nemo_w_4.dataset.dzdz.isel(z_dim=slice(1, nemo_w_4.dataset.dzdz.sizes["z_dim"])).max(), -1
-    ) or not np.isclose(nemo_w_4.dataset.dzdz.isel(z_dim=slice(1, nemo_w_4.dataset.dzdz.sizes["z_dim"])).min(), -1):
-        log_str += "Problem with numerical derivative of f(z)=-z\n"
-
-    if log_str == "":
-        print(str(sec) + chr(subsec) + " OK - Gridded.differentiate (for d/dz) method passes all tests")
-    else:
-        print(str(sec) + chr(subsec) + " X - Gridded.differentiate method failed: " + log_str)
-
-except:
-    traceback.print_exc()
-    print(str(sec) + chr(subsec) + " X - setting derivative attributes failed ")
-
-# -----------------------------------------------------------------------------#
-# %% ( 3b ) Construct density                                                    #
-#                                                                             #
-
-subsec = subsec + 1
-nemo_t = coast.Gridded(fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid)
-nemo_t.construct_density()
-yt, xt, length_of_line = nemo_t.transect_indices([54, -15], [56, -12])
-
-try:
-    if not np.allclose(
-        nemo_t.dataset.density.sel(x_dim=xr.DataArray(xt, dims=["r_dim"]), y_dim=xr.DataArray(yt, dims=["r_dim"]))
-        .sum(dim=["t_dim", "r_dim", "z_dim"])
-        .item(),
-        11185010.518671108,
-    ):
-        raise ValueError(str(sec) + chr(subsec) + " X - Density incorrect")
-    print(str(sec) + chr(subsec) + " OK - Density correct")
-except ValueError as err:
-    print(err)
-densitycopy = nemo_t.dataset.density.sel(x_dim=xr.DataArray(xt, dims=["r_dim"]), y_dim=xr.DataArray(yt, dims=["r_dim"]))
-
-# -----------------------------------------------------------------------------#
-# %% ( 3c ) Construct pycnocline depth and thickness                             #
-#                                                                             #
-
-subsec = subsec + 1
-
-nemo_t = None
-nemo_w = None
-nemo_t = coast.Gridded(dn_files + fn_nemo_grid_t_dat_summer, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-# create an empty w-grid object, to store stratification
-nemo_w = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_w_grid)
-try:
-    log_str = ""
-    # initialise Internal Tide object
-    IT = coast.InternalTide(nemo_t, nemo_w)
-    if IT is None:  # Test whether object was returned
-        log_str += "No object returned\n"
-    # Construct pycnocline variables: depth and thickness
-    IT.construct_pycnocline_vars(nemo_t, nemo_w)
-
-    if not hasattr(nemo_t.dataset, "density"):
-        log_str += "Did not create density variable\n"
-    if not hasattr(nemo_w.dataset, "rho_dz"):
-        log_str += "Did not create rho_dz variable\n"
-
-    if not hasattr(IT.dataset, "strat_1st_mom"):
-        log_str += "Missing strat_1st_mom variable\n"
-    if not hasattr(IT.dataset, "strat_1st_mom_masked"):
-        log_str += "Missing strat_1st_mom_masked variable\n"
-    if not hasattr(IT.dataset, "strat_2nd_mom"):
-        log_str += "Missing strat_2nd_mom variable\n"
-    if not hasattr(IT.dataset, "strat_2nd_mom_masked"):
-        log_str += "Missing strat_2nd_mom_masked variable\n"
-    if not hasattr(IT.dataset, "mask"):
-        log_str += "Missing mask variable\n"
-
-    # Check the calculations are as expected
-    if (
-        np.isclose(IT.dataset.strat_1st_mom.sum(), 3.74214231e08)
-        and np.isclose(IT.dataset.strat_2nd_mom.sum(), 2.44203298e08)
-        and np.isclose(IT.dataset.mask.sum(), 450580)
-        and np.isclose(IT.dataset.strat_1st_mom_masked.sum(), 3.71876949e08)
-        and np.isclose(IT.dataset.strat_2nd_mom_masked.sum(), 2.42926865e08)
-    ):
-        print(str(sec) + chr(subsec) + " OK - pyncocline depth and thickness good")
-
-except:
-    print(str(sec) + chr(subsec) + " X - computing pycnocline depth and thickness failed ")
-
-# -----------------------------------------------------------------------------#
-# %% ( 3d ) Plot pycnocline depth                                              #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    fig, ax = IT.quick_plot("strat_1st_mom_masked")
-    fig.tight_layout()
-    fig.savefig(dn_fig + "strat_1st_mom.png")
-    print(str(sec) + chr(subsec) + " OK - pycnocline depth plot saved")
-except:
-    print(str(sec) + chr(subsec) + "X - quickplot() failed")
-
-"""
-#################################################
-## ( 4 ) Test Transect related methods         ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# -----------------------------------------------------------------------------#
-# %% ( 4a ) Determining and extracting transect indices                          #
-#                                                                             #
-
-subsec = subsec + 1
-
-# Extract transect indices
-nemo_t = coast.Gridded(fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid)
-yt, xt, length_of_line = nemo_t.transect_indices([51, -5], [49, -9])
-
-# Test transect indices
-yt_ref = [
-    164,
-    163,
-    162,
-    162,
-    161,
-    160,
-    159,
-    158,
-    157,
-    156,
-    156,
-    155,
-    154,
-    153,
-    152,
-    152,
-    151,
-    150,
-    149,
-    148,
-    147,
-    146,
-    146,
-    145,
-    144,
-    143,
-    142,
-    142,
-    141,
-    140,
-    139,
-    138,
-    137,
-    136,
-    136,
-    135,
-    134,
-]
-xt_ref = [
-    134,
-    133,
-    132,
-    131,
-    130,
-    129,
-    128,
-    127,
-    126,
-    125,
-    124,
-    123,
-    122,
-    121,
-    120,
-    119,
-    118,
-    117,
-    116,
-    115,
-    114,
-    113,
-    112,
-    111,
-    110,
-    109,
-    108,
-    107,
-    106,
-    105,
-    104,
-    103,
-    102,
-    101,
-    100,
-    99,
-    98,
+# SETTINGS
+
+# Set the following to true to use the Coverage library to estimate unit_test
+# coverage. You must have installed Coverage separately for this to work.
+# You can do this using pip: pip install coverage.
+# See docs website for more info. This will take longer to execute.
+calculate_coverage = False
+
+# Set the following to True to also auto-generate a new unit_test_contents file.
+generate_unit_test_contents = False
+fn_contents = "./unit_testing/unit_test_contents.txt"
+
+# Set TEST imports and list of tests to do.
+# Import tests to test. From a module (file) import a testCase class (See template)
+from test_TEMPLATE import test_TEMPLATE
+from test_gridded_initialisation import test_gridded_initialisation
+from test_gridded_harmonics import test_gridded_harmonics
+from test_general_utils import test_general_utils
+from test_xesmf_convert import test_xesmf_convert
+from test_diagnostic_methods import test_diagnostic_methods
+from test_transect_methods import test_transect_methods
+from test_object_manipulation import test_object_manipulation
+from test_altimetry_methods import test_altimetry_methods
+from test_tidegauge_methods import test_tidegauge_methods, test_tidegauge_analysis
+from test_isobath_contour_methods import test_contour_t_methods, test_contour_f_methods
+from test_eof_methods import test_eof_methods
+from test_profile_methods import test_profile_methods
+from test_plot_utilities import test_plot_utilities
+from test_stats_utilities import test_stats_utilities
+from test_maskmaker_methods import test_maskmaker_methods
+from test_climatology import test_climatology
+from test_wod_read_data import test_wod_read_data
+from test_example_scripts import test_example_scripts
+from test_process_data import test_process_data_methods
+from test_bgc_gridded_initialisation import test_bgc_gridded_initialisation
+
+
+# Test list -- comment out ones you don't want maybe (or add your own)
+tests_to_do = [
+    # test_TEMPLATE,
+    test_gridded_initialisation,
+    test_general_utils,
+    test_xesmf_convert,
+    test_gridded_harmonics,
+    test_diagnostic_methods,
+    test_transect_methods,
+    test_object_manipulation,
+    test_altimetry_methods,
+    test_tidegauge_methods,
+    test_tidegauge_analysis,
+    test_eof_methods,
+    test_contour_f_methods,
+    test_contour_t_methods,
+    test_profile_methods,
+    test_plot_utilities,
+    test_stats_utilities,
+    test_maskmaker_methods,
+    test_climatology,
+    test_wod_read_data,
+    test_bgc_gridded_initialisation,
+    test_process_data_methods,
+    test_example_scripts,
 ]
-length_ref = 37
-
-
-if (xt == xt_ref) and (yt == yt_ref) and (length_of_line == length_ref):
-    print(str(sec) + chr(subsec) + " OK - Gridded transect indices extracted")
-else:
-    print(str(sec) + chr(subsec) + " X - Issue with transect indices extraction from Gridded")
-
-# -----------------------------------------------------------------------------#
-# %% ( 4b ) Transport velocity and depth calculations                            #
-#
-subsec = subsec + 1
-try:
-    nemo_t = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
-    )
-    nemo_u = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_u_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_u_grid
-    )
-    nemo_v = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_v_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_v_grid
-    )
-    nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_f_grid)
-
-    tran_f = coast.TransectF(nemo_f, (54, -15), (56, -12))
-    tran_f.calc_flow_across_transect(nemo_u, nemo_v)
-    cksum1 = tran_f.data_cross_tran_flow.normal_velocities.sum(dim=("t_dim", "z_dim", "r_dim")).item()
-    cksum2 = tran_f.data_cross_tran_flow.normal_transports.sum(dim=("t_dim", "r_dim")).item()
-    if np.isclose(cksum1, -253.6484375) and np.isclose(cksum2, -48.67562136873888):
-        print(str(sec) + chr(subsec) + " OK - Transect cross flow calculations as expected")
-    else:
-        print(str(sec) + chr(subsec) + " X - Transect cross flow calculations not as expected")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-
-# -----------------------------------------------------------------------------#
-# %% ( 4c ) Transport and velocity plotting                                      #
-#
-subsec = subsec + 1
-try:
-    fig, ax = tran_f.plot_transect_on_map()
-    ax.set_xlim([-20, 0])  # Problem: nice to make the land appear.
-    ax.set_ylim([45, 65])  # But can not call plt.show() before adjustments are made...
-    # fig.tight_layout()
-    fig.savefig(dn_fig + "transect_map.png")
-
-    plot_dict = {"fig_size": (5, 3), "title": "Normal velocities"}
-    fig, ax = tran_f.plot_normal_velocity(time=0, cmap="seismic", plot_info=plot_dict, smoothing_window=2)
-    fig.tight_layout()
-    fig.savefig(dn_fig + "transect_velocities.png")
-    plot_dict = {"fig_size": (5, 3), "title": "Transport across AB"}
-    fig, ax = tran_f.plot_depth_integrated_transport(time=0, plot_info=plot_dict, smoothing_window=2)
-    fig.tight_layout()
-    fig.savefig(dn_fig + "transect_transport.png")
-    print(str(sec) + chr(subsec) + " OK - Transect velocity and transport plots saved")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-
-# -----------------------------------------------------------------------------#
-# %% ( 4d ) Construct density and pressure along the transect                    #
-#
-subsec = subsec + 1
-try:
-    tran_t = coast.TransectT(nemo_t, (54, -15), (56, -12))
-    tran_t.construct_pressure()
-    cksum1 = tran_t.data.density_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).compute().item()
-    cksum2 = tran_t.data.pressure_h_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).compute().item()
-    cksum3 = tran_t.data.pressure_s.sum(dim=["t_dim", "r_dim"]).compute().item()
-    if np.allclose([cksum1, cksum2, cksum3], [23800545.87457855, 135536478.93335825, -285918.5625]):
-        print(str(sec) + chr(subsec) + " OK - Transect density and pressure calculations as expected")
-    else:
-        print(str(sec) + chr(subsec) + " X - Transect density and pressure calculations not as expected")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-
-# -----------------------------------------------------------------------------#
-# %% ( 4e ) Calculate the geostrophic flow across the transect                   #
-#
-subsec = subsec + 1
-try:
-    tran_f.calc_geostrophic_flow(nemo_t, config_u=fn_config_u_grid, config_v=fn_config_v_grid)
-    cksum1 = tran_f.data_cross_tran_flow.normal_velocity_hpg.sum(dim=("t_dim", "depth_z_levels", "r_dim")).item()
-    cksum2 = tran_f.data_cross_tran_flow.normal_velocity_spg.sum(dim=("t_dim", "r_dim")).item()
-    cksum3 = tran_f.data_cross_tran_flow.normal_transport_hpg.sum(dim=("t_dim", "r_dim")).item()
-    cksum4 = tran_f.data_cross_tran_flow.normal_transport_spg.sum(dim=("t_dim", "r_dim")).item()
-
-    if np.allclose([cksum1, cksum2, cksum3, cksum4], [84.8632969783, -5.09718418121, 115.2587369660, -106.7897376093]):
-        print(str(sec) + chr(subsec) + " OK - Transect geostrophic flow calculations as expected")
-    else:
-        print(str(sec) + chr(subsec) + " X - Transect geostrophic flow calculations now as expected")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-"""
-#################################################
-## ( 5 ) Object Manipulation (e.g. subsetting) ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-# -----------------------------------------------------------------------------#
-# %% ( 5a ) Subsetting single variable                                           #
-#                                                                             #
-subsec = subsec + 1
-
-try:
-    # Extact the variable
-    data_t = sci.get_subset_as_xarray("temperature", xt_ref, yt_ref)
-
-    # Test shape and exteme values
-    if (
-        (np.shape(data_t) == (51, 37))
-        and (np.nanmin(data_t) - 11.267578 < 1e-6)
-        and (np.nanmax(data_t) - 11.834961 < 1e-6)
-    ):
-        print(
-            str(sec)
-            + chr(subsec)
-            + " OK - Gridded Coast get_subset_as_xarray extracted expected array size and "
-            + "extreme values"
-        )
-    else:
-        print(str(sec) + chr(subsec) + " X - Issue with Gridded Coast get_subset_as_xarray method")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 5b ) Indices by distance method                                           #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    # Find indices for points with 111 km from 0E, 51N
-
-    ind = sci.subset_indices_by_distance(0, 51, 111)
-
-    # Test size of indices array
-    if np.shape(ind) == (2, 674):
-        print(
-            str(sec)
-            + chr(subsec)
-            + " OK - Gridded domain subset_indices_by_distance extracted expected "
-            + "size of indices"
-        )
-    else:
-
-        print(
-            str(sec)
-            + chr(subsec)
-            + "X - Issue with indices extraction from Gridded domain "
-            + "subset_indices_by_distance method"
-        )
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 5c ) Find nearest xy indices                                              #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    altimetry = coast.Altimetry(dn_files + fn_altimetry)
-    ind = altimetry.subset_indices_lonlat_box([-10, 10], [45, 60])
-    altimetry_nwes = altimetry.isel(time=ind)  # nwes = northwest europe shelf
-    ind_x, ind_y = general_utils.nearest_indices_2d(
-        sci.dataset.longitude, sci.dataset.latitude, altimetry_nwes.dataset.longitude, altimetry_nwes.dataset.latitude
-    )
-    if ind_x.shape == altimetry_nwes.dataset.longitude.shape:
-        print(str(sec) + chr(subsec) + " OK - nearest_xy_indices works ")
-    else:
-        print(str(sec) + chr(subsec) + "X - Problem with nearest_xy_indices()")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 5d ) Interpolate in space (nearest)                                       #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    interp_lon = np.array(altimetry_nwes.dataset.longitude).flatten()
-    interp_lat = np.array(altimetry_nwes.dataset.latitude).flatten()
-    interpolated = sci.interpolate_in_space(sci.dataset.ssh, interp_lon, interp_lat)
-
-    # Check that output array longitude has same shape as altimetry
-    if interpolated.longitude.shape == altimetry_nwes.dataset.longitude.shape:
-        print(str(sec) + chr(subsec) + " OK - Space interpolation works ")
-    else:
-        print(str(sec) + chr(subsec) + "X - Problem with space interpolation")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-# -----------------------------------------------------------------------------#
-# %% ( 5e ) Interpolate in time                                                  #
-#                                                                             #
-
-subsec = subsec + 1
-try:
-    interpolated = sci.interpolate_in_time(interpolated, altimetry_nwes.dataset.time)
-
-    # Check time in interpolated object has same shape
-    if interpolated.time.shape == altimetry_nwes.dataset.time.shape:
-        print(str(sec) + chr(subsec) + " OK - Time interpolation works ")
-    else:
-        print(str(sec) + chr(subsec) + "X - Problem with time interpolation")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-
-"""
-#################################################
-## ( 6 ) Altimetry Methods                     ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-# This section is for testing and demonstrating the use of the Altimetry
-# object. First begin by reloading Gridded t-grid test data:
-sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 6a ) Load example altimetry data                                          #
-#                                                                             #
-
-subsec = subsec + 1
-# We can load altimetry data straight from a CMEMS netcdf file on initialisation
-try:
-    altimetry = coast.Altimetry(dn_files + fn_altimetry, config="./config/example_altimetry.json")
-
-    # Test the data has loaded using attribute comparison, as for nemo_data
-    alt_attrs_ref = dict(
-        [
-            ("source", "Jason-1 measurements"),
-            ("date_created", "2019-02-20T11:20:56Z"),
-            ("institution", "CLS, CNES"),
-            ("Conventions", "CF-1.6"),
-        ]
-    )
-
-    # checking is LHS is a subset of RHS
-    if alt_attrs_ref.items() <= altimetry.dataset.attrs.items():
-        print(str(sec) + chr(subsec) + " OK - Altimetry data loaded: " + fn_altimetry)
-    else:
-        print(str(sec) + chr(subsec) + " X - There is an issue with loading: " + fn_altimetry)
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 6b ) Altimetry subsetting                                                 #
-#                                                                             #
-
-subsec = subsec + 1
-# The altimetry that we loaded is global so lets subset it for the North
-# West European Shelf.
-try:
-    ind = altimetry.subset_indices_lonlat_box([-10, 10], [45, 60])
-    ind = ind[::4]
-    altimetry_nwes = altimetry.isel(t_dim=ind)  # nwes = northwest europe shelf
-
-    if altimetry_nwes.dataset.dims["t_dim"] == 54:
-        print(str(sec) + chr(subsec) + " OK - Altimetry object subsetted using isel ")
-    else:
-        print(str(sec) + chr(subsec) + "X - Failed to subset object/ return as copy")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 6c ) Interpolate model to altimetry                                       #
-#                                                                             #
-
-subsec = subsec + 1
-# Now lets interpolate a model variable to the altimetry space using
-# obs_operator().
-
-try:
-    altimetry_nwes.obs_operator(sci, "ssh")
-    # Check new variable is in altimetry dataset and isn't all NaNs
-    try:
-        test = altimetry_nwes.dataset.interp_ssh
-        if False in np.isnan(altimetry_nwes.dataset.interp_ssh):
-            print(str(sec) + chr(subsec) + " OK - model SSH interpolated to altimetry")
-        else:
-            print(str(sec) + chr(subsec) + " OK - X - Interpolation to altimetry failed")
-    except:
-        print(str(sec) + chr(subsec) + " X - Interpolation to altimetry failed")
-except:
-    print(str(sec) + chr(subsec) + " FAILED")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 6d ) Altimetry CRPS                                                       #
-#                                                                             #
-
-
-subsec = subsec + 1
-# Compare modelled SSH to observed sea level using CRPS. This can be done
-# either by creating a new object (create_new_object=True) or by saving it
-# to the existing object. Here we look at the first option.
-
-try:
-    crps = altimetry_nwes.crps(sci, "ssh", "ocean_tide_standard_name")
-
-    # TEST: Check length of crps and that it contains values
-    check1 = crps.dataset.crps.shape[0] == altimetry_nwes.dataset.ocean_tide_standard_name.shape[0]
-    check2 = False in np.isnan(crps.dataset.crps)
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Altimetry CRPS")
-    else:
-        print(str(sec) + chr(subsec) + " X - Altimetry CRPS")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 6e ) Altimetry Stats methods                                              #
-#                                                                             #
-
-subsec = subsec + 1
-# We can batch return the basic stats methods from Altimetry using basic_stats().
-# We test all of the stats routines here by using this batch function.
-# Here we compare an altimetry variable to our interpolate model SSH
-
-try:
-    stats = altimetry_nwes.basic_stats("ocean_tide_standard_name", "interp_ssh")
-    altimetry_nwes.basic_stats("ocean_tide_standard_name", "interp_ssh", create_new_object=False)
-
-    # TEST: Check new object resembles internal object
-    check1 = all(stats.dataset.error == altimetry_nwes.dataset.error)
-    # TEST: Check lengths and values
-    check2 = stats.dataset.absolute_error.shape[0] == altimetry_nwes.dataset.ocean_tide_standard_name.shape[0]
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Basic Stats for Altimetry")
-    else:
-        print(str(sec) + chr(subsec) + " X -  Basic Stats for Altimetry")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 6f ) Altimetry quick_plot()                                               #
-#                                                                             #
-
-subsec = subsec + 1
-# Now lets take a look at our CRPS values on a map
-plt.close("all")
-
-try:
-    fig, ax = crps.quick_plot("crps")
-    fig.savefig(dn_fig + "altimetry_crps_quick_plot.png")
-    # plt.close(fig)
-    print(str(sec) + chr(subsec) + " OK - Altimetry quick plot saved")
-except:
-    print(str(sec) + chr(subsec) + " X - Altimetry quick plot not saved")
-
-plt.close("all")
-
-"""
-#################################################
-## ( 7 ) Tidegauge Methods                     ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# This section is for testing and demonstrating the use of the Tidegauge
-# object. First begin by reloading Gridded t-grid test data:
-sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 7a ) Load in GESLA tide gauge files from directory                      #
-#                                                                             #
-
-subsec = subsec + 1
-
-# Tide gauge data can be loaded straight a GESLA data file.
-# Here, I load in data for Lowestoft by providing a filename and restricting
-# the dates to January 2007 using two datetime object:
-
-try:
-    date0 = datetime.datetime(2007, 1, 10)
-    date1 = datetime.datetime(2007, 1, 12)
-    lowestoft = coast.Tidegauge(fn_tidegauge, date_start=date0, date_end=date1)
-
-    # TEST: Define Attribute dictionary for comparison
-    test_attrs = {
-        "site_name": "Lowestoft",
-        "country": "United_Kingdom",
-        "contributor": "British_Oceanographic_Data_Centre",
-        "coordinate_system": "UK",
-        "original_start_date": np.datetime64("1964-01-01 00:00:00"),
-        "original_end_date": np.datetime64("2014-12-31 23:45:00"),
-        "time_zone_hours": 0.0,
-        "precision": 0.001,
-        "null_value": -99.9999,
-    }
-
-    # TEST: Check attribute dictionary and length of sea_level.
-    check1 = len(lowestoft.dataset.sea_level) == 193
-    check2 = lowestoft.dataset.attrs == test_attrs
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Tide gauge loaded")
-    else:
-        print(str(sec) + chr(subsec) + " X - Failed to load tide gauge")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7b ) Load in BODC tide gauge data                                       #
-#                                                                             #
-
-subsec = subsec + 1
-
-
-# Load and plot BODC processed data
-try:
-    # Set the start and end dates
-    date_start = np.datetime64("2020-10-12 23:59")
-    date_end = np.datetime64("2020-10-14 00:01")
-
-    # Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA
-    # type object
-    tg = coast.Tidegauge()
-    # specify the data read as a High Low Water dataset
-    tg.dataset = tg.read_bodc_to_xarray(fn_tidegauge2, date_start, date_end)
-    # tg.plot_timeseries()
-
-    # TEST: Define Attribute dictionary for comparison
-    test_attrs = {
-        "port": "p234",
-        "site": "liverpool,_gladstone_dock",
-        "start_date": "01oct2020-00.00.00",
-        "end_date": "31oct2020-23.45.00",
-        "contributor": "national_oceanography_centre,_liverpool",
-        "datum_information": "the_data_refer_to_admiralty_chart_datum_(acd)",
-        "parameter_code": "aslvbg02_=_surface_elevation_(unspecified_datum)_of_the_water_body_by_bubbler_tide_gauge_(second_sensor)",
-        "site_name": "liverpool,_gladstone_dock",
-    }
-
-    # TEST: Check attribute dictionary and length of sea_level.
-    check1 = len(tg.dataset.sea_level) == 97
-    check2 = tg.dataset.attrs == test_attrs
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - BODC tide gauge loaded")
-    else:
-        print(str(sec) + chr(subsec) + " X - Failed to load BODC tide gauge")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7c ) Load in Environment Agency river gauge data from API               #
-#                                                                             #
-
-subsec = subsec + 1
-
-# Load in data obtained using the Environment Agency (England)
-#  API. These are only accessible for the last 28 days. This does not require
-# an API key.
-#  Details of available tidal stations are recovered with:
-#  https://environment.data.gov.uk/flood-monitoring/id/stations?type=TideGauge
-# Recover the "stationReference" for the gauge of interest and pass as
-# stationId:str. The default gauge is Liverpool: stationId="E70124"
-# Construct a recent 10 days period and extract these data
-
-try:
-    date_start = np.datetime64("now") - np.timedelta64(20, "D")
-    date_end = np.datetime64("now") - np.timedelta64(10, "D")
-    eg = coast.Tidegauge()
-    # Extract the data between explicit dates
-    eg.dataset = eg.read_ea_api_to_xarray(date_start=date_start, date_end=date_end)
-    check1 = eg.dataset.site_name == "Liverpool"
-    check2 = len(eg.dataset.sea_level) > 0
-    # eg.plot_timeseries()
-
-    # Alternatively extract the data for the last n_days, here for a specific
-    # (the default) station.
-    eg.dataset = eg.read_ea_api_to_xarray(n_days=1, station_id="E70124")
-    check3 = eg.dataset.site_name == "Liverpool"
-    # eg.plot_timeseries()
-
-    if check1 and check2 and check3:
-        print(str(sec) + chr(subsec) + " OK - EA Tide gauge loaded")
-    else:
-        print(str(sec) + chr(subsec) + " X - Failed to load EA tide gauge")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7d ) Tidegauge obs_operator                                             #
-#                                                                             #
-
-subsec = subsec + 1
-# Lets interpolate the sossheig variable in sci onto the location and times of
-# our tidegauge using obs_operator(). Here, we interpolate linearly in time.
-
-try:
-
-    lowestoft.obs_operator(sci, "ssh", time_interp="linear", model_mask="bathy")
 
-    # TEST: Check that the resulting interp_sossheig variable is of the same
-    # length as sea_level and that it is populated.
-    interp = lowestoft.dataset.interp_ssh
-    interp_len = interp.shape[0]
-    orig_len = lowestoft.dataset.sea_level.shape[0]
-    check1 = interp_len == orig_len
-    check2 = False in np.isnan(lowestoft.dataset.interp_ssh)
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Tide gauge obs_operator")
-    else:
-        print(str(sec) + chr(subsec) + " X - Tide gauge obs_operator")
 
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
+# UNIT TESTING CONTROL SCRIPT
 
-# -----------------------------------------------------------------------------#
-# %% ( 7e) Tidegauge CRPS                                                      #
-#                                                                             #
-subsec = subsec + 1
-# Compare modelled SSH to observed sea level using CRPS. This can be done
-# either by creating a new object (create_new_object=True) or by saving it
-# to the existing object. Here we look at the first option.
-
-try:
-    crps = lowestoft.crps(sci, "ssh")
-
-    # TEST: Check length of crps and that it contains values
-    check1 = crps.dataset.crps.shape[0] == lowestoft.dataset.sea_level.shape[0]
-    check2 = False in np.isnan(crps.dataset.crps)
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - CRPS between gauge and model exists")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem with guage/model CRPS")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7f ) Tidegauge Stats methods                                            #
-#                                                                             #
-subsec = subsec + 1
-# We can batch return the basic stats methods from Tidegauge using basic_stats().
-# We test all of the stats routines here by using this batch function.
-
-try:
-    stats = lowestoft.basic_stats("sea_level", "interp_ssh")
-    lowestoft.basic_stats("sea_level", "interp_ssh", create_new_object=False)
-
-    # TEST: Check new object resembles internal object
-    check1 = all(stats.dataset.error == lowestoft.dataset.error)
-    # TEST: Check lengths and values
-    check2 = stats.dataset.absolute_error.shape[0] == lowestoft.dataset.sea_level.shape[0]
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Basic Stats for Tidegauge")
-    else:
-        print(str(sec) + chr(subsec) + " X -  Basic Stats for Tidegauge")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7g ) Tidegauge Resample to hourly                                       #
-#                                                                             #
-subsec = subsec + 1
-# Lets resample the tide gauge data to be hourly.
-
-try:
-    lowestoft.resample_mean("sea_level", "1H")
-
-    # TEST: Check new times have right frequency
-    td0 = lowestoft.dataset.time_1H[1] - lowestoft.dataset.time_1H[0]
-    check1 = td0.values.astype("timedelta64[h]") == np.timedelta64(1, "h")
-    # TEST: Check length
-    check2 = np.ceil(lowestoft.dataset.time.shape[0] / 4) == lowestoft.dataset.time_1H.shape[0]
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Tidegauge resampled")
-    else:
-        print(str(sec) + chr(subsec) + " X -  Resample Tidegauge")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7h ) Apply Doodson XO filter to hourly data                             #
-#                                                                             #
-subsec = subsec + 1
-# Lets resample the tide gauge data to be hourly.
-
-try:
-    lowestoft.apply_doodson_x0_filter("sea_level_1H")
-
-    # TEST: Check new times are same length as variable
-    check1 = lowestoft.dataset.time_1H.shape == lowestoft.dataset.sea_level_1H_dx0.shape
-    # TEST: Check there are number values in output
-    check2 = False in np.isnan(lowestoft.dataset.sea_level_1H_dx0)
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Tidegauge doodson X0")
-    else:
-        print(str(sec) + chr(subsec) + " X -  Tidegauge doodson X0")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7i ) Tidegauge Loading multiple TidegaugeS                              #
-#                                                                             #
-subsec = subsec + 1
-# We can load multiple tide gauges into a list of Tidegauge objects using the
-# static method create_multiple_tidegauge.
-
-try:
-    date0 = datetime.datetime(2007, 1, 10)
-    date1 = datetime.datetime(2007, 1, 12)
-    tidegauge_list = coast.Tidegauge.create_multiple_tidegauge("./example_files/tide_gauges/l*", date0, date1)
-
-    # TEST: Check length of list
-    check1 = len(tidegauge_list) == 2
-    # TEST: Check lowestoft matches
-    check2 = all(tidegauge_list[1].dataset == lowestoft.dataset)
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Multiple tide gauge load")
-    else:
-        print(str(sec) + chr(subsec) + " X - Multiple tide gauge load")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7j ) Tidegauge map plot (single)                                        #
-#                                                                             #
-subsec = subsec + 1
-
-# We can take a look at the location of the loaded tidegauge:
-try:
-    f, a = lowestoft.plot_on_map()
-    f.savefig(dn_fig + "tidegauge_map.png")
-    print(str(sec) + chr(subsec) + " OK - Tide gauge map plot saved")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-plt.close("all")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7k ) Tidegauge map plot (single)                                        #
-#                                                                             #
-subsec = subsec + 1
-
-# Or we can plot up multiple from the list we loaded:
-try:
-    f, a = coast.Tidegauge.plot_on_map_multiple(tidegauge_list)
-    f.savefig(dn_fig + "tidegauge_multiple_map.png")
-    print(str(sec) + chr(subsec) + " OK - Tide gauge multiple map plot saved")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-plt.close("all")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7l ) Tidegauge Time series plot                                         #
-#                                                                             #
-subsec = subsec + 1
-
-# Take a look at the sea level time series stored within the object:
-
-try:
-    f, a = lowestoft.plot_timeseries(["sea_level", "sea_level_1H", "sea_level_1H_dx0"])
-    f.savefig(dn_fig + "tidegauge_timeseries.png")
-    print(str(sec) + chr(subsec) + " OK - Tide gauge time series saved")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-plt.close("all")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7m ) Tidegauge method for tabulated data                                #
-#                                                                             #
-subsec = subsec + 1
-
-# Take a look at the sea level time series stored within the object:
-try:
-    filnam = "example_files/Gladstone_2020-10_HLW.txt"
-    date_start = np.datetime64("2020-10-11 07:59")
-    date_end = np.datetime64("2020-10-20 20:21")
-
-    # Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA type object
-    tg = coast.Tidegauge()
-    tg.dataset = tg.read_hlw_to_xarray(filnam, date_start, date_end)
-
-    check1 = len(tg.dataset.sea_level) == 37
-    check2 = tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="nearest_HW").values == 8.01
-    check3 = tg.get_tide_table_times(
-        np.datetime64("2020-10-13 12:48"), method="nearest_1"
-    ).time.values == np.datetime64("2020-10-13 14:36")
-    check4 = np.array_equal(
-        tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="nearest_2").values, [2.83, 8.01]
-    )
-    check5 = np.array_equal(
-        tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="window", winsize=24).values,
-        [3.47, 7.78, 2.8, 8.01, 2.83, 8.45, 2.08, 8.71],
-    )
-
-    if check1 and check2 and check3 and check4 and check5:
-        print(str(sec) + chr(subsec) + " OK - Tide table processing")
-    else:
-        print(str(sec) + chr(subsec) + " X - Tide table processing")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# %% ( 7n ) Tidegauge method for finding extrema and troughs, compare neighbours#
-#                                                                             #
-subsec = subsec + 1
-
-# Take a look at the sea level time series stored within the object:
-try:
-    date0 = datetime.datetime(2007, 1, 10)
-    date1 = datetime.datetime(2007, 1, 20)
-    lowestoft2 = coast.Tidegauge(fn_tidegauge, date_start=date0, date_end=date1)
-
-    # Use comparison of neighbourhood method (method="comp" is assumed)
-    extrema_comp = lowestoft2.find_high_and_low_water("sea_level", distance=40)
-    # Check actual maximum/minimum is in output dataset
-    check1 = np.nanmax(lowestoft2.dataset.sea_level) in extrema_comp.dataset.sea_level_highs
-    check2 = np.nanmin(lowestoft2.dataset.sea_level) in extrema_comp.dataset.sea_level_lows
-    # Check new time dimensions have correct length (hardcoded here)
-    check3 = len(extrema_comp.dataset.time_highs) == 19
-    check4 = len(extrema_comp.dataset.time_lows) == 18
-
-    # Attempt a plot
-    f = plt.figure()
-    plt.plot(lowestoft2.dataset.time, lowestoft2.dataset.sea_level)
-    plt.scatter(extrema_comp.dataset.time_highs.values, extrema_comp.dataset.sea_level_highs, marker="o", c="g")
-    plt.scatter(extrema_comp.dataset.time_lows.values, extrema_comp.dataset.sea_level_lows, marker="o", c="r")
-
-    plt.legend(["Time Series", "Maxima", "Minima"])
-    plt.title("Tide Gauge Optima at Lowestoft")
-    f.savefig(dn_fig + "tidegauge_optima.png")
-
-    if check1 and check2 and check3 and check4:
-        print(str(sec) + chr(subsec) + " OK - Tidegauge local extrema found")
-    else:
-        print(str(sec) + chr(subsec) + " X - Tidegauge local extrema")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-
-# -----------------------------------------------------------------------------#
-# %% ( 7o ) Tidegauge method for finding extrema and troughs, fit cubic spline #
-#                                                                             #
-subsec = subsec + 1
-
-# Load and process BODC processed data
-try:
-    # Set the start and end dates
-    date_start = np.datetime64("2020-10-12 23:59")
-    date_end = np.datetime64("2020-10-14 00:01")
-
-    # Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA
-    # type object
-    tg = coast.Tidegauge()
-    # specify the data read as a High Low Water dataset
-    tg.dataset = tg.read_bodc_to_xarray(fn_tidegauge2, date_start, date_end)
-
-    # Use cubic spline fitting method
-    extrema_cubc = tg.find_high_and_low_water("sea_level", method="cubic")
-
-    # Check actual maximum/minimum is in output dataset
-    check1 = np.isclose(extrema_cubc.dataset.sea_level_highs, [7.77432795, 7.91244559])
-    check2 = np.isclose(extrema_cubc.dataset.sea_level_lows, [2.63479458, 2.54599355])
-
-    # Attempt a plot
-    f = plt.figure()
-    plt.plot(tg.dataset.time, tg.dataset.sea_level)
-    plt.scatter(extrema_cubc.dataset.time_highs.values, extrema_cubc.dataset.sea_level_highs, marker="o", c="g")
-    plt.scatter(extrema_cubc.dataset.time_lows.values, extrema_cubc.dataset.sea_level_lows, marker="o", c="r")
-
-    plt.legend(["Time Series", "Maxima", "Minima"])
-    plt.title("Tide Gauge Optima at Gladstone, fitted cubic spline")
-    f.savefig(dn_fig + "tidegauge_optima.png")
-
-    if check1.all() and check2.all():
-        print(str(sec) + chr(subsec) + " OK - Tidegauge cubic extrema found")
-    else:
-        print(str(sec) + chr(subsec) + " X - Tidegauge cubic extrema")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-
-"""
-###############################################################################
-## ( 8 ) Isobath Contour Methods                                            ##
-###############################################################################
-"""
-sec = sec + 1
-subsec = 96
-# -----------------------------------------------------------------------------#
-# %% ( 8a ) Extract isbath contour between two points and create contour object  #
-#                                                                             #
-subsec = subsec + 1
-nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_f_grid)
-contours, no_contours = coast.Contour.get_contours(nemo_f, 200)
-y_ind, x_ind, contour = coast.Contour.get_contour_segment(nemo_f, contours[0], [50, -10], [60, 3])
-cont_f = coast.ContourF(nemo_f, y_ind, x_ind, 200)
-if np.isclose(cont_f.y_ind.sum() + cont_f.y_ind.sum(), 190020) and np.isclose(
-    cont_f.data_contour.bathymetry.sum().item(), 69803.78125
-):
-    print(str(sec) + chr(subsec) + " OK - Isobath contour extracted")
-else:
-    print(str(sec) + chr(subsec) + " X - Isobath contour failed to extract correctly")
-# -----------------------------------------------------------------------------#
-# %% ( 8b ) Plot contour on map                                                  #
-#                                                                             #
-subsec = subsec + 1
-coast.Contour.plot_contour(nemo_f, contour)
-cont_path = dn_fig + "contour.png"
-plt.savefig(cont_path)
-try:
-    if os.path.isfile(cont_path) and os.path.getsize(cont_path) > 0:
-        print(str(sec) + chr(subsec) + " OK - Contour plot saved")
-    else:
-        print(str(sec) + chr(subsec) + " X - Contour plot did not save correctly")
-except OSError:
-    print(str(sec) + chr(subsec) + " X - Contour plot did not save correctly")
-# -----------------------------------------------------------------------------#
-# %% ( 8c ) Calculate pressure along contour                                     #
-#                                                                             #
-subsec = subsec + 1
-nemo_t = coast.Gridded(fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid)
-contours, no_contours = coast.Contour.get_contours(nemo_t, 200)
-y_ind, x_ind, contour = coast.Contour.get_contour_segment(nemo_t, contours[0], [50, -10], [60, 3])
-cont_t = coast.ContourT(nemo_t, y_ind, x_ind, 200)
-cont_t.construct_pressure(1027)
-if np.allclose(
-    (cont_t.data_contour.pressure_s + cont_t.data_contour.pressure_h_zlevels).sum().compute().item(), 27490693.20181531
-):
-    print(str(sec) + chr(subsec) + " OK - Perturbation pressure calculation is as expected")
-else:
-    print(str(sec) + chr(subsec) + " X - Perturbation pressure calculation is not as expected")
-# -----------------------------------------------------------------------------#
-# %% ( 8d ) Calculate flow across contour                                        #
-#                                                                             #
-subsec = subsec + 1
-nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_f_grid)
-nemo_u = coast.Gridded(fn_data=dn_files + fn_nemo_grid_u_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_u_grid)
-nemo_v = coast.Gridded(fn_data=dn_files + fn_nemo_grid_v_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_v_grid)
-contours, no_contours = coast.Contour.get_contours(nemo_f, 200)
-y_ind, x_ind, contour = coast.Contour.get_contour_segment(nemo_f, contours[0], [50, -10], [60, 3])
-cont_f = coast.ContourF(nemo_f, y_ind, x_ind, 200)
-cont_f.calc_cross_contour_flow(nemo_u, nemo_v)
-if np.allclose(
-    (cont_f.data_cross_flow.normal_velocities + cont_f.data_cross_flow.depth_integrated_normal_transport).sum(),
-    -1152.3771,
-):
-    print(str(sec) + chr(subsec) + " OK - Cross-contour flow calculations as expected")
-else:
-    print(str(sec) + chr(subsec) + " X - Cross-contour flow calculations not as expected")
-# -----------------------------------------------------------------------------#
-# %% ( 8e ) Calculate pressure gradient driven flow across contour               #
-#                                                                             #
-subsec = subsec + 1
-cont_f.calc_geostrophic_flow(nemo_t, config_u=fn_config_u_grid, config_v=fn_config_v_grid, ref_density=1027)
-if np.allclose(
-    (
-        cont_f.data_cross_flow.normal_velocity_hpg
-        + cont_f.data_cross_flow.normal_velocity_spg
-        + cont_f.data_cross_flow.transport_across_AB_hpg
-        + cont_f.data_cross_flow.transport_across_AB_spg
-    ).sum(),
-    74.65002414,
-):
-    print(str(sec) + chr(subsec) + " OK - Cross-contour geostrophic flow calculations as expected")
-else:
-    print(str(sec) + chr(subsec) + " X - Cross-contour geostrophic flow calculations not as expected")
-
-# %%
-"""
-###############################################################################
-## ( 9 ) EOF module testing                                                 ##
-###############################################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# %%---------------------------------------------------------------------------#
-# ( 9a ) Compute regular EOFs, temporal projections and variance explained   #
-#
-subsec = subsec + 1
-try:
-    nemo_t = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
-    )
-    eofs = coast.compute_eofs(nemo_t.dataset.ssh)
-
-    ssh_reconstruction = (eofs.EOF * eofs.temporal_proj).sum(dim="mode").sum(dim=["x_dim", "y_dim"])
-    ssh_anom = (nemo_t.dataset.ssh - nemo_t.dataset.ssh.mean(dim="t_dim")).sum(dim=["x_dim", "y_dim"])
-
-    # Check ssh anomaly is reconstructed at each time point
-    if np.allclose(ssh_reconstruction, ssh_anom, rtol=0.0001):
-        var_cksum = eofs.variance.sum(dim="mode").compute().item()
-        if np.isclose(var_cksum, 100):
-            print(str(sec) + chr(subsec) + " OK - Original signal reconstructed from EOFs")
-        else:
-            print(str(sec) + chr(subsec) + " X - Variance explained does not sum to 100 %")
-    else:
-        print(str(sec) + chr(subsec) + " X - Original signal not reconstructed from EOFs")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-
-# %%---------------------------------------------------------------------------#
-# ( 9b ) Compute  HEOFs, temporal projections and variance explained   #
-#
-subsec = subsec + 1
-try:
-    nemo_t = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
-    )
-    heofs = coast.compute_hilbert_eofs(nemo_t.dataset.ssh)
-
-    ssh_reconstruction = (
-        (heofs.EOF_amp * heofs.temporal_amp * uf.exp(1j * uf.radians(heofs.EOF_phase + heofs.temporal_phase)))
-        .sum(dim="mode")
-        .real.sum(dim=["x_dim", "y_dim"])
-    )
-
-    ssh_anom = (nemo_t.dataset.ssh - nemo_t.dataset.ssh.mean(dim="t_dim")).sum(dim=["x_dim", "y_dim"])
-
-    # Check ssh anomaly is reconstructed at each time point
-    if np.allclose(ssh_reconstruction, ssh_anom, rtol=0.0001):
-        var_cksum = heofs.variance.sum(dim="mode").item()
-        if np.isclose(var_cksum, 100):
-            print(str(sec) + chr(subsec) + " OK - Original signal reconstructed from HEOFs")
-        else:
-            print(str(sec) + chr(subsec) + " X - Variance explained does not sum to 100 %")
-    else:
-        print(str(sec) + chr(subsec) + " X - Original signal not reconstructed from HEOFs")
-
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.\n" + traceback.format_exc())
-
-"""
-#################################################
-## ( 10 ) Profile Methods                     ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# -----------------------------------------------------------------------------#
-# ( 10a ) Load EN4 data                                                       #
-#                                                                             #
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    profile = coast.Profile(fn_profile, config=fn_profile_config)
-    profile.dataset = profile.dataset.isel(profile=np.arange(0, profile.dataset.dims["profile"], 10)).load()
-
-    check1 = type(profile) == coast.Profile
-    check2 = profile.dataset.temperature.values[0, 0] = 8.981
-
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 10b ) Process EN4 data                                                     #
-#
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    processed = profile.process_en4()
-    processed.dataset.load()
-
-    check1 = type(processed) == coast.profile.Profile
-    check2 = np.isnan(processed.dataset.temperature.values[0, 0])
-    check3 = processed.dataset.dims["profile"] == 111
-
-    if check1 and check2 and check3:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 10c ) Gridded obs_operaor                                                  #
-#
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    # First read some new Gridded data
-    nemo_t = coast.Gridded(
-        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
-    )
-    nemo_t.dataset["landmask"] = nemo_t.dataset.bottom_level == 0
-    nemo_profiles = processed.obs_operator(nemo_t)
-
-    check1 = type(nemo_profiles) == coast.profile.Profile
-    check2 = "nearest_index_x" in list(nemo_profiles.dataset.keys())
-    check3 = nemo_profiles.dataset.interp_dist.values[0] == 151.4443554515237
-
-    if check1 and check2 and check3:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 10d ) Vertical Interpolation                                               #
-#
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    reference_depths = np.arange(0, 500, 2)
-    nemo_profiles.dataset = nemo_profiles.dataset.rename({"depth_0": "depth"})
-    model_interpolated = nemo_profiles.interpolate_vertical(processed)
-
-    check1 = type(model_interpolated) == coast.profile.Profile
-    check2 = nemo_profiles.dataset.temperature.values[0, 0] == np.float32(1.7324219)
-
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 10e ) Profile Differencing                                                 #
-#
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    difference = processed.difference(model_interpolated)
-    difference.dataset.load()
-
-    check1 = type(difference) == coast.profile.Profile
-    check2 = difference.dataset.diff_temperature.values[0, 2] == np.float32(1.1402345)
-
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 10f ) Regional Averaging                                                   #
-#
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    # First let's make some masks to define regions of the model domain
-    mm = coast.MaskMaker()
-
-    # Make some variables easier to access
-    bath = nemo_t.dataset.bathymetry.values
-    lon = nemo_t.dataset.longitude.values
-    lat = nemo_t.dataset.latitude.values
-
-    mm_north_sea = mm.region_def_nws_north_sea(lon, lat, bath)
-    mm_whole_domain = np.ones(lon.shape)
-    mask_list = [mm_north_sea, mm_whole_domain]
-    mask_names = ["North Sea", "Whole Domain"]
-
-    # Turn mask list into an xarray dataset
-    mask_list = coast.MaskMaker.make_mask_dataset(lon, lat, mask_list)
-
-    # Determine whether each profile is in each masked region or not
-    mask_indices = model_interpolated.determine_mask_indices(mask_list)
-
-    # Do average differences for each region
-    mask_means = difference.mask_means(mask_indices)
-
-    check1 = mask_means.average_diff_temperature.values[0] == np.float32(-0.78869253)
-
-    if check1:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 10g ) Surface/Bottom Averaging                                             #
-#
-subsec = subsec + 1
-# <Introduction>
-
-try:
-
-    # Lets get surface values by averaging over the top 5m of data
-    surface = 5
-    model_profiles_surface = nemo_profiles.depth_means([0, surface])
-
-    # Lets get bottom values by averaging over the bottom 30m, except whether
-    # depth is <100m, then average over the bottom 10m
-    model_profiles_bottom = nemo_profiles.bottom_means([10, 30], [100, np.inf])
-
-    check1 = type(model_profiles_surface) == coast.profile.Profile
-    check1 = type(model_profiles_bottom) == coast.profile.Profile
-    check3 = model_profiles_surface.dataset.temperature.values[0] == np.float32(1.7500391)
-
-    if check1 and check2 and check3:
-        print(str(sec) + chr(subsec) + " OK")
-    else:
-        print(str(sec) + chr(subsec) + " X")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-# %%
-"""
-#################################################
-## ( 11 ) PLOTTING UTILITY Methods             ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-# -----------------------------------------------------------------------------#
-# ( 11a ) Scatter with fit                                                     #
-#                                                                             #
-
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    # Plot an idealised dataset
-    x = np.arange(0, 50)
-    y = np.arange(0, 50) / 1.5
-    f, a = plot_util.scatter_with_fit(x, y)
-    a.set_title("Test: Scatter_with_fit()")
-
-    f.savefig(os.path.join(dn_fig, "scatter_with_fit_test.png"))
-    plt.close()
-
-    print(str(sec) + chr(subsec) + " OK. Scatter_with_fit()")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 11b ) Geo axes                                                             #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    # Plot two scatters on a map
-    lonbounds = [-20, 20]
-    latbounds = [30, 60]
-    f, a = plot_util.create_geo_axes(lonbounds, latbounds)
-    a.set_title("Test: create_geo_axes()")
-    a.scatter([0, -10], [50, 50])
-
-    f.savefig(os.path.join(dn_fig, "create_geo_axes_test.png"))
-    plt.close()
-
-    print(str(sec) + chr(subsec) + " OK. create_geo_axes()")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 11c ) Determine colorbar extension                                         #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-    # Test on some pretend dataset
-    pretend_data = np.arange(0, 50)
-    test1 = plot_util.determine_colorbar_extension(pretend_data, -50, 100)
-    test2 = plot_util.determine_colorbar_extension(pretend_data, 1, 100)
-    test3 = plot_util.determine_colorbar_extension(pretend_data, -50, 48)
-    test4 = plot_util.determine_colorbar_extension(pretend_data, 1, 48)
-
-    # TEST: <description here>
-    check1 = test1 == "neither"
-    check2 = test2 == "min"
-    check3 = test3 == "max"
-    check4 = test4 == "both"
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - determine_colorbar_extension()")
-    else:
-        print(str(sec) + chr(subsec) + " X - ")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 11d ) Determine clim by st.dev                                             #
-#                                                                             #
-
-subsec = subsec + 1
-# <Introduction>
-
-try:
-    # Data with mean = 50.51 and std = 32.2
-    # Determine clims to exclude outlier at 200
-    pretend_data = np.arange(0, 100)
-    pretend_data[-1] = 200
-    clim = plot_util.determine_clim_by_standard_deviation(pretend_data, n_std_dev=2)
-
-    # TEST: <description here>
-    check1 = clim[0] == -13.808889915793792
-    check2 = clim[1] == 114.82888991579378
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - determine_clim_by_std_dev")
-    else:
-        print(str(sec) + chr(subsec) + " X - determine_clim_by_std_dev")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# %%
-"""
-#################################################
-## ( 12 ) Stats Utility                        ##
-#################################################
-"""
-
-sec = sec + 1
-subsec = 96
-
-# -----------------------------------------------------------------------------#
-# ( 12a ) find_maxima(). Test comparison and cublic spline methods            #
-#                                                                             #
-
-subsec = subsec + 1
-
-
-try:
-    date0 = datetime.datetime(2007, 1, 15)
-    date1 = datetime.datetime(2007, 1, 16)
-    tg = coast.Tidegauge(fn_tidegauge, date_start=date0, date_end=date1)
-
-    tt, hh = stats_util.find_maxima(tg.dataset.time, tg.dataset.sea_level, method="comp")
-    check1 = np.isclose((tt.values[0] - np.datetime64("2007-01-15T00:15:00")) / np.timedelta64(1, "s"), 0)
-    check2 = np.isclose(hh.values[0], 1.027)
-
-    tt, hh = stats_util.find_maxima(tg.dataset.time, tg.dataset.sea_level, method="cubic")
-    check3 = np.isclose((tt[0] - np.datetime64("2007-01-15T00:07:49")) / np.timedelta64(1, "s"), 0)
-    check4 = np.isclose(hh[0], 1.0347638302097757)
-
-    if check1 and check2 and check3 and check4:
-        print(str(sec) + chr(subsec) + " OK - find_maxima() worked for comparison and cublic spline methods")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem with stats_util.find_maxima()")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# %%
-"""
-#################################################
-## ( 13 ) MaskMaker                           ##
-#################################################
-"""
-
-sec = sec + 1
-subsec = 96
-
-# Preparation: Create two arrays to put mask onto, one of zeros and one of ones
-# This allows us to test the additive feature.
-sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-mask00 = np.zeros((sci.dataset.dims["y_dim"], sci.dataset.dims["x_dim"]))
-mask01 = np.ones((sci.dataset.dims["y_dim"], sci.dataset.dims["x_dim"]))
-
-# -----------------------------------------------------------------------------#
-# ( 13a ) Create mask by indices                                              #
-#                                                                             #
-
-subsec = subsec + 1
-# Plot ts diagram
-
-try:
-    mm = coast.MaskMaker()
-    # Draw and fill a square
-    vertices_r = [50, 150, 150, 50]
-    vertices_c = [50, 50, 150, 150]
-    filled0 = mm.fill_polygon_by_index(mask00, vertices_r, vertices_c)
-    filled1 = mm.fill_polygon_by_index(mask01, vertices_r, vertices_c, additive=True)
-
-    # TEST: Check some data
-    check1 = filled0[49, 49] == 0 and filled0[51, 51] == 1
-    check2 = filled1[49, 49] == 1 and filled1[51, 51] == 2
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - MASKS created by index")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem mask creation by index")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# -----------------------------------------------------------------------------#
-# ( 13b ) Create mask by lonlat                                               #
-#                                                                             #
-
-subsec = subsec + 1
-# Plot ts diagram
-
-try:
-    mm = coast.MaskMaker()
-    # Draw and fill a square
-    vertices_lon = [-5, -5, 5, 5]
-    vertices_lat = [40, 60, 60, 40]
-    filled0 = mm.fill_polygon_by_lonlat(mask00, sci.dataset.longitude, sci.dataset.latitude, vertices_lon, vertices_lat)
-    filled1 = mm.fill_polygon_by_lonlat(
-        mask01, sci.dataset.longitude, sci.dataset.latitude, vertices_lon, vertices_lat, additive=True
-    )
-
-    # TEST: Check some data
-    check1 = filled0[50, 50] == 0 and filled0[50, 150] == 1
-    check2 = filled1[50, 50] == 1 and filled1[50, 150] == 2
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - MASKS created by lonlat")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem mask creation by lonlat")
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# %%
-"""
-#################################################
-## ( 14 ) Climatology Methods                  ##
-#################################################
-"""
-sec = sec + 1
-subsec = 96
-
-sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
-ds = sci.dataset[["temperature", "ssh"]].isel(z_dim=0)
-
-
-# -----------------------------------------------------------------------------#
-# ( 14a ) Monthly and Seasonal Climatology                                     #
-#                                                                             #
-
-subsec = subsec + 1
-
-try:
-
-    clim = coast.Climatology()
-    fn_out = os.path.join(dn_files, "test_climatology.nc")
-    monthly = clim.make_climatology(ds, "month").load()
-    seasonal = clim.make_climatology(ds, "season", fn_out=fn_out)
+# Import modules, including unittest
+import unittest
+import sys
+import coast
 
-    # create dataset with missing values
-    ds2 = ds.copy(deep=True)
-    ds2["temperature"][::2, :100, :100] = np.nan
-    ds2["ssh"][::2, :100, :100] = np.nan
-    seaC = clim.make_climatology(ds2, "season")
-    seaX = ds2.groupby("time.season").mean("t_dim")
-    # throws error is not close
-    xr.testing.assert_allclose(seaC, seaX)
+sys.path.append("./unit_testing")
+import logging
 
-    mn = mn = np.nanmean(ds.temperature, axis=0)
-    check1 = np.nanmax(np.abs(mn - monthly.temperature[0])) < 1e-6
-    check2 = os.path.isfile(fn_out)
-    if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Monthly and seasonal climatology made and written to file")
-    else:
-        print(str(sec) + chr(subsec) + " X - Problem with monthly and seasonal climatology ")
+# Open log file
+log_file = open("unit_testing/unit_test.log", "w")  # Need log_file.close()
+coast.logging_util.setup_logging(stream=log_file, level=logging.CRITICAL)
 
-except AssertionError:
-    print(str(sec) + chr(subsec) + " X - Problem with computing climatology when dataset has missing values")
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
+# If this is enabled, import the library and start the coverage calculation
+if calculate_coverage:
+    import coverage
 
-# %%
-"""
-###############################################################################
-## ( N ) Example script testing                                              ##
-###############################################################################
-"""
-sec = "N"
-subsec = 96
+    cov = coverage.Coverage()
+    cov.start()
 
-print(str(sec) + ". Example script testing")
-print("++++++++++++++++++++++++")
-print("  script output follows  ")
-print(" ")
-#
-# -----------------------------------------------------------------------------#
-# %% ( Na ) Example script testing                                               #
-#                                                                             #
-subsec = subsec + 1
-# Test machine name (to check for file access) in order to test additional scripts.
-example_script_flag = True if "livljobs" in gethostname().lower() else False
+# Create suite - this is a collection of tests, defined by classes
+# Add tests to test to the suite -- Add in a line for each suite
+# suite.addTest(unittest.makeSuite(test_TEMPLATE))
+suite = unittest.TestSuite()
+for test in tests_to_do:
+    suite.addTest(unittest.makeSuite(test))
 
-try:
-    # Do a thing
-    from example_scripts import altimetry_tutorial  # This runs on example_files
-    from example_scripts import tidegauge_tutorial  # This runs on example_files
-    from example_scripts import tidetable_tutorial  # This runs on example_files
-    from example_scripts import export_to_netcdf_tutorial  # This runs on example_files
-    from example_scripts import transect_tutorial  # This runs on example_files
-    from example_scripts import contour_tutorial  # This runs on example_files
-    from example_scripts import (
-        internal_tide_pycnocline_diagnostics,
-    )  # This runs on example_files unless it is on livljobs, then it is AMM60 data
+# Run test suite. Some different verbosity options available here.
+unittest.TextTestRunner(verbosity=2).run(suite)
 
+if calculate_coverage:
+    cov.stop()
+    cov.save()
     print(" ")
-    print("  script output ends  ")
-    print("++++++++++++++++++++++++")
-    print(str(sec) + chr(subsec) + " OK - tutorials on example_files data")
-    subsec = subsec + 1
-
-    if example_script_flag:
-        from example_scripts import amm15_example_plot
-
-        print(str(sec) + chr(subsec) + " OK - tutorial on AMM15 data")
-
-        subsec = subsec + 1
-        from example_scripts import anchor_plots_of_nsea_wvel
-
-        print(str(sec) + chr(subsec) + " OK - tutorial on AMM60 data")
-
-        subsec = subsec + 1
-        from example_scripts import blz_example_plot
-
-        print(str(sec) + chr(subsec) + " OK - tutorial on Belize data")
-
-        subsec = subsec + 1
-        from example_scripts import seasia_r12_example_plot
-
-        print(str(sec) + chr(subsec) + " OK - tutorial on SEAsia data")
-
-        subsec = subsec + 1
-        from example_scripts import wcssp_india_example_plot
-
-        print(str(sec) + chr(subsec) + " OK - tutorial on WCSSP-India data")
-
-        subsec = subsec + 1  # ON LIVLJOBS THIS RUNS TWICE FOR AMM60 DATA.
-        from example_scripts import internal_tide_pycnocline_diagnostics
-
-        print(str(sec) + chr(subsec) + " OK - tutorial on internal tides")
-    else:
-        print("Don't forget to test on a LIVLJOBS machine")
-
-    # TEST: <description here>
-    check1 = example_script_flag
-    if check1:
-        print(str(sec) + " OK - example_scripts ran on", gethostname())
-    else:
-        print(str(sec) + " X - example_scripts failed on", gethostname())
-
-except:
-    print(str(sec) + chr(subsec) + " FAILED.")
-
-# %% Close log file
-#################################################
-log_file.close()
+    print("COVERAGE REPORT (Classes): ")
+    cov.report(omit=["unit_testing/test_*", "example_scripts/*"])
+    print(" ")
+    print("COVERAGE REPORT (Example Scripts): ")
+    cov.report(omit=["unit_testing/test_*", "coast/*"])
+
+# Generate unit_test_contents
+if generate_unit_test_contents:
+    # Initialize counters
+    test_count = 1
+
+    # Open output file
+    with open(fn_contents, "w") as file:
+
+        # Write title things
+        file.write("     UNIT TEST CONTENTS FILE TEST    \n")
+        file.write("\n")
+
+        # Loop over tests in tests_to_do and get name of module as string
+        for test in tests_to_do:
+            test_name = test.__name__
+            file.write("{0}. {1}\n".format(test_count, test_name))
+            method_count = 97
+
+            # Loop over methods in module. If begins with 'test_' then write to file
+            for method in dir(test):
+                if method[:5] != "test_":
+                    continue
+                file.write("      {0}. {1}\n".format(chr(method_count), method[5:]))
+                method_count = method_count + 1
+            file.write("\n")
+            test_count = test_count + 1
+    print(" ")
+    print("Written modules and methods to:")
+    print("          {0}".format(fn_contents))
diff --git a/unit_testing/unit_test_BGC.py b/unit_testing/unit_test_BGC.py
new file mode 100644
index 00000000..11482e3c
--- /dev/null
+++ b/unit_testing/unit_test_BGC.py
@@ -0,0 +1,224 @@
+##
+"""
+Script to do unit testing for BGC only in SEAsia
+***SECTIONS***
+This script is separated into Subsections, for which there are two
+counters to keep track: sec and subsec respectively.  At the beginning of each
+section, the sec counter should be incremented by 1 and the subsec counter
+should be reset to 96 (code for one below 'a'). At the beginning of each
+subsection, subsec should be incremented by one.
+***OTHER FILES***
+This is supplement file to the original unit testing script:
+    - unit_test
+Run:
+ipython: cd COAsT; run unit_testing/unit_test_BGC.py  # I.e. from the git repo.
+Unit template:
+#-----------------------------------------------------------------------------#
+# ( ## ) Subsection title                                                     #
+#                                                                             #
+subsec = subsec+1
+# <Introduction>
+try:
+    # Do a thing
+    #TEST: <description here>
+    check1 = #<Boolean>
+    check2 = #<Boolean>
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK - ")
+    else:
+        print(str(sec) + chr(subsec) + " X - ")
+except:
+    print(str(sec) + chr(subsec) +' FAILED.')
+"""
+
+import coast
+import os
+import numpy as np
+import xarray as xr
+import matplotlib.pyplot as plt
+import datetime
+import os.path as path
+import logging
+import coast.general_utils as general_utils
+import coast.plot_util as plot_util
+import coast.stats_util as stats_util
+from socket import gethostname  # to get hostname
+import traceback
+import xarray.ufuncs as uf
+
+"""
+#################################################
+## ( 0 ) Files, directories for unit testing   ##
+#################################################
+"""
+## Initialise logging and save to log file
+log_file = open("unit_testing/unit_test.log", "w")  # Need log_file.close()
+coast.logging_util.setup_logging(stream=log_file, level=logging.CRITICAL)
+## Alternative logging levels
+# ..., level=logging.DEBUG) # Detailed information, typically of interest only when diagnosing problems.
+# ..., level=logging.INFO) # Confirmation that things are working as expected.
+# ..., level=logging.WARNING) # An indication that something unexpected happened, or indicative of some problem in the near future (e.g. ‘disk space low’). The software is still working as expected.
+# ..., level=logging.ERROR) # Due to a more serious problem, the software has not been able to perform some function
+# ..., level=logging.CRITICAL) # A serious error, indicating that the program itself may be unable to continue running
+
+dn_files = "./example_files/"
+
+if not os.path.isdir(dn_files):
+    print("please go download the examples file from https://linkedsystems.uk/erddap/files/COAsT_example_files/")
+    dn_files = input("what is the path to the example files:\n")
+    if not os.path.isdir(dn_files):
+        print(f"location f{dn_files} cannot be found")
+
+dn_fig = "unit_testing/figures/"
+# BGC for nemo
+fn_nemo_dat = "coast_example_SEAsia_BGC_1990.nc"
+fn_nemo_dom_bgc = "coast_example_domain_SEAsia.nc"
+# BGC for SEAsia
+fn_nemo_config_bgc_grid = path.join("./config", "example_nemo_bgc.json")
+
+sec = 1
+subsec = 96  # Code for '`' (1 below 'a')
+"""
+#################################################
+## ( 1 ) Loading/Initialisation                ##
+#################################################
+"""
+# This section is for testing the loading and initialisation of Gridded objects.
+
+# -----------------------------------------------------------------------------#
+# %% ( 1a ) BGC- Load example Gridded Nemo-nemo data for BGC (DIC, oxygen, nutrients, ph, alkalinity)                 #
+#                                                                             #
+
+subsec = subsec + 1
+
+try:
+    sci_bgc = coast.Gridded(
+        path.join(dn_files, fn_nemo_dat), path.join(dn_files, fn_nemo_dom_bgc), config=fn_nemo_config_bgc_grid
+    )
+
+    # Test the data has loaded
+    sci_bgc_attrs_ref = dict(
+        [
+            ("name", "SEAsia_HAD_1m_19900101_19901231_ptrc_T"),
+            ("description", "tracer variables"),
+            ("title", "tracer variables"),
+            ("Conventions", "CF-1.6"),
+            ("timeStamp", "2020-Oct-07 10:11:58 GMT"),
+            ("uuid", "701bb916-558d-4ee8-9cf6-89454c7bc99f"),
+        ]
+    )
+
+    # checking is LHS is a subset of RHS
+    if sci_bgc_attrs_ref.items() <= sci_bgc.dataset.attrs.items():
+        print(str(sec) + chr(subsec) + " OK - nemo BGC data loaded: " + fn_nemo_dat)
+    else:
+        print(str(sec) + chr(subsec) + " X - There is an issue with loading nemo BGC " + fn_nemo_dat)
+except:
+    print(str(sec) + chr(subsec) + " FAILED BGC")
+
+# -----------------------------------------------------------------------------#
+# %% ( 1b ) BGC Load data from existing dataset                                      #
+#                                                                             #
+
+subsec = subsec + 1
+
+try:
+    ds_bgc = xr.open_dataset(dn_files + fn_nemo_dat)
+    sci_bgc_load_ds = coast.Gridded(config=fn_nemo_config_bgc_grid)
+    sci_bgc_load_ds.load_dataset(ds_bgc)
+    sci_bgc_load_file = coast.Gridded(config=fn_nemo_config_bgc_grid)
+    sci_bgc_load_file.load(dn_files + fn_nemo_dat)
+    if sci_bgc_load_ds.dataset.identical(sci_bgc_load_file.dataset):
+        print(str(sec) + chr(subsec) + " OK bgc - coast.load_dataset()")
+    else:
+        print(
+            str(sec)
+            + chr(subsec)
+            + " X BGC - coast.load_dataset() ERROR - not identical to dataset loaded via coast.load()"
+        )
+except:
+    print(str(sec) + chr(subsec) + " FAILED BGC")
+
+
+# -----------------------------------------------------------------------------#
+# %% ( 1c )  BGC Set Gridded variable name                                               #
+#                                                                             #
+
+subsec = subsec + 1
+
+try:
+    sci_bgc = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom_bgc, config=fn_nemo_config_bgc_grid)
+    try:
+        sci_bgc.dataset.DIC
+    except NameError:
+        print(str(sec) + chr(subsec) + " X - variable name (to DIC) not reset")
+    else:
+        print(str(sec) + chr(subsec) + " OK - variable name reset (to DIC)")
+except:
+    print(str(sec) + chr(subsec) + " FAILED BGC")
+
+# -----------------------------------------------------------------------------#
+# %% ( 1d ) BGC Set Gridded grid attributes - dimension names                           #
+#                                                                             #
+
+subsec = subsec + 1  #
+
+try:
+    if sci_bgc.dataset.DIC.dims == ("t_dim", "z_dim", "y_dim", "x_dim"):
+        print(str(sec) + chr(subsec) + " OK BGC - dimension names reset")
+    else:
+        print(str(sec) + chr(subsec) + " X BGC - dimension names not reset")
+except:
+    print(str(sec) + chr(subsec) + " FAILED BCG")
+
+
+# -----------------------------------------------------------------------------#
+# %% ( 1e ) BGC Load only domain data in Gridded                                        #
+#                                                                             #
+
+subsec = subsec + 1
+
+pass_test = False
+nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom_bgc, config=fn_nemo_config_bgc_grid)
+
+if nemo_f.dataset._coord_names == {"depth_0", "latitude", "longitude"}:
+    var_name_list = []
+    for var_name in nemo_f.dataset.data_vars:
+        var_name_list.append(var_name)
+    if var_name_list == ["bathymetry", "e1", "e2", "e3_0", "bottom_level"]:
+        pass_test = True
+
+if pass_test:
+    print(str(sec) + chr(subsec) + " OK BGC - Gridded loaded domain data only")
+else:
+    print(str(sec) + chr(subsec) + " X BGC - Gridded didn't load domain data correctly")
+
+# -----------------------------------------------------------------------------#
+# %% ( 1f ) Plot surface DIC testing                                              #
+#                                                                             #
+
+subsec = subsec + 1
+try:
+    fig = plt.figure()
+    plt.pcolormesh(
+        sci_bgc.dataset.longitude,
+        sci_bgc.dataset.latitude,
+        sci_bgc.dataset.DIC.isel(t_dim=0).isel(z_dim=0),
+        cmap="RdYlBu_r",
+        vmin=1600,
+        vmax=2080,
+    )
+    plt.colorbar()
+    plt.title("DIC, mmol/m^3")
+    plt.xlabel("longitude")
+    plt.ylabel("latitude")
+    plt.show()
+    fig.savefig("SEAsia_DIC_surface.png")
+    print(str(sec) + chr(subsec) + " OK - DIC at surface plot saved")
+except:
+    print(str(sec) + chr(subsec) + "X - DIC plot failed")
+
+
+# %% Close log file
+#################################################
+log_file.close()
diff --git a/coast_v1/unit_test2.py b/unit_testing/unit_test_OLD.py
old mode 100644
new mode 100755
similarity index 84%
rename from coast_v1/unit_test2.py
rename to unit_testing/unit_test_OLD.py
index 2de2c4f9..3be40331
--- a/coast_v1/unit_test2.py
+++ b/unit_testing/unit_test_OLD.py
@@ -49,10 +49,6 @@
 import traceback
 import xarray.ufuncs as uf
 
-print("********")
-print("unit_test2.y IS ALMOST OBSOLETE NOW. MOST v2 CHANGES ARE MAPPED TO unit_test.py")
-print("THIS IS FOR REFERENCE, UNTIL IT IS (SOON) DELETED")
-print("********")
 """
 #################################################
 ## ( 0 ) Files, directories for unit testing   ##
@@ -60,7 +56,7 @@
 """
 ## Initialise logging and save to log file
 log_file = open("unit_testing/unit_test.log", "w")  # Need log_file.close()
-coast.logging_util.setup_logging(stream=log_file, level=logging.INFO)
+coast.logging_util.setup_logging(stream=log_file, level=logging.CRITICAL)
 ## Alternative logging levels
 # ..., level=logging.DEBUG) # Detailed information, typically of interest only when diagnosing problems.
 # ..., level=logging.INFO) # Confirmation that things are working as expected.
@@ -77,11 +73,6 @@
         print(f"location f{dn_files} cannot be found")
 
 dn_fig = "unit_testing/figures/"
-fn_config_t_grid = path.join("./config", "example_nemo_grid_t.json")
-fn_config_f_grid = path.join("./config", "example_nemo_grid_f.json")
-fn_config_u_grid = path.join("./config", "example_nemo_grid_u.json")
-fn_config_v_grid = path.join("./config", "example_nemo_grid_v.json")
-fn_config_w_grid = path.join("./config", "example_nemo_grid_w.json")
 fn_nemo_grid_t_dat_summer = "nemo_data_T_grid_Aug2015.nc"
 fn_nemo_grid_t_dat = "nemo_data_T_grid.nc"
 fn_nemo_grid_u_dat = "nemo_data_U_grid.nc"
@@ -95,20 +86,25 @@
 fn_nemo_harmonics = "coast_nemo_harmonics.nc"
 fn_nemo_harmonics_dom = "coast_nemo_harmonics_dom.nc"
 # EN4 profile data (NetCDF)
-fn_profile = dn_files + "EN4_example.nc"
+fn_profile = dn_files + "coast_example_EN4_201008.nc"
 fn_profile_config = "config/example_en4_profiles.json"
+fn_config_t_grid = path.join("./config", "example_nemo_grid_t.json")
+fn_config_f_grid = path.join("./config", "example_nemo_grid_f.json")
+fn_config_u_grid = path.join("./config", "example_nemo_grid_u.json")
+fn_config_v_grid = path.join("./config", "example_nemo_grid_v.json")
+fn_config_w_grid = path.join("./config", "example_nemo_grid_w.json")
 
 sec = 1
 subsec = 96  # Code for '`' (1 below 'a')
 """
 #################################################
-## ( 1 ) Nemo Loading/Initialisation           ##
+## ( 1 ) Loading/Initialisation                ##
 #################################################
 """
-# This section is for testing the loading and initialisation of Nemo objects.
+# This section is for testing the loading and initialisation of Gridded objects.
 
 # -----------------------------------------------------------------------------#
-# %% ( 1a ) Load example Nemo data (Temperature, Salinity, SSH)                  #
+# %% ( 1a ) Load example Gridded Nemo data (Temperature, Salinity, SSH)                  #
 #                                                                             #
 
 subsec = subsec + 1
@@ -159,7 +155,7 @@
     print(str(sec) + chr(subsec) + " FAILED")
 
 # -----------------------------------------------------------------------------#
-# %% ( 1c ) Set Nemo variable name                                               #
+# %% ( 1c ) Set Gridded variable name                                               #
 #                                                                             #
 
 subsec = subsec + 1
@@ -175,7 +171,7 @@
     print(str(sec) + chr(subsec) + " FAILED")
 
 # -----------------------------------------------------------------------------#
-# %% ( 1d ) Set Nemo grid attributes - dimension names                           #
+# %% ( 1d ) Set Gridded grid attributes - dimension names                           #
 #                                                                             #
 
 subsec = subsec + 1
@@ -188,7 +184,7 @@
     print(str(sec) + chr(subsec) + " FAILED")
 
 # -----------------------------------------------------------------------------#
-# %% ( 1e ) Load only domain data in Nemo                                        #
+# %% ( 1e ) Load only domain data in Gridded                                        #
 #                                                                             #
 
 subsec = subsec + 1
@@ -204,9 +200,9 @@
         pass_test = True
 
 if pass_test:
-    print(str(sec) + chr(subsec) + " OK - Nemo loaded domain data only")
+    print(str(sec) + chr(subsec) + " OK - Gridded loaded domain data only")
 else:
-    print(str(sec) + chr(subsec) + " X - Nemo didn't load domain data correctly")
+    print(str(sec) + chr(subsec) + " X - Gridded didn't load domain data correctly")
 
 # -----------------------------------------------------------------------------#
 # %% ( 1f ) Calculate depth_0 for t,u,v,w,f grids                                #
@@ -219,22 +215,22 @@
         fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
     )
     if not np.isclose(np.nansum(nemo_t.dataset.depth_0.values), 1705804300.0):
-        raise ValueError(" X - Nemo depth_0 failed on t-grid failed")
+        raise ValueError(" X - Gridded depth_0 failed on t-grid failed")
     nemo_u = coast.Gridded(
         fn_data=dn_files + fn_nemo_grid_u_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_u_grid
     )
     if not np.isclose(np.nansum(nemo_u.dataset.depth_0.values), 1705317600.0):
-        raise ValueError(" X - Nemo depth_0 failed on u-grid failed")
+        raise ValueError(" X - Gridded depth_0 failed on u-grid failed")
     nemo_v = coast.Gridded(
         fn_data=dn_files + fn_nemo_grid_v_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_v_grid
     )
     if not np.isclose(np.nansum(nemo_v.dataset.depth_0.values), 1705419100.0):
-        raise ValueError(" X - Nemo depth_0 failed on v-grid failed")
+        raise ValueError(" X - Gridded depth_0 failed on v-grid failed")
     nemo_f = coast.Gridded(fn_domain=dn_files + fn_nemo_dom, config=fn_config_f_grid)
     if not np.isclose(np.nansum(nemo_f.dataset.depth_0.values), 1704932600.0):
-        raise ValueError(" X - Nemo depth_0 failed on f-grid failed")
+        raise ValueError(" X - Gridded depth_0 failed on f-grid failed")
 
-    print(str(sec) + chr(subsec) + " OK - Nemo depth_0 calculations correct")
+    print(str(sec) + chr(subsec) + " OK - Gridded depth_0 calculations correct")
 except ValueError as err:
     print(str(sec) + chr(subsec) + str(err))
 
@@ -250,7 +246,9 @@
 
     # checking all the coordinates mapped correctly to the dataset object
     if amm7.dataset._coord_names == {"depth_0", "latitude", "longitude", "time"}:
-        print(str(sec) + chr(subsec) + " OK - Nemo data subset loaded ", "with correct coords: " + fn_nemo_dat_subset)
+        print(
+            str(sec) + chr(subsec) + " OK - Gridded data subset loaded ", "with correct coords: " + fn_nemo_dat_subset
+        )
     else:
         print(
             str(sec) + chr(subsec) + " X - There is an issue with ",
@@ -272,7 +270,7 @@
 
     # checking all the coordinates mapped correctly to the dataset object
     if amm7.dataset.time.size == 14:
-        print(str(sec) + chr(subsec) + " OK - Nemo data loaded combine ", "over time: " + file_names_amm7)
+        print(str(sec) + chr(subsec) + " OK - Gridded data loaded combine ", "over time: " + file_names_amm7)
     else:
         print(str(sec) + chr(subsec) + " X - There is an issue with loading", "multiple data files " + file_names_amm7)
 
@@ -284,8 +282,8 @@
 #                                                                             #
 
 subsec = subsec + 1
-# Load in a Nemo data file containing harmonics and combine them into a new
-# Nemo obejct and dataset.
+# Load in a Gridded data file containing harmonics and combine them into a new
+# Gridded object and dataset.
 
 try:
     harmonics = coast.Gridded(dn_files + fn_nemo_harmonics, dn_files + fn_nemo_harmonics_dom, config=fn_config_t_grid)
@@ -463,9 +461,9 @@
         log_str += "Problem with numerical derivative of f(z)=-z\n"
 
     if log_str == "":
-        print(str(sec) + chr(subsec) + " OK - Nemo.differentiate (for d/dz) method passes all tests")
+        print(str(sec) + chr(subsec) + " OK - Gridded.differentiate (for d/dz) method passes all tests")
     else:
-        print(str(sec) + chr(subsec) + " X - Nemo.differentiate method failed: " + log_str)
+        print(str(sec) + chr(subsec) + " X - Gridded.differentiate method failed: " + log_str)
 
 except:
     traceback.print_exc()
@@ -543,7 +541,7 @@
     print(str(sec) + chr(subsec) + " X - computing pycnocline depth and thickness failed ")
 
 # -----------------------------------------------------------------------------#
-# %% ( 3d ) Plot pycnocline depth                                                #
+# %% ( 3d ) Plot pycnocline depth                                              #
 #                                                                             #
 
 subsec = subsec + 1
@@ -654,10 +652,11 @@
 ]
 length_ref = 37
 
+
 if (xt == xt_ref) and (yt == yt_ref) and (length_of_line == length_ref):
-    print(str(sec) + chr(subsec) + " OK - Nemo transect indices extracted")
+    print(str(sec) + chr(subsec) + " OK - Gridded transect indices extracted")
 else:
-    print(str(sec) + chr(subsec) + " X - Issue with transect indices extraction from Nemo")
+    print(str(sec) + chr(subsec) + " X - Issue with transect indices extraction from Gridded")
 
 # -----------------------------------------------------------------------------#
 # %% ( 4b ) Transport velocity and depth calculations                            #
@@ -716,9 +715,9 @@
 try:
     tran_t = coast.TransectT(nemo_t, (54, -15), (56, -12))
     tran_t.construct_pressure()
-    cksum1 = tran_t.data.density_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).item()
-    cksum2 = tran_t.data.pressure_h_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).item()
-    cksum3 = tran_t.data.pressure_s.sum(dim=["t_dim", "r_dim"]).item()
+    cksum1 = tran_t.data.density_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).compute().item()
+    cksum2 = tran_t.data.pressure_h_zlevels.sum(dim=["t_dim", "r_dim", "depth_z_levels"]).compute().item()
+    cksum3 = tran_t.data.pressure_s.sum(dim=["t_dim", "r_dim"]).compute().item()
     if np.allclose([cksum1, cksum2, cksum3], [23800545.87457855, 135536478.93335825, -285918.5625]):
         print(str(sec) + chr(subsec) + " OK - Transect density and pressure calculations as expected")
     else:
@@ -768,11 +767,11 @@
         print(
             str(sec)
             + chr(subsec)
-            + " OK - Nemo Coast get_subset_as_xarray extracted expected array size and "
+            + " OK - Gridded Coast get_subset_as_xarray extracted expected array size and "
             + "extreme values"
         )
     else:
-        print(str(sec) + chr(subsec) + " X - Issue with Nemo Coast get_subset_as_xarray method")
+        print(str(sec) + chr(subsec) + " X - Issue with Gridded Coast get_subset_as_xarray method")
 except:
     print(str(sec) + chr(subsec) + " FAILED")
 
@@ -792,7 +791,7 @@
         print(
             str(sec)
             + chr(subsec)
-            + " OK - Nemo domain subset_indices_by_distance extracted expected "
+            + " OK - Gridded domain subset_indices_by_distance extracted expected "
             + "size of indices"
         )
     else:
@@ -800,12 +799,13 @@
         print(
             str(sec)
             + chr(subsec)
-            + "X - Issue with indices extraction from Nemo domain "
+            + "X - Issue with indices extraction from Gridded domain "
             + "subset_indices_by_distance method"
         )
 except:
     print(str(sec) + chr(subsec) + " FAILED")
 
+
 # -----------------------------------------------------------------------------#
 # %% ( 5c ) Find nearest xy indices                                              #
 #                                                                             #
@@ -859,6 +859,7 @@
 except:
     print(str(sec) + chr(subsec) + " FAILED")
 
+
 """
 #################################################
 ## ( 6 ) Altimetry Methods                     ##
@@ -867,8 +868,8 @@
 sec = sec + 1
 subsec = 96
 # This section is for testing and demonstrating the use of the Altimetry
-# object. First begin by reloading Nemo t-grid test data:
-sci = coast.Nemo(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, grid_ref="t-grid")
+# object. First begin by reloading Gridded t-grid test data:
+sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
 
 
 # -----------------------------------------------------------------------------#
@@ -1011,19 +1012,19 @@
 
 plt.close("all")
 
-
 """
 #################################################
-## ( 7 ) TideGauge Methods                     ##
+## ( 7 ) Tidegauge Methods                     ##
 #################################################
 """
 sec = sec + 1
 subsec = 96
 
-# This section is for testing and demonstrating the use of the TideGauge
-# object. First begin by reloading Nemo t-grid test data:
+# This section is for testing and demonstrating the use of the Tidegauge
+# object. First begin by reloading Gridded t-grid test data:
 sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
 
+
 # -----------------------------------------------------------------------------#
 # %% ( 7a ) Load in GESLA tide gauge files from directory                      #
 #                                                                             #
@@ -1037,7 +1038,7 @@
 try:
     date0 = datetime.datetime(2007, 1, 10)
     date1 = datetime.datetime(2007, 1, 12)
-    lowestoft = coast.TideGauge(fn_tidegauge, date_start=date0, date_end=date1)
+    lowestoft = coast.Tidegauge(fn_tidegauge, date_start=date0, date_end=date1)
 
     # TEST: Define Attribute dictionary for comparison
     test_attrs = {
@@ -1068,15 +1069,16 @@
 
 subsec = subsec + 1
 
+
 # Load and plot BODC processed data
 try:
     # Set the start and end dates
     date_start = np.datetime64("2020-10-12 23:59")
     date_end = np.datetime64("2020-10-14 00:01")
 
-    # Initiate a TideGauge object, if a filename is passed it assumes it is a GESLA
+    # Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA
     # type object
-    tg = coast.TideGauge()
+    tg = coast.Tidegauge()
     # specify the data read as a High Low Water dataset
     tg.dataset = tg.read_bodc_to_xarray(fn_tidegauge2, date_start, date_end)
     # tg.plot_timeseries()
@@ -1121,16 +1123,16 @@
 try:
     date_start = np.datetime64("now") - np.timedelta64(20, "D")
     date_end = np.datetime64("now") - np.timedelta64(10, "D")
-    eg = coast.TideGauge()
+    eg = coast.Tidegauge()
     # Extract the data between explicit dates
-    eg.dataset = eg.read_EA_API_to_xarray(date_start=date_start, date_end=date_end)
+    eg.dataset = eg.read_ea_api_to_xarray(date_start=date_start, date_end=date_end)
     check1 = eg.dataset.site_name == "Liverpool"
     check2 = len(eg.dataset.sea_level) > 0
     # eg.plot_timeseries()
 
-    # Alternatively extract the data for the last ndays, here for a specific
+    # Alternatively extract the data for the last n_days, here for a specific
     # (the default) station.
-    eg.dataset = eg.read_EA_API_to_xarray(ndays=1, stationId="E70124")
+    eg.dataset = eg.read_ea_api_to_xarray(n_days=1, station_id="E70124")
     check3 = eg.dataset.site_name == "Liverpool"
     # eg.plot_timeseries()
 
@@ -1142,7 +1144,7 @@
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7d ) TideGauge obs_operator                                             #
+# %% ( 7d ) Tidegauge obs_operator                                             #
 #                                                                             #
 
 subsec = subsec + 1
@@ -1169,7 +1171,7 @@
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7e) TideGauge CRPS                                                      #
+# %% ( 7e) Tidegauge CRPS                                                      #
 #                                                                             #
 subsec = subsec + 1
 # Compare modelled SSH to observed sea level using CRPS. This can be done
@@ -1191,10 +1193,10 @@
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7f ) TideGauge Stats methods                                            #
+# %% ( 7f ) Tidegauge Stats methods                                            #
 #                                                                             #
 subsec = subsec + 1
-# We can batch return the basic stats methods from TideGauge using basic_stats().
+# We can batch return the basic stats methods from Tidegauge using basic_stats().
 # We test all of the stats routines here by using this batch function.
 
 try:
@@ -1206,15 +1208,15 @@
     # TEST: Check lengths and values
     check2 = stats.dataset.absolute_error.shape[0] == lowestoft.dataset.sea_level.shape[0]
     if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - Basic Stats for TideGauge")
+        print(str(sec) + chr(subsec) + " OK - Basic Stats for Tidegauge")
     else:
-        print(str(sec) + chr(subsec) + " X -  Basic Stats for TideGauge")
+        print(str(sec) + chr(subsec) + " X -  Basic Stats for Tidegauge")
 
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7g ) TideGauge Resample to hourly                                       #
+# %% ( 7g ) Tidegauge Resample to hourly                                       #
 #                                                                             #
 subsec = subsec + 1
 # Lets resample the tide gauge data to be hourly.
@@ -1228,9 +1230,9 @@
     # TEST: Check length
     check2 = np.ceil(lowestoft.dataset.time.shape[0] / 4) == lowestoft.dataset.time_1H.shape[0]
     if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - TideGauge resampled")
+        print(str(sec) + chr(subsec) + " OK - Tidegauge resampled")
     else:
-        print(str(sec) + chr(subsec) + " X -  Resample TideGauge")
+        print(str(sec) + chr(subsec) + " X -  Resample Tidegauge")
 
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
@@ -1249,24 +1251,24 @@
     # TEST: Check there are number values in output
     check2 = False in np.isnan(lowestoft.dataset.sea_level_1H_dx0)
     if check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - TideGauge doodson X0")
+        print(str(sec) + chr(subsec) + " OK - Tidegauge doodson X0")
     else:
-        print(str(sec) + chr(subsec) + " X -  TideGauge doodson X0")
+        print(str(sec) + chr(subsec) + " X -  Tidegauge doodson X0")
 
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7i ) TideGauge Loading multiple TideGaugeS                              #
+# %% ( 7i ) Tidegauge Loading multiple TidegaugeS                              #
 #                                                                             #
 subsec = subsec + 1
-# We can load multiple tide gauges into a list of TideGauge objects using the
+# We can load multiple tide gauges into a list of Tidegauge objects using the
 # static method create_multiple_tidegauge.
 
 try:
     date0 = datetime.datetime(2007, 1, 10)
     date1 = datetime.datetime(2007, 1, 12)
-    tidegauge_list = coast.TideGauge.create_multiple_tidegauge("./example_files/tide_gauges/l*", date0, date1)
+    tidegauge_list = coast.Tidegauge.create_multiple_tidegauge("./example_files/tide_gauges/l*", date0, date1)
 
     # TEST: Check length of list
     check1 = len(tidegauge_list) == 2
@@ -1281,7 +1283,7 @@
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7j ) TideGauge map plot (single)                                        #
+# %% ( 7j ) Tidegauge map plot (single)                                        #
 #                                                                             #
 subsec = subsec + 1
 
@@ -1296,13 +1298,13 @@
 plt.close("all")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7k ) TideGauge map plot (single)                                        #
+# %% ( 7k ) Tidegauge map plot (single)                                        #
 #                                                                             #
 subsec = subsec + 1
 
 # Or we can plot up multiple from the list we loaded:
 try:
-    f, a = coast.TideGauge.plot_on_map_multiple(tidegauge_list)
+    f, a = coast.Tidegauge.plot_on_map_multiple(tidegauge_list)
     f.savefig(dn_fig + "tidegauge_multiple_map.png")
     print(str(sec) + chr(subsec) + " OK - Tide gauge multiple map plot saved")
 except:
@@ -1311,7 +1313,7 @@
 plt.close("all")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7l ) TideGauge Time series plot                                         #
+# %% ( 7l ) Tidegauge Time series plot                                         #
 #                                                                             #
 subsec = subsec + 1
 
@@ -1327,7 +1329,7 @@
 plt.close("all")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7m ) TideGauge method for tabulated data                                #
+# %% ( 7m ) Tidegauge method for tabulated data                                #
 #                                                                             #
 subsec = subsec + 1
 
@@ -1337,20 +1339,20 @@
     date_start = np.datetime64("2020-10-11 07:59")
     date_end = np.datetime64("2020-10-20 20:21")
 
-    # Initiate a TideGauge object, if a filename is passed it assumes it is a GESLA type object
-    tg = coast.TideGauge()
-    tg.dataset = tg.read_HLW_to_xarray(filnam, date_start, date_end)
+    # Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA type object
+    tg = coast.Tidegauge()
+    tg.dataset = tg.read_hlw_to_xarray(filnam, date_start, date_end)
 
     check1 = len(tg.dataset.sea_level) == 37
-    check2 = tg.get_tidetabletimes(np.datetime64("2020-10-13 12:48"), method="nearest_HW").values == 8.01
-    check3 = tg.get_tidetabletimes(np.datetime64("2020-10-13 12:48"), method="nearest_1").time.values == np.datetime64(
-        "2020-10-13 14:36"
-    )
+    check2 = tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="nearest_HW").values == 8.01
+    check3 = tg.get_tide_table_times(
+        np.datetime64("2020-10-13 12:48"), method="nearest_1"
+    ).time.values == np.datetime64("2020-10-13 14:36")
     check4 = np.array_equal(
-        tg.get_tidetabletimes(np.datetime64("2020-10-13 12:48"), method="nearest_2").values, [2.83, 8.01]
+        tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="nearest_2").values, [2.83, 8.01]
     )
     check5 = np.array_equal(
-        tg.get_tidetabletimes(np.datetime64("2020-10-13 12:48"), method="window", winsize=24).values,
+        tg.get_tide_table_times(np.datetime64("2020-10-13 12:48"), method="window", winsize=24).values,
         [3.47, 7.78, 2.8, 8.01, 2.83, 8.45, 2.08, 8.71],
     )
 
@@ -1362,7 +1364,7 @@
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# %% ( 7n ) TideGauge method for finding extrema and troughs, compare neighbours#
+# %% ( 7n ) Tidegauge method for finding extrema and troughs, compare neighbours#
 #                                                                             #
 subsec = subsec + 1
 
@@ -1370,7 +1372,7 @@
 try:
     date0 = datetime.datetime(2007, 1, 10)
     date1 = datetime.datetime(2007, 1, 20)
-    lowestoft2 = coast.TideGauge(fn_tidegauge, date_start=date0, date_end=date1)
+    lowestoft2 = coast.Tidegauge(fn_tidegauge, date_start=date0, date_end=date1)
 
     # Use comparison of neighbourhood method (method="comp" is assumed)
     extrema_comp = lowestoft2.find_high_and_low_water("sea_level", distance=40)
@@ -1398,8 +1400,9 @@
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
+
 # -----------------------------------------------------------------------------#
-# %% ( 7o ) TideGauge method for finding extrema and troughs, fit cubic spline #
+# %% ( 7o ) Tidegauge method for finding extrema and troughs, fit cubic spline #
 #                                                                             #
 subsec = subsec + 1
 
@@ -1409,9 +1412,9 @@
     date_start = np.datetime64("2020-10-12 23:59")
     date_end = np.datetime64("2020-10-14 00:01")
 
-    # Initiate a TideGauge object, if a filename is passed it assumes it is a GESLA
+    # Initiate a Tidegauge object, if a filename is passed it assumes it is a GESLA
     # type object
-    tg = coast.TideGauge()
+    tg = coast.Tidegauge()
     # specify the data read as a High Low Water dataset
     tg.dataset = tg.read_bodc_to_xarray(fn_tidegauge2, date_start, date_end)
 
@@ -1439,6 +1442,7 @@
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
+
 """
 ###############################################################################
 ## ( 8 ) Isobath Contour Methods                                            ##
@@ -1484,7 +1488,7 @@
 cont_t = coast.ContourT(nemo_t, y_ind, x_ind, 200)
 cont_t.construct_pressure(1027)
 if np.allclose(
-    (cont_t.data_contour.pressure_s + cont_t.data_contour.pressure_h_zlevels).sum().item(), 27490693.20181531
+    (cont_t.data_contour.pressure_s + cont_t.data_contour.pressure_h_zlevels).sum().compute().item(), 27490693.20181531
 ):
     print(str(sec) + chr(subsec) + " OK - Perturbation pressure calculation is as expected")
 else:
@@ -1549,7 +1553,7 @@
 
     # Check ssh anomaly is reconstructed at each time point
     if np.allclose(ssh_reconstruction, ssh_anom, rtol=0.0001):
-        var_cksum = eofs.variance.sum(dim="mode").item()
+        var_cksum = eofs.variance.sum(dim="mode").compute().item()
         if np.isclose(var_cksum, 100):
             print(str(sec) + chr(subsec) + " OK - Original signal reconstructed from EOFs")
         else:
@@ -1570,7 +1574,7 @@
     heofs = coast.compute_hilbert_eofs(nemo_t.dataset.ssh)
 
     ssh_reconstruction = (
-        (heofs.EOF_amp * heofs.temporal_amp * uf.exp(1j * uf.radians(heofs.EOF_phase + heofs.temporal_phase)))
+        (heofs.EOF_amp * heofs.temporal_amp * np.exp(1j * np.radians(heofs.EOF_phase + heofs.temporal_phase)))
         .sum(dim="mode")
         .real.sum(dim=["x_dim", "y_dim"])
     )
@@ -1602,68 +1606,179 @@
 # -----------------------------------------------------------------------------#
 # ( 10a ) Load EN4 data                                                       #
 #                                                                             #
-
 subsec = subsec + 1
-# Create Profile object and read EN4 example data file
+# <Introduction>
 
 try:
-    # Create object without config file
-    profiles = coast.Profile(file_path=fn_profile)
-    check0 = profiles is not None
+    profile = coast.Profile(fn_profile, config=fn_profile_config)
+    profile.dataset = profile.dataset.isel(profile=np.arange(0, profile.dataset.dims["profile"], 10)).load()
 
-    # Create object with config file
-    profiles = coast.Profile(file_path=fn_profile, config=fn_profile_config)
+    check1 = type(profile) == coast.Profile
+    check2 = profile.dataset.temperature.values[0, 0] = 8.981
 
-    # TEST: Check some data
-    check1 = profiles.dataset.dims["z_dim"] == 400
-    check2 = profiles.dataset.longitude[11].values == 9.89777
-    if check0 and check1 and check2:
-        print(str(sec) + chr(subsec) + " OK - EN4 Data read, Profile created")
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK")
     else:
-        print(str(sec) + chr(subsec) + " X - Problem with EN4 reading")
+        print(str(sec) + chr(subsec) + " X")
 
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# ( 10b ) Plot locations on map                                               #
-#                                                                             #
+# ( 10b ) Process EN4 data                                                     #
+#
+subsec = subsec + 1
+# <Introduction>
 
+try:
+    processed = profile.process_en4()
+    processed.dataset.load()
+
+    check1 = type(processed) == coast.Profile
+    check2 = np.isnan(processed.dataset.temperature.values[0, 0])
+    check3 = processed.dataset.dims["profile"] == 111
+
+    if check1 and check2 and check3:
+        print(str(sec) + chr(subsec) + " OK")
+    else:
+        print(str(sec) + chr(subsec) + " X")
+
+except:
+    print(str(sec) + chr(subsec) + " FAILED.")
+
+# -----------------------------------------------------------------------------#
+# ( 10c ) Gridded obs_operaor                                                  #
+#
 subsec = subsec + 1
-# Plot profile locations on a map
+# <Introduction>
 
 try:
-    f, a = profiles.plot_map()
-    f.savefig(dn_fig + "profiles_map.png")
-    print(str(sec) + chr(subsec) + " OK - Profiles map plot saved")
+    # First read some new Gridded data
+    nemo_t = coast.Gridded(
+        fn_data=dn_files + fn_nemo_grid_t_dat, fn_domain=dn_files + fn_nemo_dom, config=fn_config_t_grid
+    )
+    nemo_t.dataset["landmask"] = nemo_t.dataset.bottom_level == 0
+    nemo_profiles = processed.obs_operator(nemo_t)
+
+    check1 = type(nemo_profiles) == coast.Profile
+    check2 = "nearest_index_x" in list(nemo_profiles.dataset.keys())
+    check3 = np.isclose(nemo_profiles.dataset.interp_dist.values[0], 151.4443554515237)
+
+    if check1 and check2 and check3:
+        print(str(sec) + chr(subsec) + " OK")
+    else:
+        print(str(sec) + chr(subsec) + " X")
+
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# ( 10c ) Plot ts diagram                                                     #
-#                                                                             #
+# ( 10d ) Vertical Interpolation                                               #
+#
+subsec = subsec + 1
+# <Introduction>
+
+try:
+    reference_depths = np.arange(0, 500, 2)
+    nemo_profiles.dataset = nemo_profiles.dataset.rename({"depth_0": "depth"})
+    model_interpolated = nemo_profiles.interpolate_vertical(processed)
+
+    check1 = type(model_interpolated) == coast.Profile
+    check2 = nemo_profiles.dataset.temperature.values[0, 0] == np.float32(1.7324219)
+
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK")
+    else:
+        print(str(sec) + chr(subsec) + " X")
 
+except:
+    print(str(sec) + chr(subsec) + " FAILED.")
+
+# -----------------------------------------------------------------------------#
+# ( 10e ) Profile Differencing                                                 #
+#
 subsec = subsec + 1
-# Plot ts diagram
+# <Introduction>
 
 try:
-    f, a = profiles.plot_ts_diagram(10)
-    f.savefig(dn_fig + "profile_ts_diagram.png")
-    print(str(sec) + chr(subsec) + " OK - Profiles ts diagram plot saved")
+    difference = processed.difference(model_interpolated)
+    difference.dataset.load()
+
+    check1 = type(difference) == coast.Profile
+    check2 = difference.dataset.diff_temperature.values[0, 2] == np.float32(1.1402345)
+
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK")
+    else:
+        print(str(sec) + chr(subsec) + " X")
+
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
 # -----------------------------------------------------------------------------#
-# ( 10d ) Plot temperature profile                                            #
-#                                                                             #
+# ( 10f ) Regional Averaging                                                   #
+#
+subsec = subsec + 1
+# <Introduction>
+
+try:
+    # First let's make some masks to define regions of the model domain
+    mm = coast.MaskMaker()
+
+    # Make some variables easier to access
+    bath = nemo_t.dataset.bathymetry.values
+    lon = nemo_t.dataset.longitude.values
+    lat = nemo_t.dataset.latitude.values
 
+    mm_north_sea = mm.region_def_nws_north_sea(lon, lat, bath)
+    mm_whole_domain = np.ones(lon.shape)
+    mask_list = [mm_north_sea, mm_whole_domain]
+    mask_names = ["North Sea", "Whole Domain"]
+
+    # Turn mask list into an xarray dataset
+    mask_list = coast.MaskMaker.make_mask_dataset(lon, lat, mask_list)
+
+    # Determine whether each profile is in each masked region or not
+    mask_indices = model_interpolated.determine_mask_indices(mask_list)
+
+    # Do average differences for each region
+    mask_means = difference.mask_means(mask_indices)
+
+    check1 = mask_means.average_diff_temperature.values[0] == np.float32(-0.78869253)
+
+    if check1:
+        print(str(sec) + chr(subsec) + " OK")
+    else:
+        print(str(sec) + chr(subsec) + " X")
+
+except:
+    print(str(sec) + chr(subsec) + " FAILED.")
+
+# -----------------------------------------------------------------------------#
+# ( 10g ) Surface/Bottom Averaging                                             #
+#
 subsec = subsec + 1
-# Plot ts diagram
+# <Introduction>
 
 try:
-    f, a = profiles.plot_profile(var="potential_temperature", profile_indices=[10])
-    f.savefig(dn_fig + "profile_temperature_diagram.png")
-    print(str(sec) + chr(subsec) + " OK - Profiles temperature plot saved")
+
+    # Lets get surface values by averaging over the top 5m of data
+    surface = 5
+    model_profiles_surface = nemo_profiles.depth_means([0, surface])
+
+    # Lets get bottom values by averaging over the bottom 30m, except whether
+    # depth is <100m, then average over the bottom 10m
+    model_profiles_bottom = nemo_profiles.bottom_means([10, 30], [100, np.inf])
+
+    check1 = type(model_profiles_surface) == coast.Profile
+    check1 = type(model_profiles_bottom) == coast.Profile
+    check3 = model_profiles_surface.dataset.temperature.values[0] == np.float32(1.7500391)
+
+    if check1 and check2 and check3:
+        print(str(sec) + chr(subsec) + " OK")
+    else:
+        print(str(sec) + chr(subsec) + " X")
+
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 # %%
@@ -1787,10 +1902,11 @@
 
 subsec = subsec + 1
 
+
 try:
     date0 = datetime.datetime(2007, 1, 15)
     date1 = datetime.datetime(2007, 1, 16)
-    tg = coast.TideGauge(fn_tidegauge, date_start=date0, date_end=date1)
+    tg = coast.Tidegauge(fn_tidegauge, date_start=date0, date_end=date1)
 
     tt, hh = stats_util.find_maxima(tg.dataset.time, tg.dataset.sea_level, method="comp")
     check1 = np.isclose((tt.values[0] - np.datetime64("2007-01-15T00:15:00")) / np.timedelta64(1, "s"), 0)
@@ -1890,6 +2006,7 @@
 sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
 ds = sci.dataset[["temperature", "ssh"]].isel(z_dim=0)
 
+
 # -----------------------------------------------------------------------------#
 # ( 14a ) Monthly and Seasonal Climatology                                     #
 #                                                                             #
@@ -1907,7 +2024,7 @@
     ds2 = ds.copy(deep=True)
     ds2["temperature"][::2, :100, :100] = np.nan
     ds2["ssh"][::2, :100, :100] = np.nan
-    seaC = clim.make_climatology(ds2, "season", missing_values=True)
+    seaC = clim.make_climatology(ds2, "season")
     seaX = ds2.groupby("time.season").mean("t_dim")
     # throws error is not close
     xr.testing.assert_allclose(seaC, seaX)
@@ -1925,6 +2042,93 @@
 except:
     print(str(sec) + chr(subsec) + " FAILED.")
 
+# %%
+"""
+#################################################
+## ( 15 ) XESMF Interface                      ##
+#################################################
+"""
+sec = sec + 1
+subsec = 96
+
+sci = coast.Gridded(dn_files + fn_nemo_dat, dn_files + fn_nemo_dom, config=fn_config_t_grid)
+
+#%%
+# -----------------------------------------------------------------------------#
+# ( 15a ) Passing a single gridded object                                     #
+#                                                                             #
+
+subsec = subsec + 1
+
+try:
+
+    xesmf_ready = xs = coast.xesmf_convert(sci)
+    check_grid = xesmf_ready.input_grid
+    check_data = xesmf_ready.input_data
+    check1 = np.array_equal(check_grid.lat.values, sci.dataset.latitude.values)
+    check2 = np.array_equal(check_data.temperature[0, 0].values, sci.dataset.temperature[0, 0].values, equal_nan=True)
+
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK - xesmf_convert with single gridded obj")
+    else:
+        print(str(sec) + chr(subsec) + " X - xesmf_convert with single gridded obj ")
+
+except AssertionError:
+    print(str(sec) + chr(subsec) + " X - xesmf_convert with single gridded obj")
+except:
+    print(str(sec) + chr(subsec) + " FAILED.")
+
+#%%
+# -----------------------------------------------------------------------------#
+# ( 15b ) Passing two gridded object                                          #
+#                                                                             #
+
+subsec = subsec + 1
+
+try:
+
+    xesmf_ready = xs = coast.xesmf_convert(sci, sci)
+    check_grid0 = xesmf_ready.input_grid
+    check_grid1 = xesmf_ready.input_data
+    check1 = np.array_equal(check_grid0.lat.values, sci.dataset.latitude.values)
+    check2 = np.array_equal(check_grid1.lat.values, sci.dataset.latitude.values)
+
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK - xesmf_convert with single gridded obj")
+    else:
+        print(str(sec) + chr(subsec) + " X - xesmf_convert with single gridded obj ")
+
+except AssertionError:
+    print(str(sec) + chr(subsec) + " X - xesmf_convert with single gridded obj")
+except:
+    print(str(sec) + chr(subsec) + " FAILED.")
+
+#%%
+# -----------------------------------------------------------------------------#
+# ( 15c ) Convert back to gridded object                                       #
+#                                                                             #
+
+subsec = subsec + 1
+
+try:
+
+    xesmf_ready = xs = coast.xesmf_convert(sci, sci)
+    check_grid0 = xesmf_ready.input_grid
+    check_grid1 = xesmf_ready.output_grid
+    gridded_again = xesmf_ready.to_gridded(xesmf_ready.input_data)
+    check1 = np.array_equal(sci.dataset.latitude, gridded_again.dataset.latitude)
+    check2 = np.array_equal(sci.dataset.temperature[0, 0], gridded_again.dataset.temperature[0, 0], equal_nan=True)
+
+    if check1 and check2:
+        print(str(sec) + chr(subsec) + " OK - xesmf_convert to_gridded()")
+    else:
+        print(str(sec) + chr(subsec) + " X - xesmf_convert to_gridded() ")
+
+except AssertionError:
+    print(str(sec) + chr(subsec) + " X - xesmf_convert to_gridded()")
+except:
+    print(str(sec) + chr(subsec) + " FAILED.")
+
 # %%
 """
 ###############################################################################
@@ -1936,6 +2140,8 @@
 
 print(str(sec) + ". Example script testing")
 print("++++++++++++++++++++++++")
+print("  script output follows  ")
+print(" ")
 #
 # -----------------------------------------------------------------------------#
 # %% ( Na ) Example script testing                                               #
@@ -1956,6 +2162,9 @@
         internal_tide_pycnocline_diagnostics,
     )  # This runs on example_files unless it is on livljobs, then it is AMM60 data
 
+    print(" ")
+    print("  script output ends  ")
+    print("++++++++++++++++++++++++")
     print(str(sec) + chr(subsec) + " OK - tutorials on example_files data")
     subsec = subsec + 1
 
@@ -1977,7 +2186,7 @@
         subsec = subsec + 1
         from example_scripts import seasia_r12_example_plot
 
-        print(str(sec) + chr(subsec) + " OK - tutorial on SEAsia data")
+        print(str(sec) + chr(subsec) + " OK - tutorial on seasia data")
 
         subsec = subsec + 1
         from example_scripts import wcssp_india_example_plot
diff --git a/unit_testing/unit_test_contents.txt b/unit_testing/unit_test_contents.txt
index 750a0670..aa4c9471 100755
--- a/unit_testing/unit_test_contents.txt
+++ b/unit_testing/unit_test_contents.txt
@@ -1,113 +1,125 @@
-|    Unit Testing Contents                                                    |
-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
-
-0. Files, directories for unit testing
-
-1. Loading & Initialisation
-    a. Loading Gridded NEMO data file
-    b. Load data from existing dataset
-    c. Set Gridded variable name
-    d. Set Gridded grid attribute - dimension names
-    e. Load only Domain
-    f. Calculate depth_0 for t,u,v,w,f grids
-    g. Load a subregion dataset with a full domain (AMM7)
-    h. Load and combine multiple files.
-    i. Load and combine harmonics
-    j. Convert harmonics to a/g and back
-    k. Compute e3[t,u,v,f,w] from SSH
-
-2. General Utility Methods in COAsT
-    a. Copying a COAsT object
-    b. COAsT __getitem__ returns variable
-    c. Renaming variables inside a COAsT object
-    d. day of the week function
-
-3. Diagnostic Methods
-    a. Compute vertical spatial derivative
-    b. Construct density method inside Gridded class
-    c. Construct pycnocline depth and thickness
-    d. Plot pycnocline depth
-
-4. Transect Methods
-    a. Determine and extract transect indices
-    b. Transport velocity and depth calculations
-    c. Transport and velocity plotting
-    d. Construct density on z-levels along the transect. Compare with item 3b.
-    e. Geostrophic velocity & transport calculations
-
-5. Object Manipulation (e.g. indexing, subsetting)
-    a. Subsetting single variable
-    b. Indices by distance
-    c. Find nearest xy indices
-    d. Gridded.interpolate_in_space()
-    e. Gridded.interpolate_in_time()
-
-6. Altimetry Methods
-    a. Load example data
-    b. Subset altimetry data
-    c. Interpolate model onto altimetry using obs_operator
-    d. Calculate CRPS
-    e. ALTIMETRY basic stats
-    f. Altimetry plotting
-
-7. TIDEGAUGE Methods
-    a. Load GESLA data
-    b. Load BODC data
-    c. Load EA data via API
-    d. TIDEGAUGE obs operator
-    e. Tidegauge CRPS
-    f. Tiudegauge basic stats
-    g. Resample TIDEGAUGE
-    h. Apply Doodson XO Filter to data
-    i. Loading multiple tidegauges
-    j. Plotting a single tidegauge location
-    k. Plotting multiple tidegauge locations
-    l. Tidegauge time series plot
-    m. TIDEGAUGE method for tabulated data
-    n. TIDEGAUGE method for finding peaks and troughs, compare neighbours
-    o. TIDEGAUGE method for finding extrema and troughs, fit cubic spline
-
-8. Isobath Contour Methods
-    a. Extract isbath contour between two points
-    b. Plot contour on map
-    c. Calculate pressure along contour
-    d. Calculate flow across contour
-    e. Calculate pressure gradient driven flow across contour
-
-9. EOF methods
-    a. Compute EOFs, projections and variance
-    b. Compute HEOFs, projections and variance
-
-10. Profile Methods
-    a. Load En4 data
-    b. Process En4 data
-    c. Gridded obs_operator
-    d. Vertical Interpolation
-    e. Profile differencing
-    f. Regional averaging
-    g. Surface and Bottm averaging.
-
-11. Plotting Utility
-    a. scatter_with_fit()
-    b. create_geo_axes()
-    c. determine_colorbar_extension()
-    d. determine_clim_by_standard_deviation()
-
-12. Stats Utility
-    a. find_maxima()
-
-13. MASK_MASKER
-    a. Create mask by indices
-    b. Create mask by lonlat
-
-14. CLIMATOLOGY
-    a. Create monthly and seasonal climatology, write to file
-
-N. Example script testing
-    a. tutorials using example_files (altimetry and tidegauges)
-    b. tutorial on AMM15 data
-    c. tutorial on AMM60 data
-    d. tutorial on Belize data
-    e. tutorial on SEAsia data
-    f. tutorial on WCSSP-India data
-    g. tutorial on internal tides
+     UNIT TEST CONTENTS FILE TEST    
+
+1. test_gridded_initialisation
+      a. gridded_calculate_depth0_for_tuvwf
+      b. gridded_compute_e3_from_ssh
+      c. gridded_load_dimensions_correctly_renamed
+      d. gridded_load_domain_only
+      e. gridded_load_multiple
+      f. gridded_load_of_data_and_domain
+      g. gridded_load_of_data_only
+      h. gridded_load_subregion_with_domain
+      i. gridded_load_variables_correctly_renamed
+
+2. test_general_utils
+      a. bst_to_gmt
+      b. coast_variable_renaming
+      c. copy_coast_object
+      d. day_of_week
+      e. getitem
+
+3. test_xesmf_convert
+      a. basic_conversion_to_xesmf
+
+4. test_gridded_harmonics
+      a. combine_and_convert_harmonics
+
+5. test_diagnostic_methods
+      a. compute_vertical_spatial_derivative
+      b. construct_density
+      c. construct_pycnocline_depth_and_thickness
+
+6. test_transect_methods
+      a. calculate_transport_velocity_and_depth
+      b. cross_transect_geostrophic_flow
+      c. determine_extract_transect_indices
+      d. transect_density_and_pressure
+
+7. test_object_manipulation
+      a. indices_by_distance
+      b. interpolation_to_altimetry
+      c. subset_single_variable
+
+8. test_altimetry_methods
+      a. altimetry_load_subset_and_comparison
+
+9. test_tidegauge_methods
+      a. load_multiple_tidegauge
+      b. read_gesla_and_compare_to_model
+      c. tidegauge_cubic_spline_extrema
+      d. tidegauge_finding_extrema
+      e. tidegauge_for_tabulated_data
+      f. tidegauge_resample_and_apply_doodsonx0
+      g. time_mean
+      h. time_slice
+      i. time_std
+
+10. test_tidegauge_analysis
+      a. difference
+      b. harmonic_analysis_utide
+      c. match_missing_values
+      d. threshold_statistics
+
+11. test_eof_methods
+      a. compute_heofs
+      b. compute_regular_eof
+
+12. test_contour_f_methods
+      a. calculate_flow_across_contour
+      b. calculate_pressure_gradient_driven_flow
+      c. extract_isobath_contour_between_two_points
+
+13. test_contour_t_methods
+      a. along_contour_2d_flow
+      b. along_contour_flow
+      c. calculate_pressure_along_contour
+
+14. test_profile_methods
+      a. compare_processed_profile_with_model
+      b. load_process_and_compare_profile_data
+
+15. test_plot_utilities
+      a. determine_clim_by_stdev
+      b. determine_colorbar_extension
+      c. geo_axes
+      d. scatter_with_fit
+
+16. test_stats_utilities
+      a. find_maxima
+
+17. test_maskmaker_methods
+      a. fill_polygon_by_index
+      b. fill_polygon_by_lonlat
+
+18. test_climatology
+      a. monthly_and_seasonal_climatology
+
+19. test_wod_read_data
+      a. load_wod
+      b. reshape_wod
+
+20. test_bgc_gridded_initialisation
+      a. gridded_load_bgc_data
+      b. gridded_load_bgc_data_and_domain
+      c. gridded_load_bgc_dimensions_correctly_renamed
+      d. gridded_load_bgc_domain_only
+      e. gridded_load_bgc_variables_correctly_renamed
+
+21. test_process_data_methods
+      a. statsmodel_seasonal_decompose
+
+22. test_example_scripts
+      a. altimetry_tutorial
+      b. amm15_example_plot
+      c. anchor_plots_of_nsea_wvel
+      d. blz_example_plot
+      e. contour_tutorial
+      f. export_to_netcdf_tutorial
+      g. internal_tide_pycnocline_diagnostics
+      h. seasia_dic_example_plot
+      i. seasia_r12_example_plot
+      j. tidetable_tutorial
+      k. transect_tutorial
+      l. wcssp_india_example_plot
+      m. wod_bgc_ragged_example
+
diff --git a/unit_testing/unit_test_contents_OLD.txt b/unit_testing/unit_test_contents_OLD.txt
new file mode 100755
index 00000000..148f4c4e
--- /dev/null
+++ b/unit_testing/unit_test_contents_OLD.txt
@@ -0,0 +1,113 @@
+|    Unit Testing Contents                                                    |
+*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
+
+0. Files, directories for unit testing
+
+1. Loading & Initialisation
+    a. Loading Gridded NEMO data file
+    b. Load data from existing dataset
+    c. Set Gridded variable name
+    d. Set Gridded grid attribute - dimension names
+    e. Load only Domain
+    f. Calculate depth_0 for t,u,v,w,f grids
+    g. Load a subregion dataset with a full domain (AMM7)
+    h. Load and combine multiple files.
+    i. Load and combine harmonics
+    j. Convert harmonics to a/g and back
+    k. Compute e3[t,u,v,f,w] from SSH
+
+2. General Utility Methods in COAsT
+    a. Copying a COAsT object
+    b. COAsT __getitem__ returns variable
+    c. Renaming variables inside a COAsT object
+    d. day of the week function
+
+3. Diagnostic Methods
+    a. Compute vertical spatial derivative
+    b. Construct density method inside Gridded class
+    c. Construct pycnocline depth and thickness
+    d. Plot pycnocline depth
+
+4. Transect Methods
+    a. Determine and extract transect indices
+    b. Transport velocity and depth calculations
+    c. Transport and velocity plotting
+    d. Construct density on z-levels along the transect. Compare with item 3b.
+    e. Geostrophic velocity & transport calculations
+
+5. Object Manipulation (e.g. indexing, subsetting)
+    a. Subsetting single variable
+    b. Indices by distance
+    c. Find nearest xy indices
+    d. Gridded.interpolate_in_space()
+    e. Gridded.interpolate_in_time()
+
+6. Altimetry Methods
+    a. Load example data
+    b. Subset altimetry data
+    c. Interpolate model onto altimetry using obs_operator
+    d. Calculate CRPS
+    e. ALTIMETRY basic stats
+    f. Altimetry plotting
+
+7. TIDEGAUGE Methods
+    a. Load GESLA data
+    b. Load BODC data
+    c. Load EA data via API
+    d. TIDEGAUGE obs operator
+    e. Tidegauge CRPS
+    f. Tiudegauge basic stats
+    g. Resample TIDEGAUGE
+    h. Apply Doodson XO Filter to data
+    i. Loading multiple tidegauges
+    j. Plotting a single tidegauge location
+    k. Plotting multiple tidegauge locations
+    l. Tidegauge time series plot
+    m. TIDEGAUGE method for tabulated data
+    n. TIDEGAUGE method for finding peaks and troughs, compare neighbours
+    o. TIDEGAUGE method for finding extrema and troughs, fit cubic spline
+
+8. Isobath Contour Methods
+    a. Extract isbath contour between two points
+    b. Plot contour on map
+    c. Calculate pressure along contour
+    d. Calculate flow across contour
+    e. Calculate pressure gradient driven flow across contour
+
+9. EOF methods
+    a. Compute EOFs, projections and variance
+    b. Compute HEOFs, projections and variance
+
+10. Profile Methods
+    a. Load En4 data
+    b. Process En4 data
+    c. Gridded obs_operator
+    d. Vertical Interpolation
+    e. Profile differencing
+    f. Regional averaging
+    g. Surface and Bottm averaging.
+
+11. Plotting Utility
+    a. scatter_with_fit()
+    b. create_geo_axes()
+    c. determine_colorbar_extension()
+    d. determine_clim_by_standard_deviation()
+
+12. Stats Utility
+    a. find_maxima()
+
+13. MASK_MASKER
+    a. Create mask by indices
+    b. Create mask by lonlat
+
+14. CLIMATOLOGY
+    a. Create monthly and seasonal climatology, write to file
+
+N. Example script testing
+    a. tutorials using example_files (altimetry and tidegauges)
+    b. tutorial on AMM15 data
+    c. tutorial on AMM60 data
+    d. tutorial on Belize data
+    e. tutorial on seasia data
+    f. tutorial on WCSSP-India data
+    g. tutorial on internal tides
diff --git a/unit_testing/unit_test_files.py b/unit_testing/unit_test_files.py
new file mode 100644
index 00000000..71e2ce94
--- /dev/null
+++ b/unit_testing/unit_test_files.py
@@ -0,0 +1,59 @@
+"""
+Files definitions to use for unit testing.
+
+You can easily use any of these files in a test class by importing this file.
+For example:
+    
+    import unit_test_files
+    
+    get_dom = unit_test_files.fn_nemo_dom
+"""
+
+import os.path as path
+
+# All directories relative to unit_testing diretory
+dn_files = "./example_files/"  # Example data directory
+dn_config = "./config"  # Example config file directory
+dn_fig = "./unit_testing/figures/"  # Figure saving directory
+dn_scripts = "./example_scripts"  # Example scripts directory
+
+# Check dn_files directory
+if not path.isdir(dn_files):
+    print("please go download the examples file from https://linkedsystems.uk/erddap/files/COAsT_example_files/")
+    dn_files = input("what is the path to the example files:\n")
+    if not path.isdir(dn_files):
+        print(f"location f{dn_files} cannot be found")
+
+# Data files
+fn_nemo_grid_t_dat_summer = path.join(dn_files, "nemo_data_T_grid_Aug2015.nc")
+fn_nemo_grid_t_dat = path.join(dn_files, "nemo_data_T_grid.nc")
+fn_nemo_grid_u_dat = path.join(dn_files, "nemo_data_U_grid.nc")
+fn_nemo_grid_v_dat = path.join(dn_files, "nemo_data_V_grid.nc")
+fn_nemo_dat = path.join(dn_files, "coast_example_nemo_data.nc")
+fn_nemo_dat_subset = path.join(dn_files, "coast_example_nemo_subset_data.nc")
+file_names_amm7 = path.join(dn_files, "nemo_data_T_grid*.nc")
+fn_altimetry = path.join(dn_files, "coast_example_altimetry_data.nc")
+fn_tidegauge = path.join(dn_files, "tide_gauges/lowestoft-p024-uk-bodc")
+fn_tidegauge2 = path.join(dn_files, "tide_gauges/LIV2010.txt")
+fn_multiple_tidegauge = path.join(dn_files, "tide_gauges/l*")
+fn_gladstone = path.join(dn_files, "Gladstone_2020-10_HLW.txt")
+fn_nemo_harmonics = path.join(dn_files, "coast_nemo_harmonics.nc")
+fn_nemo_harmonics_dom = path.join(dn_files, "coast_nemo_harmonics_dom.nc")
+fn_profile = path.join(dn_files, "coast_example_EN4_201008.nc")
+fn_wod = path.join(dn_files, "WOD_example_ragged_standard_level.nc")
+fn_nemo_bgc = path.join(dn_files, "coast_example_SEAsia_BGC_1990.nc")
+
+# Domain files
+fn_nemo_dom = path.join(dn_files, "coast_example_nemo_domain.nc")
+fn_nemo_dom_bgc = path.join(dn_files, "coast_example_domain_SEAsia.nc")
+
+# Configuration files
+fn_profile_config = path.join(dn_config, "example_en4_profiles.json")
+fn_wod_config = path.join(dn_config, "example_wod_profiles.json")
+fn_altimetry_config = path.join(dn_config, "example_altimetry.json")
+fn_config_t_grid = path.join(dn_config, "example_nemo_grid_t.json")
+fn_config_f_grid = path.join(dn_config, "example_nemo_grid_f.json")
+fn_config_u_grid = path.join(dn_config, "example_nemo_grid_u.json")
+fn_config_v_grid = path.join(dn_config, "example_nemo_grid_v.json")
+fn_config_w_grid = path.join(dn_config, "example_nemo_grid_w.json")
+fn_nemo_config_bgc = path.join(dn_config, "example_nemo_bgc.json")