Source code for wavespectra.input.awac

"""Reader for AWAC spectra from NMEA data

https://www.nortekgroup.com/

The spectra are reconstructed from the Fourier coefficients using the maximum entropy method to remove negative energy.
Normalization is applied per frequency to keep energy over directions identical to energy in non-directional spectrum
The 2D spectral data is scaled to the significant wave height (Hs) given in the wave parameters.

The time is returned as read from the timestamps. The time-zone depends on the configuration of the device and is not included in the output.

revision history:
-----------------
2024-08-15 : First version - Ruben de Bruin


Data description and example data:
-------------


Fourier coeffcients:

Field Description Form
0 Identifier “$PNORF”
1 Fourier coefficient flag (A1/B1/A2/B2) "CC"
2 Date MMDDYY
3 Time hhmmss.s
4 Spectrum basis type (0=pressure, 1=velocity,
3=AST) n
5 Start Frequency (Hz) d.dd
6 Step Frequency (Hz) d.dd
7 Number of Frequencies N nnn
8 Fourier Coefficient CC [frequency 1] d.dddd
9 Fourier Coefficient CC [frequency 2] d.dddd
N+7 Fourier Coefficient CC [frequency N] d.dddd
N+8 Checksum (hex) *hh

$PNORF,A1,050120,000101,3,0.02,0.01,48,-0.1370,-0.1413,-0.2548,-0.7437,-0.9243,-0.9156,-0.9092,-0.9027,-0.8771,-0.8651,-0.8662,-0.8520,-0.8440,-0.8249,-0.8166,-0.8455,-0.8518,-0.8404,-0.8077,-0.7297,-0.6421,-0.5878,-0.5803,-0.5477,-0.5559,-0.5445,-0.3028,-0.2844,-0.1277,-0.2942,-0.2004,-0.1690,-0.1242,-0.0984,-0.0428,-0.0352,-0.0103,-0.0138,0.0057,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000*06
$PNORF,B1,050120,000101,3,0.02,0.01,48,0.2317,0.2715,0.2530,0.2815,0.3104,0.3728,0.3932,0.3831,0.3758,0.3861,0.3942,0.4068,0.3551,0.3375,0.2897,0.2033,0.1489,0.0765,0.1139,0.1978,0.2613,0.1794,0.2080,0.0510,0.0194,-0.0606,0.0258,0.1475,0.1210,0.0400,0.0183,-0.0247,0.0287,0.0026,-0.0161,-0.0041,0.0134,0.0183,0.0113,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000*05
$PNORF,A2,050120,000101,3,0.02,0.01,48,0.0298,-0.0182,0.0292,0.5327,0.7465,0.6843,0.6601,0.6511,0.5770,0.5368,0.5383,0.5018,0.5014,0.4659,0.4671,0.5400,0.5509,0.5443,0.4952,0.3610,0.2616,0.2780,0.3188,0.2016,0.1471,0.2066,0.1387,0.0577,0.0479,0.0829,0.0135,0.0651,-0.0119,0.0106,-0.0576,-0.0748,-0.0762,-0.0359,-0.0048,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000*2B
$PNORF,B2,050120,000101,3,0.02,0.01,48,-0.0619,-0.0377,-0.0514,-0.3674,-0.5502,-0.6668,-0.7015,-0.6633,-0.6189,-0.6227,-0.6373,-0.6422,-0.5306,-0.4705,-0.3596,-0.2469,-0.1640,-0.0557,-0.0666,-0.1313,-0.1363,-0.0540,-0.0295,0.0234,0.0481,0.0893,0.0843,-0.0011,0.0024,0.0494,0.1149,0.1554,0.0620,-0.0601,-0.0865,-0.0371,-0.0058,-0.0191,-0.0185,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000,-9.0000*0C

Spectra:

Field Description Form
0 Identifier “$PNORE”
1 Date MMDDYY
2 Time hhmmss.s
3 Spectrum basis type (0=pressure, 1=velocity,3=AST) n
4 Start Frequency (Hz) d.dd
5 Step Frequency (Hz) d.dd
6 Number of Frequencies N nnn
7 Energy Density [frequency 1] (cm2/Hz) dddd.ddd
8 Energy Density [frequency 2] (cm2/Hz) dddd.ddd
N+6 Energy Density [frequency N] (cm2/Hz) dddd.ddd

$PNORE,050120,000101,3,0.02,0.01,98,0.063,0.029,0.020,0.182,2.671,5.169,6.311,4.587,2.876,1.802,1.817,1.
442,1.163,0.878,0.653,0.575,0.549,0.409,0.306,0.195,0.184,0.167,0.159,0.119,0.105,0.085,0.072,0.052,0.053,0.045,0.034,0.025,0.022,0.018,0.018,0.018,0.014,0.011,0.008,0.008,0.008,0.007,0.006,0.006,0.005,0.005,0.005,0.005,0.005,0.004,0.003,0.003,0.002,0.003,0.002,0.002,0.002,0.001,0.002,0.002,0.002,0.002,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.001,0.000,0.000,0.000,0.000,0.000,0.000*75

Wave Parameters:

Field Description Form
0 Identifier “$PNORW”
1 Date MMDDYY
2 Time hhmmss
3 Spectrum basis type (0=pressure, 1=velocity,3=AST)
4 Processing method (1=PUV, 2=SUV, 3=MLM,4=MLMST)
5 Hm0 (m) dd.dd
6 H3 (m) dd.dd
7 H10 (m) dd.dd
8 Hmax (m) dd.dd
9 Tm02 (s) dd.dd
10 Tp (s) dd.dd
11 Tz (s) dd.dd
12 DirTp (deg) ddd.dd
13 SprTp (deg) ddd.dd
14 Main Direction (deg) ddd.dd
15 Unidirectivity Index dd.dd
16 Mean pressure (dbar) dd.dd
17 Number of no detects n
18 Number of bad detects n
19 Near surface Current speed (m/s) dd.dd
20 Near surface Current direction (deg) ddd.dd
21 Error Code hhhh
22 Checksum (hex) *hh

Example:
$PNORW,073010,051001,3,4,0.55,0.51,0.63,0.82,2.76,3.33,2.97,55.06,78.91,337.62,0.48,22.35,0,1,0.
27,129.11,0000*4E

Reconstructing a spectrum from these coefficients
--------------------------------------------------

DirRes = 4;
freq = 0.02:0.01:(48*0.01 + 0.01);
dDir = 0:pi*DirRes/180:(2*pi-pi*DirRes/180);
Spec = S;

 % (conversion from Cart to Compass)
 A1temp = A1;
 B1temp= B1;
 A1 = -B1temp;
 B1= -A1temp;
 A2 = -A2;
 B2 = B2;

% Direction From
dr = pi;
A1new = cos(dr)*A1 + sin(dr)*B1;
B1new = -sin(dr)*A1 + cos(dr)*B1;
A2new = cos(2*dr)*A2 + sin(2*dr)*B2;
B2new = -sin(2*dr)*A2 + cos(2*dr)*B2;

 % Now apply maximum entropy method to remove negative energy
 for j=1:length(freq)

    % This little gem switches from cart2compass
    for dd = 1:length(dDir)
       dNewdir = pi/2 - dDir(dd);

       %Pure Fourier coef soln w/o MEM
       DirFieldOLD(j,dd) = (A1new(j)*cos(dNewdir) + B1new(j)*sin(dNewdir) + A2new(j)*cos(2*dNewdir) + B2new(j)*sin(2*dNewdir))*Spec(ii,j);
    end
 end

 --OR--


% Now apply maximum entropy method to remove negative energy
for j=1:length(freq)
  c1 = A1new(j) + 1i*B1new(j);
  c2 = A2new(j) + 1i*B2new(j);
  %c1 = A1new(ii,j) + i*B1new(ii,j);
  %c2 = A2new(ii,j) + i*B2new(ii,j);

  fi1 = (c1 - c2*conj(c1)) / (1 - abs(c1)*abs(c1));
  fi2 = c2 - c1*fi1;

  % This little gem switches from cart2compass
  for dd = 1:length(dDir)
     dNewdir = pi/2 - dDir(dd);
     %dNewdir = dDir(dd);

     numer = 1 - fi1*conj(c1) - fi2*conj(c2);
     %denom = 1 - fi1*exp(-i*dDir(dd)) - fi2*exp(-2.0*i*dDir(dd));
     denom = 1 - fi1*exp(-1i*dNewdir) - fi2*exp(-2.0*1i*dNewdir);
     DirField(j,dd) = real(numer/(abs(denom)*abs(denom)))/(2.0*pi)*Spec(ii,j)*pi*DirRes/180;

     %Pure Fourier coef soln w/o ME
     DirFieldOLD(j,dd) = (A1new(j)*cos(dNewdir) + B1new(j)*sin(dNewdir) + A2new(j)*cos(2*dNewdir) + B2new(j)*sin(2*dNewdir))*Spec(ii,j);
  end
end


"""

