From 79a19a751887e6f8df0ba9e3d5051dc7e084be18 Mon Sep 17 00:00:00 2001
From: thopri <thopri@noc.ac.uk>
Date: Tue, 5 May 2020 12:30:43 +0100
Subject: [PATCH] added tide test script comparing PyNEMO with reference model
 (FES only)

---
 inputs/namelist_cmems.bdy      |   2 +-
 pynemo/tests/nemo_tide_test.py | 217 +++++++++++++++++++++++++++++++++
 pynemo/tide/fes_extract_HC.py  |  18 ++-
 pynemo/unit_tests/__init__.py  |   0
 pynemo_37.yml                  |   1 +
 5 files changed, 232 insertions(+), 6 deletions(-)
 create mode 100644 pynemo/tests/nemo_tide_test.py
 create mode 100644 pynemo/unit_tests/__init__.py

diff --git a/inputs/namelist_cmems.bdy b/inputs/namelist_cmems.bdy
index 2bc9c3a..c033aa7 100644
--- a/inputs/namelist_cmems.bdy
+++ b/inputs/namelist_cmems.bdy
@@ -96,7 +96,7 @@
                                           !  barotropic fields
     ln_dyn3d       = .false.              !  boundary conditions for
                                           !  baroclinic velocities
-    ln_tra         = .true.               !  boundary conditions for T and S
+    ln_tra         = .false.               !  boundary conditions for T and S
     ln_ice         = .false.              !  ice boundary condition   
     nn_rimwidth    = 9                    !  width of the relaxation zone
 