import datetime
import warnings

import numpy as np

import xarray as xr
from wavespectra.core.attributes import set_spec_attributes


[docs] def read_awac(filename): """Read Spectra from Nortec AWAC NMEA file. The spectra are reconstructed from the Fourier coefficients using the maximum entropy method to remove negative energy. Normalisation is applied per frequency to keep energy over directions identical to energy in non-directional spectrum. The 2D spectral data is scaled to the significant wave height (Hs) given in the wave parameters. Args: - filename (str): path to AWAC file. Returns: - dset (SpecDataset): spectra dataset object read from file. """ with open(filename, "r") as f: lines = [line for line in f.read().split("\n")] return read_awac_strings(lines)
def parse_awac_nmnea_wave_parameters(lines): for line in lines: if line.startswith("$PNORW"): blocks = line.split(",") timestamp = blocks[1] + blocks[2] Hm0 = float(blocks[5]) H3 = float(blocks[6]) H10 = float(blocks[7]) Hmax = float(blocks[8]) Tm02 = float(blocks[9]) Tp = float(blocks[10]) Tz = float(blocks[11]) DirTp = float(blocks[12]) SprTp = float(blocks[13]) MainDir = float(blocks[14]) UnidirectivityIndex = float(blocks[15]) MeanPressure = float(blocks[16]) NearSurfaceCurrentSpeed = float(blocks[19]) NearSurfaceCurrentDirection = float(blocks[20]) # yield as dict yield { "timestamp": timestamp, "Hm0": Hm0, "H3": H3, "H10": H10, "Hmax": Hmax, "Tm02": Tm02, "Tp": Tp, "Tz": Tz, "DirTp": DirTp, "SprTp": SprTp, "MainDir": MainDir, "UnidirectivityIndex": UnidirectivityIndex, "MeanPressure": MeanPressure, "NearSurfaceCurrentSpeed": NearSurfaceCurrentSpeed, "NearSurfaceCurrentDirection": NearSurfaceCurrentDirection, } def parse_awac_nmea(lines): # scan to find frequency grid freq = None # read spectral frequencies from PNORF for line in lines: if line.startswith("$PNORF"): blocks = line.split(",") fstart = float(blocks[5]) fstep = float(blocks[6]) fn = int(blocks[7]) freq = fstart + np.linspace(start=0, stop=fn * fstep, num=fn, endpoint=True) break if freq is None: raise ValueError("Can not determine frequency grid, $PNORF field not found") # read spectral frequencies from PNORE ok = False for line in lines: if line.startswith("$PNORE"): blocks = line.split(",") fstart2 = float(blocks[4]) fstep2 = float(blocks[5]) # assert that the frequency grid is the same assert ( fstart == fstart2 ), "Frequency start does not match between $PNORF and $PNORE" assert ( fstep == fstep2 ), "Frequency step does not match between $PNORF and $PNORE" ok = True break if not ok: raise ValueError("Can not determine frequency grid, $PNORE field not found") A1 = None A2 = None B1 = None B2 = None S = None Hs = None timestamp = None for line in lines: line = line.split("*")[0] # new timestamp? TS = None if line.startswith("$PNORF"): TS = line[10:16] + line[17:23] elif line.startswith("$PNORE"): TS = line[7:13] + line[14:20] elif line.startswith("$PNORW"): blocks = line.split(",") TS = blocks[1] + blocks[2] if TS is not None: if TS != timestamp: if A1 is not None: yield timestamp, freq, A1, A2, B1, B2, S, Hs timestamp = TS A1 = None A2 = None B1 = None B2 = None S = None Hs = None if line.startswith("$PNORF"): # remove the checksum blocks = line.split(",") if blocks[1] == "A1": if A1 is not None: warnings.warn( f"Duplicate A1 spectrum found for timestamp {timestamp}" ) A1 = np.array([float(x) for x in blocks[8:]]) elif blocks[1] == "A2": if A2 is not None: warnings.warn( f"Duplicate A2 spectrum found for timestamp {timestamp}" ) A2 = np.array([float(x) for x in blocks[8:]]) elif blocks[1] == "B1": if B1 is not None: warnings.warn( f"Duplicate B1 spectrum found for timestamp {timestamp}" ) B1 = np.array([float(x) for x in blocks[8:]]) elif blocks[1] == "B2": if B2 is not None: warnings.warn( f"Duplicate B2 spectrum found for timestamp {timestamp}" ) B2 = np.array([float(x) for x in blocks[8:]]) elif line.startswith("$PNORE"): blocks = line.split(",") n = int(blocks[6]) S = np.array([float(x) for x in blocks[7:]]) if len(S) != n: warnings.warn( f"Number of frequencies in spectrum does not match number of frequencies expected {n} got {len(S)}" ) elif line.startswith("$PNORW"): blocks = line.split(",") Hs = float(blocks[5]) if A1 is not None: yield timestamp, freq, A1, A2, B1, B2, S, Hs def read_awac_strings( lines, nDirs=90, ): """Parses AWAC NMEA data into directional spectra.""" data = [R for R in parse_awac_nmea(lines)] # each line of data contains a timestamp, freq, A1, A2, B1, B2, S # the first and last line may contain Nones for A1, A2, B1, B2, S if the file was incomplete. In that case we remove them if any(x is None for x in data[0]): warnings.warn( f"First spectrum of AWAC data incomplete, removed spectrum with timestamp {data[0][0]}" ) data = data[1:] if any(x is None for x in data[-1]): warnings.warn( f"Last spectrum of AWAC data incomplete, removed with timestamp {data[0][0]}" ) data = data[:-1] # convert to directional spectra dDir = np.linspace(0, 2 * np.pi, num=nDirs, endpoint=False) dir_step = dDir[1] - dDir[0] efth = [] times = [] for timestamp, freq, aA1, aA2, aB1, aB2, S, Hs in data: # %(conversion from Cart to Compass) # A1temp = A1.copy() # B1temp = B1.copy() # # print('A1 = [' + ','.join([str(a) for a in aA1]) + ']') # print('A2 = [' + ','.join([str(a) for a in aA2]) + ']') # print('B1 = [' + ','.join([str(a) for a in aB1]) + ']') # print('B2 = [' + ','.join([str(a) for a in aB2]) + ']') # print('S = [' + ','.join([str(a) for a in S]) + ']') A1 = -aB1.copy() B1 = -aA1.copy() A2 = -aA2.copy() B2 = aB2.copy() # Direction From dr = np.pi A1new = np.cos(dr) * A1 + np.sin(dr) * B1 B1new = -np.sin(dr) * A1 + np.cos(dr) * B1 A2new = np.cos(2 * dr) * A2 + np.sin(2 * dr) * B2 B2new = -np.sin(2 * dr) * A2 + np.cos(2 * dr) * B2 # Now apply maximum entropy method to remove negative energy # Initialize the directional field DirField = np.zeros((len(freq), len(dDir))) # Apply maximum entropy method to remove negative energy for iFreq in range(len(freq)): c1 = A1new[iFreq] + 1j * B1new[iFreq] c2 = A2new[iFreq] + 1j * B2new[iFreq] fi1 = (c1 - c2 * np.conj(c1)) / (1 - np.abs(c1) * np.abs(c1)) fi2 = c2 - c1 * fi1 # print(f"c1 = {c1}") # print(f"c2 = {c2}") # print(f"fi1 = {fi1}") # print(f"fi2 = {fi2}") factors = [] for dd in range(len(dDir)): dNewdir = np.pi / 2 - dDir[dd] numer = 1 - fi1 * np.conj(c1) - fi2 * np.conj(c2) denom = ( 1 - fi1 * np.exp(-1j * dNewdir) - fi2 * np.exp(-2.0 * 1j * dNewdir) ) dir_factor = np.real(numer / (np.abs(denom) * np.abs(denom))) scale1 = 1 / (2 * np.pi) scale2 = dir_step scale = scale1 * scale2 factor = scale * dir_factor DirField[iFreq, dd] = factor * S[iFreq] # # print("======================") # print(f"dd = {dd + 1}") # print(f"dNewdir = {dNewdir}") # print(f"numer = {numer}") # print(f"denom = {denom}") # print(f"dir_factor = {dir_factor}") factors.append(factor) # all factors should add up to 1.0, scale accordingly # print(f"Sum of factors = {np.sum(factors)}") # if np.sum(factors) > 1.1: # print('Warning: sum of factors is larger than 1.0') directional_scaling = 1.0 / np.sum(factors) DirField[iFreq] *= directional_scaling # Calculate m0 of the spectrum Sd = [np.sum(DirField[iFreq]) for iFreq in range(len(freq))] m0 = np.trapz(Sd, freq) Hs_spectrum = 4 * np.sqrt(m0) # scale the spectrum to the given Hs IF Hs_spectrum is not zero if Hs_spectrum == 0: if Hs > 0: warnings.warn( f"Spectrum with timestamp {timestamp} has zero significant wave height, can not scale to Hs = {Hs}m" ) else: DirField *= (Hs / Hs_spectrum) ** 2 # in wavespecrta we work in: # m # Hz # degrees # m2/Hz so that is already ok dir_step_deg = np.degrees(dir_step) efth.append(DirField / dir_step_deg) ts = datetime.datetime.strptime(timestamp, "%m%d%y%H%M%S") assert ts.tzinfo is None times.append(ts) # from matplotlib import pyplot as plt # plt.plot(Hss, label = 'integrated directional spectra') dir_degrees = np.degrees(dDir) freq = data[0][1] ds = xr.DataArray( data=efth, coords={"time": times, "freq": freq, "dir": dir_degrees}, dims=("time", "freq", "dir"), name="efth", ).to_dataset() # Set attributes set_spec_attributes(ds) # add site dimension ds["site"] = [0] return ds