diff --git a/pynemo/tests/nemo_tide_test.py b/pynemo/tests/nemo_tide_test.py
new file mode 100644
index 0000000..6523a45
--- /dev/null
+++ b/pynemo/tests/nemo_tide_test.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3.7
+# -*- coding: utf-8 -*-
+"""
+Created on Fri May 01 2020
+
+@author: thopri
+example usage:
+from pynemo.tides import nemo_tide_test as tt
+tt.main()
+
+all parameters have defaults applied if not supplied:
+location of bdy file - 'inputs/namelist_cmems.bdy'
+amplitude threshold - 0.25 m
+phase threshold - 10.00 degrees
+model resolution - 1/16 degree
+model - 'fes'
+
+The script generates a excel spreadsheet that contains the locations and amplitudes and phases for all HC's
+defined in the bdy file that exceed the default or defined the thresholds passed to the main function.
+File locations e.g. model reference location etc are all taken from bdy file that is passed to the main function
+
+To do this the script compiles a list of PyNEMO boundary amplitudes and phases and lat/lon's, finds the closest value
+in the reference model (currently only FES is supported), and then compares them. If the absolute difference is greater
+than defined threshold then the location and parameter (either Amp or Phase) is returned within a Pandas Dataframe
+which is then written to a spreadsheet.
+
+Notes:
+The script checks the Amplitude and Phase independently, so lat/lons for each are also returned. Each HC is saved to
+a separate sheet in the spreadsheet. The name of the spreadsheet contains meta data showing thresholds and reference
+model used.
+
+"""
+from netCDF4 import Dataset
+import numpy as np
+import logging
+import time
+import pandas as pd
+from pynemo import nemo_bdy_setup as setup
+
+# log to PyNEMO log file
+logger = logging.getLogger(__name__)
+logging.basicConfig(filename='nrct.log', level=logging.INFO)
+
+# TODO: add TPXO comparision functionality currently only uses FES as "truth"
+
+def main(bdy_file='inputs/namelist_cmems.bdy',amplitude_threshold = 0.25,phase_threshold=10.00,model_res=1/16,model='fes'):
+    logger.info('============================================')
+    logger.info('Start Tide Test Logging: ' + time.asctime())
+    logger.info('============================================')
+    # get settings dict based on bdy file
+    Setup = setup.Setup(bdy_file)  # default settings file
+    settings = Setup.settings
+    constituents = settings['clname']
+    # TODO maybe define grids in bdy file? at the moment Z, U and Vs are generated no option for Z only.
+    grids = ['Z','U','V']
+    if model == 'fes':
+        logger.info('using FES as reference.......')
+        # open writer object to write pandas dataframes to spreadsheet
+        writer = pd.ExcelWriter(settings['dst_dir'] + 'exceed_values_amp_thres-'+str(amplitude_threshold)+'_phase_thres-'+str(phase_threshold)+'_reference_model-'+str(model)+'.xlsx', engine='xlsxwriter')
+        for key in constituents:
+            for j in range(len(grids)):
+                out_fname = settings['dst_dir']+settings['fn']+'_bdytide_'+constituents[key].strip("',/\n")+'_grd_'+grids[j]+'.nc'
+                logger.info('processing output file '+out_fname)
+                fes_fname = settings['tide_fes']+constituents[key].strip("',/\n")+'_'+grids[j]+'.nc'
+                # read in FES data (whole globe)
+                fes = read_fes(fes_fname, grids[j])
+                grid = grids[j].lower()
+                # extract PyNEMO data from output files (generate list of lats,lons etc)
+                pynemo_out = extract_PyNEMO_output(out_fname, grid)
+                # subset FES to match PyNEMO list of lat lons
+                subset = subset_FES(pynemo_out, fes)
+                # compare the two lists (or dicts really)
+                error_log = compare_tides(pynemo_out, subset, amplitude_threshold, phase_threshold, model_res)
+                # return differences above threshold as a Pandas Dataframe and name using HC and Grid
+                error_log.name = constituents[key].strip("',/\n") + grids[j]
+                # if the dataframe is empty (no exceedance) then discard dataframe and log
+                if error_log.empty == True:
+                    logger.info(
+                        'output file does not exceed threshold when compared with reference model..... thats good!')
+                # if dataframe has values then these exceed the threshold, log and save to excel spreadsheet using dataset
+                # name e.g. M2Z (based on HC and grid) as name for the sheet
+                if error_log.empty == False:
+                    logger.warning(
+                        'Exceedance in thesholds detected, check spreadsheet in dst_dir')
+                    error_log.to_excel(writer,sheet_name=error_log.name)
+                # close writer object and save excel spreadsheet
+                writer.save()
+    # code runs here if TPXO is requested as reference this hasn't been written yet so raises exception
+    elif model == 'tpxo':
+        logger.exception('not set up to use TPXO yet...... exiting')
+        raise Exception('Not setup for TPXO use FES instead?')
+    # everything else goes here which shouldn't happen so is raised as an exception
+    else:
+        logger.exception('Tide reference model not recognised.... exiting')
+        raise Exception('Invalid tide referece model name provided')
+    return 0
+
+    # find nearest value in array used for finding subset of Lat and Lon
+def find_nearest(array, value):
+    array = np.asarray(array)
+    idx = (np.abs(array - value)).argmin()
+    return idx
+
+    # extract PyNEMO output from netcdf file, convert HcosG and HsinG to Amp and Phase
+    # and extract lons and lats from I and J coords. return a dict
+def extract_PyNEMO_output(out_fname,grid):
+    tide_out = Dataset(out_fname)
+    nav_lat = tide_out.variables['nav_lat'][:]
+    nav_lon = tide_out.variables['nav_lon'][:]
+    nbidta = tide_out.variables['nbidta'][:]
+    nbjdta = tide_out.variables['nbjdta'][:]
+    cosine = np.array(tide_out.variables[grid+'1'][:])
+    sine = np.array(tide_out.variables[grid+'2'][:])
+    amp = np.hypot(sine,cosine)
+    phase = np.arctan2(sine[0,:],cosine[0,:])
+    phase = np.degrees(phase)
+    lat = np.array(nav_lat[nbjdta, nbidta])
+    lon = np.array(nav_lon[nbjdta, nbidta])
+    pynemo_out = {'lat':lat,'lon':lon,'amp':amp,'phase':phase}
+    return pynemo_out
+
+    # read FES netcdf file, convert lon to -180 to 180, rather than 0-360
+    # return a dict
+def read_fes(fes_fname,grid):
+    fes_tide = Dataset(fes_fname)
+    if grid == 'Z':
+        fes_amp = np.array(fes_tide.variables['amplitude'][:])
+        fes_phase = np.array(fes_tide.variables['phase'][:])
+        fes_phase[fes_phase > 180.0] = fes_phase[fes_phase > 180.0] - 360.0
+    if grid != 'Z':
+        fes_amp = np.array(fes_tide.variables[grid+'a'][:])
+        fes_phase = np.array(fes_tide.variables[grid+'g'][:])
+        fes_phase[fes_phase > 180.0] = fes_phase[fes_phase > 180.0] - 360.0
+
+    fes_lat = fes_tide.variables['lat'][:]
+    fes_lon = fes_tide.variables['lon'][:]
+    # change to -180 to 180 lonitude convention
+    fes_lon[fes_lon > 180.0] = fes_lon[fes_lon > 180.0] - 360.0
+    fes_dict = {'lat':fes_lat,'lon':fes_lon,'amp':fes_amp,'phase':fes_phase}
+    return fes_dict
+
+    # subset FES dict from read_FES, this uses find_nearest to find nearest FES point using PyNEMO dict from extract_PyNEMO
+    # It also converts FES amplitude from cm to m.
+def subset_FES(pynemo_out, fes):
+    idx_lat = np.zeros(np.shape(pynemo_out['lat']))
+    for i in range(np.shape(pynemo_out['lat'])[1]):
+        idx_lat[0, i] = find_nearest(fes['lat'], pynemo_out['lat'][0, i])
+    idx_lat = idx_lat.astype(np.int64)
+
+    idx_lon = np.zeros(np.shape(pynemo_out['lon']))
+    for i in range(np.shape(pynemo_out['lon'])[1]):
+        idx_lon[0, i] = find_nearest(fes['lon'], pynemo_out['lon'][0, i])
+    idx_lon = idx_lon.astype(np.int64)
+
+    fes_amp_sub = fes['amp'][idx_lat, idx_lon]
+    fes_amp_sub = fes_amp_sub/100
+    fes_phase_sub = fes['phase'][idx_lat, idx_lon]
+    fes_lat_sub = fes['lat'][idx_lat]
+    fes_lon_sub = fes['lon'][idx_lon]
+    subset = {'lat':fes_lat_sub,'lon':fes_lon_sub,'amp':fes_amp_sub,'phase':fes_phase_sub}
+    return subset
+
+    # takes pynemo extract dict, subset fes dict, and the thresholds and model res passed to main function.
+    # returns a Pandas Dataframe with any PyNEMO values that exceed the nearest FES point by defined threshold
+    # It also checks lats and lons are within the model reference resolution
+    # i.e. ensure closest model reference point is used.
+def compare_tides(pynemo_out,subset_fes,amp_thres,phase_thres,model_res):
+    # compare lat and lons
+    diff_lat = np.abs(pynemo_out['lat']-subset_fes['lat'])
+    diff_lon = np.abs(pynemo_out['lon'] - subset_fes['lon'])
+    exceed_lat = diff_lat > model_res
+    exceed_lon = diff_lon > model_res
+    exceed_sum = np.sum(exceed_lat+exceed_lon)
+    if exceed_sum > 0:
+        raise Exception('Dont Panic: Lat and/or Lon further away from model point than model resolution')
+    # compare amp
+    abs_amp = np.abs(pynemo_out['amp']-subset_fes['amp'])
+    abs_amp_thres = abs_amp > amp_thres
+    err_amp = pynemo_out['amp'][abs_amp_thres].tolist()
+    err_amp_lats = pynemo_out['lat'][abs_amp_thres].tolist()
+    err_amp_lons = pynemo_out['lon'][abs_amp_thres].tolist()
+
+    # compare phase
+    abs_ph = np.abs(pynemo_out['phase']-subset_fes['phase'])
+    abs_ph_thres = abs_ph > phase_thres
+    err_pha = pynemo_out['phase'][abs_ph_thres[0,:]].tolist()
+    err_pha_lats = pynemo_out['lat'][abs_ph_thres].tolist()
+    err_pha_lons = pynemo_out['lon'][abs_ph_thres].tolist()
+
+    lerr_pha, lerr_amp = len(err_pha), len(err_amp)
+    max_len = max(lerr_pha, lerr_amp)
+    if not max_len == lerr_pha:
+        err_pha.extend([''] * (max_len - lerr_pha))
+        err_pha_lats.extend([''] * (max_len - lerr_pha))
+        err_pha_lons.extend([''] * (max_len - lerr_pha))
+    if not max_len == lerr_amp:
+        err_amp.extend([''] * (max_len - lerr_amp))
+        err_amp_lats.extend([''] * (max_len - lerr_amp))
+        err_amp_lons.extend([''] * (max_len - lerr_amp))
+
+    err_log = pd.DataFrame({'amp_lat':err_amp_lats,
+                            'amp_lon':err_amp_lons,
+                            'amp':err_amp,
+                            'phase_lat':err_pha_lats,
+                            'phase_lon':err_pha_lons,
+                            'phase':err_pha})
+
+
+    return err_log
+
+if __name__ == '__main__':
+    main()
+
+
+
+
+
diff --git a/pynemo/tide/fes_extract_HC.py b/pynemo/tide/fes_extract_HC.py
index 832bdba..7b3a229 100644
--- a/pynemo/tide/fes_extract_HC.py
+++ b/pynemo/tide/fes_extract_HC.py
@@ -53,23 +53,22 @@ class HcExtract(object):
             lon_resolution = lon_z[1] - lon_z[0]
             data_in_km = 0 # added to maintain the reference to matlab tmd code
 
-            # extract example amplitude grid for Z, U and V and change NaNs to 1 (for land) and other values to 0 (for water)
+            # extract example amplitude grid for Z, U and V and change NaNs to 0 (for land) and other values to 1 (for water)
             mask_z = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[0]+'_Z.nc').variables['amplitude'][:])))
+            mask_z[mask_z != 18446744073709551616.00000] = 1
             mask_z[mask_z == 18446744073709551616.00000] = 0
-            mask_z[mask_z != 0] = 1
             self.mask_dataset[mz_name] = mask_z
 
             mask_u = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[0]+'_U.nc').variables['Ua'][:])))
+            mask_z[mask_z != 18446744073709551616.00000] = 1
             mask_u[mask_u == 18446744073709551616.00000] = 0
-            mask_u[mask_u != 0] = 1
             self.mask_dataset[mu_name] = mask_u
 
             mask_v = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[0]+'_V.nc').variables['Va'][:])))
+            mask_z[mask_z != 18446744073709551616.00000] = 1
             mask_v[mask_v == 18446744073709551616.00000] = 0
-            mask_v[mask_v != 0] = 1
             self.mask_dataset[mv_name] = mask_v
 
-
             #read and convert the height_dataset file to complex and store in dicts
             hRe = []
             hIm = []
@@ -77,9 +76,12 @@ class HcExtract(object):
             lon_z = np.array(Dataset(settings['tide_fes'] + constituents[0] + '_Z.nc').variables['lon'][:])
             for ncon in range(len(constituents)):
                 amp = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+str(constituents[ncon])+'_Z.nc').variables['amplitude'][:])))
+                # set fill values to zero
                 amp[amp == 18446744073709551616.00000] = 0
+                # convert to m from cm
                 amp = amp/100.00
                 phase = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[ncon]+'_Z.nc').variables['phase'][:])))
+                phase[phase == 18446744073709551616.00000] = 0
                 hRe.append(amp*np.sin(phase))
                 hIm.append(amp*np.cos(phase))
             hRe = np.stack(hRe)
@@ -93,9 +95,12 @@ class HcExtract(object):
             lon_u = np.array(Dataset(settings['tide_fes'] + constituents[0] + '_U.nc').variables['lon'][:])
             for ncon in range(len(constituents)):
                 amp = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[ncon]+'_U.nc').variables['Ua'][:])))
+                # set fill values to zero
                 amp[amp == 18446744073709551616.00000] = 0
+                # convert to cm from m
                 amp = amp/100.00
                 phase = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[ncon]+'_U.nc').variables['Ug'][:])))
+                phase[phase == 18446744073709551616.00000] = 0
                 URe.append(amp*np.sin(phase))
                 UIm.append(amp*np.cos(phase))
             URe = np.stack(URe)
@@ -108,9 +113,12 @@ class HcExtract(object):
             lon_v = np.array(Dataset(settings['tide_fes'] + constituents[ncon] + '_V.nc').variables['lon'][:])
             for ncon in range(len(constituents)):
                 amp = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[ncon]+'_V.nc').variables['Va'][:])))
+                # set fill value to zero
                 amp[amp == 18446744073709551616.00000] = 0
+                # convert m to cm
                 amp = amp/100.00
                 phase = np.ma.MaskedArray.filled(np.flipud(np.rot90(Dataset(settings['tide_fes']+constituents[ncon]+'_V.nc').variables['Vg'][:])))
+                phase[phase == 18446744073709551616.00000] = 0
                 VRe.append(amp*np.sin(phase))
                 VIm.append(amp*np.cos(phase))
             VRe = np.stack(VRe)
diff --git a/pynemo/unit_tests/__init__.py b/pynemo/unit_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pynemo_37.yml b/pynemo_37.yml
index b2d0e02..c10e960 100644
--- a/pynemo_37.yml
+++ b/pynemo_37.yml
@@ -11,6 +11,7 @@ dependencies:
   - pandas=1.0.1
   - pytest=5.3.5
   - xarray=0.15.0
+  - xlsxwriter=1.2.8
   - pip:
     - idna==2.9
     - lxml==4.5.0
-- 
GitLab